mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-17 02:37:33 +00:00
fix(codex): rotate auth profiles inside harness
This commit is contained in:
committed by
Peter Steinberger
parent
f447e5b9db
commit
cc95d4dd28
@@ -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
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ function resetSuccessfulUsageStats(
|
||||
return {
|
||||
...existing,
|
||||
errorCount: 0,
|
||||
blockedUntil: undefined,
|
||||
blockedReason: undefined,
|
||||
blockedSource: undefined,
|
||||
blockedModel: undefined,
|
||||
cooldownUntil: undefined,
|
||||
cooldownReason: undefined,
|
||||
cooldownModel: undefined,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user