diff --git a/extensions/codex/harness.test.ts b/extensions/codex/harness.test.ts index c387732d9ebf..ef24f197506b 100644 --- a/extensions/codex/harness.test.ts +++ b/extensions/codex/harness.test.ts @@ -262,6 +262,17 @@ describe("Codex agent harness supports()", () => { ).toEqual({ supported: true, priority: 100 }); }); + it("lets explicit Codex model discovery run before auth has been prepared", () => { + expect( + harness.supports({ + provider: "openai", + modelId: "gpt-future", + requestedRuntime: "codex", + modelProvider: { requestTransportOverrides: "none" }, + }), + ).toEqual({ supported: true, priority: 100 }); + }); + it.each([ { label: "automatic runtime selection", diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 8fdb1698da98..942e6ab55077 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -210,9 +210,9 @@ export function createCodexAppServerAgentHarness( provider === "openai" && ctx.requestedRuntime === "codex" && Boolean(ctx.modelId?.trim()) && - preparedAuth?.source === "harness" && - preparedAuth.mode === undefined && - preparedAuth.requirement === undefined && + (preparedAuth === undefined || preparedAuth.source === "harness") && + preparedAuth?.mode === undefined && + preparedAuth?.requirement === undefined && ctx.modelProvider?.api === undefined && ctx.modelProvider?.baseUrl === undefined && ctx.modelProvider?.azureApiVersion === undefined && diff --git a/extensions/openai/openai-chatgpt-provider.ts b/extensions/openai/openai-chatgpt-provider.ts index abc8cffb168d..148e69e43617 100644 --- a/extensions/openai/openai-chatgpt-provider.ts +++ b/extensions/openai/openai-chatgpt-provider.ts @@ -327,6 +327,25 @@ function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext) maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS, cost: OPENAI_CODEX_GPT_54_MINI_COST, }; + } else if ( + ctx.agentRuntimeId === "codex" && + ctx.authProfileId === undefined && + ctx.authProfileMode === undefined && + ctx.providerConfig?.auth === undefined + ) { + // Codex owns its account-scoped model catalog. When that catalog is not yet + // available, keep the requested identity intact and let the native runtime + // decide whether the account can actually use it. + templateIds = OPENAI_CODEX_GPT_56_MODEL_IDS; + patch = { + reasoning: true, + input: ["text", "image"], + thinkingLevelMap: OPENAI_CODEX_GPT_56_THINKING_LEVEL_MAP, + compat: { + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }, + }; } else { return undefined; } @@ -365,6 +384,8 @@ function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext) contextWindow: patch?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, contextTokens: patch?.contextTokens, maxTokens: patch?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, + ...(patch?.thinkingLevelMap ? { thinkingLevelMap: patch.thinkingLevelMap } : {}), + ...(patch?.compat ? { compat: patch.compat } : {}), } as ProviderRuntimeModel) ); } diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 828469f6a9a3..ffafd1088f8a 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -1766,6 +1766,74 @@ describe("buildOpenAIProvider", () => { ).toEqual({ effort: "high", transport: "sse" }); }); + it("delegates an unlisted first-party model to its explicitly selected Codex runtime", () => { + const provider = buildOpenAIProvider(); + const model = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-future", + modelRegistry: { find: () => null }, + agentRuntimeId: "codex", + } as never); + + expect(model).toMatchObject({ + provider: "openai", + id: "gpt-future", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }, + }); + expect( + provider + .resolveThinkingProfile?.({ + provider: "openai", + modelId: "gpt-future", + agentRuntime: "codex", + api: model?.api, + compat: model?.compat, + } as never) + ?.levels.map((level) => level.id), + ).toContain("max"); + expect( + provider + .resolveThinkingProfile?.({ + provider: "openai", + modelId: "gpt-future", + agentRuntime: "codex", + } as never) + ?.levels.map((level) => level.id), + ).toEqual(expect.arrayContaining(["xhigh", "max"])); + }); + + it("does not invent an unlisted model for authored Platform credentials", () => { + const provider = buildOpenAIProvider(); + + expect( + provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-future", + modelRegistry: { find: () => null }, + agentRuntimeId: "codex", + authProfileId: "openai:platform", + authProfileMode: "api_key", + providerConfig: { + auth: "api-key", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + } as never), + ).toBeUndefined(); + expect( + provider + .resolveThinkingProfile?.({ + provider: "openai", + modelId: "gpt-future", + agentRuntime: "codex", + api: "openai-responses", + } as never) + ?.levels.map((level) => level.id), + ).not.toContain("max"); + }); + it("restores gpt-5.3-codex-spark only through ChatGPT/Codex OAuth routing", () => { const provider = buildOpenAIProvider(); diff --git a/extensions/openai/thinking-policy.ts b/extensions/openai/thinking-policy.ts index 648ef4feccaf..a354e5565e01 100644 --- a/extensions/openai/thinking-policy.ts +++ b/extensions/openai/thinking-policy.ts @@ -110,6 +110,12 @@ function buildOpenAIThinkingProfile(params: { (agentRuntime === "openclaw" || agentRuntime === "auto" || (agentRuntime === "codex" && codexSupportsUltra)); + const nativeCodexNeedsAccountEffortValidation = + agentRuntime === "codex" && + params.compat?.supportedReasoningEfforts === undefined && + (params.api === undefined || params.api === "openai-chatgpt-responses") && + !matchesExactOrPrefix(params.modelId, params.xhighModelIds) && + !modelId.startsWith("gpt-5.6"); const defaultLevel = isGpt56Variant ? "medium" : undefined; const fallbackLevels: ProviderThinkingProfile["levels"] = [ ...OPENAI_THINKING_BASE_LEVELS, @@ -118,6 +124,9 @@ function buildOpenAIThinkingProfile(params: { : []), ...(supportsMax ? [{ id: "max" as const }] : []), ...(supportsUltra ? [{ id: "ultra" as const }] : []), + ...(nativeCodexNeedsAccountEffortValidation + ? [{ id: "xhigh" as const }, { id: "max" as const }] + : []), ]; const levels = agentRuntime === "codex" && resolvedCodexEfforts !== undefined diff --git a/src/agents/model-auth-availability.test.ts b/src/agents/model-auth-availability.test.ts index cd0eac62a4f6..3b25baadfb5d 100644 --- a/src/agents/model-auth-availability.test.ts +++ b/src/agents/model-auth-availability.test.ts @@ -875,6 +875,15 @@ describe("createModelAuthAvailabilityResolver", () => { expect(result).not.toHaveProperty("selectedRoute"); }); + it("keeps one unconfigured Codex route indeterminate until native account validation", () => { + expect( + evaluate({ + resolution: { ...dualRoutes, routes: [subscriptionRoute] }, + syntheticAuthProviderRefs: ["codex"], + }), + ).toMatchObject({ availability: undefined, evidence: "synthetic" }); + }); + it("does not let invalid automatic profile evidence block synthetic Codex ownership", () => { expect( evaluate({ diff --git a/src/agents/model-auth-availability.ts b/src/agents/model-auth-availability.ts index daed53540aaf..5c01163c002d 100644 --- a/src/agents/model-auth-availability.ts +++ b/src/agents/model-auth-availability.ts @@ -1106,6 +1106,10 @@ export function createModelAuthAvailabilityResolver( sourcePlan, configuredAuthMode: automaticRouteAuthMode, ...(syntheticCodexOwnsAuth ? { runtimeAuthOwner: { id: "codex" } } : {}), + ...(syntheticCodexOwnsAuth && + resolveMergedModelProviderConfig(params.cfg, provider) === undefined + ? { allowNativeAuthOnSingleRoute: true } + : {}), }); if (routeAuthDecision.kind === "deferred" && syntheticCodexOwnsAuth) { return { availability: undefined, routeResolution, evidence: "synthetic" }; diff --git a/src/agents/openai-model-routes.ts b/src/agents/openai-model-routes.ts index 25ded9d10f6e..3be387b24f71 100644 --- a/src/agents/openai-model-routes.ts +++ b/src/agents/openai-model-routes.ts @@ -59,6 +59,7 @@ export function selectOpenAIModelRouteAuth(params: { sourcePlan: ProviderModelAuthSourcePlan; configuredAuthMode?: string; runtimeAuthOwner?: { id: string }; + allowNativeAuthOnSingleRoute?: boolean; }) { return selectProviderModelRouteAuth({ provider: OPENAI_PROVIDER_ID, ...params }); } diff --git a/src/agents/provider-model-route-auth.test.ts b/src/agents/provider-model-route-auth.test.ts index 90163e10d3c7..ef62b48eadfe 100644 --- a/src/agents/provider-model-route-auth.test.ts +++ b/src/agents/provider-model-route-auth.test.ts @@ -336,6 +336,36 @@ describe("provider model route auth", () => { }); }); + it("lets an explicit native owner authenticate its sole compatible route", () => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: { ...routes, routes: [routes.routes[1]] }, + runtimeAuthOwner: { id: "codex" }, + allowNativeAuthOnSingleRoute: true, + sourcePlan: buildProviderModelAuthSourcePlan({ profiles: [] }), + }), + ).toEqual({ + kind: "deferred", + reason: "runtime-auth-owner", + routeSupport: { + requestTransportOverrides: "none", + runtimePolicy: { compatibleIds: ["openclaw", "codex"] }, + }, + }); + }); + + it("does not infer native ownership for an explicitly authored single route", () => { + expect( + selectProviderModelRouteAuth({ + provider: "openai", + resolution: { ...routes, routes: [routes.routes[1]] }, + runtimeAuthOwner: { id: "codex" }, + sourcePlan: buildProviderModelAuthSourcePlan({ profiles: [] }), + }), + ).toMatchObject({ kind: "rejected", reason: "configured-auth" }); + }); + it.each([ ["Platform", routes.routes[0], profile("openai:chatgpt", "oauth", "ready")], ["subscription", routes.routes[1], profile("openai:platform", "api_key", "ready")], diff --git a/src/agents/provider-model-route-auth.ts b/src/agents/provider-model-route-auth.ts index 4125479207e0..e9441074e656 100644 --- a/src/agents/provider-model-route-auth.ts +++ b/src/agents/provider-model-route-auth.ts @@ -285,6 +285,8 @@ export function selectProviderModelRouteAuth(params: { configuredAuthMode?: string; /** Explicit native auth owner allowed to defer an otherwise unowned route. */ runtimeAuthOwner?: { id: string }; + /** True only when no provider transport or credentials were authored. */ + allowNativeAuthOnSingleRoute?: boolean; }): ProviderModelRouteAuthDecision { const requiredProfile = params.sourcePlan.kind === "required" && params.sourcePlan.source.kind === "profile" @@ -412,7 +414,16 @@ export function selectProviderModelRouteAuth(params: { const runtimeAuthOwnerIsCompatible = Boolean(normalizedRuntimeAuthOwner) && routeSupport.runtimePolicy.compatibleIds.includes(normalizedRuntimeAuthOwner ?? ""); - if (params.resolution.routes.length > 1 && runtimeAuthOwnerIsCompatible && !configuredRoute) { + const hostHasNoCredentialToHonor = + params.allowNativeAuthOnSingleRoute === true && + params.sourcePlan.kind === "automatic" && + params.sourcePlan.orderedProfiles.length === 0 && + params.sourcePlan.fallback === undefined; + if ( + runtimeAuthOwnerIsCompatible && + !configuredRoute && + (params.resolution.routes.length > 1 || hostHasNoCredentialToHonor) + ) { return { kind: "deferred", reason: "runtime-auth-owner", routeSupport }; } return reject( diff --git a/src/agents/runtime-plan/prepare-auth.ts b/src/agents/runtime-plan/prepare-auth.ts index 37fdabe091dc..ac7f9a5ddc01 100644 --- a/src/agents/runtime-plan/prepare-auth.ts +++ b/src/agents/runtime-plan/prepare-auth.ts @@ -520,6 +520,9 @@ export function prepareAgentRuntimeAuth( sourcePlan, configuredAuthMode: automaticRouteAuthMode, ...(runtimeAuthOwner ? { runtimeAuthOwner } : {}), + ...(runtimeAuthOwner && configuredProvider === undefined + ? { allowNativeAuthOnSingleRoute: true } + : {}), }); if (routeAuthDecision.kind === "deferred") { const plan = buildAgentRuntimeAuthPlan({