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 <paul@paulfrederiksen.com>
This commit is contained in:
Paul Frederiksen
2026-05-27 15:35:48 -07:00
committed by GitHub
parent 316fd5b625
commit 77fe36bb98
4 changed files with 203 additions and 5 deletions

View File

@@ -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 /".'),

View File

@@ -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.`;
}

View File

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

View File

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