Files
galaxy/app/src/ai/bedrock/external_config.rs
T

243 lines
6.8 KiB
Rust

use std::collections::HashMap;
use std::path::PathBuf;
use crate::settings::ai::BedrockModelConfig;
#[derive(Debug, Clone, Default)]
pub struct ExternalBedrockConfig {
pub profile: Option<String>,
pub region: Option<String>,
pub models: Vec<BedrockModelConfig>,
pub auth_refresh_command: Option<String>,
}
impl ExternalBedrockConfig {
pub fn load() -> Self {
let claude_config = load_claude_code_config();
let opencode_config = load_opencode_config();
// Claude Code takes priority over OpenCode since it has richer model mappings
Self {
profile: claude_config
.profile
.or(opencode_config.profile),
region: claude_config
.region
.or(opencode_config.region),
models: if claude_config.models.is_empty() {
opencode_config.models
} else {
claude_config.models
},
auth_refresh_command: claude_config.auth_refresh_command,
}
}
pub fn is_empty(&self) -> bool {
self.profile.is_none() && self.region.is_none() && self.models.is_empty()
}
}
fn load_claude_code_config() -> ExternalBedrockConfig {
let path = dirs::home_dir()
.map(|h| h.join(".claude").join("settings.json"))
.unwrap_or_default();
parse_claude_code_config(path)
}
fn parse_claude_code_config(path: PathBuf) -> ExternalBedrockConfig {
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return ExternalBedrockConfig::default(),
};
let json: serde_json::Value = match serde_json::from_str(&contents) {
Ok(v) => v,
Err(_) => return ExternalBedrockConfig::default(),
};
// Read the top-level awsAuthRefresh command (used by Claude Code for SSO login)
let auth_refresh_command = json
.get("awsAuthRefresh")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let env = match json.get("env").and_then(|v| v.as_object()) {
Some(e) => e,
None => return ExternalBedrockConfig {
auth_refresh_command,
..Default::default()
},
};
let profile = env
.get("AWS_PROFILE")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let region = env
.get("AWS_REGION")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let models = parse_claude_code_model_map(env);
ExternalBedrockConfig {
profile,
region,
models,
auth_refresh_command,
}
}
fn parse_claude_code_model_map(
env: &serde_json::Map<String, serde_json::Value>,
) -> Vec<BedrockModelConfig> {
let model_map_str = match env.get("DCP_MODEL_MAP").and_then(|v| v.as_str()) {
Some(s) => s,
None => return Vec::new(),
};
let model_map: HashMap<String, String> = match serde_json::from_str(model_map_str) {
Ok(m) => m,
Err(_) => return Vec::new(),
};
model_map
.into_iter()
.map(|(source_id, arn)| {
let display_name = derive_display_name(&source_id);
BedrockModelConfig {
model_id: arn,
display_name,
vision_supported: true,
}
})
.collect()
}
fn derive_display_name(model_id: &str) -> String {
// Strip region prefix (e.g. "us." from "us.anthropic.claude-opus-4-6-v1")
let id = model_id
.strip_prefix("us.")
.or_else(|| model_id.strip_prefix("eu."))
.or_else(|| model_id.strip_prefix("ap."))
.or_else(|| model_id.strip_prefix("global."))
.unwrap_or(model_id);
// Strip vendor prefix
let id = id
.strip_prefix("anthropic.")
.or_else(|| id.strip_prefix("amazon."))
.or_else(|| id.strip_prefix("meta."))
.unwrap_or(id);
// Convert model slug to display name
// e.g. "claude-opus-4-6-v1" -> "Claude Opus 4.6"
// e.g. "claude-sonnet-4-5-20250929-v1:0" -> "Claude Sonnet 4.5"
prettify_model_slug(id)
}
fn prettify_model_slug(slug: &str) -> String {
// Remove version suffixes like "-v1:0", "-v1", "-v2:0"
let slug = slug
.split("-v")
.next()
.unwrap_or(slug);
// Remove date suffixes like "-20250514" or "-20251001"
let parts: Vec<&str> = slug.split('-').collect();
let mut cleaned: Vec<&str> = Vec::new();
let mut i = 0;
while i < parts.len() {
// Skip parts that look like dates (8 digits)
if parts[i].len() == 8 && parts[i].chars().all(|c| c.is_ascii_digit()) {
i += 1;
continue;
}
cleaned.push(parts[i]);
i += 1;
}
// Try to detect version numbers: consecutive single-digit parts become "X.Y"
let mut result = String::new();
let mut i = 0;
while i < cleaned.len() {
if i > 0 {
result.push(' ');
}
// Check if this and the next part form a version number (e.g. "4" "6" -> "4.6")
if cleaned[i].len() == 1
&& cleaned[i].chars().all(|c| c.is_ascii_digit())
&& i + 1 < cleaned.len()
&& cleaned[i + 1].len() == 1
&& cleaned[i + 1].chars().all(|c| c.is_ascii_digit())
{
result.push_str(cleaned[i]);
result.push('.');
result.push_str(cleaned[i + 1]);
i += 2;
continue;
}
// Capitalize first letter of each word
let word = cleaned[i];
let mut chars = word.chars();
if let Some(first) = chars.next() {
result.push(first.to_ascii_uppercase());
result.extend(chars);
}
i += 1;
}
result
}
fn load_opencode_config() -> ExternalBedrockConfig {
let path = dirs::home_dir()
.map(|h| h.join(".config").join("opencode").join("opencode.json"))
.unwrap_or_default();
parse_opencode_config(path)
}
fn parse_opencode_config(path: PathBuf) -> ExternalBedrockConfig {
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return ExternalBedrockConfig::default(),
};
let json: serde_json::Value = match serde_json::from_str(&contents) {
Ok(v) => v,
Err(_) => return ExternalBedrockConfig::default(),
};
let bedrock_opts = json
.get("provider")
.and_then(|p| p.get("amazon-bedrock"))
.and_then(|b| b.get("options"));
let profile = bedrock_opts
.and_then(|o| o.get("profile"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let region = bedrock_opts
.and_then(|o| o.get("region"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
ExternalBedrockConfig {
profile,
region,
models: Vec::new(),
auth_refresh_command: None,
}
}
#[cfg(test)]
#[path = "external_config_tests.rs"]
mod tests;