fix(codex): rotate auth profiles inside harness

This commit is contained in:
pashpashpash
2026-05-10 20:09:40 -07:00
committed by Peter Steinberger
parent f447e5b9db
commit cc95d4dd28
28 changed files with 910 additions and 135 deletions

View File

@@ -123,15 +123,38 @@ OpenClaw **pins the chosen auth profile per session** to keep provider caches wa
Manual selection via `/model …@<profileId>` sets a **user override** for that session and is not auto-rotated until a new session starts.
<Note>
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.
</Note>
### 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

View File

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

View File

@@ -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",
],
},
},
}
```
<Note>
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.
</Note>
@@ -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

View File

@@ -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" }));

View File

@@ -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<typeof ensureAuthProfileStore>;
authProfileId?: string;

View File

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

View File

@@ -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 () => {

View File

@@ -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<void> {
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<typeof buildCodexSystemPromptReport>;
}): 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<CodexDynamicToolBridge, "handleToolCall">;
@@ -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,
};
}

View File

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

View File

@@ -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", {

View File

@@ -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<ProviderAuthResult["conf
};
}
function applyOpenAICodexAuthConfig(
cfg: ProviderAuthContext["config"],
): ProviderAuthContext["config"] {
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models: {
...cfg.agents?.defaults?.models,
[OPENAI_CODEX_DEFAULT_MODEL]: {},
},
},
},
};
}
async function refreshOpenAICodexOAuthCredential(cred: OAuthCredential) {
try {
const { refreshOpenAICodexToken } = await import("./openai-codex-provider.runtime.js");
@@ -485,6 +507,27 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
},
run: async (ctx) => 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",

View File

@@ -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 <key>",
"cliDescription": "OpenAI API Key Backup"
},
{
"provider": "openai",
"method": "api-key",

View File

@@ -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)",
);

View File

@@ -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],
};
}

View File

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

View File

@@ -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,
});

View File

@@ -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);
}

View File

@@ -18,6 +18,10 @@ function resetSuccessfulUsageStats(
return {
...existing,
errorCount: 0,
blockedUntil: undefined,
blockedReason: undefined,
blockedSource: undefined,
blockedModel: undefined,
cooldownUntil: undefined,
cooldownReason: undefined,
cooldownModel: undefined,

View File

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

View File

@@ -7,9 +7,9 @@ export function isAuthCooldownBypassedForProvider(provider: string | undefined):
}
export function resolveProfileUnusableUntil(
stats: Pick<ProfileUsageStats, "cooldownUntil" | "disabledUntil">,
stats: Pick<ProfileUsageStats, "blockedUntil" | "cooldownUntil" | "disabledUntil">,
): 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;

View File

@@ -57,6 +57,8 @@ function makeStore(usageStats: AuthProfileStore["usageStats"]): AuthProfileStore
function expectProfileErrorStateCleared(
stats: NonNullable<AuthProfileStore["usageStats"]>[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 () => {

View File

@@ -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<void> {
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).

View File

@@ -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<boolean> => {
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,

View File

@@ -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 }
: {}),
};
}

View File

@@ -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();
});

View File

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

View File

@@ -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<string, unknown>;

View File

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