From cc95d4dd28ebcf0bba542dbc3cf73625ed00cabc Mon Sep 17 00:00:00 2001 From: pashpashpash Date: Sun, 10 May 2026 20:09:40 -0700 Subject: [PATCH] fix(codex): rotate auth profiles inside harness --- docs/concepts/model-failover.md | 33 +++- docs/plugins/codex-harness.md | 45 +++-- docs/providers/openai.md | 40 ++++- .../codex/src/app-server/auth-bridge.test.ts | 29 +++ .../codex/src/app-server/auth-bridge.ts | 27 ++- .../codex/src/app-server/rate-limits.ts | 7 + .../codex/src/app-server/run-attempt.test.ts | 44 ++--- .../codex/src/app-server/run-attempt.ts | 93 +++++++++- extensions/openai/auth-choice-copy.ts | 3 + .../openai/openai-codex-provider.test.ts | 10 +- extensions/openai/openai-codex-provider.ts | 43 +++++ extensions/openai/openclaw.plugin.json | 15 ++ extensions/openai/openclaw.plugin.test.ts | 8 + extensions/openai/setup-api.ts | 19 +- src/agents/auth-profiles.ts | 3 + src/agents/auth-profiles/order.test.ts | 41 ++++- src/agents/auth-profiles/order.ts | 115 +++++++++++- src/agents/auth-profiles/profiles.ts | 4 + src/agents/auth-profiles/types.ts | 7 + src/agents/auth-profiles/usage-state.ts | 16 +- src/agents/auth-profiles/usage.test.ts | 62 ++++++- src/agents/auth-profiles/usage.ts | 167 +++++++++++++++++- src/agents/pi-embedded-runner/run.ts | 153 +++++++++++----- src/agents/runtime-plan/auth.ts | 11 +- src/agents/runtime-plan/build.test.ts | 42 ++++- src/agents/runtime-plan/build.ts | 2 + src/agents/runtime-plan/types.ts | 3 + src/plugin-sdk/agent-runtime.ts | 3 + 28 files changed, 910 insertions(+), 135 deletions(-) diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md index 8e5d14d6937..423d5369c2b 100644 --- a/docs/concepts/model-failover.md +++ b/docs/concepts/model-failover.md @@ -123,15 +123,38 @@ OpenClaw **pins the chosen auth profile per session** to keep provider caches wa Manual selection via `/model …@` sets a **user override** for that session and is not auto-rotated until a new session starts. -Auto-pinned profiles (selected by the session router) are treated as a **preference**: they are tried first, but OpenClaw may rotate to another profile on rate limits/timeouts. User-pinned profiles stay locked to that profile; if it fails and model fallbacks are configured, OpenClaw moves to the next model instead of switching profiles. +Auto-pinned profiles (selected by the session router) are treated as a **preference**: they are tried first, but OpenClaw may rotate to another profile on rate limits/timeouts. When the original profile becomes available again, new runs can prefer it again without changing the selected model or runtime. User-pinned profiles stay locked to that profile; if it fails and model fallbacks are configured, OpenClaw moves to the next model instead of switching profiles. -### Why OAuth can "look lost" +### OpenAI Codex subscription plus API-key backup -If you have both an OAuth profile and an API key profile for the same provider, round-robin can switch between them across messages unless pinned. To force a single profile: +For OpenAI agent models, auth and runtime are separate. `openai/gpt-*` stays on +the Codex harness while auth can rotate between a Codex subscription profile and +an OpenAI API-key backup. -- Pin with `auth.order[provider] = ["provider:profileId"]`, or -- Use a per-session override via `/model …` with a profile override (when supported by your UI/chat surface). +Use `auth.order.openai` for the user-facing order: + +```json5 +{ + auth: { + order: { + openai: ["openai-codex:user@example.com", "openai:api-key-backup"], + }, + }, +} +``` + +Existing Codex subscription profiles may still use the legacy +`openai-codex:*` profile id. The ordered API-key backup can be a normal +`openai:*` API-key profile. When the subscription hits a Codex usage limit, +OpenClaw records the exact reset time when Codex provides one, tries the next +ordered auth profile, and keeps the run inside the Codex harness. Once the reset +time passes, the subscription profile is eligible again and the next automatic +selection can return to it. + +Use a user-pinned profile only when you want to force one account/key for that +session. User-pinned profiles are intentionally strict and do not silently jump +to another profile. ## Cooldowns diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 1041b10e9c4..4f36fa45209 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -101,23 +101,38 @@ turn resolves the harness from current config. The quickstart config is the minimum viable Codex harness config. Set Codex harness options in OpenClaw config, and use the CLI only for Codex auth: -| Need | Set | Where | -| -------------------------------------- | ------------------------------------------------------------------ | ------------------------------ | -| Enable the harness | `plugins.entries.codex.enabled: true` | OpenClaw config | -| Keep an allowlisted plugin install | Include `codex` in `plugins.allow` | OpenClaw config | -| Route OpenAI agent turns through Codex | `agents.defaults.model` or `agents.list[].model` as `openai/gpt-*` | OpenClaw agent config | -| Sign in with Codex OAuth | `openclaw models auth login --provider openai-codex` | CLI auth profile | -| Fail closed when Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | OpenClaw model/provider config | -| Use direct OpenAI API traffic | Provider or model `agentRuntime.id: "pi"` with normal OpenAI auth | OpenClaw model/provider config | -| Tune app-server behavior | `plugins.entries.codex.config.appServer.*` | Codex plugin config | -| Enable native Codex plugin apps | `plugins.entries.codex.config.codexPlugins.*` | Codex plugin config | -| Enable Codex Computer Use | `plugins.entries.codex.config.computerUse.*` | Codex plugin config | +| Need | Set | Where | +| -------------------------------------- | -------------------------------------------------------------------------------- | ---------------------------------- | +| Enable the harness | `plugins.entries.codex.enabled: true` | OpenClaw config | +| Keep an allowlisted plugin install | Include `codex` in `plugins.allow` | OpenClaw config | +| Route OpenAI agent turns through Codex | `agents.defaults.model` or `agents.list[].model` as `openai/gpt-*` | OpenClaw agent config | +| Sign in with Codex OAuth | `openclaw models auth login --provider openai-codex` | CLI auth profile | +| Add API-key backup for Codex runs | `openai:*` API-key profile listed after subscription auth in `auth.order.openai` | CLI auth profile + OpenClaw config | +| Fail closed when Codex is unavailable | Provider or model `agentRuntime.id: "codex"` | OpenClaw model/provider config | +| Use direct OpenAI API traffic | Provider or model `agentRuntime.id: "pi"` with normal OpenAI auth | OpenClaw model/provider config | +| Tune app-server behavior | `plugins.entries.codex.config.appServer.*` | Codex plugin config | +| Enable native Codex plugin apps | `plugins.entries.codex.config.codexPlugins.*` | Codex plugin config | +| Enable Codex Computer Use | `plugins.entries.codex.config.computerUse.*` | Codex plugin config | Use `openai/gpt-*` model refs for Codex-backed OpenAI agent turns. Prefer `auth.order.openai` for subscription-first/API-key-backup ordering. Existing `openai-codex:*` auth profiles and `auth.order.openai-codex` remain valid, but do not write new `openai-codex/gpt-*` model refs. +```json5 +{ + auth: { + order: { + openai: ["openai-codex:user@example.com", "openai:api-key-backup"], + }, + }, +} +``` + +In that shape, both profiles still run through Codex for `openai/gpt-*` agent +turns. The API key is only an auth fallback, not a request to switch to PI or +plain OpenAI Responses. + The rest of this page covers common variants users must choose between: deployment shape, fail-closed routing, guardian approval policy, native Codex plugins, and Computer Use. For full option lists, defaults, enums, discovery, @@ -385,7 +400,8 @@ For upload mechanics and runtime-level diagnostics boundaries, see Auth is selected in this order: -1. An explicit OpenClaw Codex auth profile for the agent. +1. Ordered OpenAI auth profiles for the agent, preferably under + `auth.order.openai`. Existing `openai-codex:*` profile ids remain valid. 2. The app-server's existing account in that agent's Codex home. 3. For local stdio app-server launches only, `CODEX_API_KEY`, then `OPENAI_API_KEY`, when no app-server account is present and OpenAI auth is @@ -400,6 +416,11 @@ login instead of inherited child-process env. WebSocket app-server connections do not receive Gateway env API-key fallback; use an explicit auth profile or the remote app-server's own account. +If a subscription profile hits a Codex usage limit, OpenClaw records the reset +time when Codex reports one and tries the next ordered auth profile for the same +Codex run. When the reset time passes, the subscription profile becomes eligible +again without changing the selected `openai/gpt-*` model or Codex runtime. + If a deployment needs additional environment isolation, add those variables to `appServer.clearEnv`: diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 5413d5165e5..3ac8df79497 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -242,7 +242,7 @@ Choose your preferred auth method and follow the setup steps. | Model ref | Runtime config | Route | Auth | |-----------|----------------|-------|------| - | `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or selected `openai-codex` profile | + | `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or ordered `openai` auth profile | | `openai/gpt-5.5` | provider/model `agentRuntime.id: "pi"` | PI embedded runtime with internal Codex-auth transport | Selected `openai-codex` profile | | `openai-codex/gpt-5.5` | repaired by doctor | Legacy route rewritten to `openai/gpt-5.5` | Existing `openai-codex` profile | @@ -274,6 +274,29 @@ Choose your preferred auth method and follow the setup steps. } ``` + With an API-key backup, keep the model on `openai/gpt-5.5` and put the + auth order under `openai`. OpenClaw will try the subscription first, then + the API key, while staying on the Codex harness: + + ```json5 + { + plugins: { entries: { codex: { enabled: true } } }, + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + }, + }, + auth: { + order: { + openai: [ + "openai-codex:user@example.com", + "openai:api-key-backup", + ], + }, + }, + } + ``` + Onboarding no longer imports OAuth material from `~/.codex`. Sign in with browser OAuth (default) or the device-code flow above — OpenClaw manages the resulting credentials in its own agent auth store. @@ -371,11 +394,12 @@ Choose your preferred auth method and follow the setup steps. ## Native Codex app-server auth The native Codex app-server harness uses `openai/*` model refs plus omitted -runtime config or provider/model `agentRuntime.id: "codex"`, but its auth is still -account-based. OpenClaw -selects auth in this order: +runtime config or provider/model `agentRuntime.id: "codex"`, but its auth is +still account-based. OpenClaw selects auth in this order: -1. An explicit OpenClaw `openai-codex` auth profile bound to the agent. +1. Ordered OpenAI auth profiles for the agent, preferably under + `auth.order.openai`. Existing `openai-codex:*` profiles and + `auth.order.openai-codex` remain valid for older installs. 2. The app-server's existing account, such as a local Codex CLI ChatGPT sign-in. 3. For local stdio app-server launches only, `CODEX_API_KEY`, then `OPENAI_API_KEY`, when the app-server reports no account and still requires @@ -387,7 +411,11 @@ or embeddings. Env API-key fallback is only the local stdio no-account path; it is not sent to WebSocket app-server connections. When a subscription-style Codex profile is selected, OpenClaw also keeps `CODEX_API_KEY` and `OPENAI_API_KEY` out of the spawned stdio app-server child and sends the selected credentials -through the app-server login RPC. +through the app-server login RPC. When that subscription profile is blocked by a +Codex usage limit, OpenClaw can rotate to the next ordered `openai:*` API-key +profile without changing the selected model or dropping out of the Codex +harness. Once the subscription reset time passes, the subscription profile is +eligible again. ## Image generation diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 2f3cd21604d..5e2170c3382 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -521,6 +521,35 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it("applies a normal OpenAI API-key profile as a Codex app-server backup", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "apiKey" })); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:default", + credential: { + type: "api_key", + provider: "openai", + key: "sk-openai-backup", + }, + }); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:default", + }); + + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "apiKey", + apiKey: "sk-openai-backup", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("applies the default OpenAI Codex OAuth profile when no profile id is explicit", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index f87671d15d7..73262bae6aa 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -24,6 +24,7 @@ import type { import { resolveCodexAppServerSpawnEnv } from "./transport-stdio.js"; const CODEX_APP_SERVER_AUTH_PROVIDER = "openai-codex"; +const OPENAI_PROVIDER = "openai"; const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default"; const CODEX_HOME_ENV_VAR = "CODEX_HOME"; const HOME_ENV_VAR = "HOME"; @@ -113,7 +114,7 @@ export async function resolveCodexAppServerAuthAccountCacheKey(params: { return undefined; } const credential = store.profiles[profileId]; - if (!credential || !isCodexAppServerAuthProvider(credential.provider, params.config)) { + if (!credential || !isCodexAppServerAuthProfileCredential(credential, params.config)) { return undefined; } if (credential.type === "api_key") { @@ -304,9 +305,9 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: { if (!credential) { throw new Error(`Codex app-server auth profile "${profileId}" was not found.`); } - if (!isCodexAppServerAuthProvider(credential.provider, params.config)) { + if (!isCodexAppServerAuthProfileCredential(credential, params.config)) { throw new Error( - `Codex app-server auth profile "${profileId}" must belong to provider "openai-codex" or a supported alias.`, + `Codex app-server auth profile "${profileId}" must be OpenAI Codex auth or an OpenAI API-key backup.`, ); } const loginParams = await resolveLoginParamsForCredential(profileId, credential, { @@ -419,6 +420,26 @@ function isCodexAppServerAuthProvider(provider: string, config?: AuthProfileOrde return resolveProviderIdForAuth(provider, { config }) === CODEX_APP_SERVER_AUTH_PROVIDER; } +function isOpenAIApiKeyBackupCredential( + credential: AuthProfileCredential, + config?: AuthProfileOrderConfig, +): boolean { + return ( + credential.type === "api_key" && + resolveProviderIdForAuth(credential.provider, { config }) === OPENAI_PROVIDER + ); +} + +function isCodexAppServerAuthProfileCredential( + credential: AuthProfileCredential, + config?: AuthProfileOrderConfig, +): boolean { + return ( + isCodexAppServerAuthProvider(credential.provider, config) || + isOpenAIApiKeyBackupCredential(credential, config) + ); +} + function shouldClearOpenAiApiKeyForCodexAuthProfile(params: { store: ReturnType; authProfileId?: string; diff --git a/extensions/codex/src/app-server/rate-limits.ts b/extensions/codex/src/app-server/rate-limits.ts index b5c52f0f408..a99c5d91477 100644 --- a/extensions/codex/src/app-server/rate-limits.ts +++ b/extensions/codex/src/app-server/rate-limits.ts @@ -93,6 +93,13 @@ export function summarizeCodexAccountRateLimits( ]; } +export function resolveCodexUsageLimitResetAtMs( + value: JsonValue | undefined, + nowMs = Date.now(), +): number | undefined { + return selectNextRateLimitReset(value, nowMs)?.resetsAtMs; +} + function isCodexUsageLimitError( codexErrorInfo: JsonValue | null | undefined, message: string | undefined, diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index e26eb5546e8..5c45919c8e5 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -2177,16 +2177,10 @@ describe("runCodexAppServerAttempt", () => { }); harnessRef.current = harness; - const runError = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)).catch( - (error: unknown) => error, - ); - - const error = await runError; - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain( - "You've reached your Codex subscription usage limit.", - ); - expect((error as Error).message).toContain("Next reset in"); + const result = await runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)); + expect(result.promptErrorSource).toBe("prompt"); + expect(result.promptError).toContain("You've reached your Codex subscription usage limit."); + expect(result.promptError).toContain("Next reset in"); }); it("uses a recent Codex rate-limit snapshot when turn/start omits reset details", async () => { @@ -2214,17 +2208,13 @@ describe("runCodexAppServerAttempt", () => { return undefined; }); - const runError = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)).catch( - (error: unknown) => error, - ); + const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)); await harness.waitForMethod("turn/start"); - const error = await runError; - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain( - "You've reached your Codex subscription usage limit.", - ); - expect((error as Error).message).toContain("Next reset in"); + const result = await run; + expect(result.promptErrorSource).toBe("prompt"); + expect(result.promptError).toContain("You've reached your Codex subscription usage limit."); + expect(result.promptError).toContain("Next reset in"); }); it("refreshes Codex account rate limits when turn/start omits reset details", async () => { @@ -2243,18 +2233,14 @@ describe("runCodexAppServerAttempt", () => { return undefined; }); - const runError = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)).catch( - (error: unknown) => error, - ); + const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir)); await harness.waitForMethod("account/rateLimits/read"); - const error = await runError; - expect(error).toBeInstanceOf(Error); - expect((error as Error).message).toContain( - "You've reached your Codex subscription usage limit.", - ); - expect((error as Error).message).toContain("Next reset in"); - expect((error as Error).message).not.toContain("Codex did not return a reset time"); + const result = await run; + expect(result.promptErrorSource).toBe("prompt"); + expect(result.promptError).toContain("You've reached your Codex subscription usage limit."); + expect(result.promptError).toContain("Next reset in"); + expect(result.promptError).not.toContain("Codex did not return a reset time"); }); it("cleans up native hook relay state when the Codex turn aborts", async () => { diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 4611bfb8a6e..5bc91ea32cc 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -38,7 +38,7 @@ import { type NativeHookRelayEvent, type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; +import { markAuthProfileBlockedUntil, resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime"; import { emitTrustedDiagnosticEvent } from "openclaw/plugin-sdk/diagnostic-runtime"; import { pathExists } from "openclaw/plugin-sdk/security-runtime"; import { @@ -107,6 +107,7 @@ import { import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js"; import { formatCodexUsageLimitErrorMessage, + resolveCodexUsageLimitResetAtMs, shouldRefreshCodexRateLimitsForUsageLimitMessage, } from "./rate-limits.js"; import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./session-binding.js"; @@ -1331,8 +1332,9 @@ export async function runCodexAppServerAttempt( const turnStartFailureMessages = [ ...historyMessages, { - role: "user", - content: [{ type: "text", text: promptBuild.prompt }], + role: "user" as const, + content: promptBuild.prompt, + timestamp: Date.now(), }, ]; @@ -1417,8 +1419,15 @@ export async function runCodexAppServerAttempt( }); params.abortSignal?.removeEventListener("abort", abortFromUpstream); if (usageLimitError) { - throw new Error(usageLimitError, { - cause: error, + await markCodexAuthProfileBlockedFromRecentRateLimits({ + params, + authProfileId: startupAuthProfileId, + }); + return buildCodexTurnStartFailureResult({ + params, + message: usageLimitError, + messagesSnapshot: turnStartFailureMessages, + systemPromptReport, }); } throw error; @@ -1670,6 +1679,74 @@ export async function runCodexAppServerAttempt( } } +async function markCodexAuthProfileBlockedFromRecentRateLimits(params: { + params: EmbeddedRunAttemptParams; + authProfileId?: string; +}): Promise { + const authProfileId = params.authProfileId?.trim(); + if (!authProfileId || !params.params.authProfileStore) { + return; + } + const blockedUntil = resolveCodexUsageLimitResetAtMs(readRecentCodexRateLimits()); + if (!blockedUntil) { + return; + } + try { + await markAuthProfileBlockedUntil({ + store: params.params.authProfileStore, + profileId: authProfileId, + blockedUntil, + source: "codex_rate_limits", + agentDir: params.params.agentDir, + runId: params.params.runId, + modelId: params.params.modelId, + }); + } catch (error) { + embeddedAgentLog.debug("failed to mark Codex auth profile blocked from app-server limits", { + authProfileId, + error: formatErrorMessage(error), + }); + } +} + +function buildCodexTurnStartFailureResult(params: { + params: EmbeddedRunAttemptParams; + message: string; + messagesSnapshot: AgentMessage[]; + systemPromptReport: ReturnType; +}): EmbeddedRunAttemptResult { + return { + aborted: false, + externalAbort: false, + timedOut: false, + idleTimedOut: false, + timedOutDuringCompaction: false, + timedOutDuringToolExecution: false, + promptError: params.message, + promptErrorSource: "prompt", + sessionIdUsed: params.params.sessionId, + messagesSnapshot: params.messagesSnapshot, + assistantTexts: [], + toolMetas: [], + lastAssistant: undefined, + didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + messagingToolSentTargets: [], + cloudCodeAssistFormatError: false, + replayMetadata: { + hadPotentialSideEffects: false, + replaySafe: true, + }, + itemLifecycle: { + startedCount: 0, + completedCount: 0, + activeCount: 0, + }, + systemPromptReport: params.systemPromptReport, + }; +} + async function handleDynamicToolCallWithTimeout(params: { call: CodexDynamicToolCallParams; toolBridge: Pick; @@ -2271,10 +2348,14 @@ function readCodexErrorPayload(error: unknown): { return { message }; } const nestedError = isJsonObject(data.error) ? data.error : data; + const rateLimits = nestedError.rateLimits ?? data.rateLimits; + if (rateLimits !== undefined) { + rememberCodexRateLimits(rateLimits); + } return { message: readString(nestedError, "message") ?? message, codexErrorInfo: nestedError.codexErrorInfo, - rateLimits: nestedError.rateLimits ?? data.rateLimits, + rateLimits, }; } diff --git a/extensions/openai/auth-choice-copy.ts b/extensions/openai/auth-choice-copy.ts index 6b872a34a42..3b386efea8d 100644 --- a/extensions/openai/auth-choice-copy.ts +++ b/extensions/openai/auth-choice-copy.ts @@ -1,4 +1,7 @@ export const OPENAI_API_KEY_LABEL = "OpenAI API Key"; +export const OPENAI_CODEX_API_KEY_BACKUP_LABEL = "OpenAI API Key Backup"; +export const OPENAI_CODEX_API_KEY_BACKUP_HINT = + "Use an OpenAI API key when your Codex subscription is unavailable"; export const OPENAI_CODEX_LOGIN_LABEL = "OpenAI Codex Browser Login"; export const OPENAI_CODEX_LOGIN_HINT = "Sign in with OpenAI in your browser"; export const OPENAI_CODEX_DEVICE_PAIRING_LABEL = "OpenAI Codex Device Pairing"; diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index c34907ecfc6..0d8e48a786b 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -167,6 +167,7 @@ describe("openai codex provider", () => { const provider = buildOpenAICodexProviderPlugin(); const oauth = requireAuthMethod(provider, "oauth"); const deviceCode = requireAuthMethod(provider, "device-code"); + const apiKey = requireAuthMethod(provider, "api-key"); expectRecordFields(oauth.wizard, "oauth wizard", { choiceLabel: "OpenAI Codex Browser Login", @@ -180,6 +181,13 @@ describe("openai codex provider", () => { groupLabel: "OpenAI Codex", groupHint: "ChatGPT/Codex sign-in", }); + expectRecordFields(apiKey.wizard, "api-key wizard", { + choiceLabel: "OpenAI API Key Backup", + choiceHint: "Use an OpenAI API key when your Codex subscription is unavailable", + groupId: "openai-codex", + groupLabel: "OpenAI Codex", + groupHint: "ChatGPT/Codex sign-in", + }); }); it("returns deprecated-profile doctor guidance for legacy Codex CLI ids", () => { @@ -213,7 +221,7 @@ describe("openai codex provider", () => { const oauth = requireAuthMethod(provider, "oauth"); const deviceCode = requireAuthMethod(provider, "device-code"); - expect(provider.auth?.map((method) => method.id)).toEqual(["oauth", "device-code"]); + expect(provider.auth?.map((method) => method.id)).toEqual(["oauth", "device-code", "api-key"]); expect(oauth.label).toBe("OpenAI Codex Browser Login"); expect(oauth.hint).toBe("Sign in with OpenAI in your browser"); expectRecordFields(oauth.wizard, "oauth wizard", { diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index d789d6be6f4..ac6dc852dee 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -7,6 +7,7 @@ import type { } from "openclaw/plugin-sdk/plugin-entry"; import { CODEX_CLI_PROFILE_ID, + createProviderApiKeyAuthMethod, ensureAuthProfileStoreForLocalUpdate, listProfilesForProvider, type OAuthCredential, @@ -26,6 +27,8 @@ import { import { OPENAI_CODEX_DEVICE_PAIRING_HINT, OPENAI_CODEX_DEVICE_PAIRING_LABEL, + OPENAI_CODEX_API_KEY_BACKUP_HINT, + OPENAI_CODEX_API_KEY_BACKUP_LABEL, OPENAI_CODEX_LOGIN_HINT, OPENAI_CODEX_LOGIN_LABEL, OPENAI_CODEX_WIZARD_GROUP, @@ -50,6 +53,7 @@ import { import { resolveOpenAICodexThinkingProfile } from "./thinking-policy.js"; const PROVIDER_ID = "openai-codex"; +const OPENAI_PROVIDER_ID = "openai"; const OPENAI_CODEX_BASE_URL = OPENAI_CODEX_RESPONSES_BASE_URL; const OPENAI_CODEX_LOGIN_ASSISTANT_PRIORITY = -30; const OPENAI_CODEX_DEVICE_PAIRING_ASSISTANT_PRIORITY = -10; @@ -317,6 +321,24 @@ function buildOpenAICodexAuthConfigPatch(): NonNullable await runOpenAICodexDeviceCode(ctx), }, + createProviderApiKeyAuthMethod({ + providerId: OPENAI_PROVIDER_ID, + methodId: "api-key", + label: OPENAI_CODEX_API_KEY_BACKUP_LABEL, + hint: OPENAI_CODEX_API_KEY_BACKUP_HINT, + optionKey: "openaiApiKey", + flagName: "--openai-api-key", + envVar: "OPENAI_API_KEY", + promptMessage: "Enter OpenAI API key", + profileId: "openai:default", + defaultModel: OPENAI_CODEX_DEFAULT_MODEL, + expectedProviders: [OPENAI_PROVIDER_ID], + applyConfig: applyOpenAICodexAuthConfig, + wizard: { + choiceId: "openai-codex-api-key", + choiceLabel: OPENAI_CODEX_API_KEY_BACKUP_LABEL, + choiceHint: OPENAI_CODEX_API_KEY_BACKUP_HINT, + assistantPriority: 5, + ...OPENAI_CODEX_WIZARD_GROUP, + }, + }), ], catalog: { order: "profile", diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 489a430660e..50cc6315c4d 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -784,6 +784,21 @@ "groupLabel": "OpenAI Codex", "groupHint": "ChatGPT/Codex sign-in" }, + { + "provider": "openai-codex", + "method": "api-key", + "choiceId": "openai-codex-api-key", + "choiceLabel": "OpenAI API Key Backup", + "choiceHint": "Use an OpenAI API key when your Codex subscription is unavailable", + "assistantPriority": 5, + "groupId": "openai-codex", + "groupLabel": "OpenAI Codex", + "groupHint": "ChatGPT/Codex sign-in", + "optionKey": "openaiApiKey", + "cliFlag": "--openai-api-key", + "cliOption": "--openai-api-key ", + "cliDescription": "OpenAI API Key Backup" + }, { "provider": "openai", "method": "api-key", diff --git a/extensions/openai/openclaw.plugin.test.ts b/extensions/openai/openclaw.plugin.test.ts index f169a2b2b61..17d5f98057e 100644 --- a/extensions/openai/openclaw.plugin.test.ts +++ b/extensions/openai/openclaw.plugin.test.ts @@ -113,6 +113,7 @@ describe("OpenAI plugin manifest", () => { const apiKey = choices.find( (choice) => choice.provider === "openai" && choice.method === "api-key", ); + const codexApiKey = choices.find((choice) => choice.choiceId === "openai-codex-api-key"); expect(codexBrowserLogin?.choiceLabel).toBe("OpenAI Codex Browser Login"); expect(codexBrowserLogin?.choiceHint).toBe("Sign in with OpenAI in your browser"); @@ -128,6 +129,13 @@ describe("OpenAI plugin manifest", () => { expect(apiKey?.groupId).toBe("openai"); expect(apiKey?.groupLabel).toBe("OpenAI"); expect(apiKey?.groupHint).toBe("Direct API key"); + expect(codexApiKey?.choiceLabel).toBe("OpenAI API Key Backup"); + expect(codexApiKey?.choiceHint).toBe( + "Use an OpenAI API key when your Codex subscription is unavailable", + ); + expect(codexApiKey?.groupId).toBe("openai-codex"); + expect(codexApiKey?.groupLabel).toBe("OpenAI Codex"); + expect(codexApiKey?.groupHint).toBe("ChatGPT/Codex sign-in"); expect(choices.map((choice) => choice.choiceLabel)).not.toContain( "OpenAI Codex (ChatGPT OAuth)", ); diff --git a/extensions/openai/setup-api.ts b/extensions/openai/setup-api.ts index 0993f07f54c..7304fb0fd16 100644 --- a/extensions/openai/setup-api.ts +++ b/extensions/openai/setup-api.ts @@ -5,6 +5,8 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { OPENAI_API_KEY_LABEL, OPENAI_API_KEY_WIZARD_GROUP, + OPENAI_CODEX_API_KEY_BACKUP_HINT, + OPENAI_CODEX_API_KEY_BACKUP_LABEL, OPENAI_CODEX_DEVICE_PAIRING_HINT, OPENAI_CODEX_DEVICE_PAIRING_LABEL, OPENAI_CODEX_LOGIN_HINT, @@ -91,11 +93,26 @@ function buildOpenAICodexSetupProvider(): ProviderPlugin { run: async (ctx) => runOpenAICodexProviderAuthMethod("device-code", ctx), } satisfies ProviderAuthMethod; + const apiKeyBackupMethod = { + id: "api-key", + label: OPENAI_CODEX_API_KEY_BACKUP_LABEL, + hint: OPENAI_CODEX_API_KEY_BACKUP_HINT, + kind: "api_key", + wizard: { + choiceId: "openai-codex-api-key", + choiceLabel: OPENAI_CODEX_API_KEY_BACKUP_LABEL, + choiceHint: OPENAI_CODEX_API_KEY_BACKUP_HINT, + assistantPriority: 5, + ...OPENAI_CODEX_WIZARD_GROUP, + }, + run: async (ctx) => runOpenAICodexProviderAuthMethod("api-key", ctx), + } satisfies ProviderAuthMethod; + return { id: "openai-codex", label: "OpenAI Codex", docsPath: "/providers/models", - auth: [oauthMethod, deviceCodeMethod], + auth: [oauthMethod, deviceCodeMethod, apiKeyBackupMethod], }; } diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index f8d391a6158..999885c7fe2 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -60,6 +60,8 @@ export { } from "./auth-profiles/store.js"; export type { ApiKeyCredential, + AuthProfileBlockedReason, + AuthProfileBlockedSource, AuthProfileCredential, AuthProfileFailureReason, AuthProfileIdRepairResult, @@ -76,6 +78,7 @@ export { getSoonestCooldownExpiry, isProfileInCooldown, markAuthProfileCooldown, + markAuthProfileBlockedUntil, markAuthProfileFailure, resolveProfilesUnavailableReason, resolveProfileUnusableUntilForDisplay, diff --git a/src/agents/auth-profiles/order.test.ts b/src/agents/auth-profiles/order.test.ts index 005ea2eb86f..893728b4831 100644 --- a/src/agents/auth-profiles/order.test.ts +++ b/src/agents/auth-profiles/order.test.ts @@ -258,7 +258,42 @@ describe("resolveAuthProfileOrder", () => { provider: "openai-codex", }); - expect(order).toEqual(["openai:personal", "openai:backup"]); + expect(order).toEqual(["openai:personal", "openai:backup", "openai:platform"]); + }); + + it("lets Codex auth discover normal OpenAI API-key profiles as backups", async () => { + const { resolveAuthProfileOrder } = await importAuthProfileModulesWithAliasRegistry(); + const store: AuthProfileStore = { + version: 1, + profiles: { + "openai-codex:personal": { + type: "oauth", + provider: "openai-codex", + access: "access", + refresh: "refresh", + expires: Date.now() + 60_000, + }, + "openai:backup": { + type: "api_key", + provider: "openai", + key: "sk-platform", + }, + "openai:oauth": { + type: "oauth", + provider: "openai", + access: "wrong-provider-access", + refresh: "wrong-provider-refresh", + expires: Date.now() + 60_000, + }, + }, + }; + + const order = resolveAuthProfileOrder({ + store, + provider: "openai-codex", + }); + + expect(order).toEqual(["openai-codex:personal", "openai:backup"]); }); it("keeps direct OpenAI Codex auth order ahead of the friendly OpenAI alias", async () => { @@ -317,6 +352,8 @@ describe("resolveAuthProfileOrder", () => { usageStats: { "fixture-provider:default": { errorCount: 3, + blockedUntil: Date.now() + 120_000, + blockedReason: "subscription_limit", cooldownUntil: Date.now() + 60_000, cooldownReason: "rate_limit", }, @@ -338,6 +375,8 @@ describe("resolveAuthProfileOrder", () => { }); expect(store.usageStats?.["fixture-provider:default"]).toMatchObject({ errorCount: 0, + blockedUntil: undefined, + blockedReason: undefined, cooldownUntil: undefined, cooldownReason: undefined, }); diff --git a/src/agents/auth-profiles/order.ts b/src/agents/auth-profiles/order.ts index 941bb40a68e..b32e5c8a497 100644 --- a/src/agents/auth-profiles/order.ts +++ b/src/agents/auth-profiles/order.ts @@ -6,7 +6,7 @@ import { type AuthCredentialReasonCode, } from "./credential-state.js"; import { dedupeProfileIds, listProfilesForProvider } from "./profile-list.js"; -import type { AuthProfileStore } from "./types.js"; +import type { AuthProfileCredential, AuthProfileStore } from "./types.js"; import { clearExpiredCooldowns, isProfileInCooldown, @@ -27,6 +27,82 @@ export type AuthProfileEligibility = { const OPENAI_PROVIDER_ID = "openai"; const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; +function isOpenAIApiKeyCompatibleWithCodexAuth(params: { + cfg?: OpenClawConfig; + providerAuthKey: string; + credential?: AuthProfileCredential; + profileProvider?: string; + profileMode?: string; +}): boolean { + if (params.providerAuthKey !== OPENAI_CODEX_PROVIDER_ID) { + return false; + } + const providerKey = resolveProviderIdForAuth(params.profileProvider ?? "", { + config: params.cfg, + }); + const mode = params.credential?.type ?? params.profileMode; + return providerKey === OPENAI_PROVIDER_ID && mode === "api_key"; +} + +function isCredentialProviderCompatibleWithAuthProvider(params: { + cfg?: OpenClawConfig; + providerAuthKey: string; + credential: AuthProfileCredential; +}): boolean { + const credentialProviderKey = resolveProviderIdForAuth(params.credential.provider, { + config: params.cfg, + }); + return ( + credentialProviderKey === params.providerAuthKey || + isOpenAIApiKeyCompatibleWithCodexAuth({ + cfg: params.cfg, + providerAuthKey: params.providerAuthKey, + credential: params.credential, + profileProvider: params.credential.provider, + }) + ); +} + +function isConfiguredProfileCompatibleWithAuthProvider(params: { + cfg?: OpenClawConfig; + providerAuthKey: string; + provider: string; + mode?: string; + credential?: AuthProfileCredential; +}): boolean { + const configProviderKey = resolveProviderIdForAuth(params.provider, { config: params.cfg }); + return ( + configProviderKey === params.providerAuthKey || + isOpenAIApiKeyCompatibleWithCodexAuth({ + cfg: params.cfg, + providerAuthKey: params.providerAuthKey, + credential: params.credential, + profileProvider: params.provider, + profileMode: params.mode, + }) + ); +} + +function listProfilesCompatibleWithAuthProvider(params: { + cfg?: OpenClawConfig; + store: AuthProfileStore; + provider: string; + providerAuthKey: string; +}): string[] { + if (params.providerAuthKey !== OPENAI_CODEX_PROVIDER_ID) { + return listProfilesForProvider(params.store, params.provider); + } + return Object.entries(params.store.profiles) + .filter(([, credential]) => + isCredentialProviderCompatibleWithAuthProvider({ + cfg: params.cfg, + providerAuthKey: params.providerAuthKey, + credential, + }), + ) + .map(([profileId]) => profileId); +} + function resolveProviderAuthMode( cfg: OpenClawConfig | undefined, provider: string, @@ -87,13 +163,25 @@ export function resolveAuthProfileEligibility(params: { } return { eligible: false, reasonCode: "profile_missing" }; } - if (resolveProviderIdForAuth(cred.provider, { config: params.cfg }) !== providerAuthKey) { + if ( + !isCredentialProviderCompatibleWithAuthProvider({ + cfg: params.cfg, + providerAuthKey, + credential: cred, + }) + ) { return { eligible: false, reasonCode: "provider_mismatch" }; } const profileConfig = params.cfg?.auth?.profiles?.[params.profileId]; if (profileConfig) { if ( - resolveProviderIdForAuth(profileConfig.provider, { config: params.cfg }) !== providerAuthKey + !isConfiguredProfileCompatibleWithAuthProvider({ + cfg: params.cfg, + providerAuthKey, + provider: profileConfig.provider, + mode: profileConfig.mode, + credential: cred, + }) ) { return { eligible: false, reasonCode: "provider_mismatch" }; } @@ -148,15 +236,25 @@ export function resolveAuthProfileOrder(params: { const explicitOrder = storedOrder ?? configuredOrder; const explicitProfiles = cfg?.auth?.profiles ? Object.entries(cfg.auth.profiles) - .filter( - ([, profile]) => - resolveProviderIdForAuth(profile.provider, { config: cfg }) === providerAuthKey, + .filter(([profileId, profile]) => + isConfiguredProfileCompatibleWithAuthProvider({ + cfg, + providerAuthKey, + provider: profile.provider, + mode: profile.mode, + credential: store.profiles[profileId], + }), ) .map(([profileId]) => profileId) : []; + const storeProfiles = listProfilesCompatibleWithAuthProvider({ + cfg, + store, + provider, + providerAuthKey, + }); const baseOrder = - explicitOrder ?? - (explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, provider)); + explicitOrder ?? (explicitProfiles.length > 0 ? explicitProfiles : storeProfiles); if (baseOrder.length === 0) { return []; } @@ -176,7 +274,6 @@ export function resolveAuthProfileOrder(params: { // provider's stored credentials and use any valid entries. const allBaseProfilesMissing = baseOrder.every((profileId) => !store.profiles[profileId]); if (filtered.length === 0 && explicitProfiles.length > 0 && allBaseProfilesMissing) { - const storeProfiles = listProfilesForProvider(store, provider); filtered = storeProfiles.filter(isValidProfile); } diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index 39eb0bb8aa0..62af2a6a597 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -18,6 +18,10 @@ function resetSuccessfulUsageStats( return { ...existing, errorCount: 0, + blockedUntil: undefined, + blockedReason: undefined, + blockedSource: undefined, + blockedModel: undefined, cooldownUntil: undefined, cooldownReason: undefined, cooldownModel: undefined, diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index 99e17e8757b..a8eca7460f5 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -83,9 +83,16 @@ export type AuthProfileFailureReason = | "unclassified" | "unknown"; +export type AuthProfileBlockedReason = "subscription_limit"; +export type AuthProfileBlockedSource = "codex_rate_limits" | "wham"; + /** Per-profile usage statistics for round-robin and cooldown tracking */ export type ProfileUsageStats = { lastUsed?: number; + blockedUntil?: number; + blockedReason?: AuthProfileBlockedReason; + blockedSource?: AuthProfileBlockedSource; + blockedModel?: string; cooldownUntil?: number; cooldownReason?: AuthProfileFailureReason; cooldownModel?: string; diff --git a/src/agents/auth-profiles/usage-state.ts b/src/agents/auth-profiles/usage-state.ts index 9b0889a3e94..784877e9926 100644 --- a/src/agents/auth-profiles/usage-state.ts +++ b/src/agents/auth-profiles/usage-state.ts @@ -7,9 +7,9 @@ export function isAuthCooldownBypassedForProvider(provider: string | undefined): } export function resolveProfileUnusableUntil( - stats: Pick, + stats: Pick, ): number | null { - const values = [stats.cooldownUntil, stats.disabledUntil] + const values = [stats.blockedUntil, stats.cooldownUntil, stats.disabledUntil] .filter((value): value is number => typeof value === "number") .filter((value) => Number.isFinite(value) && value > 0); if (values.length === 0) { @@ -150,6 +150,11 @@ export function clearExpiredCooldowns(store: AuthProfileStore, now?: number): bo Number.isFinite(stats.cooldownUntil) && stats.cooldownUntil > 0 && ts >= stats.cooldownUntil; + const blockedExpired = + typeof stats.blockedUntil === "number" && + Number.isFinite(stats.blockedUntil) && + stats.blockedUntil > 0 && + ts >= stats.blockedUntil; const disabledExpired = typeof stats.disabledUntil === "number" && Number.isFinite(stats.disabledUntil) && @@ -162,6 +167,13 @@ export function clearExpiredCooldowns(store: AuthProfileStore, now?: number): bo stats.cooldownModel = undefined; profileMutated = true; } + if (blockedExpired) { + stats.blockedUntil = undefined; + stats.blockedReason = undefined; + stats.blockedSource = undefined; + stats.blockedModel = undefined; + profileMutated = true; + } if (disabledExpired) { stats.disabledUntil = undefined; stats.disabledReason = undefined; diff --git a/src/agents/auth-profiles/usage.test.ts b/src/agents/auth-profiles/usage.test.ts index a9664a5139f..05753d47b14 100644 --- a/src/agents/auth-profiles/usage.test.ts +++ b/src/agents/auth-profiles/usage.test.ts @@ -57,6 +57,8 @@ function makeStore(usageStats: AuthProfileStore["usageStats"]): AuthProfileStore function expectProfileErrorStateCleared( stats: NonNullable[string] | undefined, ) { + expect(stats?.blockedUntil).toBeUndefined(); + expect(stats?.blockedReason).toBeUndefined(); expect(stats?.cooldownUntil).toBeUndefined(); expect(stats?.disabledUntil).toBeUndefined(); expect(stats?.disabledReason).toBeUndefined(); @@ -65,13 +67,15 @@ function expectProfileErrorStateCleared( } describe("resolveProfileUnusableUntil", () => { - it("returns null when both values are missing or invalid", () => { + it("returns null when all values are missing or invalid", () => { expect(resolveProfileUnusableUntil({})).toBeNull(); expect(resolveProfileUnusableUntil({ cooldownUntil: 0, disabledUntil: Number.NaN })).toBeNull(); }); it("returns the latest active timestamp", () => { - expect(resolveProfileUnusableUntil({ cooldownUntil: 100, disabledUntil: 200 })).toBe(200); + expect( + resolveProfileUnusableUntil({ blockedUntil: 300, cooldownUntil: 100, disabledUntil: 200 }), + ).toBe(300); expect(resolveProfileUnusableUntil({ cooldownUntil: 300 })).toBe(300); }); }); @@ -116,6 +120,16 @@ describe("isProfileInCooldown", () => { expect(isProfileInCooldown(store, "anthropic:default")).toBe(true); }); + it("returns true when blockedUntil is in the future", () => { + const store = makeStore({ + "openai-codex:default": { + blockedUntil: Date.now() + 60_000, + blockedReason: "subscription_limit", + }, + }); + expect(isProfileInCooldown(store, "openai-codex:default")).toBe(true); + }); + it("returns false when cooldownUntil has passed", () => { const store = makeStore({ "anthropic:default": { cooldownUntil: Date.now() - 1_000 }, @@ -383,6 +397,30 @@ describe("clearExpiredCooldowns", () => { expect(stats?.lastFailureAt).toBe(lastFailureAt); }); + it("clears expired blockedUntil and resets errorCount", () => { + const lastFailureAt = Date.now() - 120_000; + const store = makeStore({ + "openai-codex:default": { + blockedUntil: Date.now() - 1_000, + blockedReason: "subscription_limit", + blockedSource: "codex_rate_limits", + errorCount: 4, + failureCounts: { rate_limit: 4 }, + lastFailureAt, + }, + }); + + expect(clearExpiredCooldowns(store)).toBe(true); + + const stats = store.usageStats?.["openai-codex:default"]; + expect(stats?.blockedUntil).toBeUndefined(); + expect(stats?.blockedReason).toBeUndefined(); + expect(stats?.blockedSource).toBeUndefined(); + expect(stats?.errorCount).toBe(0); + expect(stats?.failureCounts).toBeUndefined(); + expect(stats?.lastFailureAt).toBe(lastFailureAt); + }); + it("clears expired disabledUntil and disabledReason", () => { const store = makeStore({ "anthropic:default": { @@ -803,7 +841,8 @@ describe("markAuthProfileFailure — WHAM-aware Codex cooldowns", () => { primary_window: { used_percent: 100, reset_after_seconds: 7_200 }, }, }, - expectedMs: 3_600_000, + expectedMs: 7_200_000, + exactBlocked: true, }, { label: "team rolling window", @@ -814,7 +853,8 @@ describe("markAuthProfileFailure — WHAM-aware Codex cooldowns", () => { secondary_window: { used_percent: 85, reset_after_seconds: 201_600 }, }, }, - expectedMs: 3_600_000, + expectedMs: 7_200_000, + exactBlocked: true, }, { label: "team weekly window", @@ -825,9 +865,10 @@ describe("markAuthProfileFailure — WHAM-aware Codex cooldowns", () => { secondary_window: { used_percent: 100, reset_after_seconds: 28_800 }, }, }, - expectedMs: 14_400_000, + expectedMs: 28_800_000, + exactBlocked: true, }, - ])("maps $label to the expected cooldown", async ({ response, expectedMs }) => { + ])("maps $label to the expected cooldown", async ({ response, expectedMs, exactBlocked }) => { const now = 1_700_000_000_000; const store = makeStore({}); mockWhamResponse(200, response); @@ -843,7 +884,14 @@ describe("markAuthProfileFailure — WHAM-aware Codex cooldowns", () => { expect(headers["ChatGPT-Account-Id"]).toBe("acct_test_123"); expect(headers.originator).toBe("openclaw"); expect(headers["User-Agent"]).toMatch(/^openclaw\//); - expect(store.usageStats?.["openai-codex:default"]?.cooldownUntil).toBe(now + expectedMs); + const stats = store.usageStats?.["openai-codex:default"]; + if (exactBlocked) { + expect(stats?.blockedUntil).toBe(now + expectedMs); + expect(stats?.blockedReason).toBe("subscription_limit"); + expect(stats?.cooldownUntil).toBeUndefined(); + } else { + expect(stats?.cooldownUntil).toBe(now + expectedMs); + } }); it("maps HTTP 401 to a 12h cooldown", async () => { diff --git a/src/agents/auth-profiles/usage.ts b/src/agents/auth-profiles/usage.ts index 904cbcf1b53..d54b1239034 100644 --- a/src/agents/auth-profiles/usage.ts +++ b/src/agents/auth-profiles/usage.ts @@ -3,7 +3,12 @@ import { normalizeProviderId } from "../provider-id.js"; import { resolveProviderRequestHeaders } from "../provider-request-config.js"; import { logAuthProfileFailureStateChange } from "./state-observation.js"; import { saveAuthProfileStore, updateAuthProfileStoreWithLock } from "./store.js"; -import type { AuthProfileFailureReason, AuthProfileStore, ProfileUsageStats } from "./types.js"; +import type { + AuthProfileBlockedSource, + AuthProfileFailureReason, + AuthProfileStore, + ProfileUsageStats, +} from "./types.js"; import { isActiveUnusableWindow, isAuthCooldownBypassedForProvider, @@ -61,9 +66,6 @@ const WHAM_PROBE_FAILURE_COOLDOWN_MS = 30_000; const WHAM_HTTP_ERROR_COOLDOWN_MS = 5 * 60 * 1000; const WHAM_TOKEN_EXPIRED_COOLDOWN_MS = 12 * 60 * 60 * 1000; const WHAM_DEAD_ACCOUNT_COOLDOWN_MS = 24 * 60 * 60 * 1000; -const WHAM_TEAM_ROLLING_MAX_COOLDOWN_MS = 2 * 60 * 60 * 1000; -const WHAM_PERSONAL_MAX_COOLDOWN_MS = 4 * 60 * 60 * 1000; -const WHAM_TEAM_WEEKLY_MAX_COOLDOWN_MS = 4 * 60 * 60 * 1000; type WhamUsageWindow = { limit_window_seconds?: number; @@ -83,6 +85,8 @@ type WhamUsageResponse = { type WhamCooldownProbeResult = { cooldownMs: number; reason: string; + blockedUntil?: number; + blockedSource?: AuthProfileBlockedSource; }; function shouldProbeWhamForFailure( @@ -136,12 +140,31 @@ function applyWhamCooldownResult(params: { whamResult: WhamCooldownProbeResult; }): ProfileUsageStats { const existingCooldownUntil = params.existing.cooldownUntil; + const existingBlockedUntil = params.existing.blockedUntil; const existingActiveCooldownUntil = typeof existingCooldownUntil === "number" && Number.isFinite(existingCooldownUntil) && existingCooldownUntil > params.now ? existingCooldownUntil : 0; + const existingActiveBlockedUntil = + typeof existingBlockedUntil === "number" && + Number.isFinite(existingBlockedUntil) && + existingBlockedUntil > params.now + ? existingBlockedUntil + : 0; + if (params.whamResult.blockedUntil) { + return { + ...params.computed, + blockedUntil: Math.max(existingActiveBlockedUntil, params.whamResult.blockedUntil), + blockedReason: "subscription_limit", + blockedSource: params.whamResult.blockedSource ?? "wham", + blockedModel: undefined, + cooldownUntil: undefined, + cooldownReason: undefined, + cooldownModel: undefined, + }; + } return { ...params.computed, cooldownUntil: Math.max(existingActiveCooldownUntil, params.now + params.whamResult.cooldownMs), @@ -210,7 +233,9 @@ async function probeWhamForCooldown( return { cooldownMs: WHAM_PROBE_FAILURE_COOLDOWN_MS, reason: "wham_probe_failed" }; } return { - cooldownMs: Math.min(Math.floor(primaryResetMs / 2), WHAM_PERSONAL_MAX_COOLDOWN_MS), + cooldownMs: WHAM_BURST_COOLDOWN_MS, + blockedUntil: now + primaryResetMs, + blockedSource: "wham", reason: "wham_personal_rolling", }; } @@ -220,7 +245,9 @@ async function probeWhamForCooldown( return { cooldownMs: WHAM_PROBE_FAILURE_COOLDOWN_MS, reason: "wham_probe_failed" }; } return { - cooldownMs: Math.min(Math.floor(secondaryResetMs / 2), WHAM_TEAM_WEEKLY_MAX_COOLDOWN_MS), + cooldownMs: WHAM_BURST_COOLDOWN_MS, + blockedUntil: now + secondaryResetMs, + blockedSource: "wham", reason: "wham_team_weekly", }; } @@ -230,7 +257,9 @@ async function probeWhamForCooldown( return { cooldownMs: WHAM_PROBE_FAILURE_COOLDOWN_MS, reason: "wham_probe_failed" }; } return { - cooldownMs: Math.min(Math.floor(primaryResetMs / 2), WHAM_TEAM_ROLLING_MAX_COOLDOWN_MS), + cooldownMs: WHAM_BURST_COOLDOWN_MS, + blockedUntil: now + primaryResetMs, + blockedSource: "wham", reason: "wham_team_rolling", }; } @@ -276,6 +305,11 @@ export function resolveProfilesUnavailableReason(params: { continue; } + if (isActiveUnusableWindow(stats.blockedUntil, now)) { + addScore("rate_limit", 1_000); + continue; + } + const cooldownActive = isActiveUnusableWindow(stats.cooldownUntil, now); if (!cooldownActive) { continue; @@ -468,6 +502,10 @@ function resetUsageStats( return { ...existing, errorCount: 0, + blockedUntil: undefined, + blockedReason: undefined, + blockedSource: undefined, + blockedModel: undefined, cooldownUntil: undefined, cooldownReason: undefined, cooldownModel: undefined, @@ -722,6 +760,121 @@ export async function markAuthProfileFailure(params: { }); } +export async function markAuthProfileBlockedUntil(params: { + store: AuthProfileStore; + profileId: string; + blockedUntil: number; + source: AuthProfileBlockedSource; + agentDir?: string; + runId?: string; + modelId?: string; +}): Promise { + const { store, profileId, blockedUntil, agentDir, runId, modelId, source } = params; + const profile = store.profiles[profileId]; + if ( + !profile || + isAuthCooldownBypassedForProvider(profile.provider) || + !Number.isFinite(blockedUntil) || + blockedUntil <= Date.now() + ) { + return; + } + + let nextStats: ProfileUsageStats | undefined; + let previousStats: ProfileUsageStats | undefined; + let updateTime = 0; + const updated = await authProfileUsageDeps.updateAuthProfileStoreWithLock({ + agentDir, + updater: (freshStore) => { + const profile = freshStore.profiles[profileId]; + if (!profile || isAuthCooldownBypassedForProvider(profile.provider)) { + return false; + } + const now = Date.now(); + previousStats = freshStore.usageStats?.[profileId]; + updateTime = now; + const existingBlockedUntil = previousStats?.blockedUntil; + const activeBlockedUntil = + typeof existingBlockedUntil === "number" && + Number.isFinite(existingBlockedUntil) && + existingBlockedUntil > now + ? existingBlockedUntil + : 0; + nextStats = { + ...previousStats, + blockedUntil: Math.max(activeBlockedUntil, blockedUntil), + blockedReason: "subscription_limit", + blockedSource: source, + blockedModel: modelId, + cooldownUntil: undefined, + cooldownReason: undefined, + cooldownModel: undefined, + lastFailureAt: now, + failureCounts: { + ...previousStats?.failureCounts, + rate_limit: (previousStats?.failureCounts?.rate_limit ?? 0) + 1, + }, + }; + updateUsageStatsEntry(freshStore, profileId, () => nextStats as ProfileUsageStats); + return true; + }, + }); + if (updated) { + store.usageStats = updated.usageStats; + if (nextStats) { + logAuthProfileFailureStateChange({ + runId, + profileId, + provider: profile.provider, + reason: "rate_limit", + previous: previousStats, + next: nextStats, + now: updateTime, + }); + } + return; + } + if (!store.profiles[profileId]) { + return; + } + + const now = Date.now(); + previousStats = store.usageStats?.[profileId]; + const existingBlockedUntil = previousStats?.blockedUntil; + const activeBlockedUntil = + typeof existingBlockedUntil === "number" && + Number.isFinite(existingBlockedUntil) && + existingBlockedUntil > now + ? existingBlockedUntil + : 0; + nextStats = { + ...previousStats, + blockedUntil: Math.max(activeBlockedUntil, blockedUntil), + blockedReason: "subscription_limit", + blockedSource: source, + blockedModel: modelId, + cooldownUntil: undefined, + cooldownReason: undefined, + cooldownModel: undefined, + lastFailureAt: now, + failureCounts: { + ...previousStats?.failureCounts, + rate_limit: (previousStats?.failureCounts?.rate_limit ?? 0) + 1, + }, + }; + updateUsageStatsEntry(store, profileId, () => nextStats as ProfileUsageStats); + authProfileUsageDeps.saveAuthProfileStore(store, agentDir); + logAuthProfileFailureStateChange({ + runId, + profileId, + provider: store.profiles[profileId]?.provider ?? profile.provider, + reason: "rate_limit", + previous: previousStats, + next: nextStats, + now, + }); +} + /** * Mark a profile as transiently failed. Applies stepped backoff cooldown. * Cooldown times: 30s, 1min, 5min (capped). diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 4936995ec71..43651d47dd2 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -31,6 +31,7 @@ import { import { type AuthProfileFailureReason, type AuthProfileStore, + isProfileInCooldown, markAuthProfileFailure, markAuthProfileSuccess, resolveAuthProfileEligibility, @@ -263,17 +264,22 @@ function createEmptyAuthProfileStore(): AuthProfileStore { function createScopedAuthProfileStore( store: AuthProfileStore, - profileId: string | undefined, + profileIds: string | undefined | string[], ): AuthProfileStore { - const normalizedProfileId = profileId?.trim(); const profiles = store.profiles ?? {}; - const credential = normalizedProfileId ? profiles[normalizedProfileId] : undefined; - return credential && normalizedProfileId + const normalizedProfileIds = (Array.isArray(profileIds) ? profileIds : [profileIds]) + .map((profileId) => profileId?.trim()) + .filter((profileId): profileId is string => !!profileId); + const scopedProfiles = Object.fromEntries( + normalizedProfileIds.flatMap((profileId) => { + const credential = profiles[profileId]; + return credential ? [[profileId, credential] as const] : []; + }), + ); + return Object.keys(scopedProfiles).length > 0 ? { version: store.version, - profiles: { - [normalizedProfileId]: credential, - }, + profiles: scopedProfiles, } : createEmptyAuthProfileStore(); } @@ -610,12 +616,34 @@ export async function runEmbeddedPiAgent( }) : authStore; const requestedProfileId = params.authProfileId?.trim(); - const resolvePluginHarnessPreferredProfileId = (): string | undefined => { + const isForwardablePluginHarnessAuthProfile = ( + profileId: string | undefined, + ): profileId is string => { + if (!pluginHarnessOwnsTransport || !profileId) { + return false; + } + const credential = attemptAuthProfileStore.profiles?.[profileId]; + const runtimeAuthPlan = buildAgentRuntimeAuthPlan({ + provider, + authProfileProvider: credential?.provider ?? profileId.split(":", 1)[0], + authProfileMode: credential?.type, + sessionAuthProfileId: profileId, + config: params.config, + workspaceDir: resolvedWorkspace, + harnessId: agentHarness.id, + harnessRuntime: agentHarness.id, + allowHarnessAuthProfileForwarding: true, + }); + return runtimeAuthPlan.forwardedAuthProfileId === profileId; + }; + const resolvePluginHarnessProfileOrder = (): string[] => { if (requestedProfileId) { - return requestedProfileId; + return isForwardablePluginHarnessAuthProfile(requestedProfileId) + ? [requestedProfileId] + : []; } if (!pluginHarnessOwnsTransport) { - return undefined; + return []; } const runtimeAuthPlan = buildAgentRuntimeAuthPlan({ provider, @@ -627,40 +655,26 @@ export async function runEmbeddedPiAgent( }); const harnessAuthProvider = runtimeAuthPlan.harnessAuthProvider; if (!harnessAuthProvider) { - return undefined; + return []; } return resolveAuthProfileOrder({ cfg: params.config, store: attemptAuthProfileStore, provider: harnessAuthProvider, - })[0]?.trim(); + }).filter(isForwardablePluginHarnessAuthProfile); }; + const pluginHarnessProfileOrder = pluginHarnessOwnsTransport + ? resolvePluginHarnessProfileOrder() + : []; + const resolvePluginHarnessPreferredProfileId = (): string | undefined => + pluginHarnessProfileOrder[0]; const preferredProfileId = pluginHarnessOwnsTransport ? resolvePluginHarnessPreferredProfileId() : requestedProfileId; let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined; - const canForwardPluginHarnessAuthProfile = ( - profileId: string | undefined, - ): profileId is string => { - if (!pluginHarnessOwnsTransport || !profileId) { - return false; - } - const profileCredentialProvider = attemptAuthProfileStore.profiles?.[profileId]?.provider; - const runtimeAuthPlan = buildAgentRuntimeAuthPlan({ - provider, - authProfileProvider: profileCredentialProvider ?? profileId.split(":", 1)[0], - sessionAuthProfileId: profileId, - config: params.config, - workspaceDir: resolvedWorkspace, - harnessId: agentHarness.id, - harnessRuntime: agentHarness.id, - allowHarnessAuthProfileForwarding: true, - }); - return runtimeAuthPlan.forwardedAuthProfileId === profileId; - }; if (lockedProfileId) { if (pluginHarnessOwnsTransport) { - if (!canForwardPluginHarnessAuthProfile(lockedProfileId)) { + if (!isForwardablePluginHarnessAuthProfile(lockedProfileId)) { lockedProfileId = undefined; } } else { @@ -683,7 +697,7 @@ export async function runEmbeddedPiAgent( const forwardedPluginHarnessProfileId = pluginHarnessOwnsTransport && !lockedProfileId && - canForwardPluginHarnessAuthProfile(preferredProfileId) + isForwardablePluginHarnessAuthProfile(preferredProfileId) ? preferredProfileId : undefined; if (lockedProfileId && !pluginHarnessOwnsTransport) { @@ -730,11 +744,21 @@ export async function runEmbeddedPiAgent( ...profileOrder.filter((profileId) => profileId !== providerPreferredProfileId), ] : profileOrder; - const profileCandidates = lockedProfileId - ? [lockedProfileId] - : providerOrderedProfiles.length > 0 - ? providerOrderedProfiles - : [undefined]; + const profileCandidates = pluginHarnessOwnsTransport + ? lockedProfileId + ? [lockedProfileId] + : pluginHarnessProfileOrder.length > 0 + ? pluginHarnessProfileOrder + : [undefined] + : lockedProfileId + ? [lockedProfileId] + : providerOrderedProfiles.length > 0 + ? providerOrderedProfiles + : [undefined]; + const pluginHarnessForwardedProfileCandidates = pluginHarnessOwnsTransport + ? profileCandidates.filter(isForwardablePluginHarnessAuthProfile) + : []; + const profileFailureStore = pluginHarnessOwnsTransport ? attemptAuthProfileStore : authStore; let profileIndex = 0; const traceAttempts: TraceAttempt[] = []; @@ -797,6 +821,29 @@ export async function runEmbeddedPiAgent( }, log, }); + const advancePluginHarnessAuthProfile = async (): Promise => { + if (!pluginHarnessOwnsTransport || lockedProfileId) { + return false; + } + let nextIndex = profileIndex + 1; + while (nextIndex < profileCandidates.length) { + const candidate = profileCandidates[nextIndex]; + if (!candidate || !isForwardablePluginHarnessAuthProfile(candidate)) { + nextIndex += 1; + continue; + } + if (isProfileInCooldown(attemptAuthProfileStore, candidate, undefined, modelId)) { + nextIndex += 1; + continue; + } + profileIndex = nextIndex; + lastProfileId = candidate; + thinkLevel = initialThinkLevel; + attemptedThinking.clear(); + return true; + } + return false; + }; // Plugin harnesses own their model transport/auth. Running PI's generic // auth bootstrap here can turn synthetic provider markers into real @@ -811,7 +858,12 @@ export async function runEmbeddedPiAgent( startupStages.mark("auth"); notifyExecutionPhase("auth", { provider, model: modelId }); const runAttemptAuthProfileStore = pluginHarnessOwnsTransport - ? createScopedAuthProfileStore(attemptAuthProfileStore, lastProfileId) + ? createScopedAuthProfileStore( + attemptAuthProfileStore, + pluginHarnessForwardedProfileCandidates.length > 0 + ? pluginHarnessForwardedProfileCandidates + : lastProfileId, + ) : attemptAuthProfileStore; const { sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, @@ -960,7 +1012,7 @@ export async function runEmbeddedPiAgent( return; } await markAuthProfileFailure({ - store: authStore, + store: profileFailureStore, profileId, reason, cfg: params.config, @@ -1156,8 +1208,17 @@ export async function runEmbeddedPiAgent( harnessId: agentHarness.id, harnessRuntime: agentHarness.id, allowHarnessAuthProfileForwarding: pluginHarnessOwnsTransport, - authProfileProvider: lastProfileId?.split(":", 1)[0], + authProfileProvider: + (lastProfileId + ? attemptAuthProfileStore.profiles?.[lastProfileId]?.provider + : undefined) ?? lastProfileId?.split(":", 1)[0], + authProfileMode: lastProfileId + ? attemptAuthProfileStore.profiles?.[lastProfileId]?.type + : undefined, sessionAuthProfileId: lastProfileId, + sessionAuthProfileCandidateIds: pluginHarnessOwnsTransport + ? pluginHarnessForwardedProfileCandidates + : undefined, config: params.config, workspaceDir: resolvedWorkspace, agentDir, @@ -2094,7 +2155,9 @@ export async function runEmbeddedPiAgent( }); if ( promptFailoverDecision.action === "rotate_profile" && - (await advanceAuthProfile()) + (await (pluginHarnessOwnsTransport + ? advancePluginHarnessAuthProfile() + : advanceAuthProfile())) ) { if (failedPromptProfileId && promptProfileFailureReason) { void maybeMarkAuthProfileFailure({ @@ -2323,7 +2386,9 @@ export async function runEmbeddedPiAgent( maybeMarkAuthProfileFailure, maybeEscalateRateLimitProfileFallback, maybeBackoffBeforeOverloadFailover, - advanceAuthProfile, + advanceAuthProfile: pluginHarnessOwnsTransport + ? advancePluginHarnessAuthProfile + : advanceAuthProfile, }); overloadProfileRotations = assistantFailoverOutcome.overloadProfileRotations; if (assistantFailoverOutcome.action === "retry") { @@ -2883,7 +2948,7 @@ export async function runEmbeddedPiAgent( ); if (lastProfileId) { await markAuthProfileSuccess({ - store: authStore, + store: profileFailureStore, provider, profileId: lastProfileId, agentDir: params.agentDir, diff --git a/src/agents/runtime-plan/auth.ts b/src/agents/runtime-plan/auth.ts index 1e34dd10ffc..57565755acf 100644 --- a/src/agents/runtime-plan/auth.ts +++ b/src/agents/runtime-plan/auth.ts @@ -5,6 +5,7 @@ import { resolveProviderIdForAuth } from "../provider-auth-aliases.js"; import type { AgentRuntimeAuthPlan } from "./types.js"; const CODEX_HARNESS_AUTH_PROVIDER = "openai-codex"; +const OPENAI_PROVIDER = "openai"; function resolveHarnessAuthProvider(params: { harnessId?: string; @@ -18,7 +19,9 @@ function resolveHarnessAuthProvider(params: { export function buildAgentRuntimeAuthPlan(params: { provider: string; authProfileProvider?: string; + authProfileMode?: string; sessionAuthProfileId?: string; + sessionAuthProfileCandidateIds?: string[]; config?: OpenClawConfig; workspaceDir?: string; harnessId?: string; @@ -41,7 +44,10 @@ export function buildAgentRuntimeAuthPlan(params: { const harnessCanForwardProfile = params.allowHarnessAuthProfileForwarding !== false && harnessProviderForAuth && - harnessProviderForAuth === authProfileProviderForAuth; + (harnessProviderForAuth === authProfileProviderForAuth || + (harnessProviderForAuth === CODEX_HARNESS_AUTH_PROVIDER && + authProfileProviderForAuth === OPENAI_PROVIDER && + params.authProfileMode === "api_key")); const openAIPiCanForwardCodexProfile = shouldRouteOpenAIPiThroughCodexAuthProvider({ provider: providerForAuth, harnessRuntime: params.harnessRuntime, @@ -61,5 +67,8 @@ export function buildAgentRuntimeAuthPlan(params: { authProfileProviderForAuth, ...(harnessProviderForAuth ? { harnessAuthProvider: harnessProviderForAuth } : {}), ...(canForwardProfile ? { forwardedAuthProfileId: params.sessionAuthProfileId } : {}), + ...(canForwardProfile && params.sessionAuthProfileCandidateIds?.length + ? { forwardedAuthProfileCandidateIds: params.sessionAuthProfileCandidateIds } + : {}), }; } diff --git a/src/agents/runtime-plan/build.test.ts b/src/agents/runtime-plan/build.test.ts index 574193d92ed..7ea95a4e2c4 100644 --- a/src/agents/runtime-plan/build.test.ts +++ b/src/agents/runtime-plan/build.test.ts @@ -191,7 +191,7 @@ describe("AgentRuntimePlan", () => { expect(normalized[0]?.parameters).toStrictEqual({}); }); - it("does not forward OpenAI API-key profiles into the Codex harness auth slot", () => { + it("forwards OpenAI API-key backup profiles into the Codex harness auth slot", () => { const plan = buildAgentRuntimePlan({ provider: "openai", modelId: "gpt-5.4", @@ -199,6 +199,7 @@ describe("AgentRuntimePlan", () => { harnessId: "codex", harnessRuntime: "codex", authProfileProvider: "openai", + authProfileMode: "api_key", sessionAuthProfileId: "openai:work", config: {}, workspaceDir: "/tmp/openclaw-runtime-plan", @@ -207,6 +208,45 @@ describe("AgentRuntimePlan", () => { expect(plan.auth.providerForAuth).toBe("openai"); expect(plan.auth.authProfileProviderForAuth).toBe("openai"); expect(plan.auth.harnessAuthProvider).toBe("openai-codex"); + expect(plan.auth.forwardedAuthProfileId).toBe("openai:work"); + }); + + it("carries forwarded Codex harness auth candidates", () => { + const plan = buildAgentRuntimePlan({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-responses", + harnessId: "codex", + harnessRuntime: "codex", + authProfileProvider: "openai-codex", + authProfileMode: "oauth", + sessionAuthProfileId: "openai-codex:work", + sessionAuthProfileCandidateIds: ["openai-codex:work", "openai:backup"], + config: {}, + workspaceDir: "/tmp/openclaw-runtime-plan", + }); + + expect(plan.auth.forwardedAuthProfileId).toBe("openai-codex:work"); + expect(plan.auth.forwardedAuthProfileCandidateIds).toEqual([ + "openai-codex:work", + "openai:backup", + ]); + }); + + it("does not forward non-api-key OpenAI profiles into the Codex harness auth slot", () => { + const plan = buildAgentRuntimePlan({ + provider: "openai", + modelId: "gpt-5.4", + modelApi: "openai-responses", + harnessId: "codex", + harnessRuntime: "codex", + authProfileProvider: "openai", + authProfileMode: "oauth", + sessionAuthProfileId: "openai:work", + config: {}, + workspaceDir: "/tmp/openclaw-runtime-plan", + }); + expect(plan.auth.forwardedAuthProfileId).toBeUndefined(); }); diff --git a/src/agents/runtime-plan/build.ts b/src/agents/runtime-plan/build.ts index ba5bb7c7236..7d6278e2ccf 100644 --- a/src/agents/runtime-plan/build.ts +++ b/src/agents/runtime-plan/build.ts @@ -156,7 +156,9 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen const auth = buildAgentRuntimeAuthPlan({ provider: params.provider, authProfileProvider: params.authProfileProvider, + authProfileMode: params.authProfileMode, sessionAuthProfileId: params.sessionAuthProfileId, + sessionAuthProfileCandidateIds: params.sessionAuthProfileCandidateIds, config, workspaceDir: params.workspaceDir, harnessId: params.harnessId, diff --git a/src/agents/runtime-plan/types.ts b/src/agents/runtime-plan/types.ts index e84474a42f7..9f063295d32 100644 --- a/src/agents/runtime-plan/types.ts +++ b/src/agents/runtime-plan/types.ts @@ -267,6 +267,7 @@ export type AgentRuntimeAuthPlan = { authProfileProviderForAuth: string; harnessAuthProvider?: string; forwardedAuthProfileId?: string; + forwardedAuthProfileCandidateIds?: string[]; }; export type AgentRuntimePromptPlan = { @@ -389,7 +390,9 @@ export type BuildAgentRuntimePlanParams = { harnessRuntime?: string; allowHarnessAuthProfileForwarding?: boolean; authProfileProvider?: string; + authProfileMode?: string; sessionAuthProfileId?: string; + sessionAuthProfileCandidateIds?: string[]; agentId?: string; thinkingLevel?: AgentRuntimeThinkLevel; extraParamsOverride?: Record; diff --git a/src/plugin-sdk/agent-runtime.ts b/src/plugin-sdk/agent-runtime.ts index b8c975ca1c9..23ecbaf07e1 100644 --- a/src/plugin-sdk/agent-runtime.ts +++ b/src/plugin-sdk/agent-runtime.ts @@ -58,6 +58,7 @@ export { getSoonestCooldownExpiry, isProfileInCooldown, markAuthProfileCooldown, + markAuthProfileBlockedUntil, markAuthProfileFailure, resolveProfilesUnavailableReason, resolveProfileUnusableUntilForDisplay, @@ -71,6 +72,8 @@ export { export type { ApiKeyCredential, AuthCredentialReasonCode, + AuthProfileBlockedReason, + AuthProfileBlockedSource, AuthProfileCredential, AuthProfileEligibilityReasonCode, AuthProfileFailureReason,