diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 37e2a8e7e7..8f05150002 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -24,6 +24,7 @@ import { DEFAULT_DESKTOP_SETTINGS, type AiProvider, type AiApiStyle, + type AiAuthMethod, type EditorTheme, type DesktopIconTheme, type DisconnectTabHandlingMode, @@ -915,6 +916,7 @@ const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvid const aiEditProvider = ref(settingsStore.aiConfig.provider); const aiEditApiKey = ref(settingsStore.aiConfig.apiKey); +const aiEditAuthMethod = ref(settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[settingsStore.aiConfig.provider].authMethod); const aiEditEndpoint = ref(settingsStore.aiConfig.endpoint); const aiEditModel = ref(settingsStore.aiConfig.model); const aiEditApiStyle = ref(settingsStore.aiConfig.apiStyle || "completions"); @@ -934,6 +936,13 @@ const aiTesting = ref(false); const aiTestResult = ref<"" | "success" | "error">(""); const aiTestError = ref(""); const aiRequiresApiKey = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value].requiresApiKey); +const aiSupportsAuthMethod = computed(() => aiEditProvider.value === "claude"); +const aiCredentialLabel = computed(() => (aiSupportsAuthMethod.value && aiEditAuthMethod.value === "bearer" ? "Auth Token" : "API Key")); +const aiCredentialPlaceholder = computed(() => { + if (!aiRequiresApiKey.value) return "Optional"; + if (aiSupportsAuthMethod.value && aiEditAuthMethod.value === "bearer") return "ANTHROPIC_AUTH_TOKEN"; + return ""; +}); const aiSupportsApiStyle = computed(() => aiEditProvider.value === "openai" || aiEditProvider.value === "openai-compatible" || aiEditProvider.value === "custom"); const aiModelListSupported = computed(() => aiEditProvider.value !== "gemini"); const aiCanListModels = computed(() => aiModelListSupported.value && !!aiEditEndpoint.value.trim() && (!aiRequiresApiKey.value || !!aiEditApiKey.value.trim())); @@ -957,6 +966,7 @@ function aiModelConfigSignature() { provider: aiEditProvider.value, endpoint: aiEditEndpoint.value.trim(), apiKey: aiEditApiKey.value.trim(), + authMethod: aiEditAuthMethod.value, proxyEnabled: aiEditProxyEnabled.value, proxyUrl: aiEditProxyUrl.value.trim(), }); @@ -966,6 +976,7 @@ function currentAiEditConfig() { return { provider: aiEditProvider.value, apiKey: aiEditApiKey.value, + authMethod: aiEditAuthMethod.value, endpoint: aiEditEndpoint.value, model: aiEditModel.value, apiStyle: aiEditApiStyle.value, @@ -1038,6 +1049,7 @@ function aiSelectModel(modelId: string) { function syncAiEditState() { aiEditProvider.value = settingsStore.aiConfig.provider; aiEditApiKey.value = settingsStore.aiConfig.apiKey; + aiEditAuthMethod.value = settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[settingsStore.aiConfig.provider].authMethod; aiEditEndpoint.value = settingsStore.aiConfig.endpoint; aiEditModel.value = settingsStore.aiConfig.model; aiEditApiStyle.value = settingsStore.aiConfig.apiStyle || "completions"; @@ -1054,6 +1066,7 @@ function aiSelectProvider(provider: AiProvider) { aiEditEndpoint.value = AI_PROVIDER_PRESETS[provider].endpoint; aiEditModel.value = AI_PROVIDER_PRESETS[provider].model; aiEditApiStyle.value = AI_PROVIDER_PRESETS[provider].apiStyle; + aiEditAuthMethod.value = AI_PROVIDER_PRESETS[provider].authMethod; if (!AI_PROVIDER_PRESETS[provider].requiresApiKey) aiEditApiKey.value = ""; clearAiModelOptions(); } @@ -1062,6 +1075,7 @@ function aiHasChanges(): boolean { return ( aiEditProvider.value !== settingsStore.aiConfig.provider || aiEditApiKey.value !== settingsStore.aiConfig.apiKey || + aiEditAuthMethod.value !== (settingsStore.aiConfig.authMethod || AI_PROVIDER_PRESETS[settingsStore.aiConfig.provider].authMethod) || aiEditEndpoint.value !== settingsStore.aiConfig.endpoint || aiEditModel.value !== settingsStore.aiConfig.model || aiEditApiStyle.value !== (settingsStore.aiConfig.apiStyle || "completions") || @@ -1929,9 +1943,22 @@ watch( +
+ + +
+
- - + +
diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 04bb6ce5d0..66c14ce55a 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -13,10 +13,12 @@ import { setDebugLoggingEnabled } from "@/lib/debugLog"; export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "custom"; export type AiApiStyle = "completions" | "responses"; +export type AiAuthMethod = "api-key" | "bearer"; export interface AiConfig { provider: AiProvider; apiKey: string; + authMethod: AiAuthMethod; endpoint: string; model: string; apiStyle: AiApiStyle; @@ -65,6 +67,7 @@ export const AI_PROVIDER_PRESETS: Record = { endpoint: "https://api.anthropic.com/v1/messages", model: "claude-sonnet-4-20250514", apiStyle: "completions", + authMethod: "api-key", requiresApiKey: true, }, openai: { @@ -74,6 +77,7 @@ export const AI_PROVIDER_PRESETS: Record = { endpoint: "https://api.openai.com/v1/chat/completions", model: "gpt-4o-mini", apiStyle: "completions", + authMethod: "bearer", requiresApiKey: true, }, gemini: { @@ -83,6 +87,7 @@ export const AI_PROVIDER_PRESETS: Record = { endpoint: "https://generativelanguage.googleapis.com", model: "gemini-1.5-pro", apiStyle: "completions", + authMethod: "api-key", requiresApiKey: true, }, deepseek: { @@ -92,6 +97,7 @@ export const AI_PROVIDER_PRESETS: Record = { endpoint: "https://api.deepseek.com/v1", model: "deepseek-v4-flash", apiStyle: "completions", + authMethod: "bearer", requiresApiKey: true, }, qwen: { @@ -101,6 +107,7 @@ export const AI_PROVIDER_PRESETS: Record = { endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen-plus", apiStyle: "completions", + authMethod: "bearer", requiresApiKey: true, }, ollama: { @@ -110,6 +117,7 @@ export const AI_PROVIDER_PRESETS: Record = { endpoint: "http://localhost:11434/v1", model: "llama3.1", apiStyle: "completions", + authMethod: "bearer", requiresApiKey: false, }, "openai-compatible": { @@ -119,6 +127,7 @@ export const AI_PROVIDER_PRESETS: Record = { endpoint: "", model: "", apiStyle: "completions", + authMethod: "bearer", requiresApiKey: true, }, custom: { @@ -127,6 +136,7 @@ export const AI_PROVIDER_PRESETS: Record = { endpoint: "", model: "", apiStyle: "completions", + authMethod: "bearer", requiresApiKey: true, }, }; @@ -146,6 +156,7 @@ export function normalizeAiConfig(config: Partial | null | undefined): ...config, provider, apiStyle: config?.apiStyle ?? defaultConfigs[provider].apiStyle, + authMethod: config?.authMethod ?? defaultConfigs[provider].authMethod, proxyEnabled: !!config?.proxyEnabled, proxyUrl: config?.proxyUrl ?? "", enableThinking: config?.enableThinking ?? true, diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 2297ca07a1..7af5038ef7 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -60,6 +60,14 @@ pub enum AiApiStyle { Responses, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum AiAuthMethod { + #[default] + ApiKey, + Bearer, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AiConfig { @@ -67,6 +75,8 @@ pub struct AiConfig { #[serde(default)] pub api_key: String, #[serde(default)] + pub auth_method: AiAuthMethod, + #[serde(default)] pub endpoint: String, #[serde(default)] pub model: String, @@ -393,7 +403,17 @@ fn maybe_bearer_headers(config: &AiConfig) -> Result { fn claude_headers(config: &AiConfig) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - headers.insert("x-api-key", HeaderValue::from_str(&config.api_key).map_err(|e| e.to_string())?); + match config.auth_method { + AiAuthMethod::Bearer => { + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {}", config.api_key)).map_err(|e| e.to_string())?, + ); + } + AiAuthMethod::ApiKey => { + headers.insert("x-api-key", HeaderValue::from_str(&config.api_key).map_err(|e| e.to_string())?); + } + } headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); Ok(headers) } @@ -1130,9 +1150,10 @@ pub fn load_config(path: &Path) -> Result, String> { #[cfg(test)] mod tests { use super::{ - build_ai_http_client, gemini_text, openai_response_text, openai_stream_text, parse_model_list_response, - resolve_endpoint, resolve_model_list_endpoint, responses_max_output_tokens, responses_text, - supports_temperature, validate_config, AiApiStyle, AiConfig, AiModelInfo, AiProvider, + build_ai_http_client, claude_headers, gemini_text, openai_response_text, openai_stream_text, + parse_model_list_response, resolve_endpoint, resolve_model_list_endpoint, responses_max_output_tokens, + responses_text, supports_temperature, validate_config, AiApiStyle, AiAuthMethod, AiConfig, AiModelInfo, + AiProvider, AUTHORIZATION, }; #[test] @@ -1149,6 +1170,7 @@ mod tests { assert!(!config.proxy_enabled); assert_eq!(config.proxy_url, ""); assert!(config.enable_thinking); + assert_eq!(config.auth_method, AiAuthMethod::ApiKey); } #[test] @@ -1156,6 +1178,7 @@ mod tests { let config = AiConfig { provider: AiProvider::Openai, api_key: "key".to_string(), + auth_method: AiAuthMethod::Bearer, endpoint: "https://api.openai.com/v1/chat/completions".to_string(), model: "gpt-4o".to_string(), api_style: AiApiStyle::Completions, @@ -1174,6 +1197,7 @@ mod tests { let config = AiConfig { provider: AiProvider::Openai, api_key: "key".to_string(), + auth_method: AiAuthMethod::Bearer, endpoint: "https://api.openai.com/v1/chat/completions".to_string(), model: "gpt-4o".to_string(), api_style: AiApiStyle::Completions, @@ -1190,6 +1214,7 @@ mod tests { let config = AiConfig { provider: AiProvider::OpenaiCompatible, api_key: "key".to_string(), + auth_method: AiAuthMethod::Bearer, endpoint: "http://127.0.0.1:3456/v1".to_string(), model: "gpt-4o".to_string(), api_style: AiApiStyle::Completions, @@ -1206,6 +1231,7 @@ mod tests { let gemini = AiConfig { provider: AiProvider::Gemini, api_key: "key".to_string(), + auth_method: AiAuthMethod::ApiKey, endpoint: "https://generativelanguage.googleapis.com".to_string(), model: "gemini-1.5-pro".to_string(), api_style: AiApiStyle::Completions, @@ -1222,6 +1248,7 @@ mod tests { let ollama = AiConfig { provider: AiProvider::Ollama, api_key: String::new(), + auth_method: AiAuthMethod::Bearer, endpoint: "http://localhost:11434/v1".to_string(), model: "llama3.1".to_string(), api_style: AiApiStyle::Completions, @@ -1239,6 +1266,7 @@ mod tests { let openai = AiConfig { provider: AiProvider::Openai, api_key: "key".to_string(), + auth_method: AiAuthMethod::Bearer, endpoint: "https://api.openai.com/v1/chat/completions".to_string(), model: String::new(), api_style: AiApiStyle::Completions, @@ -1251,6 +1279,7 @@ mod tests { let claude = AiConfig { provider: AiProvider::Claude, api_key: "key".to_string(), + auth_method: AiAuthMethod::ApiKey, endpoint: "https://api.anthropic.com/v1/messages".to_string(), model: String::new(), api_style: AiApiStyle::Completions, @@ -1261,6 +1290,30 @@ mod tests { assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models"); } + #[test] + fn claude_headers_support_api_key_and_bearer_auth() { + let mut config = AiConfig { + provider: AiProvider::Claude, + api_key: "secret".to_string(), + auth_method: AiAuthMethod::ApiKey, + endpoint: "https://api.anthropic.com/v1/messages".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + api_style: AiApiStyle::Completions, + proxy_enabled: false, + proxy_url: String::new(), + enable_thinking: true, + }; + + let api_key_headers = claude_headers(&config).unwrap(); + assert_eq!(api_key_headers.get("x-api-key").unwrap(), "secret"); + assert!(api_key_headers.get(AUTHORIZATION).is_none()); + + config.auth_method = AiAuthMethod::Bearer; + let bearer_headers = claude_headers(&config).unwrap(); + assert_eq!(bearer_headers.get(AUTHORIZATION).unwrap(), "Bearer secret"); + assert!(bearer_headers.get("x-api-key").is_none()); + } + #[test] fn parses_openai_and_claude_model_list_items() { let data = serde_json::json!({ @@ -1297,6 +1350,7 @@ mod tests { let mut config = AiConfig { provider: AiProvider::Openai, api_key: "key".to_string(), + auth_method: AiAuthMethod::Bearer, endpoint: "https://api.openai.com/v1/chat/completions".to_string(), model: "gpt-5.5".to_string(), api_style: AiApiStyle::Completions, diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index eb8df0b288..92d59b1ff2 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -239,6 +239,8 @@ test("AI provider presets include common hosted and local providers", () => { assert.equal(AI_PROVIDER_PRESETS.qwen.endpoint, "https://dashscope.aliyuncs.com/compatible-mode/v1"); assert.equal(AI_PROVIDER_PRESETS.ollama.endpoint, "http://localhost:11434/v1"); assert.equal(AI_PROVIDER_PRESETS.ollama.requiresApiKey, false); + assert.equal(AI_PROVIDER_PRESETS.claude.authMethod, "api-key"); + assert.equal(AI_PROVIDER_PRESETS.openai.authMethod, "bearer"); assert.equal(AI_PROVIDER_PRESETS.openai.iconSlug, "openai"); assert.equal(AI_PROVIDER_PRESETS.deepseek.iconSlug, "deepseek"); }); @@ -254,11 +256,16 @@ test("normalizes legacy AI config and fills provider defaults", () => { assert.equal(legacy.apiStyle, "completions"); assert.equal(legacy.provider, "openai"); assert.equal(legacy.apiKey, "key"); + assert.equal(legacy.authMethod, "bearer"); const ollama = normalizeAiConfig({ provider: "ollama" } as any); assert.equal(ollama.endpoint, "http://localhost:11434/v1"); assert.equal(ollama.model, "llama3.1"); assert.equal(ollama.apiKey, ""); + assert.equal(ollama.authMethod, "bearer"); + + const claudeToken = normalizeAiConfig({ provider: "claude", apiKey: "token", authMethod: "bearer" } as any); + assert.equal(claudeToken.authMethod, "bearer"); }); test("infers legacy AI provider from saved endpoint and model", () => {