diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index f138e4a4f1..4baf995600 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -142,7 +142,8 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `ANTHROPIC_API_VERSION` | `2023-06-01` | | | `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. | | `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. | -| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, Ollama, etc. | +| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Canonical OpenAI-compatible endpoint. Point at vLLM, llama.cpp, Ollama, or another compatible gateway. | +| `OPENAI_BASE_URL` | — | Generic OpenAI SDK fallback when `OPENAI_COMPAT_BASE_URL` is blank or unset. The canonical variable wins when both are set. | | `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. | | `OPENROUTER_API_KEY` | — | Required when provider=openrouter. | | `OPENROUTER_MODEL` | — | Required when provider=openrouter. Use OpenRouter's `vendor/model` id, e.g. `anthropic/claude-sonnet-4.5`. | diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index a0e64f1a9d..709021e181 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -791,7 +791,12 @@ impl Config { env("OPENAI_COMPAT_MODEL").as_deref(), ) .ok_or_else(|| "config: OPENAI_COMPAT_MODEL required".to_string())?, - env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), + // Desktop keeps OPENAI_COMPAT_BASE_URL canonical; fall back to + // the generic OpenAI SDK OPENAI_BASE_URL for CLI/native agents. + resolve_openai_compat_base_url( + env("OPENAI_COMPAT_BASE_URL").as_deref(), + env("OPENAI_BASE_URL").as_deref(), + ), parse_openai_api(env("OPENAI_COMPAT_API").as_deref())?, ), Provider::Databricks | Provider::DatabricksV2 => ( @@ -1058,6 +1063,32 @@ fn parse_openai_api(raw: Option<&str>) -> Result { } } +/// Default OpenAI-compatible base URL used when neither +/// `OPENAI_COMPAT_BASE_URL` nor `OPENAI_BASE_URL` is set to a non-blank value. +/// Kept as `…/v1` so path joining (`{base}/chat/completions`, `{base}/models`) +/// and `is_openai_host` Auto→Responses routing stay correct. +pub const DEFAULT_OPENAI_COMPAT_BASE_URL: &str = "https://api.openai.com/v1"; + +/// Resolve the OpenAI-compatible HTTP base URL. +/// +/// Precedence (first non-blank after trim wins): +/// 1. `OPENAI_COMPAT_BASE_URL` — canonical for the Buzz desktop / agent surface +/// 2. `OPENAI_BASE_URL` — generic OpenAI SDK env var for CLI/native agents +/// 3. [`DEFAULT_OPENAI_COMPAT_BASE_URL`] +/// +/// Pure (env-free) so unit tests do not mutate process-global environment. +fn resolve_openai_compat_base_url( + openai_compat_base_url: Option<&str>, + openai_base_url: Option<&str>, +) -> String { + for candidate in [openai_compat_base_url, openai_base_url] { + if let Some(trimmed) = candidate.map(str::trim).filter(|s| !s.is_empty()) { + return trimmed.to_owned(); + } + } + DEFAULT_OPENAI_COMPAT_BASE_URL.to_owned() +} + /// `true` when `base_url` is an official OpenAI host. Hosts on /// `*.openai.com` get Responses under `Auto`; everything else (vLLM, /// Ollama, OpenRouter, Block Gateway, …) gets Chat Completions. @@ -1317,6 +1348,74 @@ mod tests { } } + #[test] + fn openai_compat_base_url_prefers_native_over_generic() { + // Desktop/canonical OPENAI_COMPAT_BASE_URL wins over the generic + // OpenAI SDK variable OPENAI_BASE_URL when both are non-blank. + assert_eq!( + resolve_openai_compat_base_url( + Some("https://native.example/v1"), + Some("https://generic.example/v1"), + ), + "https://native.example/v1" + ); + } + + #[test] + fn openai_compat_base_url_falls_back_to_generic() { + assert_eq!( + resolve_openai_compat_base_url(None, Some("https://generic.example/v1")), + "https://generic.example/v1" + ); + assert_eq!( + resolve_openai_compat_base_url(Some(""), Some("https://generic.example/v1")), + "https://generic.example/v1" + ); + } + + #[test] + fn openai_compat_base_url_blank_values_fall_through() { + // Empty and whitespace-only values are treated as unset so a blank + // native override cannot block the generic fallback or the default. + assert_eq!( + resolve_openai_compat_base_url(Some(" "), Some("https://generic.example/v1")), + "https://generic.example/v1" + ); + assert_eq!( + resolve_openai_compat_base_url(Some("https://native.example/v1"), Some(" ")), + "https://native.example/v1" + ); + assert_eq!( + resolve_openai_compat_base_url(Some(""), Some(" \t ")), + DEFAULT_OPENAI_COMPAT_BASE_URL + ); + assert_eq!( + resolve_openai_compat_base_url(Some(" https://native.example/v1 "), None), + "https://native.example/v1" + ); + } + + #[test] + fn openai_compat_base_url_defaults_to_openai() { + assert_eq!( + resolve_openai_compat_base_url(None, None), + DEFAULT_OPENAI_COMPAT_BASE_URL + ); + assert_eq!( + resolve_openai_compat_base_url(None, Some("")), + DEFAULT_OPENAI_COMPAT_BASE_URL + ); + } + + #[test] + fn openai_compat_base_url_default_remains_openai_host_for_auto_routing() { + // Path joining and Auto→Responses routing depend on the default + // resolving to an official OpenAI host (`…/v1` base). + let default = resolve_openai_compat_base_url(None, None); + assert_eq!(default, "https://api.openai.com/v1"); + assert!(is_openai_host(&default)); + } + #[test] fn resolve_model_prefers_explicit_override() { let result = resolve_model(Some("override-model"), Some("provider-model")); diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af8976..4bd8f66aee 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -42,7 +42,6 @@ import { AgentDropdownSelect, AgentModelField, } from "@/features/agents/ui/agentConfigControls"; -import { PersonaProviderApiKeyField } from "@/features/agents/ui/PersonaProviderApiKeyField"; import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery"; import { BUZZ_AGENT_THINKING_EFFORT, @@ -55,6 +54,7 @@ import { import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { getGlobalAgentCredentialState } from "./globalAgentCredentialState"; +import { usePersonaProviderStructuredEnv } from "./PersonaProviderStructuredEnvFields"; export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { env_vars: {}, @@ -351,8 +351,40 @@ export function AgentConfigFields({ runtimeFileConfig, runtimeId: credentialRuntimeId, }); + const blockClassName = unstyled ? "" : "p-3"; + const structuredEnv = usePersonaProviderStructuredEnv({ + apiKey: apiKeyEnvVar + ? { + advancedRequiredEnvKeys, + inheritedLabel: apiKeyFileSatisfied + ? "Set in runtime config" + : "Provided by this build", + isInherited: apiKeyInherited, + isRequired: !apiKeyInherited && apiKeyValue.length === 0, + secretEnvVar: apiKeyEnvVar, + value: apiKeyValue, + } + : null, + apiKeyLabel: + effectiveProvider === "anthropic" + ? "Anthropic API Key" + : "OpenAI API Key", + bakedEnvKeys, + disabled: false, + effectiveEnvVars: config.env_vars, + envVars: config.env_vars, + fileSatisfiedEnvKeys: runtimeFileConfig?.satisfiedEnvKeys ?? [], + globalEnvVars: {}, + onEnvVarsChange: (envVars) => + onConfigChange({ ...config, env_vars: envVars }), + provider: credentialProvider, + wrapperClassName: blockClassName, + }); const configIsValid = - selectedRuntimeId.length > 0 && modelIsValid && credentialsValid; + selectedRuntimeId.length > 0 && + modelIsValid && + credentialsValid && + structuredEnv.isValid; React.useEffect(() => { onValidityChange?.(configIsValid); }, [configIsValid, onValidityChange]); @@ -646,7 +678,6 @@ export function AgentConfigFields({ ? "space-y-1.5" : "space-y-4" : "space-y-1.5 p-3"; - const blockClassName = unstyled ? "" : "p-3"; const fieldLabelClassName = unstyled && !progressiveDefaults ? "pl-3" : undefined; const providerDropdownOptions = [ @@ -743,32 +774,7 @@ export function AgentConfigFields({ const dependentContent = ( <> - {providerFieldVisible && apiKeyEnvVar ? ( -
- - onConfigChange({ - ...config, - env_vars: { ...config.env_vars, [apiKeyEnvVar]: value }, - }) - } - value={apiKeyValue} - /> -
- ) : null} + {providerFieldVisible ? structuredEnv.fields : null} {/* Model field — omitted only after confirmed successful empty discovery */} {modelControlVisible ? ( @@ -909,7 +915,7 @@ export function AgentConfigFields({ > ) : null} - {llmProviderFieldVisible && - aiConfigurationMode === "custom" && - topLevelSecretEnvVar ? ( - { - setEnvVars((prev) => ({ - ...prev, - [topLevelSecretEnvVar]: next, - })); - }} - value={apiKeyValue} - /> - ) : null} + {llmProviderFieldVisible && aiConfigurationMode === "custom" + ? structuredEnv.fields + : null} {modelFieldVisible && aiConfigurationMode === "custom" ? ( @@ -1015,9 +1005,7 @@ export function AgentDefinitionDialog({ disabled={isPending} envVars={envVars} fileSatisfiedEnvKeys={localModeGate.fileSatisfiedEnvKeys} - hiddenEnvKeys={ - topLevelSecretEnvVar ? [topLevelSecretEnvVar] : [] - } + hiddenEnvKeys={structuredEnv.hiddenEnvKeys} inheritedEnvVars={inheritedEnvVarsForAdvanced} model={model} modelTuningRuntimeId={runtime} diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 79d1e9a790..3461de60df 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -70,7 +70,6 @@ import { MODEL_DISCOVERY_LOADING_VALUE, usePersonaModelDiscovery, } from "./usePersonaModelDiscovery"; -import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField"; import { getBakedModelInheritLabel, getBakedProviderInheritLabel, @@ -80,6 +79,10 @@ import { useAgentDialogDefaults } from "./useAgentDialogDefaults"; import { AgentAiDefaultsNotice } from "./AgentAiDefaults"; import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; +import { + isOpenAiCompatBaseUrlOwned, + usePersonaProviderStructuredEnv, +} from "./PersonaProviderStructuredEnvFields"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; import { showAgentProfileSyncWarning } from "./agentProfileSyncWarning"; @@ -443,14 +446,27 @@ export function AgentInstanceEditDialog({ provider: effectiveProvider, requiredEnvKeys, }); - const { - advancedRequiredEnvKeys, - inheritedLabel: apiKeyInheritedLabel, - isInherited: apiKeyIsInherited, - isRequired: apiKeyIsRequired, - secretEnvVar: topLevelSecretEnvVar, - value: apiKeyValue, - } = apiKeyFieldState; + const { advancedRequiredEnvKeys } = apiKeyFieldState; + const baseUrlPersonaSatisfied = isOpenAiCompatBaseUrlOwned( + envVars, + inheritedEnvVars, + ); + const structuredEnv = usePersonaProviderStructuredEnv({ + apiKey: apiKeyFieldState, + apiKeyLabel: + effectiveProvider === "anthropic" + ? "Anthropic API Key" + : "OpenAI API Key", + bakedEnvKeys, + disabled: updateMutation.isPending, + effectiveEnvVars: inheritedSubmission.envVars, + envVars, + fileSatisfiedEnvKeys, + globalEnvVars: globalConfig.env_vars, + onEnvVarsChange: setEnvVars, + personaSatisfied: baseUrlPersonaSatisfied, + provider: effectiveProvider, + }); // Clear model when provider scope changes and current model is no longer valid. React.useEffect(() => { if ( @@ -611,6 +627,7 @@ export function AgentInstanceEditDialog({ requiredEnvKeyMissing, }) && providerValid && + structuredEnv.isValid && !updateMutation.isPending && !isAvatarUploadPending; @@ -1058,26 +1075,7 @@ export function AgentInstanceEditDialog({ ) : null} - {llmProviderFieldVisible && topLevelSecretEnvVar ? ( - { - setEnvVars((prev) => ({ - ...prev, - [topLevelSecretEnvVar]: next, - })); - }} - value={apiKeyValue} - /> - ) : null} + {llmProviderFieldVisible ? structuredEnv.fields : null} {/* Model */}
@@ -1184,9 +1182,7 @@ export function AgentInstanceEditDialog({ disabled={updateMutation.isPending} envVars={envVars} fileSatisfiedEnvKeys={fileSatisfiedEnvKeys} - hiddenEnvKeys={ - topLevelSecretEnvVar ? [topLevelSecretEnvVar] : [] - } + hiddenEnvKeys={structuredEnv.hiddenEnvKeys} focusKey={ initialFocus?.type === "env_key" ? initialFocus.key diff --git a/desktop/src/features/agents/ui/PersonaProviderBaseUrlField.tsx b/desktop/src/features/agents/ui/PersonaProviderBaseUrlField.tsx new file mode 100644 index 0000000000..da2a377b30 --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaProviderBaseUrlField.tsx @@ -0,0 +1,88 @@ +import { cn } from "@/shared/lib/cn"; +import { Input } from "@/shared/ui/input"; +import { RequiredFieldLabel } from "./agentConfigControls"; +import { + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + PERSONA_LABEL_OPTIONAL_CLASS, +} from "./agentConfigOptions"; + +/** + * Top-level OpenAI-compatible base URL field. + * + * Pure view over `envVars[OPENAI_COMPAT_BASE_URL]` — writes go through + * `onValueChange` into the same env_vars map used by Advanced. Blank is valid + * (native default); non-empty invalid values surface an inline error. + */ +export function PersonaProviderBaseUrlField({ + disabled, + isInherited, + inheritedLabel, + isInvalid, + label = "OpenAI-compatible base URL", + onValueChange, + value, +}: { + disabled: boolean; + /** True when the URL is satisfied by an inherited layer. */ + isInherited: boolean; + /** Human-readable source of the inherited value. */ + inheritedLabel: string; + /** True when a non-empty local value fails URL validation. */ + isInvalid: boolean; + /** Display label. */ + label?: string; + onValueChange: (next: string) => void; + /** Current agent-local value of the base URL env var. */ + value: string; +}) { + const inputId = "persona-provider-base-url"; + const errorId = `${inputId}-error`; + + return ( +
+ + {label} + Optional + +
+ onValueChange(event.target.value)} + placeholder={ + isInherited ? inheritedLabel : "https://api.openai.com/v1" + } + spellCheck={false} + type="url" + value={value} + /> +
+ {isInvalid ? ( + + ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/PersonaProviderStructuredEnvFields.tsx b/desktop/src/features/agents/ui/PersonaProviderStructuredEnvFields.tsx new file mode 100644 index 0000000000..981ef5f753 --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaProviderStructuredEnvFields.tsx @@ -0,0 +1,131 @@ +import type * as React from "react"; + +import type { EnvVarsValue } from "./EnvVarsEditor"; +import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField"; +import { PersonaProviderBaseUrlField } from "./PersonaProviderBaseUrlField"; +import type { ProviderApiKeyFieldState } from "./providerApiKeyFieldState"; +import { + OPENAI_COMPAT_BASE_URL_ENV_KEY, + useProviderBaseUrlFieldState, + type ProviderBaseUrlFieldState, +} from "./providerBaseUrlFieldState"; + +export function advancedHiddenEnvKeys( + secretEnvVar: string | null | undefined, + baseUrlEnvKey: string | null | undefined, +): string[] { + return [ + ...(secretEnvVar ? [secretEnvVar] : []), + ...(baseUrlEnvKey ? [baseUrlEnvKey] : []), + ]; +} + +export function withBaseUrlEnvVar( + envVars: EnvVarsValue, + next: string, +): EnvVarsValue { + return { ...envVars, [OPENAI_COMPAT_BASE_URL_ENV_KEY]: next }; +} + +export function isOpenAiCompatBaseUrlOwned( + envVars: EnvVarsValue, + inheritedEnvVars: EnvVarsValue, +): boolean { + return ( + !(OPENAI_COMPAT_BASE_URL_ENV_KEY in envVars) && + (inheritedEnvVars[OPENAI_COMPAT_BASE_URL_ENV_KEY] ?? "").length > 0 + ); +} + +/** Compact structured API-key + base-URL fields for provider config surfaces. */ +export function usePersonaProviderStructuredEnv({ + apiKey, + apiKeyLabel, + bakedEnvKeys, + disabled, + effectiveEnvVars, + envVars, + fileSatisfiedEnvKeys, + globalEnvVars, + onEnvVarsChange, + personaSatisfied, + provider, + wrapperClassName, +}: { + apiKey: ProviderApiKeyFieldState | null; + apiKeyLabel: string; + bakedEnvKeys: readonly string[] | undefined; + disabled: boolean; + effectiveEnvVars: EnvVarsValue; + envVars: EnvVarsValue; + fileSatisfiedEnvKeys?: readonly string[]; + globalEnvVars: EnvVarsValue; + onEnvVarsChange: (next: EnvVarsValue) => void; + personaSatisfied?: boolean; + provider: string; + wrapperClassName?: string; +}): { + fields: React.ReactNode; + hiddenEnvKeys: string[]; + isValid: boolean; + baseUrl: ProviderBaseUrlFieldState; +} { + const baseUrl = useProviderBaseUrlFieldState({ + bakedEnvKeys, + effectiveEnvVars, + envVars, + fileSatisfiedEnvKeys, + globalEnvVars, + personaSatisfied, + provider, + }); + const secretEnvVar = apiKey?.secretEnvVar; + const showApiKey = apiKey != null && secretEnvVar != null; + const wrap = (node: React.ReactNode) => + wrapperClassName ?
{node}
: node; + + const fields = + showApiKey || baseUrl.visible ? ( + <> + {showApiKey && apiKey + ? wrap( + { + if (!secretEnvVar) return; + onEnvVarsChange({ ...envVars, [secretEnvVar]: next }); + }} + value={apiKey.value} + />, + ) + : null} + {baseUrl.visible + ? wrap( + { + onEnvVarsChange(withBaseUrlEnvVar(envVars, next)); + }} + value={baseUrl.value} + />, + ) + : null} + + ) : null; + + return { + baseUrl, + fields, + hiddenEnvKeys: advancedHiddenEnvKeys(secretEnvVar, baseUrl.envKey), + isValid: baseUrl.isValid, + }; +} + +export { OPENAI_COMPAT_BASE_URL_ENV_KEY }; diff --git a/desktop/src/features/agents/ui/providerBaseUrlFieldState.test.mjs b/desktop/src/features/agents/ui/providerBaseUrlFieldState.test.mjs new file mode 100644 index 0000000000..9e1b3d2041 --- /dev/null +++ b/desktop/src/features/agents/ui/providerBaseUrlFieldState.test.mjs @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getProviderBaseUrlFieldState, + isValidProviderBaseUrl, + normalizeProviderBaseUrl, + OPENAI_COMPAT_BASE_URL_ENV_KEY, + providerOwnsBaseUrlField, +} from "./providerBaseUrlFieldState.ts"; + +test("OPENAI_COMPAT_BASE_URL_ENV_KEY is the canonical env key", () => { + assert.equal(OPENAI_COMPAT_BASE_URL_ENV_KEY, "OPENAI_COMPAT_BASE_URL"); +}); + +test("isValidProviderBaseUrl_blank_isValidForNativeDefault", () => { + assert.equal(isValidProviderBaseUrl(""), true); + assert.equal(isValidProviderBaseUrl(" "), true); +}); + +test("isValidProviderBaseUrl_httpsWithPath_isAccepted", () => { + assert.equal(isValidProviderBaseUrl("https://api.openai.com/v1"), true); +}); + +test("isValidProviderBaseUrl_httpLocalhost_isAccepted", () => { + assert.equal(isValidProviderBaseUrl("http://127.0.0.1:9337/v1"), true); + assert.equal(isValidProviderBaseUrl("http://localhost:8080/v1/"), true); +}); + +test("isValidProviderBaseUrl_malformedAndNonHttp_areRejected", () => { + assert.equal(isValidProviderBaseUrl("not-a-url"), false); + assert.equal(isValidProviderBaseUrl("ftp://example.com/v1"), false); + assert.equal(isValidProviderBaseUrl("https://"), false); + assert.equal(isValidProviderBaseUrl("://missing-scheme"), false); +}); + +test("normalizeProviderBaseUrl_trimsSurroundingWhitespace", () => { + assert.equal( + normalizeProviderBaseUrl(" https://api.example.com/v1 "), + "https://api.example.com/v1", + ); + assert.equal(normalizeProviderBaseUrl(" "), ""); +}); + +test("providerOwnsBaseUrlField_onlyOpenaiCompat", () => { + assert.equal(providerOwnsBaseUrlField("openai-compat"), true); + assert.equal(providerOwnsBaseUrlField("OpenAI-Compat"), true); + assert.equal(providerOwnsBaseUrlField("openai"), false); + assert.equal(providerOwnsBaseUrlField("anthropic"), false); + assert.equal(providerOwnsBaseUrlField("openrouter"), false); + assert.equal(providerOwnsBaseUrlField(""), false); +}); + +test("providerBaseUrlFieldState_openaiCompat_absentLocal_isValidAndNotInherited", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: {}, + envVars: {}, + globalEnvVars: {}, + provider: "openai-compat", + }); + + assert.equal(state.envKey, OPENAI_COMPAT_BASE_URL_ENV_KEY); + assert.equal(state.visible, true); + assert.equal(state.value, ""); + assert.equal(state.isInherited, false); + assert.equal(state.inheritedLabel, ""); + assert.equal(state.isValid, true); + assert.equal(state.isInvalid, false); +}); + +test("providerBaseUrlFieldState_openaiCompat_validLocalUrl_wins", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: { + OPENAI_COMPAT_BASE_URL: "https://global.example/v1", + }, + envVars: { + OPENAI_COMPAT_BASE_URL: "https://local.example/v1", + }, + globalEnvVars: { + OPENAI_COMPAT_BASE_URL: "https://global.example/v1", + }, + provider: "openai-compat", + }); + + assert.equal(state.value, "https://local.example/v1"); + assert.equal(state.isInherited, false); + assert.equal(state.isValid, true); + assert.equal(state.isInvalid, false); +}); + +test("providerBaseUrlFieldState_openaiCompat_malformedLocal_isInvalid", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: {}, + envVars: { OPENAI_COMPAT_BASE_URL: "not-a-url" }, + globalEnvVars: {}, + provider: "openai-compat", + }); + + assert.equal(state.isValid, false); + assert.equal(state.isInvalid, true); + assert.equal(state.value, "not-a-url"); +}); + +test("providerBaseUrlFieldState_openaiCompat_globalInherited_showsLabelWithoutLocalCopy", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: {}, + envVars: {}, + globalEnvVars: { + OPENAI_COMPAT_BASE_URL: "https://global.example/v1", + }, + provider: "openai-compat", + }); + + assert.equal(state.value, ""); + assert.equal(state.isInherited, true); + assert.equal(state.inheritedLabel, "Inherited from global config"); + assert.equal(state.isValid, true); +}); + +test("providerBaseUrlFieldState_openaiCompat_personaInherited", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: { + OPENAI_COMPAT_BASE_URL: "https://persona.example/v1", + }, + envVars: {}, + globalEnvVars: {}, + personaSatisfied: true, + provider: "openai-compat", + }); + + assert.equal(state.isInherited, true); + assert.equal(state.inheritedLabel, "Inherited from agent profile"); + assert.equal(state.value, ""); + assert.equal(state.isValid, true); +}); + +test("providerBaseUrlFieldState_openaiCompat_localEmptyShadowsInherited", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: ["OPENAI_COMPAT_BASE_URL"], + effectiveEnvVars: { OPENAI_COMPAT_BASE_URL: "" }, + envVars: { OPENAI_COMPAT_BASE_URL: "" }, + globalEnvVars: { + OPENAI_COMPAT_BASE_URL: "https://global.example/v1", + }, + provider: "openai-compat", + }); + + assert.equal(state.value, ""); + assert.equal(state.isInherited, false); + assert.equal(state.inheritedLabel, ""); + assert.equal(state.isValid, true); +}); + +test("providerBaseUrlFieldState_openaiCompat_fileInherited", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: {}, + envVars: {}, + fileSatisfiedEnvKeys: ["OPENAI_COMPAT_BASE_URL"], + globalEnvVars: {}, + provider: "openai-compat", + }); + + assert.equal(state.isInherited, true); + assert.equal(state.inheritedLabel, "Set in runtime config"); + assert.equal(state.isValid, true); +}); + +test("providerBaseUrlFieldState_openaiCompat_buildInherited", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: ["OPENAI_COMPAT_BASE_URL"], + effectiveEnvVars: {}, + envVars: {}, + globalEnvVars: {}, + provider: "openai-compat", + }); + + assert.equal(state.isInherited, true); + assert.equal(state.inheritedLabel, "Inherited from build"); + assert.equal(state.isValid, true); +}); + +test("providerBaseUrlFieldState_openai_doesNotOwnStructuredField", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: {}, + envVars: { + OPENAI_COMPAT_BASE_URL: "https://should-not-surface.example/v1", + }, + globalEnvVars: {}, + provider: "openai", + }); + + assert.equal(state.envKey, null); + assert.equal(state.visible, false); + assert.equal(state.isValid, true); + assert.equal(state.isInvalid, false); +}); + +test("providerBaseUrlFieldState_whitespaceOnlyLocal_isValidAfterNormalize", () => { + const state = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: {}, + envVars: { OPENAI_COMPAT_BASE_URL: " " }, + globalEnvVars: {}, + provider: "openai-compat", + }); + + // Local key is present but blank after trim → valid native default. + assert.equal(state.isValid, true); + assert.equal(state.isInvalid, false); + assert.equal(state.isInherited, false); +}); + +test("providerBaseUrlFieldState_advancedHideKeys_onlyWhenStructuredFieldOwns", () => { + const owned = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: {}, + envVars: {}, + globalEnvVars: {}, + provider: "openai-compat", + }); + const notOwned = getProviderBaseUrlFieldState({ + bakedEnvKeys: [], + effectiveEnvVars: {}, + envVars: { OPENAI_COMPAT_BASE_URL: "https://raw.example/v1" }, + globalEnvVars: {}, + provider: "anthropic", + }); + + // Advanced should hide the key only while the structured field owns it. + assert.deepEqual( + [ + ...(owned.envKey ? [owned.envKey] : []), + ...(notOwned.envKey ? [notOwned.envKey] : []), + ], + [OPENAI_COMPAT_BASE_URL_ENV_KEY], + ); + assert.equal(notOwned.envKey, null); +}); diff --git a/desktop/src/features/agents/ui/providerBaseUrlFieldState.ts b/desktop/src/features/agents/ui/providerBaseUrlFieldState.ts new file mode 100644 index 0000000000..9cd0c1f883 --- /dev/null +++ b/desktop/src/features/agents/ui/providerBaseUrlFieldState.ts @@ -0,0 +1,178 @@ +import * as React from "react"; + +import type { EnvVarsValue } from "./EnvVarsEditor"; +import { + getBakedSatisfiedEnvKeys, + isGloballySatisfiedCredentialKey, +} from "./agentConfigOptions"; + +/** Canonical env key persisted through existing env_vars maps. */ +export const OPENAI_COMPAT_BASE_URL_ENV_KEY = "OPENAI_COMPAT_BASE_URL"; + +/** + * OpenAI-compatible custom endpoints own a structured base-URL field. + * Plain `openai` keeps the native default (or Advanced raw env) and does not + * surface the field — matching product scope for custom gateways only. + */ +export function providerOwnsBaseUrlField(provider: string): boolean { + return provider.trim().toLowerCase() === "openai-compat"; +} + +/** Trim surrounding whitespace before validation/persistence. */ +export function normalizeProviderBaseUrl(value: string): string { + return value.trim(); +} + +/** + * Blank is valid (native default / backward compatibility). + * Non-empty values must be http(s) URLs with a hostname. + */ +export function isValidProviderBaseUrl(value: string): boolean { + const trimmed = normalizeProviderBaseUrl(value); + if (trimmed.length === 0) return true; + try { + const parsed = new URL(trimmed); + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + parsed.hostname.length > 0 + ); + } catch { + return false; + } +} + +export type ProviderBaseUrlFieldState = { + /** Env key owned by the structured field, or null when not shown. */ + envKey: string | null; + inheritedLabel: string; + isInherited: boolean; + /** True when a non-empty local value fails URL validation. */ + isInvalid: boolean; + /** Inverse of isInvalid — form gates use this. */ + isValid: boolean; + /** Local (agent/global config) value; never copies inherited secrets/values. */ + value: string; + /** Whether the structured field should render for this provider. */ + visible: boolean; +}; + +/** + * Derive structured base-URL field state using the same env layering as the + * API-key field. Local presence of the key (including explicit empty string) + * shadows inherited layers. Blank remains valid for backward compatibility. + */ +export function getProviderBaseUrlFieldState({ + bakedEnvKeys, + effectiveEnvVars, + envVars, + fileSatisfiedEnvKeys = [], + globalEnvVars, + personaSatisfied = false, + provider, +}: { + bakedEnvKeys: readonly string[] | undefined; + effectiveEnvVars: EnvVarsValue; + envVars: EnvVarsValue; + fileSatisfiedEnvKeys?: readonly string[]; + globalEnvVars: EnvVarsValue; + personaSatisfied?: boolean; + provider: string; +}): ProviderBaseUrlFieldState { + if (!providerOwnsBaseUrlField(provider)) { + return { + envKey: null, + inheritedLabel: "", + isInherited: false, + isInvalid: false, + isValid: true, + value: "", + visible: false, + }; + } + + const envKey = OPENAI_COMPAT_BASE_URL_ENV_KEY; + const value = envVars[envKey] ?? ""; + const localOverride = envKey in envVars; + const source = + normalizeProviderBaseUrl(value).length > 0 + ? null + : personaSatisfied && !localOverride + ? "persona" + : isGloballySatisfiedCredentialKey( + envKey, + globalEnvVars, + effectiveEnvVars, + ) + ? "global" + : getBakedSatisfiedEnvKeys([envKey], effectiveEnvVars, bakedEnvKeys) + .length > 0 + ? "build" + : !localOverride && + !(envKey in effectiveEnvVars) && + fileSatisfiedEnvKeys.includes(envKey) + ? "file" + : null; + + const inheritedLabel = + source === "persona" + ? "Inherited from agent profile" + : source === "global" + ? "Inherited from global config" + : source === "build" + ? "Inherited from build" + : source === "file" + ? "Set in runtime config" + : ""; + + const isValid = isValidProviderBaseUrl(value); + + return { + envKey, + inheritedLabel, + isInherited: source !== null, + isInvalid: !isValid, + isValid, + value, + visible: true, + }; +} + +export function useProviderBaseUrlFieldState({ + bakedEnvKeys, + effectiveEnvVars, + envVars, + fileSatisfiedEnvKeys, + globalEnvVars, + personaSatisfied, + provider, +}: { + bakedEnvKeys: readonly string[] | undefined; + effectiveEnvVars: EnvVarsValue; + envVars: EnvVarsValue; + fileSatisfiedEnvKeys?: readonly string[]; + globalEnvVars: EnvVarsValue; + personaSatisfied?: boolean; + provider: string; +}): ProviderBaseUrlFieldState { + return React.useMemo( + () => + getProviderBaseUrlFieldState({ + bakedEnvKeys, + effectiveEnvVars, + envVars, + fileSatisfiedEnvKeys, + globalEnvVars, + personaSatisfied, + provider, + }), + [ + bakedEnvKeys, + effectiveEnvVars, + envVars, + fileSatisfiedEnvKeys, + globalEnvVars, + personaSatisfied, + provider, + ], + ); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 07eaa77902..83a9c139b1 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8178,6 +8178,7 @@ async function handleUpdateManagedAgent(args: { pubkey: string; name?: string; model?: string | null; + provider?: string | null; systemPrompt?: string | null; envVars?: Record; respondTo?: "owner-only" | "allowlist" | "anyone"; @@ -8191,6 +8192,9 @@ async function handleUpdateManagedAgent(args: { if (args.input.model !== undefined) { agent.model = args.input.model; } + if (args.input.provider !== undefined) { + agent.provider = args.input.provider; + } if (args.input.systemPrompt !== undefined) { agent.system_prompt = args.input.systemPrompt; } diff --git a/desktop/tests/e2e/edit-agent.spec.ts b/desktop/tests/e2e/edit-agent.spec.ts index 95ef231556..e10a8617a2 100644 --- a/desktop/tests/e2e/edit-agent.spec.ts +++ b/desktop/tests/e2e/edit-agent.spec.ts @@ -18,8 +18,8 @@ const BAKED_DEFAULTS = [ // pre-existing spec rather than one written alongside it. // // Mock-boundary caveat: the e2eBridge `update_managed_agent` handler echoes -// name/model/systemPrompt/envVars/respondTo/respondToAllowlist into the -// mock store — it does NOT +// name/model/provider/systemPrompt/envVars/respondTo/respondToAllowlist into +// the mock store — it does NOT // model the diff-based partial-update wire semantics (change-detected-or-omit, // tri-state provider, harnessOverride derivation), and it ignores // agentCommand/harnessOverride entirely. This spec therefore pins UI behavior @@ -278,6 +278,73 @@ test.describe("edit agent dialog", () => { ).toBeVisible(); }); + test("openai-compatible base URL field persists and is hidden from Advanced", async ({ + page, + }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: AGENT_PUBKEY, + name: AGENT_NAME, + status: "stopped", + channelNames: ["agents"], + }, + ], + }); + + await openEditDialog(page); + + await pickDropdownOption( + page, + "edit-agent-llm-provider", + "OpenAI-compatible", + ); + const baseUrl = page.getByTestId("persona-provider-base-url"); + await expect(baseUrl).toBeVisible(); + await expect(page.getByTestId("persona-provider-api-key")).toBeVisible(); + + await page.getByTestId("persona-provider-api-key").fill("sk-test-compat"); + await baseUrl.fill("not-a-url"); + await expect( + page.getByTestId("persona-provider-base-url-error"), + ).toBeVisible(); + await expect(page.getByTestId("edit-agent-dialog-submit")).toBeDisabled(); + + await baseUrl.fill("http://127.0.0.1:9337/v1"); + await expect( + page.getByTestId("persona-provider-base-url-error"), + ).not.toBeVisible(); + await pickDropdownOption(page, "edit-agent-model", "Custom model..."); + await page.locator("#edit-agent-custom-model").fill("local-model"); + + const advanced = page.getByRole("button", { + name: "Advanced", + exact: true, + }); + await advanced.click(); + // Structured fields own the API key + base URL; Advanced must not mirror them. + await expect( + page.locator('input[value="OPENAI_COMPAT_BASE_URL"]'), + ).toHaveCount(0); + await expect( + page.locator('input[value="OPENAI_COMPAT_API_KEY"]'), + ).toHaveCount(0); + + const submit = page.getByTestId("edit-agent-dialog-submit"); + await expect(submit).toBeEnabled({ timeout: 10_000 }); + await submit.click(); + await expect(page.getByTestId("edit-agent-dialog")).not.toBeVisible(); + + await page.getByTestId("user-profile-edit-agent").click(); + await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByTestId("persona-provider-base-url")).toHaveValue( + "http://127.0.0.1:9337/v1", + { timeout: 10_000 }, + ); + }); + test("profile Edit routes persona-linked agents to the definition editor", async ({ page, }) => {