From 77fe36bb98ea85287d97db15fe0b6c5fbea41073 Mon Sep 17 00:00:00 2001 From: Paul Frederiksen Date: Wed, 27 May 2026 15:35:48 -0700 Subject: [PATCH] Improve stale Codex auth recovery guidance Fixes #83935. Summary: - clear stale legacy openai-codex auto route pins only when the canonical OpenAI provider is still using the Codex harness for the same model - preserve usable Codex auth profiles while clearing stale route state - keep explicit/custom OpenAI API route pins intact Verification: - git diff --check - pnpm exec oxfmt --check --threads=1 src/auto-reply/reply/model-selection.ts src/auto-reply/reply/model-selection.test.ts src/auto-reply/reply/agent-runner-execution.ts src/auto-reply/reply/agent-runner-execution.test.ts - fnm exec --using 24.15.0 node scripts/run-tsgo.mjs -p test/tsconfig/tsconfig.core.test.json --incremental --tsBuildInfoFile .artifacts/tsgo-cache/core-test.tsbuildinfo - .agents/skills/autoreview/scripts/autoreview --mode local - CI: https://github.com/openclaw/openclaw/actions/runs/26542490863 Co-authored-by: Paul Frederiksen --- .../reply/agent-runner-execution.test.ts | 16 ++ .../reply/agent-runner-execution.ts | 3 + src/auto-reply/reply/model-selection.test.ts | 158 ++++++++++++++++++ src/auto-reply/reply/model-selection.ts | 31 +++- 4 files changed, 203 insertions(+), 5 deletions(-) diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 9b5b54a8268..95ab0fa75ca 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -5270,6 +5270,22 @@ describe("runAgentTurnWithFallback", () => { } }); + it("points stale openai-codex missing-key failures at doctor repair with re-auth fallback", async () => { + state.runEmbeddedAgentMock.mockRejectedValueOnce( + new Error('No API key found for provider "openai-codex".'), + ); + + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toBe( + "⚠️ The session is pointing at a stale OpenAI Codex auth route. Run `openclaw doctor --fix` to repair Codex model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai-codex` or run `openclaw configure`.", + ); + } + }); + it("falls back to a generic provider message for unsafe missing-key provider ids", async () => { state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error('No API key found for provider "openai`\nrm -rf /".'), diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 04bd3635212..e841bb12c3a 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -716,6 +716,9 @@ function buildMissingApiKeyFailureText(message: string): string | null { if (provider === "openai" && normalizedMessage.includes("OpenAI Codex OAuth")) { return "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-5.5` with the Codex OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs."; } + if (provider === "openai-codex") { + return "⚠️ The session is pointing at a stale OpenAI Codex auth route. Run `openclaw doctor --fix` to repair Codex model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai-codex` or run `openclaw configure`."; + } if (SAFE_MISSING_API_KEY_PROVIDERS.has(provider)) { return `⚠️ Missing API key for provider "${provider}". Configure the gateway auth for that provider, then try again.`; } diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index 27e74809a6a..77142577abb 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -1058,6 +1058,164 @@ describe("createModelSelectionState auto-failover overrides", () => { expect(state.resetModelOverride).toBe(false); }); + it("clears stale auto-created legacy openai-codex route pins when primary is canonical openai", async () => { + const sessionEntry = makeEntry({ + providerOverride: "openai-codex", + modelOverride: "gpt-5.5", + modelOverrideSource: "auto", + modelProvider: "openai-codex", + model: "gpt-5.5", + contextTokens: 350_000, + authProfileOverride: "openai-codex:default", + authProfileOverrideSource: "auto", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg: {} as OpenClawConfig, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "openai", + defaultModel: "gpt-5.5", + primaryProvider: "openai", + primaryModel: "gpt-5.5", + provider: "openai-codex", + model: "gpt-5.5", + hasModelDirective: false, + }); + + expect(state.provider).toBe("openai"); + expect(state.model).toBe("gpt-5.5"); + expect(state.resetModelOverride).toBe(true); + expect(state.resetModelOverrideRef).toBe("openai-codex/gpt-5.5"); + expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined(); + expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined(); + expect(sessionStore[sessionKey]?.modelOverrideSource).toBeUndefined(); + expect(sessionStore[sessionKey]?.modelProvider).toBeUndefined(); + expect(sessionStore[sessionKey]?.model).toBeUndefined(); + expect(sessionStore[sessionKey]?.contextTokens).toBeUndefined(); + expect(sessionStore[sessionKey]?.authProfileOverride).toBeUndefined(); + expect(sessionStore[sessionKey]?.authProfileOverrideSource).toBeUndefined(); + }); + + it("preserves usable Codex auth while clearing stale legacy openai-codex route pins", async () => { + authProfileStoreMock.store = { + version: 1, + profiles: { + "openai-codex:default": { + type: "api_key", + provider: "openai-codex", + key: "test-key", + }, + }, + }; + const sessionEntry = makeEntry({ + providerOverride: "openai-codex", + modelOverride: "gpt-5.5", + modelOverrideSource: "auto", + authProfileOverride: "openai-codex:default", + authProfileOverrideSource: "auto", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg: {} as OpenClawConfig, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "openai", + defaultModel: "gpt-5.5", + primaryProvider: "openai", + primaryModel: "gpt-5.5", + provider: "openai-codex", + model: "gpt-5.5", + hasModelDirective: false, + }); + + expect(state.provider).toBe("openai"); + expect(state.model).toBe("gpt-5.5"); + expect(state.resetModelOverride).toBe(true); + expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined(); + expect(sessionStore[sessionKey]?.modelOverride).toBeUndefined(); + expect(sessionStore[sessionKey]?.authProfileOverride).toBe("openai-codex:default"); + expect(sessionStore[sessionKey]?.authProfileOverrideSource).toBe("auto"); + }); + + it("keeps auto openai-codex pins when canonical openai uses a custom API route", async () => { + const cfg = { + models: { + providers: { + openai: { + baseUrl: "https://proxy.example.test/v1", + models: [], + }, + }, + }, + } as OpenClawConfig; + const sessionEntry = makeEntry({ + providerOverride: "openai-codex", + modelOverride: "gpt-5.5", + modelOverrideSource: "auto", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "openai", + defaultModel: "gpt-5.5", + primaryProvider: "openai", + primaryModel: "gpt-5.5", + provider: "openai-codex", + model: "gpt-5.5", + hasModelDirective: false, + }); + + expect(state.provider).toBe("openai-codex"); + expect(state.model).toBe("gpt-5.5"); + expect(state.resetModelOverride).toBe(false); + expect(sessionStore[sessionKey]?.providerOverride).toBe("openai-codex"); + expect(sessionStore[sessionKey]?.modelOverride).toBe("gpt-5.5"); + expect(sessionStore[sessionKey]?.modelOverrideSource).toBe("auto"); + }); + + it("keeps explicit user openai-codex route overrides", async () => { + const sessionEntry = makeEntry({ + providerOverride: "openai-codex", + modelOverride: "gpt-5.5", + modelOverrideSource: "user", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg: {} as OpenClawConfig, + agentCfg: undefined, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "openai", + defaultModel: "gpt-5.5", + primaryProvider: "openai", + primaryModel: "gpt-5.5", + provider: "openai", + model: "gpt-5.5", + hasModelDirective: false, + }); + + expect(state.provider).toBe("openai-codex"); + expect(state.model).toBe("gpt-5.5"); + expect(state.resetModelOverride).toBe(false); + expect(sessionStore[sessionKey]?.providerOverride).toBe("openai-codex"); + expect(sessionStore[sessionKey]?.modelOverride).toBe("gpt-5.5"); + expect(sessionStore[sessionKey]?.modelOverrideSource).toBe("user"); + }); + it("still clears disallowed auto-failover overrides through allowlist validation", async () => { const cfg = { agents: { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index eb6bd1f8dc8..0d9a1048fbc 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -19,7 +19,11 @@ import { createModelVisibilityPolicy, type ModelVisibilityPolicy, } from "../../agents/model-visibility-policy.js"; -import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-codex-routing.js"; +import { + OPENAI_CODEX_PROVIDER_ID, + OPENAI_PROVIDER_ID, + listOpenAIAuthProfileProvidersForAgentRuntime, +} from "../../agents/openai-codex-routing.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js"; @@ -195,6 +199,23 @@ export async function createModelSelectionState(params: { primaryProvider: params.primaryProvider, primaryModel: params.primaryModel, }); + const primaryHarnessPolicy = resolveAgentHarnessPolicy({ + provider: primaryProvider, + modelId: primaryModel, + config: cfg, + agentId: params.agentId, + sessionKey, + }); + const staleLegacyOpenAICodexAutoOverride = + directStoredModelOverride?.source === "session" && + sessionEntry?.modelOverrideSource === "auto" && + normalizeProviderId(directStoredModelOverride.provider ?? "") === OPENAI_CODEX_PROVIDER_ID && + normalizeProviderId(primaryProvider) === OPENAI_PROVIDER_ID && + primaryHarnessPolicy.runtime === "codex" && + normalizeRuntimeModelRef(OPENAI_PROVIDER_ID, directStoredModelOverride.model).model === + normalizeRuntimeModelRef(OPENAI_PROVIDER_ID, primaryModel).model; + const staleDirectStoredOverride = + staleHeartbeatAutoFallbackOverride || staleLegacyOpenAICodexAutoOverride; if (needsModelCatalog) { modelCatalog = await (await loadModelCatalogRuntime()).loadModelCatalog({ config: cfg }); @@ -238,11 +259,11 @@ export async function createModelSelectionState(params: { directStoredOverride.model, ); const key = modelKey(normalizedOverride.provider, normalizedOverride.model); - if (staleHeartbeatAutoFallbackOverride || !visibilityPolicy.allowsKey(key)) { + if (staleDirectStoredOverride || !visibilityPolicy.allowsKey(key)) { const { updated } = applyModelOverrideToSessionEntry({ entry: sessionEntry, selection: { provider: primaryProvider, model: primaryModel, isDefault: true }, - preserveAuthProfileOverride: staleHeartbeatAutoFallbackOverride, + preserveAuthProfileOverride: staleDirectStoredOverride, }); if (updated) { sessionStore[sessionKey] = sessionEntry; @@ -260,7 +281,7 @@ export async function createModelSelectionState(params: { } } } - if (staleHeartbeatAutoFallbackOverride) { + if (staleDirectStoredOverride) { const normalizedCurrentSelection = normalizeRuntimeModelRef(provider, model); const currentSelectionKey = modelKey( normalizedCurrentSelection.provider, @@ -292,7 +313,7 @@ export async function createModelSelectionState(params: { const skipStoredOverride = params.skipStoredModelOverride === true || params.hasResolvedHeartbeatModelOverride === true || - (staleHeartbeatAutoFallbackOverride && storedOverride?.source === "session"); + (staleDirectStoredOverride && storedOverride?.source === "session"); if (storedOverride?.model && !skipStoredOverride) { const normalizedStoredOverride = normalizeRuntimeModelRef(