diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed3a3077a9..5ed90965df8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda. - Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan. - Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus. +- Models/openai-codex snapshot merge: synthesize the implicit `openai-codex` runtime provider from OAuth presence and replace stale agent `models.json` `baseUrl` values only when the provider API surface has changed, while preserving matching agent-local base URL overrides. (#39860) Thanks @xdanger. ## 2026.3.7 diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift index 3364cac36c6..2981a60bbf7 100644 --- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift +++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift @@ -22,7 +22,7 @@ enum HostEnvSecurityPolicy { "PS4", "GCONV_PATH", "IFS", - "SSLKEYLOGFILE", + "SSLKEYLOGFILE" ] static let blockedOverrideKeys: Set = [ @@ -50,17 +50,17 @@ enum HostEnvSecurityPolicy { "OPENSSL_ENGINES", "PYTHONSTARTUP", "WGETRC", - "CURL_HOME", + "CURL_HOME" ] static let blockedOverridePrefixes: [String] = [ "GIT_CONFIG_", - "NPM_CONFIG_", + "NPM_CONFIG_" ] static let blockedPrefixes: [String] = [ "DYLD_", "LD_", - "BASH_FUNC_", + "BASH_FUNC_" ] } diff --git a/src/agents/models-config.providers.openai-codex.test.ts b/src/agents/models-config.providers.openai-codex.test.ts index 98051eb05da..1a4e94094a4 100644 --- a/src/agents/models-config.providers.openai-codex.test.ts +++ b/src/agents/models-config.providers.openai-codex.test.ts @@ -106,6 +106,43 @@ describe("openai-codex implicit provider", () => { }); }); + it("preserves existing baseUrl when the api surface already matches", async () => { + await withModelsTempHome(async () => { + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + const agentDir = resolveOpenClawAgentDir(); + await writeCodexOauthProfile(agentDir); + await fs.writeFile( + path.join(agentDir, "models.json"), + JSON.stringify( + { + providers: { + "openai-codex": { + baseUrl: "https://proxy.example/codex", + api: "openai-codex-responses", + models: [], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + await ensureOpenClawModelsJson({}); + + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers["openai-codex"]).toMatchObject({ + baseUrl: "https://proxy.example/codex", + api: "openai-codex-responses", + }); + }); + }); + }); + it("preserves an existing baseUrl for explicit openai-codex config without oauth synthesis", async () => { await withModelsTempHome(async () => { await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index f891f5a65a4..31988a2a73b 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -22,7 +22,6 @@ type ModelsConfig = NonNullable; const DEFAULT_MODE: NonNullable = "merge"; const MODELS_JSON_WRITE_LOCKS = new Map>(); -const AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS = new Set(["openai-codex"]); function isPositiveFiniteTokenLimit(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; @@ -142,18 +141,10 @@ async function readJson(pathname: string): Promise { async function resolveProvidersForModelsJson(params: { cfg: OpenClawConfig; agentDir: string; -}): Promise<{ - providers: Record; - authoritativeImplicitBaseUrlProviders: ReadonlySet; -}> { +}): Promise> { const { cfg, agentDir } = params; const explicitProviders = cfg.models?.providers ?? {}; const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders }); - const authoritativeImplicitBaseUrlProviders = new Set( - [...AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS].filter((key) => - Boolean(implicitProviders?.[key]), - ), - ); const providers: Record = mergeProviders({ implicit: implicitProviders, explicit: explicitProviders, @@ -171,7 +162,33 @@ async function resolveProvidersForModelsJson(params: { if (implicitCopilot && !providers["github-copilot"]) { providers["github-copilot"] = implicitCopilot; } - return { providers, authoritativeImplicitBaseUrlProviders }; + return providers; +} + +function shouldPreserveExistingBaseUrl(params: { + key: string; + existing: NonNullable[string] & { baseUrl?: string; api?: string }; + nextProvider: ProviderConfig; + explicitBaseUrlProviders: ReadonlySet; +}): boolean { + if (params.explicitBaseUrlProviders.has(params.key)) { + return false; + } + if (typeof params.existing.baseUrl !== "string" || !params.existing.baseUrl) { + return false; + } + + const existingApi = + typeof params.existing.api === "string" ? params.existing.api.trim() : undefined; + const nextApi = typeof params.nextProvider.api === "string" ? params.nextProvider.api.trim() : ""; + + // Merge mode preserves existing baseUrl values for agent-local customization, + // but not when the resolved provider API surface has changed underneath them. + if (existingApi && nextApi && existingApi !== nextApi) { + return false; + } + + return true; } function mergeWithExistingProviderSecrets(params: { @@ -179,15 +196,9 @@ function mergeWithExistingProviderSecrets(params: { existingProviders: Record[string]>; secretRefManagedProviders: ReadonlySet; explicitBaseUrlProviders: ReadonlySet; - authoritativeImplicitBaseUrlProviders: ReadonlySet; }): Record { - const { - nextProviders, - existingProviders, - secretRefManagedProviders, - explicitBaseUrlProviders, - authoritativeImplicitBaseUrlProviders, - } = params; + const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } = + params; const mergedProviders: Record = {}; for (const [key, entry] of Object.entries(existingProviders)) { mergedProviders[key] = entry; @@ -213,10 +224,12 @@ function mergeWithExistingProviderSecrets(params: { preserved.apiKey = existing.apiKey; } if ( - !authoritativeImplicitBaseUrlProviders.has(key) && - !explicitBaseUrlProviders.has(key) && - typeof existing.baseUrl === "string" && - existing.baseUrl + shouldPreserveExistingBaseUrl({ + key, + existing, + nextProvider: newEntry, + explicitBaseUrlProviders, + }) ) { preserved.baseUrl = existing.baseUrl; } @@ -231,7 +244,6 @@ async function resolveProvidersForMode(params: { providers: Record; secretRefManagedProviders: ReadonlySet; explicitBaseUrlProviders: ReadonlySet; - authoritativeImplicitBaseUrlProviders: ReadonlySet; }): Promise> { if (params.mode !== "merge") { return params.providers; @@ -249,7 +261,6 @@ async function resolveProvidersForMode(params: { existingProviders, secretRefManagedProviders: params.secretRefManagedProviders, explicitBaseUrlProviders: params.explicitBaseUrlProviders, - authoritativeImplicitBaseUrlProviders: params.authoritativeImplicitBaseUrlProviders, }); } @@ -316,8 +327,7 @@ export async function ensureOpenClawModelsJson( // through the full loadConfig() pipeline which applies these. applyConfigEnvVars(cfg); - const { providers, authoritativeImplicitBaseUrlProviders } = - await resolveProvidersForModelsJson({ cfg, agentDir }); + const providers = await resolveProvidersForModelsJson({ cfg, agentDir }); if (Object.keys(providers).length === 0) { return { agentDir, wrote: false }; @@ -348,7 +358,6 @@ export async function ensureOpenClawModelsJson( providers: normalizedProviders, secretRefManagedProviders, explicitBaseUrlProviders, - authoritativeImplicitBaseUrlProviders, }); const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`; const existingRaw = await readRawFile(targetPath);