Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
101 changes: 100 additions & 1 deletion crates/buzz-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => (
Expand Down Expand Up @@ -1058,6 +1063,32 @@ fn parse_openai_api(raw: Option<&str>) -> Result<OpenAiApi, String> {
}
}

/// 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.
Expand Down Expand Up @@ -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"));
Expand Down
68 changes: 37 additions & 31 deletions desktop/src/features/agents/ui/AgentConfigFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: {},
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -743,32 +774,7 @@ export function AgentConfigFields({

const dependentContent = (
<>
{providerFieldVisible && apiKeyEnvVar ? (
<div className={blockClassName}>
<PersonaProviderApiKeyField
disabled={false}
inheritedLabel={
apiKeyFileSatisfied
? "Set in runtime config"
: "Provided by this build"
}
isInherited={apiKeyInherited}
isRequired={!apiKeyInherited && apiKeyValue.length === 0}
label={
effectiveProvider === "anthropic"
? "Anthropic API Key"
: "OpenAI API Key"
}
onValueChange={(value) =>
onConfigChange({
...config,
env_vars: { ...config.env_vars, [apiKeyEnvVar]: value },
})
}
value={apiKeyValue}
/>
</div>
) : null}
{providerFieldVisible ? structuredEnv.fields : null}

{/* Model field — omitted only after confirmed successful empty discovery */}
{modelControlVisible ? (
Expand Down Expand Up @@ -909,7 +915,7 @@ export function AgentConfigFields({
>
<EnvVarsEditor
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
hiddenKeys={structuredEnv.hiddenEnvKeys}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
label="Environment variables"
Expand All @@ -927,7 +933,7 @@ export function AgentConfigFields({
) : advancedOpen ? (
<EnvVarsEditor
fileSatisfiedKeys={advancedFileSatisfiedEnvKeys}
hiddenKeys={apiKeyEnvVar ? [apiKeyEnvVar] : []}
hiddenKeys={structuredEnv.hiddenEnvKeys}
inheritedRows={bakedGenericRows}
inheritedRowsLabel="build"
label="Environment variables"
Expand Down
56 changes: 22 additions & 34 deletions desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import type { EnvVarsValue } from "./EnvVarsEditor";
import { PersonaAdvancedFields } from "./PersonaAdvancedFields";
import { PersonaModelField } from "./PersonaModelField";
import { runtimeAvailabilityWarning } from "./runtimeAvailabilityWarning";
import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField";
import {
canSubmitPersonaDialog,
formatPersonaNamePoolText,
Expand Down Expand Up @@ -81,6 +80,7 @@ import {
initialAgentAiConfigurationMode,
} from "./agentAiConfigurationPolicy";
import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState";
import { usePersonaProviderStructuredEnv } from "./PersonaProviderStructuredEnvFields";
import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload";
import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter";
import { AddCustomHarnessDialog } from "./AddCustomHarnessDialog";
Expand Down Expand Up @@ -470,14 +470,22 @@ export function AgentDefinitionDialog({
provider: effectiveProvider,
requiredEnvKeys,
});
const {
advancedRequiredEnvKeys,
inheritedLabel: apiKeyInheritedLabel,
isInherited: apiKeyIsInherited,
isRequired: apiKeyIsRequired,
secretEnvVar: topLevelSecretEnvVar,
value: apiKeyValue,
} = apiKeyFieldState;
const { advancedRequiredEnvKeys } = apiKeyFieldState;
const structuredEnv = usePersonaProviderStructuredEnv({
apiKey: apiKeyFieldState,
apiKeyLabel:
effectiveProvider === "anthropic"
? "Anthropic API key"
: "OpenAI API key",
bakedEnvKeys,
disabled: isPending,
effectiveEnvVars: envVars,
envVars,
fileSatisfiedEnvKeys: localModeGate.fileSatisfiedEnvKeys,
globalEnvVars: globalConfig.env_vars,
onEnvVarsChange: setEnvVars,
provider: effectiveProvider,
});
const providerIsRequired =
aiConfigurationMode === "custom" && runtimeCanChooseLlmProvider;
const modelFieldVisible =
Expand Down Expand Up @@ -511,6 +519,7 @@ export function AgentDefinitionDialog({
// missingEnvKeys — credential env keys now block submit, not just display.
localModeSatisfied &&
customAiPairSatisfied &&
structuredEnv.isValid &&
!isAvatarUploadPending;

// Merge global env as the base layer so credential keys satisfied via global
Expand Down Expand Up @@ -902,28 +911,9 @@ export function AgentDefinitionDialog({
</div>
) : null}

{llmProviderFieldVisible &&
aiConfigurationMode === "custom" &&
topLevelSecretEnvVar ? (
<PersonaProviderApiKeyField
disabled={isPending}
isInherited={apiKeyIsInherited}
inheritedLabel={apiKeyInheritedLabel}
isRequired={apiKeyIsRequired}
label={
effectiveProvider === "anthropic"
? "Anthropic API key"
: "OpenAI API key"
}
onValueChange={(next) => {
setEnvVars((prev) => ({
...prev,
[topLevelSecretEnvVar]: next,
}));
}}
value={apiKeyValue}
/>
) : null}
{llmProviderFieldVisible && aiConfigurationMode === "custom"
? structuredEnv.fields
: null}

<AnimatePresence initial={false}>
{modelFieldVisible && aiConfigurationMode === "custom" ? (
Expand Down Expand Up @@ -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}
Expand Down
Loading