fix: clean up openai-codex implicit provider merge (#39860) (thanks @xdanger)

This commit is contained in:
Peter Steinberger
2026-03-08 13:48:02 +00:00
parent c7fddd95c3
commit 087fe1f72a
4 changed files with 79 additions and 32 deletions

View File

@@ -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

View File

@@ -22,7 +22,7 @@ enum HostEnvSecurityPolicy {
"PS4",
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
"SSLKEYLOGFILE"
]
static let blockedOverrideKeys: Set<String> = [
@@ -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_"
]
}

View File

@@ -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<string, { baseUrl?: string; api?: string }>;
}>();
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 () => {

View File

@@ -22,7 +22,6 @@ type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
const MODELS_JSON_WRITE_LOCKS = new Map<string, Promise<void>>();
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<unknown> {
async function resolveProvidersForModelsJson(params: {
cfg: OpenClawConfig;
agentDir: string;
}): Promise<{
providers: Record<string, ProviderConfig>;
authoritativeImplicitBaseUrlProviders: ReadonlySet<string>;
}> {
}): Promise<Record<string, ProviderConfig>> {
const { cfg, agentDir } = params;
const explicitProviders = cfg.models?.providers ?? {};
const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders });
const authoritativeImplicitBaseUrlProviders = new Set<string>(
[...AUTHORITATIVE_IMPLICIT_BASEURL_PROVIDERS].filter((key) =>
Boolean(implicitProviders?.[key]),
),
);
const providers: Record<string, ProviderConfig> = 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<ModelsConfig["providers"]>[string] & { baseUrl?: string; api?: string };
nextProvider: ProviderConfig;
explicitBaseUrlProviders: ReadonlySet<string>;
}): 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, NonNullable<ModelsConfig["providers"]>[string]>;
secretRefManagedProviders: ReadonlySet<string>;
explicitBaseUrlProviders: ReadonlySet<string>;
authoritativeImplicitBaseUrlProviders: ReadonlySet<string>;
}): Record<string, ProviderConfig> {
const {
nextProviders,
existingProviders,
secretRefManagedProviders,
explicitBaseUrlProviders,
authoritativeImplicitBaseUrlProviders,
} = params;
const { nextProviders, existingProviders, secretRefManagedProviders, explicitBaseUrlProviders } =
params;
const mergedProviders: Record<string, ProviderConfig> = {};
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<string, ProviderConfig>;
secretRefManagedProviders: ReadonlySet<string>;
explicitBaseUrlProviders: ReadonlySet<string>;
authoritativeImplicitBaseUrlProviders: ReadonlySet<string>;
}): Promise<Record<string, ProviderConfig>> {
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);