Fix OpenAI Codex runtime provider routing (#82864)

* fix: route Codex OpenAI runtime through Codex provider

* docs: add Codex routing evidence collection

* fix(agents): bootstrap OAuth credentials for Codex harness with openai/* model refs

When a plugin harness (e.g. Codex) owns its transport but the runtime
plan resolved to openai-codex via agentRuntime.id: codex, the auth
profile store was left empty because pluginHarnessOwnsTransport short-
circuited initializeAuthProfile(). This caused 'No API key found for
openai-codex' at runtime even though the OAuth profile existed in OpenClaw's
store.

- Add pluginHarnessNeedsOpenClawAuthBootstrap flag when harness owns
transport but the provider is openai-codex and the API is openai-codex-
responses
- Populate authStore and attemptAuthProfileStore from OpenClaw's profile
store in this case
- Run initializeAuthProfile() to forward the OAuth token into the harness
- Update overflow-compaction tests to expect 'openai-codex' provider
  and add dedicated test for OAuth bootstrap path

* fix(agents): refresh Codex OAuth credentials on profile rotation

---------

Co-authored-by: PsiClawOps <267826480+PsiClawOps@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
ragesaq
2026-05-16 23:06:18 -07:00
committed by GitHub
parent 451563b950
commit 58f1db1bc8
5 changed files with 352 additions and 25 deletions

View File

@@ -658,6 +658,36 @@ installed and enabled. If you need strict proof while testing, set provider or
model `agentRuntime.id: "codex"`. A forced Codex runtime fails instead of
falling back to PI.
**OpenAI Codex runtime falls back to the API-key path:** collect a redacted
gateway excerpt that shows the model, runtime, selected provider, and failure.
Ask affected collaborators to run this read-only command on their OpenClaw host:
```bash
(
pattern='openai/gpt-5\.[45]|agentRuntime(\.id)?|harnessRuntime|Runtime: OpenAI Codex|openai-codex|resolveSelectedOpenAIPiRuntimeProvider|candidateProvider[": ]+openai|status[": ]+401|Incorrect API key|No API key|api-key path|API-key path|OAuth'
if ls /tmp/openclaw/openclaw-*.log >/dev/null 2>&1; then
grep -E -i -n "$pattern" /tmp/openclaw/openclaw-*.log 2>/dev/null || true
else
journalctl --user -u openclaw-gateway --since today --no-pager 2>/dev/null \
| grep -E -i "$pattern" || true
fi
) | sed -E \
-e 's/(Authorization: Bearer )[A-Za-z0-9._~+\/-]+/\1[REDACTED]/Ig' \
-e 's/(Bearer )[A-Za-z0-9._~+\/-]+/\1[REDACTED]/Ig' \
-e 's/(api[_ -]?key[=: ]+)[^ ,}"]+/\1[REDACTED]/Ig' \
-e 's/(OPENAI_API_KEY[=: ]+)[^ ,}"]+/\1[REDACTED]/Ig' \
-e 's/sk-[A-Za-z0-9_-]{12,}/sk-[REDACTED]/g' \
-e 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/[EMAIL-REDACTED]/g' \
| tail -200
```
Useful excerpts usually include `openai/gpt-5.5` or `openai/gpt-5.4`,
`Runtime: OpenAI Codex`, `agentRuntime.id` or `harnessRuntime`,
`candidateProvider: "openai"`, and a `401`, `Incorrect API key`, or
`No API key` result. A corrected run should show the `openai-codex` OAuth
path instead of a plain OpenAI API-key failure.
**Legacy `openai-codex/*` config remains:** run `openclaw doctor --fix`.
Doctor rewrites legacy model refs to `openai/*`, removes stale session and
whole-agent runtime pins, and preserves existing auth-profile overrides.

View File

@@ -150,4 +150,22 @@ describe("OpenAI Codex routing policy", () => {
}),
).toEqual(["openai-codex"]);
});
it("routes openai provider to openai-codex when harness runtime is codex", () => {
expect(
resolveSelectedOpenAIPiRuntimeProvider({
provider: "openai",
harnessRuntime: "codex",
}),
).toBe("openai-codex");
});
it("does not route non-OpenAI providers when runtime is codex", () => {
expect(
resolveSelectedOpenAIPiRuntimeProvider({
provider: "anthropic",
harnessRuntime: "codex",
}),
).toBe("anthropic");
});
});

View File

@@ -181,8 +181,13 @@ export function resolveSelectedOpenAIPiRuntimeProvider(params: {
return OPENAI_CODEX_PROVIDER_ID;
}
const runtime = normalizeEmbeddedAgentRuntime(params.agentHarnessId ?? params.harnessRuntime);
return isOpenAIProvider(params.provider) &&
runtime === "pi" &&
if (!isOpenAIProvider(params.provider)) {
return params.provider;
}
if (runtime === "codex") {
return OPENAI_CODEX_PROVIDER_ID;
}
return runtime === "pi" &&
!params.authProfileId?.trim() &&
configuredOpenAIAuthOrderStartsWithCodexProfile(params.config)
? OPENAI_CODEX_PROVIDER_ID

View File

@@ -27,6 +27,7 @@ import {
mockedResolveAuthProfileOrder,
mockedResolveContextWindowInfo,
mockedResolveFailoverStatus,
mockedResolveModelAsync,
mockedRunContextEngineMaintenance,
mockedRunEmbeddedAttempt,
mockedSessionLikelyHasOversizedToolResults,
@@ -496,7 +497,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1);
expect(pluginRunAttempt).toHaveBeenCalledTimes(1);
const pluginParams = expectMockCallFields(pluginRunAttempt, {
provider: "openai",
provider: "openai-codex",
authProfileId: "openai-codex:work",
authProfileIdSource: "user",
});
@@ -522,6 +523,272 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(successParams.profileId).toBe("openai-codex:work");
});
it("bootstraps OAuth credentials for forced openai/* Codex response runs", async () => {
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () =>
makeAttemptResult({ assistantTexts: ["ok"] }),
);
const codexAuthStorage = {
setRuntimeApiKey: vi.fn(),
getApiKey: vi.fn(async () => "stored-test-key"),
};
const runtimePlan = makeForwardedRuntimePlan({
resolvedRef: {
provider: "openai-codex",
modelId: "gpt-5.5",
harnessId: "codex",
},
auth: {
providerForAuth: "openai-codex",
authProfileProviderForAuth: "openai-codex",
harnessAuthProvider: "openai-codex",
forwardedAuthProfileId: "openai-codex:work",
},
});
const codexAuthStore = {
version: 1 as const,
profiles: {
"openai-codex:work": {
type: "oauth" as const,
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
};
clearAgentHarnesses();
registerAgentHarness({
id: "codex",
label: "Codex",
supports: () => ({ supported: false }),
runAttempt: pluginRunAttempt,
});
mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore);
mockedResolveModelAsync
.mockResolvedValueOnce({
model: {
id: "gpt-5.5",
provider: "openai",
contextWindow: 200000,
api: "openai-responses",
},
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
})
.mockResolvedValueOnce({
model: {
id: "gpt-5.5",
provider: "openai-codex",
contextWindow: 200000,
api: "openai-codex-responses",
},
error: null,
authStorage: codexAuthStorage,
modelRegistry: {},
});
mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan);
try {
await runEmbeddedPiAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
config: {
agents: {
defaults: {
agentRuntime: { id: "codex" },
},
},
},
authProfileId: "openai-codex:work",
authProfileIdSource: "user",
runId: "forced-openai-codex-responses-bootstrap-oauth",
});
} finally {
clearAgentHarnesses();
}
expect(mockedGetApiKeyForModel).toHaveBeenCalledTimes(1);
expectMockCallFields(mockedGetApiKeyForModel, {
profileId: "openai-codex:work",
});
expect(codexAuthStorage.setRuntimeApiKey).toHaveBeenCalledWith("openai-codex", "test-key");
expect(pluginRunAttempt).toHaveBeenCalledTimes(1);
expectMockCallFields(pluginRunAttempt, {
provider: "openai-codex",
authProfileId: "openai-codex:work",
authProfileIdSource: "user",
resolvedApiKey: "test-key",
});
});
it("refreshes bootstrapped Codex OAuth credentials when rotating profiles", async () => {
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
const subscriptionLimit = new Error(
"You've reached your Codex subscription usage limit. Next reset in 20 hours.",
);
const normalizedLimit = Object.assign(new Error(subscriptionLimit.message), {
name: "FailoverError",
reason: "rate_limit",
status: 429,
});
let attemptCount = 0;
const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () => {
attemptCount += 1;
return attemptCount === 1
? makeAttemptResult({ promptError: subscriptionLimit })
: makeAttemptResult({ assistantTexts: ["backup ok"], promptError: null });
});
const codexAuthStorage = {
setRuntimeApiKey: vi.fn(),
getApiKey: vi.fn(async () => "stored-test-key"),
};
const firstRuntimePlan = makeForwardedRuntimePlan({
resolvedRef: {
provider: "openai-codex",
modelId: "gpt-5.5",
harnessId: "codex",
},
auth: {
providerForAuth: "openai-codex",
authProfileProviderForAuth: "openai-codex",
harnessAuthProvider: "openai-codex",
forwardedAuthProfileId: "openai-codex:sub",
forwardedAuthProfileCandidateIds: ["openai-codex:sub", "openai-codex:backup"],
},
});
const secondRuntimePlan = makeForwardedRuntimePlan({
resolvedRef: {
provider: "openai-codex",
modelId: "gpt-5.5",
harnessId: "codex",
},
auth: {
providerForAuth: "openai-codex",
authProfileProviderForAuth: "openai-codex",
harnessAuthProvider: "openai-codex",
forwardedAuthProfileId: "openai-codex:backup",
forwardedAuthProfileCandidateIds: ["openai-codex:sub", "openai-codex:backup"],
},
});
const codexAuthStore = {
version: 1 as const,
profiles: {
"openai-codex:sub": {
type: "oauth" as const,
provider: "openai-codex",
access: "sub-access-token",
refresh: "sub-refresh-token",
expires: Date.now() + 60_000,
},
"openai-codex:backup": {
type: "oauth" as const,
provider: "openai-codex",
access: "backup-access-token",
refresh: "backup-refresh-token",
expires: Date.now() + 60_000,
},
},
};
clearAgentHarnesses();
registerAgentHarness({
id: "codex",
label: "Codex",
supports: () => ({ supported: false }),
runAttempt: pluginRunAttempt,
});
mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore);
mockedResolveAuthProfileOrder.mockReturnValueOnce(["openai-codex:sub", "openai-codex:backup"]);
mockedResolveModelAsync
.mockResolvedValueOnce({
model: {
id: "gpt-5.5",
provider: "openai",
contextWindow: 200000,
api: "openai-responses",
},
error: null,
authStorage: { setRuntimeApiKey: vi.fn() },
modelRegistry: {},
})
.mockResolvedValueOnce({
model: {
id: "gpt-5.5",
provider: "openai-codex",
contextWindow: 200000,
api: "openai-codex-responses",
},
error: null,
authStorage: codexAuthStorage,
modelRegistry: {},
});
mockedBuildAgentRuntimePlan
.mockReturnValueOnce(firstRuntimePlan)
.mockReturnValueOnce(secondRuntimePlan);
mockedGetApiKeyForModel.mockImplementation(
async ({ profileId }: { profileId?: string } = {}) => ({
apiKey: profileId === "openai-codex:backup" ? "backup-token" : "sub-token",
profileId: profileId ?? "openai-codex:sub",
source: "test",
mode: "api-key",
}),
);
mockedCoerceToFailoverError.mockReturnValueOnce(normalizedLimit);
mockedDescribeFailoverError.mockImplementation((err: unknown) => ({
message: err instanceof Error ? err.message : String(err),
reason: err === normalizedLimit ? "rate_limit" : undefined,
status: err === normalizedLimit ? 429 : undefined,
code: undefined,
}));
try {
await runEmbeddedPiAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
config: {
agents: {
defaults: {
agentRuntime: { id: "codex" },
},
},
},
runId: "forced-openai-codex-responses-rotates-oauth",
});
} finally {
clearAgentHarnesses();
}
expect(mockedGetApiKeyForModel).toHaveBeenCalledTimes(2);
expect(codexAuthStorage.setRuntimeApiKey).toHaveBeenNthCalledWith(
1,
"openai-codex",
"sub-token",
);
expect(codexAuthStorage.setRuntimeApiKey).toHaveBeenNthCalledWith(
2,
"openai-codex",
"backup-token",
);
expect(pluginRunAttempt).toHaveBeenCalledTimes(2);
expectMockCallFields(pluginRunAttempt, {
provider: "openai-codex",
authProfileId: "openai-codex:sub",
resolvedApiKey: "sub-token",
});
expectMockCallFields(
pluginRunAttempt,
{
provider: "openai-codex",
authProfileId: "openai-codex:backup",
resolvedApiKey: "backup-token",
},
1,
);
});
it("keeps auto-selected OpenAI Codex auth profiles for forced codex harness runs", async () => {
const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js");
const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () =>
@@ -573,7 +840,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1);
expect(pluginRunAttempt).toHaveBeenCalledTimes(1);
const pluginParams = expectMockCallFields(pluginRunAttempt, {
provider: "openai",
provider: "openai-codex",
authProfileId: "openai-codex:default",
authProfileIdSource: "auto",
});
@@ -646,7 +913,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1);
expect(pluginRunAttempt).toHaveBeenCalledTimes(1);
const pluginParams = expectMockCallFields(pluginRunAttempt, {
provider: "openai",
provider: "openai-codex",
authProfileId: "openai-codex:default",
authProfileIdSource: "auto",
});
@@ -731,7 +998,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1);
expect(pluginRunAttempt).toHaveBeenCalledTimes(1);
const pluginParams = expectMockCallFields(pluginRunAttempt, {
provider: "openai",
provider: "openai-codex",
authProfileId: "openai:personal",
authProfileIdSource: "auto",
});
@@ -863,14 +1130,14 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
expect(mockedGetApiKeyForModel).not.toHaveBeenCalled();
expect(pluginRunAttempt).toHaveBeenCalledTimes(2);
const firstAttempt = expectMockCallFields(pluginRunAttempt, {
provider: "openai",
provider: "openai-codex",
authProfileId: "openai-codex:sub",
authProfileIdSource: "auto",
});
const secondAttempt = expectMockCallFields(
pluginRunAttempt,
{
provider: "openai",
provider: "openai-codex",
authProfileId: "openai:backup",
authProfileIdSource: "auto",
},

View File

@@ -62,6 +62,7 @@ import {
} from "../model-auth.js";
import { ensureOpenClawModelsJson } from "../models-config.js";
import {
OPENAI_CODEX_PROVIDER_ID,
listOpenAIAuthProfileProvidersForAgentRuntime,
resolveContextConfigProviderForRuntime,
resolveSelectedOpenAIPiRuntimeProvider,
@@ -685,16 +686,22 @@ export async function runEmbeddedPiAgent(
startupStages.mark("model-resolution");
notifyExecutionPhase("model_resolution", { provider, model: modelId });
const authStore = pluginHarnessOwnsTransport
? createEmptyAuthProfileStore()
: ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
allowKeychainPrompt: false,
});
const attemptAuthProfileStore = pluginHarnessOwnsTransport
? ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
allowKeychainPrompt: false,
})
: authStore;
const pluginHarnessNeedsOpenClawAuthBootstrap =
pluginHarnessOwnsTransport &&
provider === OPENAI_CODEX_PROVIDER_ID &&
effectiveModel.api === "openai-codex-responses";
const authStore =
pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap
? createEmptyAuthProfileStore()
: ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
allowKeychainPrompt: false,
});
const attemptAuthProfileStore =
pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap
? ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
allowKeychainPrompt: false,
})
: authStore;
const requestedProfileId = params.authProfileId?.trim();
const requestedProfileIsUserLocked = params.authProfileIdSource === "user";
const isForwardablePluginHarnessAuthProfile = (
@@ -943,11 +950,15 @@ export async function runEmbeddedPiAgent(
}
return false;
};
const advanceAttemptAuthProfile =
pluginHarnessOwnsTransport && !pluginHarnessNeedsOpenClawAuthBootstrap
? advancePluginHarnessAuthProfile
: advanceAuthProfile;
// Plugin harnesses own their model transport/auth. Running PI's generic
// auth bootstrap here can turn synthetic provider markers into real
// vendor-token refresh attempts before the plugin gets control.
if (!pluginHarnessOwnsTransport) {
if (!pluginHarnessOwnsTransport || pluginHarnessNeedsOpenClawAuthBootstrap) {
await initializeAuthProfile();
} else if (lockedProfileId) {
lastProfileId = lockedProfileId;
@@ -2277,9 +2288,7 @@ export async function runEmbeddedPiAgent(
});
if (
promptFailoverDecision.action === "rotate_profile" &&
(await (pluginHarnessOwnsTransport
? advancePluginHarnessAuthProfile()
: advanceAuthProfile()))
(await advanceAttemptAuthProfile())
) {
if (failedPromptProfileId && promptProfileFailureReason) {
void maybeMarkAuthProfileFailure({
@@ -2509,9 +2518,7 @@ export async function runEmbeddedPiAgent(
maybeMarkAuthProfileFailure,
maybeEscalateRateLimitProfileFallback,
maybeBackoffBeforeOverloadFailover,
advanceAuthProfile: pluginHarnessOwnsTransport
? advancePluginHarnessAuthProfile
: advanceAuthProfile,
advanceAuthProfile: advanceAttemptAuthProfile,
});
overloadProfileRotations = assistantFailoverOutcome.overloadProfileRotations;
if (assistantFailoverOutcome.action === "retry") {