diff --git a/apps/desktop/public/icons/ai/jalapeno-cloud.png b/apps/desktop/public/icons/ai/jalapeno-cloud.png new file mode 100644 index 0000000000..59e8801d0d Binary files /dev/null and b/apps/desktop/public/icons/ai/jalapeno-cloud.png differ diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 31586cf773..85ac68678d 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -55,7 +55,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { useTheme } from "@/composables/useTheme"; import CodeSnapshotDialog from "@/components/codeSnapshot/CodeSnapshotDialog.vue"; import type { CodeSnapshotSource } from "@/lib/codeSnapshot/codeSnapshot"; -import { useSettingsStore, AI_PROVIDER_PRESETS, aiProviderLabel, normalizeAiConfig } from "@/stores/settingsStore"; +import { useSettingsStore, AI_PROVIDER_PRESETS, aiProviderLabel, getAiProviderPreset, normalizeAiConfig } from "@/stores/settingsStore"; import AiProviderLogo from "@/components/icons/AiProviderLogo.vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; @@ -715,7 +715,7 @@ const activeFullConfig = computed(() => { }); function isModelCandidate(config: AiConfigItem): boolean { - return isAiConfigModelCandidate(config, AI_PROVIDER_PRESETS[config.provider].requiresApiKey, supportsCliProviders); + return isAiConfigModelCandidate(config, getAiProviderPreset(config.provider, config.endpoint).requiresApiKey, supportsCliProviders); } function getModelsForConfig(configId: string) { @@ -724,8 +724,14 @@ function getModelsForConfig(configId: string) { return aiModelOptions(config, modelCatalogs.get(configId)?.models ?? []); } +function aiConfigProviderLabel(config: { provider: AiConfigItem["provider"]; endpoint: string } | null | undefined): string { + if (!config) return aiProviderLabel("claude", t); + const preset = getAiProviderPreset(config.provider, config.endpoint); + return preset.provider === "custom" ? aiProviderLabel(config.provider, t) : preset.label; +} + function configMatchesModelQuery(config: AiConfigItem, query: string): boolean { - return config.name.toLowerCase().includes(query) || config.provider.toLowerCase().includes(query) || aiProviderLabel(config.provider, t).toLowerCase().includes(query) || AI_PROVIDER_PRESETS[config.provider].label.toLowerCase().includes(query); + return config.name.toLowerCase().includes(query) || config.provider.toLowerCase().includes(query) || aiConfigProviderLabel(config).toLowerCase().includes(query); } function getConfigModelOptions(config: AiConfigItem) { @@ -5001,7 +5007,13 @@ async function openExternalUrl(url: string) { @@ -5015,7 +5027,13 @@ async function openExternalUrl(url: string) {
+
+ @@ -7388,7 +7423,13 @@ onUnmounted(() => {
- +
+ + +
diff --git a/apps/desktop/src/components/icons/AiProviderLogo.vue b/apps/desktop/src/components/icons/AiProviderLogo.vue index 5e602bff04..475540be45 100644 --- a/apps/desktop/src/components/icons/AiProviderLogo.vue +++ b/apps/desktop/src/components/icons/AiProviderLogo.vue @@ -9,22 +9,23 @@ const props = defineProps<{ provider: AiProvider; label: string; iconSlug?: string; + iconPath?: string; }>(); const failed = ref(false); const { isDark } = useTheme(); -watch( - () => props.iconSlug, - () => { - failed.value = false; - }, -); +watch([() => props.iconSlug, () => props.iconPath], () => { + failed.value = false; +}); const usesWhiteDarkIcon = computed( - () => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli" || props.provider === "cursor-cli" || props.provider === "grok-cli", + () => + !props.iconPath && + (props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli" || props.provider === "cursor-cli" || props.provider === "grok-cli"), ); const localIconUrl = computed(() => { + if (props.iconPath) return webPath(props.iconPath); if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg"); return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : ""; }); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index dd89247310..3107a4588b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2494,6 +2494,12 @@ export default { noConfig: "AI is not configured. Please set up your AI provider in Settings > AI.", provider: "Provider", providerCustom: "Custom", + builtinProviders: "Built-in support", + partnerProviders: "Premium sponsors", + jalapenoSponsored: "$1 free credit", + jalapenoDescription: "🎉 Enterprise-grade AI trusted by leading teams is now available to you! Try cutting-edge models at 50% off and get more bonus credit when you top up more!", + visitPartner: "Visit partner website", + getApiKey: "Get API key", providerStatusActive: "Active", providerStatusConfigured: "Configured", switchProvider: "Switch Provider", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 16b0f58b09..7feb5620d3 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2430,6 +2430,12 @@ export default withEnglishFallback({ noConfig: "La IA no está configurada. Configura tu proveedor de IA en Ajustes > IA.", provider: "Proveedor", providerCustom: "Personalizado", + builtinProviders: "Compatibilidad integrada", + partnerProviders: "Patrocinadores destacados", + jalapenoSponsored: "1 USD de crédito gratis", + jalapenoDescription: "🎉 ¡La IA de nivel empresarial que utilizan los equipos líderes ya está disponible para ti! ¡Prueba modelos de vanguardia con un 50 % de descuento y recibe más bonificaciones al recargar!", + visitPartner: "Visitar el sitio web del socio", + getApiKey: "Obtener clave API", providerStatusActive: "Activo", providerStatusConfigured: "Configurado", switchProvider: "Cambiar proveedor", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 80d371f979..667980fade 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2428,6 +2428,12 @@ export default withEnglishFallback({ noConfig: "L'AI non è configurata. Configura il tuo provider AI in Impostazioni > AI.", provider: "Provider", providerCustom: "Personalizzato", + builtinProviders: "Supporto integrato", + partnerProviders: "Sponsor selezionati", + jalapenoSponsored: "$1 di credito gratuito", + jalapenoDescription: "🎉 L'AI di livello enterprise usata dai team migliori è ora disponibile per te! Prova i modelli all'avanguardia con il 50% di sconto e ottieni più bonus ricaricando di più!", + visitPartner: "Visita il sito del partner", + getApiKey: "Ottieni la chiave API", providerStatusActive: "Attivo", providerStatusConfigured: "Configurato", switchProvider: "Cambia provider", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 85ce619382..00516a36e3 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2454,6 +2454,12 @@ export default withEnglishFallback({ noConfig: "AIが設定されていません。設定 > AIでAIプロバイダーを設定してください。", provider: "プロバイダー", providerCustom: "カスタム", + builtinProviders: "組み込みサポート", + partnerProviders: "優良スポンサー", + jalapenoSponsored: "$1分の無料クレジット", + jalapenoDescription: "🎉トップチームも利用するエンタープライズ向けAIが、あなたのもとへ!最先端モデルを50%オフで今すぐ体験。さらにチャージするほどボーナスも増量!", + visitPartner: "パートナーサイトを開く", + getApiKey: "APIキーを取得", providerStatusActive: "使用中", providerStatusConfigured: "設定済み", switchProvider: "プロバイダーを切替", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 853b1d96db..d439fc6e39 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2326,6 +2326,12 @@ export default withEnglishFallback({ noConfig: "AI가 구성되지 않았습니다. 설정 > AI에서 AI 공급자를 설정하세요.", provider: "공급자", providerCustom: "사용자 지정", + builtinProviders: "내장 지원", + partnerProviders: "우수 스폰서", + jalapenoSponsored: "$1 무료 크레딧", + jalapenoDescription: "🎉 최고의 팀들이 사용하는 엔터프라이즈급 AI를 이제 만나보세요! 최첨단 모델을 50% 할인으로 바로 체험하고, 충전할수록 더 많은 보너스를 받으세요!", + visitPartner: "파트너 웹사이트 방문", + getApiKey: "API 키 받기", providerStatusActive: "활성", providerStatusConfigured: "구성됨", switchProvider: "공급자 전환", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 4b726238a2..453b5a31ee 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2430,6 +2430,12 @@ export default withEnglishFallback({ noConfig: "A AI não está configurada. Configure seu provedor de AI em Configurações > AI.", provider: "Provedor", providerCustom: "Personalizado", + builtinProviders: "Suporte integrado", + partnerProviders: "Patrocinadores premium", + jalapenoSponsored: "$1 de crédito grátis", + jalapenoDescription: "🎉 A IA de nível empresarial usada pelas principais equipes agora está disponível para você! Experimente modelos de ponta com 50% de desconto e ganhe mais bônus ao recarregar mais!", + visitPartner: "Visitar site do parceiro", + getApiKey: "Obter chave de API", providerStatusActive: "Ativo", providerStatusConfigured: "Configurado", switchProvider: "Trocar provedor", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 43138519cc..5a4cda7da2 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2417,6 +2417,12 @@ export default withEnglishFallback({ noConfig: "AI 尚未配置。请在 设置 > AI 中配置你的 AI 服务。", provider: "提供商", providerCustom: "自定义", + builtinProviders: "内置支持", + partnerProviders: "优质赞助商", + jalapenoSponsored: "$1 免费额度", + jalapenoDescription: "🎉顶尖团队都在用的企业级 AI,现在为你而来!【5折前沿模型】立刻体验!更有【多充多送】羊毛狠狠薅!", + visitPartner: "访问合作伙伴网站", + getApiKey: "获取 API Key", providerStatusActive: "当前", providerStatusConfigured: "已配置", switchProvider: "切换服务商", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 6d8136c21e..0bab8cc23a 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2427,6 +2427,12 @@ export default withEnglishFallback({ noConfig: "AI 尚未設定。請在 設定 > AI 中設定你的 AI 服務。", provider: "供應商", providerCustom: "自訂", + builtinProviders: "內建支援", + partnerProviders: "優質贊助商", + jalapenoSponsored: "$1 免費額度", + jalapenoDescription: "🎉頂尖團隊都在使用的企業級 AI,現在也為你而來!【5 折前沿模型】立即體驗!還有【儲值越多送越多】的優惠!", + visitPartner: "前往合作夥伴網站", + getApiKey: "取得 API Key", providerStatusActive: "目前", providerStatusConfigured: "已設定", switchProvider: "切換服務商", diff --git a/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts index ac2d37963e..37d7aedb61 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsPageNavigation.spec.ts @@ -29,6 +29,7 @@ describe("settings page navigation", () => { expect(appSource).toContain(':ai-config-draft="settingsAiConfigDraft"'); expect(appSource).toContain(':ai-config-request-id="settingsAiConfigRequestId"'); expect(settingsDialogSource).toContain("aiEnterEditMode();"); + expect(handlerSource).toContain("aiEditProviderPresetId.value = getAiProviderPresetId(draft.provider, draft.endpoint);"); expect(settingsDialogSource).toContain('aiEditApiKey.value = "";'); expect(settingsDialogSource).toContain("importClipboardApiKeyAfterConfirmation"); expect(handlerStart).toBeGreaterThanOrEqual(0); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 25b68404de..731cb4aa73 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -170,7 +170,17 @@ export function normalizeDuckDbWorkerMaxProcesses(value: unknown): number { export interface AiProviderPreset extends Omit { label: string; iconSlug?: string; + iconPath?: string; requiresApiKey: boolean; + group?: "builtin" | "partner"; +} + +export interface AiPartnerProviderPreset extends AiProviderPreset { + id: string; + group: "partner"; + websiteUrl: string; + apiKeyUrl: string; + descriptionKey: string; } export const AI_PROVIDER_PRESETS: Record = { @@ -360,9 +370,51 @@ export function aiProviderLabel(provider: AiProvider, t: (key: string) => string return AI_PROVIDER_PRESETS[provider].label; } +export const AI_PROVIDER_PARTNER_PRESETS: readonly AiPartnerProviderPreset[] = [ + { + id: "jalapeno-cloud", + label: "Jalapeno Cloud", + iconPath: "/icons/ai/jalapeno-cloud.png", + group: "partner", + provider: "openai-compatible", + endpoint: "https://api.jalapeno-cloud.ai/v1", + model: "GLM-5.2", + models: [{ name: "GLM-5.2" }, { name: "DeepSeek-V4-Pro" }, { name: "MiniMax-M3" }], + apiStyle: "completions", + authMethod: "bearer", + requiresApiKey: true, + websiteUrl: "https://www.jalapeno-cloud.ai/dbx", + apiKeyUrl: "https://www.jalapeno-cloud.ai/dbx", + descriptionKey: "ai.jalapenoDescription", + }, +]; + +function normalizeAiProviderEndpoint(endpoint: string): string { + return endpoint.trim().replace(/\/+$/, "").toLowerCase(); +} + +export function getAiProviderPreset(provider: AiProvider, endpoint = ""): AiProviderPreset | AiPartnerProviderPreset { + const normalizedEndpoint = normalizeAiProviderEndpoint(endpoint); + const partnerPreset = AI_PROVIDER_PARTNER_PRESETS.find((preset) => preset.provider === provider && normalizeAiProviderEndpoint(preset.endpoint) === normalizedEndpoint); + return partnerPreset ?? AI_PROVIDER_PRESETS[provider]; +} + +export function getAiProviderPresetOption(id: string): AiProviderPreset | AiPartnerProviderPreset { + return AI_PROVIDER_PARTNER_PRESETS.find((preset) => preset.id === id) ?? AI_PROVIDER_PRESETS[id as AiProvider] ?? AI_PROVIDER_PRESETS.custom; +} + +export function getAiProviderPresetId(provider: AiProvider, endpoint = ""): string { + const preset = getAiProviderPreset(provider, endpoint); + return "id" in preset ? preset.id : provider; +} + +export function isAiPartnerProviderPreset(preset: AiProviderPreset | AiPartnerProviderPreset): preset is AiPartnerProviderPreset { + return preset.group === "partner"; +} + const defaultConfigs: Record> = Object.fromEntries( Object.entries(AI_PROVIDER_PRESETS).map(([provider, preset]) => { - const { label: _label, iconSlug: _iconSlug, requiresApiKey: _requiresApiKey, ...config } = preset; + const { label: _label, iconSlug: _iconSlug, iconPath: _iconPath, group: _group, requiresApiKey: _requiresApiKey, ...config } = preset; return [provider, config]; }), ) as Record>; @@ -1814,7 +1866,7 @@ export const useSettingsStore = defineStore("settings", () => { if (!activeModel.value) return false; const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId); if (!config) return false; - const preset = AI_PROVIDER_PRESETS[config.provider]; + const preset = getAiProviderPreset(config.provider, config.endpoint); if ( config.provider === "codex-cli" || config.provider === "claude-code-cli" || diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 8119629e58..5b059b8204 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -1557,6 +1557,16 @@ fn normalized_api_key(config: &AiConfig) -> &str { config.api_key.trim() } +fn is_jalapeno_config(config: &AiConfig) -> bool { + if !matches!(config.provider, AiProvider::OpenaiCompatible) { + return false; + } + reqwest::Url::parse(config.endpoint.trim()) + .ok() + .and_then(|url| url.host_str().map(|host| host.eq_ignore_ascii_case("api.jalapeno-cloud.ai"))) + .unwrap_or(false) +} + fn validate_config(config: &AiConfig) -> Result<(), String> { crate::ai_effort::validate_runtime_effort(config)?; if is_cli_provider(&config.provider) { @@ -1591,6 +1601,9 @@ fn validate_model_list_config(config: &AiConfig) -> Result<(), String> { pub fn maybe_bearer_headers(config: &AiConfig) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + if is_jalapeno_config(config) { + headers.insert("HTTP-Referer", HeaderValue::from_static("https://dbxio.com")); + } let api_key = normalized_api_key(config); if !api_key.is_empty() { headers.insert(AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|e| e.to_string())?); @@ -6148,6 +6161,22 @@ mod tests { assert!(maybe_bearer_headers(&config).unwrap_err().contains("Invalid AI custom header value")); } + #[test] + fn jalapeno_requests_include_dbx_referer() { + let mut config = test_config(AiProvider::OpenaiCompatible); + config.endpoint = "https://api.jalapeno-cloud.ai/v1".to_string(); + + let headers = maybe_bearer_headers(&config).unwrap(); + assert_eq!(headers.get("HTTP-Referer").unwrap(), "https://dbxio.com"); + + config.endpoint = "https://api.jalapeno-cloud.ai.example.com/v1".to_string(); + assert!(maybe_bearer_headers(&config).unwrap().get("HTTP-Referer").is_none()); + + config.endpoint = "https://api.jalapeno-cloud.ai/v1".to_string(); + config.provider = AiProvider::Custom; + assert!(maybe_bearer_headers(&config).unwrap().get("HTTP-Referer").is_none()); + } + #[test] fn claude_system_prompt_substitutes_default_when_empty() { // Empty or whitespace-only prompts must fall back to a non-empty value, diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts index 3ae9bbaf46..4222bab3b0 100644 --- a/packages/app-tests/settingsStore.test.ts +++ b/packages/app-tests/settingsStore.test.ts @@ -6,7 +6,7 @@ import { DEFAULT_SQL_FORMATTER_SETTINGS } from "../../apps/desktop/src/lib/sql/s import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS } from "../../apps/desktop/src/lib/table/tableColumnTemplates.ts"; import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, SYSTEM_UI_FONT_FAMILY } from "../../apps/desktop/src/lib/app/appFonts.ts"; import { tableOpenPageLimit } from "../../apps/desktop/src/lib/table/tableOpenPageLimit.ts"; -import { AI_PROVIDER_PRESETS, DEFAULT_EDITOR_SETTINGS, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeAiConfig, normalizeEditorSettings, useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts"; +import { AI_PROVIDER_PARTNER_PRESETS, AI_PROVIDER_PRESETS, DEFAULT_EDITOR_SETTINGS, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, getAiProviderPreset, getAiProviderPresetId, normalizeAiConfig, normalizeEditorSettings, useSettingsStore } from "../../apps/desktop/src/stores/settingsStore.ts"; import { DEFAULT_SHORTCUT_SETTINGS, tabNavigationHistoryDefaultShortcut, type ShortcutSettings } from "../../apps/desktop/src/lib/editor/shortcutRegistry.ts"; const saveEditorSettingsMock = vi.hoisted(() => vi.fn()); @@ -874,6 +874,22 @@ test("AI provider presets include common hosted and local providers", () => { assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli")); }); +test("AI partner presets reuse a supported runtime adapter", () => { + const jalapeno = AI_PROVIDER_PARTNER_PRESETS.find((preset) => preset.id === "jalapeno-cloud"); + + assert.ok(jalapeno); + assert.equal(jalapeno.provider, "openai-compatible"); + assert.equal(jalapeno.endpoint, "https://api.jalapeno-cloud.ai/v1"); + assert.equal(jalapeno.model, "GLM-5.2"); + assert.deepEqual(jalapeno.models, [{ name: "GLM-5.2" }, { name: "DeepSeek-V4-Pro" }, { name: "MiniMax-M3" }]); + assert.equal(jalapeno.requiresApiKey, true); + assert.equal(jalapeno.websiteUrl, "https://www.jalapeno-cloud.ai/dbx"); + assert.equal(jalapeno.apiKeyUrl, "https://www.jalapeno-cloud.ai/dbx"); + assert.equal(getAiProviderPreset("openai-compatible", "https://api.jalapeno-cloud.ai/v1/").label, "Jalapeno Cloud"); + assert.equal(getAiProviderPresetId("openai-compatible", "https://api.jalapeno-cloud.ai/v1/"), "jalapeno-cloud"); + assert.equal(getAiProviderPreset("openai-compatible", "https://api.example.com/v1").label, "OpenAI Compatible"); +}); + test("API AI provider settings expose and persist a default model ID", () => { const source = readFileSync("apps/desktop/src/components/editor/EditorSettingsDialog.vue", "utf8"); const modelControl = source.indexOf('