diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index d4d82a061615..6c1fce777ce2 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -380,7 +380,7 @@ compare restored legacy artifacts with the SQLite rows before importing. - Doctor reports cron jobs still marked in-flight (`state.runningAtMs`), which can make `openclaw cron list` show them as `running`. This check is read-only: if no Gateway is currently executing a marked job, the next cron service startup records the interrupted run and clears the marker. - On Linux, doctor warns when the user's crontab still runs the unmaintained legacy `~/.openclaw/bin/ensure-whatsapp.sh`, which can misreport `Gateway inactive` when cron lacks the systemd user-bus environment. - When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops. -- Doctor rewrites legacy `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale session route pins. `--fix` also migrates legacy `openai-codex:*` auth profiles and `auth.order.openai-codex` entries to `openai:*`, moves Codex intent onto provider/model-scoped `agentRuntime.id: "codex"` entries, removes stale whole-agent/session runtime pins, and keeps repaired OpenAI agent refs on Codex auth routing instead of direct OpenAI API-key auth. +- Doctor rewrites legacy `codex/*` and `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, model allowlists, image/video generation models, heartbeat/subagent/compaction overrides, hooks, channel model overrides, cron payloads, and stale session/transcript route pins. `--fix` also merges legacy `models.providers.codex` and `models.providers.openai-codex` config when safe, migrates legacy `openai-codex:*` auth profiles and `auth.order.openai-codex` entries to `openai:*`, moves Codex intent onto provider/model-scoped `agentRuntime.id: "codex"` entries, removes stale whole-agent/session runtime pins, and keeps repaired OpenAI agent refs on Codex auth routing instead of direct OpenAI API-key auth. - Doctor reports nonempty `auth.order.` lists whose referenced profiles are all gone while compatible stored credentials exist. `doctor --fix` deletes only those stale overrides, restoring automatic per-agent credential selection; explicit empty orders, partially live lists, and orders without a compatible stored credential stay unchanged. If an active SQLite auth store is unreadable or malformed, doctor explains why it skipped this repair. Restart a running Gateway before rechecking auth status if its config reload mode does not apply the write automatically. - Doctor cleans legacy plugin dependency staging state from older OpenClaw versions and relinks the host `openclaw` package for managed npm plugins that declare it as a peer dependency. It also repairs missing downloadable plugins referenced by config (`plugins.entries`, configured channels, configured provider/search settings, configured agent runtimes). During package updates, doctor skips package-manager plugin repair until the package swap completes; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If a download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt. - Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.deny`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides, when plugin discovery is healthy. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index f358e02ad0cc..285a7ddf5907 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -158,7 +158,7 @@ Claude CLI reuse (`claude -p`) is a sanctioned OpenClaw integration path. Anthro - Auth: OAuth (ChatGPT) - Fresh native Codex app-server harness ref: `openai/gpt-5.6-sol` - Native Codex app-server harness docs: [Codex harness](/plugins/codex-harness) -- Legacy model refs: `codex/gpt-*` +- Legacy model refs: `codex/gpt-*`, `openai-codex/gpt-*` - Plugin boundary: `openai/*` loads the OpenAI plugin; explicit runtime policy or the provider-owned effective route decides whether the native Codex app-server plugin is selected. - CLI: `openclaw onboard --auth-choice openai` or `openclaw models auth login --provider openai` - OpenClaw's embedded ChatGPT Responses transport defaults to `auto` (WebSocket-first, SSE fallback). @@ -169,7 +169,7 @@ Claude CLI reuse (`claude -p`) is a sanctioned OpenClaw integration path. Anthro - `openai/gpt-5.5` uses the Codex catalog native `contextWindow = 400000` and default runtime `contextTokens = 272000`; override the runtime cap with `models.providers.openai.models[].contextTokens` - Sign in with `openai` auth and use `openai/gpt-5.6-sol` for a fresh subscription-backed setup. Select `openai/gpt-5.5` explicitly if that Codex workspace does not expose GPT-5.6. - Use provider/model `agentRuntime.id: "openclaw"` to keep an otherwise eligible route on the built-in runtime. With runtime unset or `auto`, only an exact official HTTPS Responses/ChatGPT-compatible route with no authored request override may select Codex implicitly. -- Legacy Codex GPT refs are legacy state, not a live provider route. Use canonical `openai/*` refs for new agent config, and run `openclaw doctor --fix` to migrate old legacy Codex model refs without upgrading an existing explicit `openai/gpt-5.5` selection. +- Legacy Codex GPT refs are legacy state, not a live provider route. Use canonical `openai/*` refs for new agent config, and run `openclaw doctor --fix` to migrate `codex/*` and `openai-codex/*` refs while preserving their native Codex semantics with model-scoped `agentRuntime.id: "codex"`. Existing explicit canonical `openai/gpt-5.5` selections are not upgraded. ```json5 { diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 44ecda518a8d..b50e159c169a 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -19,8 +19,9 @@ implicitly. The `openai/*` prefix alone does not select a runtime. profile when you want key-based billing. - **Non-agent OpenAI APIs** - direct OpenAI Platform access, billed per use, through `OPENAI_API_KEY` or an `openai` API-key auth profile. -- **Legacy config** - old Codex model refs and profile ids are repaired to - `openai/*` by `openclaw doctor --fix`. +- **Legacy config** - `codex/*` and `openai-codex/*` refs are repaired to + `openai/*` plus model-scoped `agentRuntime.id: "codex"` by + `openclaw doctor --fix`. OpenAI explicitly supports subscription OAuth usage in external tools and workflows like OpenClaw. @@ -86,9 +87,10 @@ closed when the effective route is not declared Codex-compatible. Runtime selection does not change credential type or billing: Platform API-key auth and ChatGPT/Codex subscription auth remain distinct. -`openclaw doctor --fix` migrates legacy Codex model refs, legacy Codex auth -profile ids, and legacy Codex auth-order entries to the canonical `openai` -route. Use `auth.order.openai` for new auth-order config. +`openclaw doctor --fix` migrates legacy `codex/*` and `openai-codex/*` model +refs, legacy Codex auth profile ids, and legacy Codex auth-order entries to the +canonical `openai` route. Migrated model refs receive model-scoped +`agentRuntime.id: "codex"`; use `auth.order.openai` for new auth-order config. Fresh OpenAI setup applies a GPT-5.6 primary only when no primary model is diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 366fc28eb308..1dcdcb686933 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -15,6 +15,8 @@ import type { } from "./src/app-server/models.js"; import type { CodexAppServerBindingStore } from "./src/app-server/session-binding.js"; +// `codex` is legacy input only until Part 2 doctor migration rewrites stored refs. +// New runtime identity uses the `openai` provider. const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex", "openai"]); const SHARED_CODEX_APP_SERVER_CLIENT_DISPOSER = Symbol.for("openclaw.codexAppServerClientDisposer"); const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [ diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index a65d37ca9d71..f62e7fdc0105 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { describe, expect, it, vi } from "vitest"; +import openAIPlugin from "../openai/index.js"; import { createCodexAppServerAgentHarness } from "./harness.js"; import plugin from "./index.js"; import { @@ -45,12 +46,13 @@ function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex } describe("codex plugin", () => { - it("is opt-in by default", () => { + it("is opt-in and does not advertise a text provider", () => { const manifest = JSON.parse( fs.readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"), - ) as { enabledByDefault?: unknown }; + ) as { enabledByDefault?: unknown; providers?: unknown }; expect(manifest.enabledByDefault).toBeUndefined(); + expect(manifest.providers).toBeUndefined(); }); it("does not open plugin state while registering with the base runtime", () => { @@ -73,7 +75,7 @@ describe("codex plugin", () => { expect(openSyncKeyedStore).not.toHaveBeenCalled(); }); - it("registers the codex provider, agent harness, native thread tool, and hosted web search", () => { + it("registers the agent harness, native thread tool, and hosted web search", () => { const registerAgentHarness = vi.fn(); const registerCommand = vi.fn(); const registerMediaUnderstandingProvider = vi.fn(); @@ -106,7 +108,6 @@ describe("codex plugin", () => { }), ); - const providerRegistration = mockCallArg(registerProvider) as Record; const agentHarnessRegistration = mockCallArg(registerAgentHarness) as Record; const mediaProviderRegistration = mockCallArg(registerMediaUnderstandingProvider) as | Record @@ -116,8 +117,7 @@ describe("codex plugin", () => { | [unknown] | undefined; - expect(providerRegistration.id).toBe("codex"); - expect(providerRegistration.label).toBe("Codex"); + expect(registerProvider).not.toHaveBeenCalled(); expect(agentHarnessRegistration.id).toBe("codex"); expect(agentHarnessRegistration.label).toBe("Codex agent harness"); expect(agentHarnessRegistration.deliveryDefaults).toEqual({ @@ -184,7 +184,7 @@ describe("codex plugin", () => { ); expect(registerAgentHarness).toHaveBeenCalledOnce(); - expect(registerProvider).toHaveBeenCalledOnce(); + expect(registerProvider).not.toHaveBeenCalled(); const nodeCommands = registerNodeHostCommand.mock.calls.map( ([command]) => (command as { command: string }).command, ); @@ -194,6 +194,34 @@ describe("codex plugin", () => { expect(registerSessionCatalog).not.toHaveBeenCalled(); }); + it("leaves OpenAI as the only text provider when both plugins register", () => { + const providers: Array<{ id: string }> = []; + const registerProvider = (provider: { id: string }) => providers.push(provider); + openAIPlugin.register( + createTestPluginApi({ + id: "openai", + name: "OpenAI Provider", + source: "test", + config: {}, + runtime: {} as never, + registerProvider, + }), + ); + plugin.register( + createTestPluginApi({ + id: "codex", + name: "Codex", + source: "test", + config: {}, + pluginConfig: {}, + runtime: createCodexTestRuntime(), + registerProvider, + }), + ); + + expect(providers.map((provider) => provider.id)).toEqual(["openai"]); + }); + it("registers the five shipped supervision tools only when supervision is enabled", () => { const registerTool = vi.fn(); plugin.register( @@ -382,8 +410,7 @@ describe("codex plugin", () => { delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved; plugin.register(api); - expect(registerProvider).toHaveBeenCalledTimes(1); - expect((mockCallArg(registerProvider) as { id?: string } | undefined)?.id).toBe("codex"); + expect(registerProvider).not.toHaveBeenCalled(); }); it("claims the Codex routing providers by default", () => { diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index 3dbd0951557e..31949c194eb2 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -1,6 +1,6 @@ /** - * Bundled Codex plugin entry: app-server harness, model provider, media - * understanding, migration provider, CLI-session commands, and binding hooks. + * Bundled Codex plugin entry: app-server harness, media understanding, + * migration provider, CLI-session commands, and binding hooks. */ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { mutateConfigFile } from "openclaw/plugin-sdk/config-mutation"; @@ -14,7 +14,6 @@ import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state import { registerCodexCliMetadata } from "./cli-metadata.js"; import { createCodexAppServerAgentHarness } from "./harness.js"; import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js"; -import { buildCodexProvider } from "./provider.js"; import { readCodexPluginConfig } from "./src/app-server/config.js"; import { CODEX_APP_SERVER_BINDING_MAX_ENTRIES, @@ -57,8 +56,7 @@ const ENDED_SESSION_REASONS: ReadonlySet = new Set([ export default definePluginEntry({ id: "codex", name: "Codex", - description: - "Codex app-server harness, Codex-managed GPT catalog, and native session supervision.", + description: "Codex app-server harness and native session supervision.", register(api) { const resolveCurrentConfig = () => api.runtime.config?.current ? (api.runtime.config.current() as OpenClawConfig) : undefined; @@ -156,7 +154,6 @@ export default definePluginEntry({ resolvePluginConfig: resolveCurrentPluginConfig, }), ); - api.registerProvider(buildCodexProvider({ pluginConfig: api.pluginConfig })); api.registerMediaUnderstandingProvider( buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }), ); diff --git a/extensions/codex/media-understanding-provider.ts b/extensions/codex/media-understanding-provider.ts index 0b02cd0e820e..928afef6deeb 100644 --- a/extensions/codex/media-understanding-provider.ts +++ b/extensions/codex/media-understanding-provider.ts @@ -13,16 +13,14 @@ import type { StructuredExtractionRequest, StructuredExtractionResult, } from "openclaw/plugin-sdk/media-understanding"; -import { CODEX_PROVIDER_ID, FALLBACK_CODEX_MODELS } from "./provider-catalog.js"; import { runBoundedCodexAppServerTurn, type CodexBoundedTurnOptions, } from "./src/app-server/bounded-turn.js"; import type { CodexUserInput } from "./src/app-server/protocol.js"; -const DEFAULT_CODEX_IMAGE_MODEL = - FALLBACK_CODEX_MODELS.find((model) => model.inputModalities.includes("image"))?.id ?? - FALLBACK_CODEX_MODELS[0]?.id; +const CODEX_MEDIA_PROVIDER_ID = "codex"; +const DEFAULT_CODEX_IMAGE_MODEL = "gpt-5.6-sol"; const DEFAULT_CODEX_IMAGE_PROMPT = "Describe the image."; export type CodexMediaUnderstandingProviderOptions = CodexBoundedTurnOptions; @@ -35,9 +33,9 @@ export function buildCodexMediaUnderstandingProvider( options: CodexMediaUnderstandingProviderOptions = {}, ): MediaUnderstandingProvider { return { - id: CODEX_PROVIDER_ID, + id: CODEX_MEDIA_PROVIDER_ID, capabilities: ["image"], - ...(DEFAULT_CODEX_IMAGE_MODEL ? { defaultModels: { image: DEFAULT_CODEX_IMAGE_MODEL } } : {}), + defaultModels: { image: DEFAULT_CODEX_IMAGE_MODEL }, describeImage: async (req) => describeCodexImages( { diff --git a/extensions/codex/openclaw.plugin.json b/extensions/codex/openclaw.plugin.json index 5068c12cc669..e9b3b963b32f 100644 --- a/extensions/codex/openclaw.plugin.json +++ b/extensions/codex/openclaw.plugin.json @@ -1,8 +1,7 @@ { "id": "codex", "name": "Codex", - "description": "Codex app-server harness, model provider, and native session catalog.", - "providers": ["codex"], + "description": "Codex app-server harness and native session catalog.", "contracts": { "mediaUnderstandingProviders": ["codex"], "migrationProviders": ["codex"], @@ -24,9 +23,6 @@ } } }, - "providerCatalogEntry": "./provider-discovery.ts", - "syntheticAuthRefs": ["codex"], - "nonSecretAuthMarkers": ["codex-app-server"], "activation": { "onStartup": false, "onAgentHarnesses": ["codex"], diff --git a/extensions/codex/prompt-overlay-runtime-contract.test.ts b/extensions/codex/prompt-overlay-runtime-contract.test.ts deleted file mode 100644 index f03c2f14c6a8..000000000000 --- a/extensions/codex/prompt-overlay-runtime-contract.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Codex tests cover prompt overlay runtime contract plugin behavior. -import { - codexPromptOverlayContext, - GPT5_CONTRACT_MODEL_ID, - NON_GPT5_CONTRACT_MODEL_ID, - sharedGpt5PersonalityConfig, -} from "openclaw/plugin-sdk/agent-runtime-test-contracts"; -import { describe, expect, it } from "vitest"; -import { buildCodexProvider } from "./provider.js"; - -describe("Codex prompt overlay runtime contract", () => { - it("adds the shared GPT-5 behavior contract to Codex GPT-5 provider runs", () => { - const provider = buildCodexProvider(); - const contribution = provider.resolveSystemPromptContribution?.( - codexPromptOverlayContext({ modelId: GPT5_CONTRACT_MODEL_ID }), - ); - - expect(contribution?.stablePrefix).toContain(""); - expect(contribution?.sectionOverrides?.interaction_style).toContain( - "Live chat: short, natural, human.", - ); - expect(contribution?.sectionOverrides?.interaction_style).not.toContain( - "Heartbeat = useful proactive progress", - ); - }); - - it("respects shared GPT-5 prompt overlay config for Codex runs", () => { - const provider = buildCodexProvider(); - const contribution = provider.resolveSystemPromptContribution?.( - codexPromptOverlayContext({ - modelId: GPT5_CONTRACT_MODEL_ID, - config: sharedGpt5PersonalityConfig("off"), - }), - ); - - expect(contribution?.stablePrefix).toContain(""); - expect(contribution?.sectionOverrides).toStrictEqual({}); - }); - - it("does not add the shared GPT-5 overlay to non-GPT-5 Codex provider runs", () => { - const provider = buildCodexProvider(); - - expect( - provider.resolveSystemPromptContribution?.( - codexPromptOverlayContext({ modelId: NON_GPT5_CONTRACT_MODEL_ID }), - ), - ).toBeUndefined(); - }); -}); diff --git a/extensions/codex/prompt-overlay.ts b/extensions/codex/prompt-overlay.ts deleted file mode 100644 index f84cab98eb20..000000000000 --- a/extensions/codex/prompt-overlay.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Codex prompt-overlay facade for GPT-5 behavior and heartbeat guidance. - */ -import { - GPT5_BEHAVIOR_CONTRACT, - GPT5_HEARTBEAT_PROMPT_OVERLAY, - resolveGpt5SystemPromptContribution, -} from "openclaw/plugin-sdk/provider-model-shared"; - -/** GPT-5 behavior contract re-exported under the Codex provider namespace. */ -export const CODEX_GPT5_BEHAVIOR_CONTRACT = GPT5_BEHAVIOR_CONTRACT; -/** Heartbeat prompt overlay re-exported under the Codex provider namespace. */ -export const CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY = GPT5_HEARTBEAT_PROMPT_OVERLAY; - -/** Resolves the Codex system-prompt contribution for GPT-5-family models. */ -export function resolveCodexSystemPromptContribution( - params: Parameters[0], -) { - return resolveGpt5SystemPromptContribution(params); -} diff --git a/extensions/codex/provider-catalog.ts b/extensions/codex/provider-catalog.ts deleted file mode 100644 index da441d9ce4d4..000000000000 --- a/extensions/codex/provider-catalog.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Codex provider catalog constants and model definition helpers. - */ -import type { - ModelDefinitionConfig, - ModelProviderConfig, -} from "openclaw/plugin-sdk/provider-model-shared"; -import type { CodexAppServerModel } from "./src/app-server/models.js"; - -/** Provider id used by Codex model refs. */ -export const CODEX_PROVIDER_ID = "codex"; -/** Synthetic base URL used to route Codex app-server model requests. */ -export const CODEX_BASE_URL = "https://chatgpt.com/backend-api"; -/** Synthetic auth marker understood by Codex app-server runtime paths. */ -export const CODEX_APP_SERVER_AUTH_MARKER = "codex-app-server"; - -const DEFAULT_CONTEXT_WINDOW = 272_000; -const DEFAULT_MAX_TOKENS = 128_000; -const KNOWN_CONTEXT_WINDOW_BY_MODEL_ID: Readonly> = Object.freeze({ - "gpt-5.6-sol": 372_000, - "gpt-5.6-terra": 372_000, - "gpt-5.6-luna": 372_000, -}); - -/** Offline fallback catalog used when live app-server discovery is unavailable. */ -export const FALLBACK_CODEX_MODELS = [ - { - id: "gpt-5.6-sol", - model: "gpt-5.6-sol", - displayName: "GPT-5.6 Sol", - description: "Latest frontier agentic coding model.", - isDefault: true, - contextWindow: 372_000, - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], - }, - { - id: "gpt-5.6-luna", - model: "gpt-5.6-luna", - displayName: "GPT-5.6 Luna", - description: "High-throughput frontier agentic coding model.", - contextWindow: 372_000, - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"], - }, - { - id: "gpt-5.5", - model: "gpt-5.5", - displayName: "GPT-5.5", - description: "Previous frontier agentic coding model.", - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], - }, - { - id: "gpt-5.4-mini", - model: "gpt-5.4-mini", - displayName: "GPT-5.4-Mini", - description: "Smaller frontier agentic coding model.", - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], - }, -] satisfies Array; - -/** - * Converts a Codex app-server model record into OpenClaw provider model config. - */ -export function buildCodexModelDefinition(model: { - id: string; - model: string; - displayName?: string; - contextWindow?: number; - inputModalities: string[]; - supportedReasoningEfforts?: string[]; -}): ModelDefinitionConfig { - const id = model.id.trim() || model.model.trim(); - const supportedReasoningEfforts = model.supportedReasoningEfforts; - return { - id, - name: model.displayName?.trim() || id, - api: "openai-chatgpt-responses", - reasoning: - supportedReasoningEfforts !== undefined - ? supportedReasoningEfforts.length > 0 - : shouldDefaultToReasoningModel(id), - input: model.inputModalities.includes("image") ? ["text", "image"] : ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: - model.contextWindow ?? KNOWN_CONTEXT_WINDOW_BY_MODEL_ID[id] ?? DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, - compat: { - ...(supportedReasoningEfforts !== undefined - ? { supportsReasoningEffort: supportedReasoningEfforts.length > 0 } - : {}), - ...(supportedReasoningEfforts && supportedReasoningEfforts.length > 0 - ? { supportedReasoningEfforts: [...supportedReasoningEfforts] } - : {}), - supportsUsageInStreaming: true, - }, - }; -} - -/** Builds the synthetic Codex provider config for a model list. */ -export function buildCodexProviderConfig(models: CodexAppServerModel[]): ModelProviderConfig { - return { - baseUrl: CODEX_BASE_URL, - apiKey: CODEX_APP_SERVER_AUTH_MARKER, - auth: "token", - api: "openai-chatgpt-responses", - models: models.map(buildCodexModelDefinition), - }; -} - -function shouldDefaultToReasoningModel(modelId: string): boolean { - const lower = modelId.toLowerCase(); - return ( - lower.startsWith("gpt-5") || - lower.startsWith("o1") || - lower.startsWith("o3") || - lower.startsWith("o4") - ); -} diff --git a/extensions/codex/provider-discovery.ts b/extensions/codex/provider-discovery.ts deleted file mode 100644 index 2692a3639e91..000000000000 --- a/extensions/codex/provider-discovery.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Static provider discovery entry for Codex, used before the full plugin entry - * is loaded. - */ -import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared"; -import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; -import { - buildCodexProviderConfig, - CODEX_APP_SERVER_AUTH_MARKER, - CODEX_PROVIDER_ID, - FALLBACK_CODEX_MODELS, -} from "./provider-catalog.js"; - -function resolveCodexPluginConfig(ctx: ProviderCatalogContext): unknown { - return (ctx.config.plugins?.entries as Record)?.codex - ?.config; -} - -async function runCodexCatalog(ctx: ProviderCatalogContext) { - const { buildCodexProviderCatalog } = await import("./provider.js"); - return await buildCodexProviderCatalog({ - env: ctx.env, - pluginConfig: resolveCodexPluginConfig(ctx), - }); -} - -/** Provider discovery descriptor with static fallback and synthetic auth. */ -export const codexProviderDiscovery: ProviderPlugin = { - id: CODEX_PROVIDER_ID, - label: "Codex", - docsPath: "/providers/models", - auth: [], - catalog: { - order: "late", - run: runCodexCatalog, - }, - staticCatalog: { - order: "late", - run: async () => ({ - provider: buildCodexProviderConfig(FALLBACK_CODEX_MODELS), - }), - }, - resolveSyntheticAuth: () => ({ - apiKey: CODEX_APP_SERVER_AUTH_MARKER, - source: "codex-app-server", - mode: "token", - }), -}; - -export default codexProviderDiscovery; diff --git a/extensions/codex/provider.test.ts b/extensions/codex/provider.test.ts deleted file mode 100644 index ab4fa9222946..000000000000 --- a/extensions/codex/provider.test.ts +++ /dev/null @@ -1,819 +0,0 @@ -// Codex tests cover provider plugin behavior. -import { afterEach, describe, expect, it, vi } from "vitest"; -import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "./prompt-overlay.js"; -import { buildCodexModelDefinition } from "./provider-catalog.js"; -import { codexProviderDiscovery } from "./provider-discovery.js"; -import { - buildCodexProvider, - buildCodexProviderCatalog, - resolveCodexSupportedReasoningEffort, -} from "./provider.js"; -import { CodexAppServerClient } from "./src/app-server/client.js"; -import type { listCodexAppServerModels } from "./src/app-server/models.js"; -import { - createIsolatedCodexAppServerClient, - getSharedCodexAppServerClient, - resetSharedCodexAppServerClientForTests, -} from "./src/app-server/shared-client.js"; - -afterEach(() => { - resetSharedCodexAppServerClientForTests(); - vi.restoreAllMocks(); -}); - -function expectStaticFallbackCatalog( - result: Awaited>, -) { - expect(result.provider.models.map((model) => model.id)).toEqual([ - "gpt-5.6-sol", - "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.4-mini", - ]); -} - -function createFakeCodexClient(): CodexAppServerClient { - return { - initialize: vi.fn(async () => undefined), - request: vi.fn(async () => ({ data: [] })), - addNotificationHandler: vi.fn(() => () => undefined), - addRequestHandler: vi.fn(() => () => undefined), - addCloseHandler: vi.fn(() => () => undefined), - setThreadSessionRequestGuard: vi.fn(), - close: vi.fn(), - } as unknown as CodexAppServerClient; -} - -const TEST_CODEX_APP_SERVER_CONFIG = { - appServer: { - command: "/tmp/openclaw-test-codex", - }, -}; - -async function listTestCodexAppServerModels( - options: Parameters[0] = {}, -) { - expect(options.sharedClient).toBe(false); - const client = await createIsolatedCodexAppServerClient({ - startOptions: options.startOptions, - timeoutMs: options.timeoutMs, - authProfileId: null, - }); - try { - await client.request( - "model/list", - { - limit: options.limit ?? null, - cursor: options.cursor ?? null, - includeHidden: options.includeHidden ?? null, - }, - { timeoutMs: options.timeoutMs }, - ); - return { models: [] }; - } finally { - client.close(); - } -} - -function expectRecordFields(value: unknown, expected: Record) { - if (!value || typeof value !== "object") { - throw new Error("Expected record"); - } - const actual = value as Record; - for (const [key, expectedValue] of Object.entries(expected)) { - expect(actual[key]).toEqual(expectedValue); - } - return actual; -} - -function mockCallArg(mockFn: { mock: { calls: unknown[][] } }, callIndex: number): unknown { - return mockFn.mock.calls[callIndex]?.[0]; -} - -describe("codex provider", () => { - it.each(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"])( - "uses the known context window for discovered %s models", - (modelId) => { - const model = buildCodexModelDefinition({ - id: modelId, - model: modelId, - inputModalities: ["text", "image"], - }); - - expect(model.contextWindow).toBe(372_000); - }, - ); - - it.each(["gpt-5.5-pro", "gpt-5.4-pro"] as const)( - "classifies %s as a modern Codex model", - (modelId) => { - const provider = buildCodexProvider(); - - expect( - provider.isModernModelRef?.({ - provider: "openai", - modelId, - } as never), - ).toBe(true); - }, - ); - - it("maps Codex app-server models to a Codex provider catalog", async () => { - const listModels = vi.fn(async () => ({ - models: [ - { - id: "gpt-5.4", - model: "gpt-5.4", - displayName: "gpt-5.4", - hidden: false, - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], - }, - { - id: "hidden-model", - model: "hidden-model", - hidden: true, - inputModalities: ["text"], - supportedReasoningEfforts: [], - }, - ], - })); - - const result = await buildCodexProviderCatalog({ - env: {}, - listModels, - pluginConfig: { discovery: { timeoutMs: 1234 } }, - }); - - expectRecordFields(mockCallArg(listModels, 0), { - limit: 100, - timeoutMs: 1234, - sharedClient: false, - }); - expectRecordFields(result.provider, { - auth: "token", - api: "openai-chatgpt-responses", - }); - expect(result.provider.models).toHaveLength(1); - expectRecordFields(result.provider.models[0], { - id: "gpt-5.4", - name: "gpt-5.4", - reasoning: true, - input: ["text", "image"], - compat: { - supportsReasoningEffort: true, - supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], - supportsUsageInStreaming: true, - }, - }); - }); - - it("keeps a static fallback catalog when discovery is disabled", async () => { - const listModels = vi.fn(); - - const result = await buildCodexProviderCatalog({ - env: {}, - listModels, - pluginConfig: { discovery: { enabled: false } }, - }); - - expect(listModels).not.toHaveBeenCalled(); - expectStaticFallbackCatalog(result); - }); - - it("uses live plugin config to re-enable discovery after startup disable", async () => { - const listModels = vi.fn(async () => ({ - models: [ - { - id: "gpt-5.4", - model: "gpt-5.4", - displayName: "gpt-5.4", - hidden: false, - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["low", "medium", "high", "xhigh"], - }, - ], - })); - const provider = buildCodexProvider({ - pluginConfig: { discovery: { enabled: false } }, - listModels, - }); - - const result = await provider.catalog?.run({ - config: { - plugins: { - entries: { - codex: { - config: { - discovery: { - enabled: true, - timeoutMs: 4321, - }, - }, - }, - }, - }, - }, - env: {}, - } as never); - - expectRecordFields(mockCallArg(listModels, 0), { - limit: 100, - timeoutMs: 4321, - sharedClient: false, - }); - const resultProvider = result && "provider" in result ? result.provider : undefined; - expect(resultProvider?.models.map((model) => model.id)).toEqual(["gpt-5.4"]); - }); - - it("pages through live discovery before building the provider catalog", async () => { - const listModels = vi - .fn() - .mockResolvedValueOnce({ - models: [ - { - id: "gpt-5.4", - model: "gpt-5.4", - hidden: false, - inputModalities: ["text", "image"], - supportedReasoningEfforts: ["medium"], - }, - ], - nextCursor: "page-2", - }) - .mockResolvedValueOnce({ - models: [ - { - id: "gpt-5.5", - model: "gpt-5.5", - hidden: false, - inputModalities: ["text"], - supportedReasoningEfforts: [], - }, - ], - }); - - const result = await buildCodexProviderCatalog({ - env: {}, - listModels, - }); - - expectRecordFields(mockCallArg(listModels, 0), { - cursor: undefined, - limit: 100, - sharedClient: false, - }); - expectRecordFields(mockCallArg(listModels, 1), { - cursor: "page-2", - limit: 100, - sharedClient: false, - }); - expect(result.provider.models.map((model) => model.id)).toEqual(["gpt-5.4", "gpt-5.5"]); - }); - - it("reports discovery failures before using the fallback catalog", async () => { - const error = new Error("app-server down"); - const onDiscoveryFailure = vi.fn(); - const listModels = vi.fn(async () => { - throw error; - }); - - const result = await buildCodexProviderCatalog({ - env: {}, - listModels, - onDiscoveryFailure, - }); - - expect(onDiscoveryFailure).toHaveBeenCalledWith(error); - expectStaticFallbackCatalog(result); - }); - - it("keeps a static fallback catalog when live discovery is explicitly disabled by env", async () => { - const listModels = vi.fn(); - - const result = await buildCodexProviderCatalog({ - env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "0" }, - listModels, - }); - - expect(listModels).not.toHaveBeenCalled(); - expectStaticFallbackCatalog(result); - }); - - it("closes the transient app-server client after live discovery", async () => { - const client = createFakeCodexClient(); - vi.spyOn(CodexAppServerClient, "start").mockReturnValue(client); - - await buildCodexProviderCatalog({ - env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "1" }, - pluginConfig: TEST_CODEX_APP_SERVER_CONFIG, - listModels: listTestCodexAppServerModels, - }); - - expect(client["close"]).toHaveBeenCalledTimes(1); - }); - - it("does not close an active shared app-server client during live discovery", async () => { - const activeClient = createFakeCodexClient(); - const discoveryClient = createFakeCodexClient(); - vi.spyOn(CodexAppServerClient, "start") - .mockReturnValueOnce(activeClient) - .mockReturnValueOnce(discoveryClient); - - await getSharedCodexAppServerClient({ - startOptions: { - transport: "stdio", - command: "/tmp/openclaw-test-codex", - commandSource: "config", - args: ["app-server", "--listen", "stdio://"], - headers: {}, - }, - timeoutMs: 1000, - authProfileId: null, - }); - await buildCodexProviderCatalog({ - env: { OPENCLAW_CODEX_DISCOVERY_LIVE: "1" }, - pluginConfig: TEST_CODEX_APP_SERVER_CONFIG, - listModels: listTestCodexAppServerModels, - }); - - expect(activeClient["close"]).not.toHaveBeenCalled(); - expect(discoveryClient["close"]).toHaveBeenCalledTimes(1); - }); - - it("resolves arbitrary Codex app-server model ids as text-only until discovered", () => { - const provider = buildCodexProvider(); - - const model = provider.resolveDynamicModel?.({ - provider: "codex", - modelId: " custom-model ", - modelRegistry: { find: () => null }, - } as never); - - expectRecordFields(model, { - id: "custom-model", - provider: "codex", - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api", - input: ["text"], - }); - }); - - it("keeps fallback Codex app-server models image-capable", () => { - const provider = buildCodexProvider(); - - const model = provider.resolveDynamicModel?.({ - provider: "codex", - modelId: "gpt-5.5", - modelRegistry: { find: () => null }, - } as never); - - expectRecordFields(model, { - id: "gpt-5.5", - input: ["text", "image"], - }); - }); - - it("treats o4 ids as reasoning-capable Codex models", () => { - const provider = buildCodexProvider(); - - const model = provider.resolveDynamicModel?.({ - provider: "codex", - modelId: "o4-mini", - modelRegistry: { find: () => null }, - } as never); - - expectRecordFields(model, { - id: "o4-mini", - reasoning: true, - compat: { - supportsUsageInStreaming: true, - }, - }); - expect( - provider - .resolveThinkingProfile?.({ provider: "codex", modelId: "o4-mini" } as never) - ?.levels.some((level) => level.id === "xhigh"), - ).toBe(true); - }); - - it("uses fallback reasoning metadata for GPT-5.6 Luna", () => { - const provider = buildCodexProvider(); - const model = provider.resolveDynamicModel?.({ - provider: "codex", - modelId: "gpt-5.6-luna", - modelRegistry: { find: () => null }, - } as never); - - expectRecordFields(model, { - id: "gpt-5.6-luna", - reasoning: true, - contextWindow: 372_000, - compat: { - supportsReasoningEffort: true, - supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"], - supportsUsageInStreaming: true, - }, - }); - expect( - provider - .resolveThinkingProfile?.({ provider: "codex", modelId: "gpt-5.6-luna" } as never) - ?.levels.map((level) => level.id), - ).toContain("max"); - }); - - it("exposes max only for known native GPT-5.6 models", () => { - const provider = buildCodexProvider(); - const levels = (modelId: string) => - provider - .resolveThinkingProfile?.({ provider: "codex", modelId } as never) - ?.levels.map((level) => level.id); - - expect(levels("gpt-5.6-sol")).toContain("max"); - expect(levels("gpt-5.6-terra")).toContain("max"); - expect(levels("gpt-5.6-luna")).toContain("max"); - expect(levels("gpt-5.6")).not.toContain("max"); - expect(levels("gpt-5.6-sol-oai")).not.toContain("max"); - }); - - it("uses app-server reasoning metadata as the authoritative thinking profile", () => { - const provider = buildCodexProvider(); - - expect( - provider - .resolveThinkingProfile?.({ - provider: "codex", - modelId: "gpt-5.4-pro", - compat: { supportedReasoningEfforts: ["medium", "high", "xhigh"] }, - } as never) - ?.levels.map((level) => level.id), - ).toEqual(["off", "medium", "high", "xhigh"]); - }); - - it("uses known GPT-5.6 native Codex fallbacks when model/list metadata is unavailable", () => { - const provider = buildCodexProvider(); - const levels = (modelId: string, supportedReasoningEfforts?: string[]) => - provider - .resolveThinkingProfile?.({ - provider: "codex", - modelId, - ...(supportedReasoningEfforts ? { compat: { supportedReasoningEfforts } } : {}), - } as never) - ?.levels.map((level) => level.id); - - expect(levels("gpt-5.6-sol")).toContain("ultra"); - expect(levels("gpt-5.6-terra")).toContain("ultra"); - expect(levels("gpt-5.6-luna")).toEqual(["off", "low", "medium", "high", "xhigh", "max"]); - expect(levels("gpt-5.6")).not.toContain("ultra"); - - const directOpenAIEfforts = ["none", "low", "medium", "high", "xhigh", "max"]; - expect(levels("gpt-5.6-sol", directOpenAIEfforts)).toContain("ultra"); - expect(levels("gpt-5.6-terra", directOpenAIEfforts)).toContain("ultra"); - }); - - it.each([ - { modelId: "gpt-5.6-sol", expected: "low" }, - { modelId: "gpt-5.6-terra", expected: "medium" }, - { modelId: "gpt-5.6-luna", expected: "medium" }, - ] as const)("uses the native $modelId default reasoning effort", ({ modelId, expected }) => { - const provider = buildCodexProvider(); - - expect( - provider.resolveThinkingProfile?.({ provider: "codex", modelId } as never)?.defaultLevel, - ).toBe(expected); - }); - - it("omits the native default when authoritative model/list metadata does not support it", () => { - const provider = buildCodexProvider(); - - expect( - provider.resolveThinkingProfile?.({ - provider: "codex", - modelId: "gpt-5.6-sol", - compat: { supportedReasoningEfforts: ["high"] }, - } as never)?.defaultLevel, - ).toBeUndefined(); - }); - - it("uses app-server model/list reasoning metadata as authoritative", () => { - const provider = buildCodexProvider(); - const levels = (modelId: string, supportedReasoningEfforts: string[]) => - provider - .resolveThinkingProfile?.({ - provider: "codex", - modelId, - compat: { supportedReasoningEfforts }, - } as never) - ?.levels.map((level) => level.id); - - const maxEfforts = ["low", "medium", "high", "xhigh", "max"]; - const ultraEfforts = [...maxEfforts, "ultra"]; - expect(levels("gpt-5.6-sol", maxEfforts)).not.toContain("ultra"); - expect(levels("gpt-5.6-terra", maxEfforts)).not.toContain("ultra"); - expect(levels("gpt-5.6-sol", ultraEfforts)).toContain("ultra"); - expect(levels("gpt-5.6-terra", ultraEfforts)).toContain("ultra"); - expect(levels("gpt-5.6-luna", maxEfforts)).not.toContain("ultra"); - }); - - it.each([ - ["max", ["low", "medium", "high", "xhigh", "ultra"], "xhigh"], - ["xhigh", ["low", "medium", "high", "ultra"], "high"], - ] as const)( - "does not upgrade requested %s to Ultra when model metadata omits that effort", - (requested, supportedReasoningEfforts, expected) => { - expect(resolveCodexSupportedReasoningEffort({ requested, supportedReasoningEfforts })).toBe( - expected, - ); - }, - ); - - it.each(["gpt-5.5-pro", "gpt-5.4-pro"] as const)( - "uses the known %s effort profile when app-server metadata is absent", - (modelId) => { - const provider = buildCodexProvider(); - - expect( - provider - .resolveThinkingProfile?.({ - provider: "codex", - modelId, - } as never) - ?.levels.map((level) => level.id), - ).toEqual(["off", "medium", "high", "xhigh"]); - }, - ); - - it("declares synthetic auth because the harness owns Codex credentials", () => { - const provider = buildCodexProvider(); - - expect(provider.resolveSyntheticAuth?.({ provider: "codex" })).toEqual({ - apiKey: "codex-app-server", - source: "codex-app-server", - mode: "token", - }); - }); - - // Mirrors CODEX_APP_SERVER_AUTH_MARKER without a scanner-visible token literal. - function appServerMarkerToken(): string { - return ["codex", "app", "server"].join("-"); - } - - it("fetches usage from native Codex app-server rate limits for synthetic auth", async () => { - const readUsage = vi.fn(async () => ({ - rateLimits: { - rateLimitsByLimitId: { - codex: { - limitId: "codex", - primary: { - usedPercent: 9, - windowDurationMins: 300, - resetsAt: 1_700_003_600, - }, - }, - }, - }, - accountEmail: "codex-account@example.com", - })); - const provider = buildCodexProvider({ readUsage }); - - await expect( - provider.fetchUsageSnapshot?.({ - provider: "openai", - token: "codex-app-server", - timeoutMs: 3500, - config: {}, - env: {}, - fetchFn: fetch, - } as never), - ).resolves.toEqual({ - provider: "openai", - displayName: "OpenAI", - windows: [{ label: "5h", usedPercent: 9, resetAt: 1_700_003_600_000 }], - plan: undefined, - accountEmail: "codex-account@example.com", - }); - expect(readUsage).toHaveBeenCalledWith({ - timeoutMs: 3500, - agentDir: undefined, - config: {}, - startOptions: expect.objectContaining({ - command: "codex", - commandSource: "managed", - }), - }); - }); - - it("keeps synthetic usage reads on the configured Codex auth bridge", async () => { - const scopedRequest = vi.fn(async ({ method }: { method: string }) => - method === "account/rateLimits/read" - ? { rateLimitsByLimitId: {} } - : { account: { email: "bridge@example.com" } }, - ); - const withCodexAppServerJsonClient = vi.fn( - async ( - _params: unknown, - run: (request: typeof scopedRequest) => Promise, - ): Promise => await run(scopedRequest), - ); - vi.doMock("./src/app-server/request.js", () => ({ - withCodexAppServerJsonClient, - })); - try { - const provider = buildCodexProvider(); - - await provider.fetchUsageSnapshot?.({ - provider: "openai", - token: "codex-app-server", - authProfileId: "openai:work", - timeoutMs: 3500, - config: { - plugins: { - entries: { - codex: { - config: TEST_CODEX_APP_SERVER_CONFIG, - }, - }, - }, - }, - env: {}, - fetchFn: fetch, - } as never); - - expect(withCodexAppServerJsonClient).toHaveBeenCalledWith( - expect.objectContaining({ - timeoutMs: 3500, - authProfileId: "openai:work", - config: { - plugins: { - entries: { - codex: { - config: TEST_CODEX_APP_SERVER_CONFIG, - }, - }, - }, - }, - startOptions: expect.objectContaining({ - command: "/tmp/openclaw-test-codex", - commandSource: "config", - args: ["app-server", "--listen", "stdio://"], - }), - isolated: true, - isolatedShutdown: { forceKillDelayMs: 200, exitTimeoutMs: 300 }, - }), - expect.any(Function), - ); - expect(scopedRequest).toHaveBeenCalledWith({ method: "account/rateLimits/read" }); - expect(scopedRequest).toHaveBeenCalledWith({ method: "account/read", requestParams: {} }); - } finally { - vi.doUnmock("./src/app-server/request.js"); - } - }); - - it("keeps the rate-limit windows when the account identity read fails", async () => { - const readUsage = vi.fn(async () => ({ - rateLimits: { - rateLimitsByLimitId: { - codex: { - limitId: "codex", - primary: { usedPercent: 12, windowDurationMins: 300 }, - }, - }, - }, - })); - const provider = buildCodexProvider({ readUsage }); - - await expect( - provider.fetchUsageSnapshot?.({ - provider: "openai", - token: appServerMarkerToken(), - timeoutMs: 3500, - config: {}, - env: {}, - fetchFn: fetch, - } as never), - ).resolves.toEqual({ - provider: "openai", - displayName: "OpenAI", - windows: [{ label: "5h", usedPercent: 12 }], - plan: undefined, - }); - }); - - it("returns the rate-limit windows when the account identity read hangs", async () => { - const scopedRequest = vi.fn(({ method }: { method: string }) => - method === "account/rateLimits/read" - ? Promise.resolve({ - rateLimitsByLimitId: { - codex: { - limitId: "codex", - primary: { usedPercent: 7, windowDurationMins: 300 }, - }, - }, - }) - : // account/read never settles; the best-effort bound must win. - new Promise(() => {}), - ); - const withCodexAppServerJsonClient = vi.fn( - async ( - _params: unknown, - run: (request: typeof scopedRequest) => Promise, - ): Promise => await run(scopedRequest), - ); - vi.doMock("./src/app-server/request.js", () => ({ - withCodexAppServerJsonClient, - })); - try { - const provider = buildCodexProvider(); - // A tiny usage budget drives the identity bound to a few real - // milliseconds so the hung account read is dropped promptly. - await expect( - provider.fetchUsageSnapshot?.({ - provider: "openai", - token: appServerMarkerToken(), - timeoutMs: 900, - config: {}, - env: {}, - fetchFn: fetch, - } as never), - ).resolves.toEqual({ - provider: "openai", - displayName: "OpenAI", - windows: [{ label: "5h", usedPercent: 7 }], - plan: undefined, - }); - } finally { - vi.doUnmock("./src/app-server/request.js"); - } - }); - - it("exposes a setup auth choice for installing Codex as an external provider", async () => { - const provider = buildCodexProvider(); - - const authChoice = provider.auth[0]; - expectRecordFields(authChoice, { - id: "app-server", - kind: "custom", - }); - expectRecordFields(authChoice?.wizard, { - choiceId: "codex", - choiceLabel: "Codex app-server", - onboardingScopes: ["text-inference"], - }); - const authResult = await authChoice?.run({} as never); - expectRecordFields(authResult, { - profiles: [], - defaultModel: "codex/gpt-5.6-sol", - }); - }); - - it("exposes a lightweight provider-discovery entry for model list/status", async () => { - expect(codexProviderDiscovery.id).toBe("codex"); - expect(codexProviderDiscovery.resolveSyntheticAuth?.({ provider: "codex" })).toEqual({ - apiKey: "codex-app-server", - source: "codex-app-server", - mode: "token", - }); - - const result = await codexProviderDiscovery.staticCatalog?.run({ - config: {}, - env: {}, - agentDir: "/tmp/openclaw-agent", - } as never); - - const models = result && "provider" in result ? result.provider.models : []; - expect(models.map((model) => model.id)).toEqual([ - "gpt-5.6-sol", - "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.4-mini", - ]); - expect(models.find((model) => model.id === "gpt-5.6-sol")?.contextWindow).toBe(372_000); - expect(models.find((model) => model.id === "gpt-5.6-luna")?.contextWindow).toBe(372_000); - }); - - it("adds the GPT-5 prompt overlay to Codex provider runs", () => { - const provider = buildCodexProvider(); - - const contribution = provider.resolveSystemPromptContribution?.({ - provider: "codex", - modelId: "gpt-5.4", - } as never); - expectRecordFields(contribution, { - stablePrefix: CODEX_GPT5_BEHAVIOR_CONTRACT, - }); - const interactionStyle = contribution?.sectionOverrides?.interaction_style; - expect(interactionStyle).toContain("Live chat: short, natural, human."); - expect(interactionStyle).not.toContain("Heartbeat = useful proactive progress"); - }); - - it("does not add the GPT-5 prompt overlay to non-GPT-5 Codex provider runs", () => { - const provider = buildCodexProvider(); - - expect( - provider.resolveSystemPromptContribution?.({ - provider: "codex", - modelId: "o4-mini", - } as never), - ).toBeUndefined(); - }); -}); diff --git a/extensions/codex/provider.ts b/extensions/codex/provider.ts deleted file mode 100644 index 18bd8a9112bb..000000000000 --- a/extensions/codex/provider.ts +++ /dev/null @@ -1,497 +0,0 @@ -/** - * Codex provider plugin and live app-server model catalog discovery. - */ -import { createSubsystemLogger } from "openclaw/plugin-sdk/core"; -import { expectDefined } from "openclaw/plugin-sdk/expect-runtime"; -import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; -import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; -import { - normalizeModelCompat, - type ModelProviderConfig, - type ProviderPlugin, -} from "openclaw/plugin-sdk/provider-model-shared"; -import { resolveCodexSystemPromptContribution } from "./prompt-overlay.js"; -import { - buildCodexModelDefinition, - buildCodexProviderConfig, - CODEX_APP_SERVER_AUTH_MARKER, - CODEX_BASE_URL, - CODEX_PROVIDER_ID, - FALLBACK_CODEX_MODELS, -} from "./provider-catalog.js"; -import { - type CodexAppServerStartOptions, - readCodexPluginConfig, - resolveCodexAppServerRuntimeOptions, -} from "./src/app-server/config.js"; -import type { - CodexAppServerModel, - CodexAppServerModelListResult, -} from "./src/app-server/models.js"; -import { buildCodexAppServerUsageSnapshot } from "./src/app-server/rate-limits.js"; - -const DEFAULT_DISCOVERY_TIMEOUT_MS = 2500; -const LIVE_DISCOVERY_ENV = "OPENCLAW_CODEX_DISCOVERY_LIVE"; -const MODEL_DISCOVERY_PAGE_LIMIT = 100; -const CODEX_APP_SERVER_SETUP_METHOD_ID = "app-server"; -const CODEX_DEFAULT_MODEL_REF = `${CODEX_PROVIDER_ID}/${ - expectDefined(FALLBACK_CODEX_MODELS[0], "Codex fallback model catalog must not be empty").id -}`; -const codexCatalogLog = createSubsystemLogger("codex/catalog"); -const CODEX_REASONING_EFFORTS = [ - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", - "ultra", -] as const; -export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number]; - -const GPT_56_MAX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; -const GPT_56_ULTRA_REASONING_EFFORTS = [...GPT_56_MAX_REASONING_EFFORTS, "ultra"] as const; -const GPT_56_ULTRA_MODEL_IDS = new Set(["gpt-5.6-sol", "gpt-5.6-terra"]); -const GPT_56_MAX_MODEL_IDS = new Set([...GPT_56_ULTRA_MODEL_IDS, "gpt-5.6-luna"]); -const GPT_56_DEFAULT_REASONING_EFFORTS = new Map([ - ["gpt-5.6-sol", "low"], - ["gpt-5.6-terra", "medium"], - ["gpt-5.6-luna", "medium"], -]); -const GPT_5_PRO_REASONING_EFFORTS = ["medium", "high", "xhigh"] as const; - -type CodexModelLister = (options: { - timeoutMs: number; - limit?: number; - cursor?: string; - startOptions?: CodexAppServerStartOptions; - sharedClient?: boolean; -}) => Promise; - -type CodexUsageRead = { - rateLimits: unknown; - accountEmail?: string; -}; - -type CodexUsageReader = (options: { - timeoutMs: number; - agentDir?: string; - authProfileId?: string; - config?: Parameters[0]["config"]; - startOptions?: CodexAppServerStartOptions; -}) => Promise; - -type BuildCodexProviderOptions = { - pluginConfig?: unknown; - listModels?: CodexModelLister; - readUsage?: CodexUsageReader; -}; - -type BuildCatalogOptions = { - env?: NodeJS.ProcessEnv; - pluginConfig?: unknown; - listModels?: CodexModelLister; - onDiscoveryFailure?: (error: unknown) => void; -}; - -/** - * Builds the Codex provider plugin, including setup metadata, catalog discovery, - * dynamic model resolution, and prompt/thinking hooks. - */ -export function buildCodexProvider(options: BuildCodexProviderOptions = {}): ProviderPlugin { - return { - id: CODEX_PROVIDER_ID, - label: "Codex", - docsPath: "/providers/models", - auth: [ - { - id: CODEX_APP_SERVER_SETUP_METHOD_ID, - label: "Codex app-server", - hint: "Use the Codex app-server runtime and managed model catalog.", - kind: "custom", - wizard: { - choiceId: CODEX_PROVIDER_ID, - choiceLabel: "Codex app-server", - choiceHint: "Use the Codex app-server runtime and managed model catalog.", - assistantPriority: -40, - groupId: CODEX_PROVIDER_ID, - groupLabel: "Codex", - groupHint: "Codex app-server model provider", - onboardingScopes: ["text-inference"], - }, - run: async () => ({ profiles: [], defaultModel: CODEX_DEFAULT_MODEL_REF }), - }, - ], - catalog: { - order: "late", - run: async (ctx) => { - const runtimePluginConfig = resolvePluginConfigObject(ctx.config, CODEX_PROVIDER_ID); - const pluginConfig = runtimePluginConfig ?? (ctx.config ? undefined : options.pluginConfig); - return await buildCodexProviderCatalog({ - env: ctx.env, - pluginConfig, - listModels: options.listModels, - }); - }, - }, - staticCatalog: { - order: "late", - run: async () => ({ - provider: buildCodexProviderConfig(FALLBACK_CODEX_MODELS), - }), - }, - resolveDynamicModel: (ctx) => resolveCodexDynamicModel(ctx.modelId), - resolveSyntheticAuth: () => ({ - apiKey: CODEX_APP_SERVER_AUTH_MARKER, - source: "codex-app-server", - mode: "token", - }), - fetchUsageSnapshot: async (ctx) => { - if (ctx.token !== CODEX_APP_SERVER_AUTH_MARKER) { - return null; - } - const runtimePluginConfig = resolvePluginConfigObject(ctx.config, CODEX_PROVIDER_ID); - const pluginConfig = runtimePluginConfig ?? (ctx.config ? undefined : options.pluginConfig); - const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig }); - const usage = await (options.readUsage ?? requestCodexAppServerUsageLazy)({ - timeoutMs: ctx.timeoutMs, - agentDir: ctx.agentDir, - ...(ctx.authProfileId ? { authProfileId: ctx.authProfileId } : {}), - config: ctx.config, - startOptions: appServer.start, - }); - const snapshot = buildCodexAppServerUsageSnapshot(usage.rateLimits); - const accountEmail = ctx.email ?? usage.accountEmail; - return accountEmail && !snapshot.error ? { ...snapshot, accountEmail } : snapshot; - }, - resolveThinkingProfile: ({ modelId, compat }) => { - const efforts = resolveCodexThinkingEfforts({ - modelId, - supportedReasoningEfforts: readCodexSupportedReasoningEfforts(compat), - }); - const defaultLevel = GPT_56_DEFAULT_REASONING_EFFORTS.get(modelId.trim().toLowerCase()); - return { - levels: [{ id: "off" }, ...efforts.map((id) => ({ id }))], - ...(defaultLevel && efforts.includes(defaultLevel) ? { defaultLevel } : {}), - }; - }, - resolveSystemPromptContribution: ({ config, modelId }) => - resolveCodexSystemPromptContribution({ config, modelId }), - isModernModelRef: ({ modelId }) => isModernCodexModel(modelId), - }; -} - -/** - * Builds the Codex model catalog from live app-server discovery, falling back - * to built-in model records when discovery is disabled or unavailable. - */ -export async function buildCodexProviderCatalog( - options: BuildCatalogOptions = {}, -): Promise<{ provider: ModelProviderConfig }> { - const config = readCodexPluginConfig(options.pluginConfig); - const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig }); - const timeoutMs = normalizeTimeoutMs(config.discovery?.timeoutMs); - let discovered: CodexAppServerModel[] = []; - if (config.discovery?.enabled !== false && !shouldSkipLiveDiscovery(options.env)) { - discovered = await listModelsBestEffort({ - listModels: options.listModels ?? listCodexAppServerModelsLazy, - timeoutMs, - startOptions: appServer.start, - onDiscoveryFailure: options.onDiscoveryFailure, - }); - } - return { - provider: buildCodexProviderConfig(discovered.length > 0 ? discovered : FALLBACK_CODEX_MODELS), - }; -} - -function resolveCodexDynamicModel(modelId: string) { - const id = modelId.trim(); - if (!id) { - return undefined; - } - const fallbackModel = FALLBACK_CODEX_MODELS.find((model) => model.id === id); - return normalizeModelCompat({ - ...buildCodexModelDefinition({ - id, - model: id, - inputModalities: fallbackModel?.inputModalities ?? ["text"], - supportedReasoningEfforts: fallbackModel?.supportedReasoningEfforts, - }), - provider: CODEX_PROVIDER_ID, - baseUrl: CODEX_BASE_URL, - } as ProviderRuntimeModel); -} - -async function listModelsBestEffort(params: { - listModels: CodexModelLister; - timeoutMs: number; - startOptions: CodexAppServerStartOptions; - onDiscoveryFailure?: (error: unknown) => void; -}): Promise { - try { - const models: CodexAppServerModel[] = []; - let cursor: string | undefined; - do { - // App-server model listing is paginated; collect every visible model so - // aliases and picker rows match the current Codex account. - const result = await params.listModels({ - timeoutMs: params.timeoutMs, - limit: MODEL_DISCOVERY_PAGE_LIMIT, - cursor, - startOptions: params.startOptions, - sharedClient: false, - }); - models.push(...result.models.filter((model) => !model.hidden)); - cursor = result.nextCursor; - } while (cursor); - return models; - } catch (error) { - params.onDiscoveryFailure?.(error); - codexCatalogLog.debug("codex model discovery failed; using fallback catalog", { - error: error instanceof Error ? error.message : String(error), - }); - return []; - } -} - -async function listCodexAppServerModelsLazy(options: { - timeoutMs: number; - limit?: number; - cursor?: string; - startOptions?: CodexAppServerStartOptions; - sharedClient?: boolean; -}): Promise { - const { listCodexAppServerModels } = await import("./src/app-server/models.js"); - return listCodexAppServerModels(options); -} - -function extractCodexAccountEmail(value: unknown): string | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - const record = value as { account?: unknown; email?: unknown; accountEmail?: unknown }; - const account = - record.account && typeof record.account === "object" - ? (record.account as { email?: unknown; accountEmail?: unknown }) - : record; - const email = account.email ?? account.accountEmail; - return typeof email === "string" && email.trim() ? email.trim() : undefined; -} - -async function requestCodexAppServerUsageLazy(options: { - timeoutMs: number; - agentDir?: string; - authProfileId?: string; - config?: Parameters< - typeof import("./src/app-server/request.js").requestCodexAppServerJson - >[0]["config"]; - startOptions?: CodexAppServerStartOptions; -}): Promise<{ rateLimits: unknown; accountEmail?: string }> { - const { withCodexAppServerJsonClient } = await import("./src/app-server/request.js"); - // Bound the whole usage read (client acquisition + both requests) so the - // best-effort identity read can be capped against the time actually left. - const deadline = Date.now() + options.timeoutMs; - // One session serves both reads so the identity is guaranteed to belong to - // the same account the rate limits describe. - return await withCodexAppServerJsonClient( - { - timeoutMs: options.timeoutMs, - timeoutMessage: "codex app-server usage read timed out", - agentDir: options.agentDir, - ...(options.authProfileId ? { authProfileId: options.authProfileId } : {}), - config: options.config, - startOptions: options.startOptions, - isolated: true, - // Keep isolated-client shutdown cheap so cleanup after a hung read cannot - // breach the usage deadline; the reserve below leaves room for it. - isolatedShutdown: CODEX_USAGE_ISOLATED_SHUTDOWN, - }, - async (request) => { - const rateLimits = await request({ method: "account/rateLimits/read" }); - // Identity is best-effort: rate limits stay useful without it, and a slow - // or hung account read must never turn a successful window fetch into a - // usage-snapshot timeout. - const accountEmail = await readCodexAccountEmailBestEffort(request, deadline); - return { rateLimits, ...(accountEmail ? { accountEmail } : {}) }; - }, - ); -} - -// Isolated usage-read shutdown: a throwaway read-only child, so force-kill -// quickly and wait only briefly for exit. Its total bounds the reserve below. -const CODEX_USAGE_ISOLATED_SHUTDOWN = { forceKillDelayMs: 200, exitTimeoutMs: 300 } as const; - -// Cap the best-effort identity read, and reserve enough of the shared usage -// deadline for the isolated-client shutdown plus a margin so this read cannot -// convert a successful rate-limit fetch into an outer timeout. -const CODEX_ACCOUNT_READ_MAX_TIMEOUT_MS = 4_000; -const CODEX_ACCOUNT_READ_DEADLINE_MARGIN_MS = 250; -const CODEX_USAGE_DEADLINE_RESERVE_MS = - CODEX_USAGE_ISOLATED_SHUTDOWN.forceKillDelayMs + - CODEX_USAGE_ISOLATED_SHUTDOWN.exitTimeoutMs + - CODEX_ACCOUNT_READ_DEADLINE_MARGIN_MS; - -async function readCodexAccountEmailBestEffort( - request: (params: { method: string; requestParams?: unknown }) => Promise, - deadline: number, -): Promise { - const boundMs = Math.min( - CODEX_ACCOUNT_READ_MAX_TIMEOUT_MS, - deadline - Date.now() - CODEX_USAGE_DEADLINE_RESERVE_MS, - ); - // No usable budget left after the rate-limit read: keep the windows and skip - // identity rather than risk tripping the outer timeout. - if (boundMs <= 0) { - return undefined; - } - // account/read requires an (empty) params object per the app-server protocol - // (GetAccountParams; refreshToken defaults false when omitted). - // Resolves, never rejects: a failing account read yields undefined so the - // caller still returns the rate-limit windows. - const read = request({ method: "account/read", requestParams: {} }).then( - (account) => extractCodexAccountEmail(account), - () => undefined, - ); - let timer: ReturnType | undefined; - const timeout = new Promise((resolve) => { - timer = setTimeout(() => resolve(undefined), boundMs); - timer.unref?.(); - }); - try { - return await Promise.race([read, timeout]); - } finally { - if (timer) { - clearTimeout(timer); - } - // When the timer wins, the still-pending read settles after the client - // closes; it already swallows rejections, so there is nothing to leak. - } -} - -function normalizeTimeoutMs(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) && value > 0 - ? value - : DEFAULT_DISCOVERY_TIMEOUT_MS; -} - -function shouldSkipLiveDiscovery(env: NodeJS.ProcessEnv = process.env): boolean { - const override = env[LIVE_DISCOVERY_ENV]?.trim().toLowerCase(); - if (override === "0" || override === "false") { - return true; - } - return Boolean(env.VITEST) && override !== "1"; -} - -function isKnownXHighCodexModel(modelId: string): boolean { - const lower = modelId.trim().toLowerCase(); - return ( - lower.startsWith("gpt-5") || - lower.startsWith("o3") || - lower.startsWith("o4") || - lower.includes("codex") - ); -} - -function normalizeCodexReasoningEfforts( - efforts: readonly string[] | null | undefined, -): CodexReasoningEffort[] { - if (!efforts) { - return []; - } - const supported = new Set(efforts.map((effort) => effort.trim().toLowerCase())); - return CODEX_REASONING_EFFORTS.filter((effort) => supported.has(effort)); -} - -/** Read app-server reasoning metadata from a runtime model compat union. */ -export function readCodexSupportedReasoningEfforts(compat: unknown): string[] | undefined { - if (!compat || typeof compat !== "object" || Array.isArray(compat)) { - return undefined; - } - const efforts = (compat as { supportedReasoningEfforts?: unknown }).supportedReasoningEfforts; - if (!Array.isArray(efforts)) { - return undefined; - } - const strings = efforts.filter((effort): effort is string => typeof effort === "string"); - // Direct OpenAI Responses metadata advertises `none`; Codex model/list does - // not. Do not let the direct API contract override native Codex capabilities. - return strings.some((effort) => effort.trim().toLowerCase() === "none") ? undefined : strings; -} - -function resolveCodexThinkingEfforts(params: { - modelId: string; - supportedReasoningEfforts?: readonly string[] | null; -}): CodexReasoningEffort[] { - if (params.supportedReasoningEfforts) { - return normalizeCodexReasoningEfforts(params.supportedReasoningEfforts); - } - const fallbackEfforts = resolveCodexFallbackReasoningEfforts(params.modelId); - if (fallbackEfforts) { - return [...fallbackEfforts]; - } - return [ - "minimal", - "low", - "medium", - "high", - ...(isKnownXHighCodexModel(params.modelId) ? (["xhigh"] as const) : []), - ...(isMaxReasoningCodexModel(params.modelId) ? (["max"] as const) : []), - ]; -} - -/** Map a requested effort onto the authoritative app-server model contract. */ -export function resolveCodexSupportedReasoningEffort(params: { - requested: CodexReasoningEffort; - supportedReasoningEfforts: readonly string[]; -}): CodexReasoningEffort | undefined { - const supported = normalizeCodexReasoningEfforts(params.supportedReasoningEfforts); - if (supported.includes(params.requested)) { - return params.requested; - } - // Ultra enables proactive multi-agent behavior, so it must be explicit. - // Lower-effort fallback may select Max or below, never Ultra. - const fallbackEfforts = - params.requested === "ultra" ? supported : supported.filter((effort) => effort !== "ultra"); - const requestedRank = CODEX_REASONING_EFFORTS.indexOf(params.requested); - return ( - fallbackEfforts.find((effort) => CODEX_REASONING_EFFORTS.indexOf(effort) >= requestedRank) ?? - fallbackEfforts.at(-1) - ); -} - -/** Return the known effort contract when app-server model metadata is unavailable. */ -export function resolveCodexFallbackReasoningEfforts( - modelId: string, -): readonly CodexReasoningEffort[] | undefined { - const normalized = modelId.trim().toLowerCase(); - if (GPT_56_ULTRA_MODEL_IDS.has(normalized)) { - return GPT_56_ULTRA_REASONING_EFFORTS; - } - if (normalized === "gpt-5.6-luna") { - return GPT_56_MAX_REASONING_EFFORTS; - } - if (normalized === "gpt-5.5-pro" || normalized === "gpt-5.4-pro") { - return GPT_5_PRO_REASONING_EFFORTS; - } - return undefined; -} - -/** Return whether the model uses the modern Codex reasoning profile. */ -export function isModernCodexModel(modelId: string): boolean { - const lower = modelId.trim().toLowerCase(); - return ( - GPT_56_MAX_MODEL_IDS.has(lower) || - lower === "gpt-5.5" || - lower === "gpt-5.5-pro" || - lower === "gpt-5.4" || - lower === "gpt-5.4-pro" || - lower === "gpt-5.4-mini" || - lower === "gpt-5.3-codex-spark" - ); -} - -/** Return whether Codex accepts the preview GPT-5.6 `max` reasoning effort. */ -export function isMaxReasoningCodexModel(modelId: string): boolean { - const lower = modelId.trim().toLowerCase(); - return GPT_56_MAX_MODEL_IDS.has(lower); -} diff --git a/extensions/codex/src/app-server/reasoning-effort.ts b/extensions/codex/src/app-server/reasoning-effort.ts new file mode 100644 index 000000000000..d4cb84a1fd21 --- /dev/null +++ b/extensions/codex/src/app-server/reasoning-effort.ts @@ -0,0 +1,118 @@ +import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; + +const CODEX_REASONING_EFFORTS = [ + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +] as const; +export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number]; + +const GPT_56_MAX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; +const GPT_56_ULTRA_REASONING_EFFORTS = [...GPT_56_MAX_REASONING_EFFORTS, "ultra"] as const; +const GPT_5_PRO_REASONING_EFFORTS = ["medium", "high", "xhigh"] as const; +const GPT_56_ULTRA_MODEL_IDS = new Set(["gpt-5.6-sol", "gpt-5.6-terra"]); +const GPT_56_MAX_MODEL_IDS = new Set([...GPT_56_ULTRA_MODEL_IDS, "gpt-5.6-luna"]); +const MODERN_CODEX_MODEL_IDS = new Set([ + ...GPT_56_MAX_MODEL_IDS, + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.3-codex-spark", +]); + +function normalizeCodexReasoningEfforts( + efforts: readonly string[] | null | undefined, +): CodexReasoningEffort[] { + if (!efforts) { + return []; + } + const supported = new Set(efforts.map((effort) => effort.trim().toLowerCase())); + return CODEX_REASONING_EFFORTS.filter((effort) => supported.has(effort)); +} + +/** Read reasoning metadata after the Codex app-server route has been selected. */ +export function readCodexSupportedReasoningEfforts(compat: unknown): string[] | undefined { + if (!compat || typeof compat !== "object" || Array.isArray(compat)) { + return undefined; + } + const efforts = (compat as { supportedReasoningEfforts?: unknown }).supportedReasoningEfforts; + if (!Array.isArray(efforts)) { + return undefined; + } + return efforts.filter((effort): effort is string => typeof effort === "string"); +} + +function resolveSupportedReasoningEffort(params: { + requested: CodexReasoningEffort; + supportedReasoningEfforts: readonly string[]; +}): CodexReasoningEffort | undefined { + const supported = normalizeCodexReasoningEfforts(params.supportedReasoningEfforts); + if (supported.includes(params.requested)) { + return params.requested; + } + // Ultra enables proactive multi-agent behavior, so it must be explicit. + // Lower-effort fallback may select Max or below, never Ultra. + const fallbackEfforts = + params.requested === "ultra" ? supported : supported.filter((effort) => effort !== "ultra"); + const requestedRank = CODEX_REASONING_EFFORTS.indexOf(params.requested); + return ( + fallbackEfforts.find((effort) => CODEX_REASONING_EFFORTS.indexOf(effort) >= requestedRank) ?? + fallbackEfforts.at(-1) + ); +} + +function resolveFallbackReasoningEfforts( + modelId: string, +): readonly CodexReasoningEffort[] | undefined { + const normalized = modelId.trim().toLowerCase(); + if (GPT_56_ULTRA_MODEL_IDS.has(normalized)) { + return GPT_56_ULTRA_REASONING_EFFORTS; + } + if (normalized === "gpt-5.6-luna") { + return GPT_56_MAX_REASONING_EFFORTS; + } + if (normalized === "gpt-5.5-pro" || normalized === "gpt-5.4-pro") { + return GPT_5_PRO_REASONING_EFFORTS; + } + return undefined; +} + +/** Resolve a turn effort from app-server metadata, with exact-name offline fallbacks. */ +export function resolveCodexAppServerReasoningEffort(params: { + thinkLevel: EmbeddedRunAttemptParams["thinkLevel"] | "ultra"; + modelId: string; + supportedReasoningEfforts?: readonly string[]; +}): CodexReasoningEffort | null { + if (params.thinkLevel === "off" || params.thinkLevel === "adaptive") { + return null; + } + const supportedReasoningEfforts = + params.supportedReasoningEfforts ?? resolveFallbackReasoningEfforts(params.modelId); + if (supportedReasoningEfforts) { + return ( + resolveSupportedReasoningEffort({ + requested: params.thinkLevel, + supportedReasoningEfforts, + }) ?? null + ); + } + const normalizedModelId = params.modelId.trim().toLowerCase(); + if (params.thinkLevel === "minimal") { + return MODERN_CODEX_MODEL_IDS.has(normalizedModelId) ? "low" : "minimal"; + } + if ( + params.thinkLevel === "low" || + params.thinkLevel === "medium" || + params.thinkLevel === "high" || + params.thinkLevel === "xhigh" + ) { + return params.thinkLevel; + } + return params.thinkLevel === "max" && GPT_56_MAX_MODEL_IDS.has(normalizedModelId) ? "max" : null; +} diff --git a/extensions/codex/src/app-server/run-attempt.hooks.test.ts b/extensions/codex/src/app-server/run-attempt.hooks.test.ts index 7440a7bf02ed..833b361751a4 100644 --- a/extensions/codex/src/app-server/run-attempt.hooks.test.ts +++ b/extensions/codex/src/app-server/run-attempt.hooks.test.ts @@ -18,8 +18,8 @@ import { createMockPluginRegistry, onTrustedInternalDiagnosticEvent, } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { GPT5_BEHAVIOR_CONTRACT as CODEX_GPT5_BEHAVIOR_CONTRACT } from "openclaw/plugin-sdk/provider-model-shared"; import { describe, expect, it, vi } from "vitest"; -import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js"; import { assistantMessage, createAppServerHarness, diff --git a/extensions/codex/src/app-server/run-attempt.steering.test.ts b/extensions/codex/src/app-server/run-attempt.steering.test.ts index 0f39fc21e83a..8fd6c0ed95a2 100644 --- a/extensions/codex/src/app-server/run-attempt.steering.test.ts +++ b/extensions/codex/src/app-server/run-attempt.steering.test.ts @@ -1,7 +1,7 @@ // Codex tests cover run attempt.steering plugin behavior. import path from "node:path"; +import { GPT5_BEHAVIOR_CONTRACT as CODEX_GPT5_BEHAVIOR_CONTRACT } from "openclaw/plugin-sdk/provider-model-shared"; import { describe, expect, it, vi } from "vitest"; -import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js"; import type { CodexServerNotification } from "./protocol.js"; import { createParams, diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index aa626d364d83..b439928e84dd 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -16,11 +16,11 @@ import { registerMemoryCapability } from "openclaw/plugin-sdk/memory-core-host-r import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints"; import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { GPT5_BEHAVIOR_CONTRACT as CODEX_GPT5_BEHAVIOR_CONTRACT } from "openclaw/plugin-sdk/provider-model-shared"; import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime"; import { describe, expect, it, vi } from "vitest"; import WebSocket from "ws"; -import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js"; import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js"; import { buildCodexOpenClawPromptContext, diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index 0aa3df5ef3a0..818f13cbda59 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -18,7 +18,6 @@ import { type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime"; -import { readCodexSupportedReasoningEfforts } from "../../provider.js"; import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; import { resolveCodexAppServerPreparedAuthHandoff } from "./auth-bridge.js"; @@ -89,6 +88,7 @@ import { import { resolveCodexProviderWebSearchSupportForClient } from "./provider-capabilities.js"; import { readRecentCodexRateLimits } from "./rate-limit-cache.js"; import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js"; +import { readCodexSupportedReasoningEfforts } from "./reasoning-effort.js"; import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js"; import { sessionBindingIdentity, type CodexAppServerBindingStore } from "./session-binding.js"; import { diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index 71ae69a1c760..3f8e3a67a897 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -3,8 +3,8 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { GPT5_BEHAVIOR_CONTRACT as CODEX_GPT5_BEHAVIOR_CONTRACT } from "openclaw/plugin-sdk/provider-model-shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js"; import { CodexAppServerRpcError } from "./client.js"; import { buildCodexAppServerConnectionFingerprint } from "./plugin-app-cache-key.js"; import { CODEX_OPENCLAW_DIRECT_DYNAMIC_TOOL_NAMESPACE } from "./protocol.js"; @@ -3089,8 +3089,8 @@ describe("resolveReasoningEffort (#71946)", () => { describe("native Codex Ultra turn mapping", () => { it.each([ - { modelId: "gpt-5.6-sol", expected: "ultra" }, - { modelId: "gpt-5.6-terra", expected: "ultra" }, + { modelId: "gpt-5.6-sol", expected: "max" }, + { modelId: "gpt-5.6-terra", expected: "max" }, { modelId: "gpt-5.6-luna", expected: "max" }, ] as const)( "maps Ultra to $expected for $modelId with direct OpenAI API metadata", diff --git a/extensions/codex/src/app-server/thread-model-selection.ts b/extensions/codex/src/app-server/thread-model-selection.ts index 27db987ea68a..f0d8878d32e7 100644 --- a/extensions/codex/src/app-server/thread-model-selection.ts +++ b/extensions/codex/src/app-server/thread-model-selection.ts @@ -1,11 +1,8 @@ import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import { - isMaxReasoningCodexModel, - isModernCodexModel, - resolveCodexFallbackReasoningEfforts, - resolveCodexSupportedReasoningEffort, + resolveCodexAppServerReasoningEffort, type CodexReasoningEffort, -} from "../../provider.js"; +} from "./reasoning-effort.js"; import { isCodexAppServerNativeAuthProfile, type CodexAppServerAuthProfileLookup, @@ -138,7 +135,7 @@ export function resolveCodexAppServerModelProvider(params: { } // Modern Codex models reject the legacy CLI `minimal` default. Prefer -// app-server metadata, then use the provider-owned fallback effort contract +// app-server metadata, then use the app-server-owned fallback effort contract // for Pro models whose minimum supported effort is `medium`. // Other modern models translate `minimal` to `low`. (#71946) // Exported for unit-test coverage of the model-aware translation path. @@ -147,39 +144,9 @@ export function resolveReasoningEffort( modelId: string, supportedReasoningEfforts?: readonly string[], ): CodexReasoningEffort | null { - if (thinkLevel === "off" || thinkLevel === "adaptive") { - return null; - } - if (supportedReasoningEfforts) { - return ( - resolveCodexSupportedReasoningEffort({ - requested: thinkLevel, - supportedReasoningEfforts, - }) ?? null - ); - } - const fallbackReasoningEfforts = resolveCodexFallbackReasoningEfforts(modelId); - if (fallbackReasoningEfforts) { - return ( - resolveCodexSupportedReasoningEffort({ - requested: thinkLevel, - supportedReasoningEfforts: fallbackReasoningEfforts, - }) ?? null - ); - } - if (thinkLevel === "minimal") { - return isModernCodexModel(modelId) ? "low" : "minimal"; - } - if ( - thinkLevel === "low" || - thinkLevel === "medium" || - thinkLevel === "high" || - thinkLevel === "xhigh" - ) { - return thinkLevel; - } - if (thinkLevel === "max" && isMaxReasoningCodexModel(modelId)) { - return "max"; - } - return null; + return resolveCodexAppServerReasoningEffort({ + thinkLevel, + modelId, + supportedReasoningEfforts, + }); } diff --git a/extensions/codex/src/app-server/turn-params.ts b/extensions/codex/src/app-server/turn-params.ts index 28decb1f63e1..2a33d7f082dc 100644 --- a/extensions/codex/src/app-server/turn-params.ts +++ b/extensions/codex/src/app-server/turn-params.ts @@ -1,6 +1,6 @@ import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; -import { CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY } from "../../prompt-overlay.js"; -import { readCodexSupportedReasoningEfforts } from "../../provider.js"; +import { GPT5_HEARTBEAT_PROMPT_OVERLAY as CODEX_GPT5_HEARTBEAT_PROMPT_OVERLAY } from "openclaw/plugin-sdk/provider-model-shared"; +import { readCodexSupportedReasoningEfforts } from "./reasoning-effort.js"; import { codexSandboxPolicyForTurn, type CodexAppServerRuntimeOptions } from "./config.js"; import { invalidInlineImageText, sanitizeInlineImageDataUrl } from "./image-payload-sanitizer.js"; import type { diff --git a/extensions/openai/openai-chatgpt-provider.ts b/extensions/openai/openai-chatgpt-provider.ts index 88ed3895799a..6059d1bd05da 100644 --- a/extensions/openai/openai-chatgpt-provider.ts +++ b/extensions/openai/openai-chatgpt-provider.ts @@ -630,8 +630,8 @@ export function buildOpenAICodexProviderHooks(): Pick< return { resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx), buildAuthDoctorHint: (ctx) => buildOpenAICodexAuthDoctorHint(ctx), - resolveThinkingProfile: ({ modelId, agentRuntime, compat }) => - resolveOpenAICodexThinkingProfile(modelId, agentRuntime, compat), + resolveThinkingProfile: ({ modelId, agentRuntime, api, compat }) => + resolveOpenAICodexThinkingProfile(modelId, agentRuntime, compat, api), isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CHATGPT_MODERN_MODEL_IDS), preferRuntimeResolvedModel: (ctx) => { diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index ec63fee400de..f867e2db81b0 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -569,6 +569,7 @@ describe("buildOpenAIProvider", () => { provider: "openai", modelId: "gpt-5.6-sol", agentRuntime: "codex", + api: "openai-chatgpt-responses", compat: liveSol?.compat, } as never)?.levels, ).not.toContainEqual({ id: "ultra" }); @@ -748,6 +749,7 @@ describe("buildOpenAIProvider", () => { provider: "openai", modelId: "gpt-5.6-sol", agentRuntime: "codex", + api: "openai-chatgpt-responses", compat: sol?.compat, } as never)?.levels, ).not.toContainEqual({ id: "ultra" }); @@ -810,6 +812,7 @@ describe("buildOpenAIProvider", () => { provider: "openai", modelId, agentRuntime: "codex", + api: "openai-chatgpt-responses", compat: model?.compat, } as never) ?.levels.map((level) => level.id), @@ -822,6 +825,7 @@ describe("buildOpenAIProvider", () => { provider: "openai", modelId: "gpt-5.6-luna", agentRuntime: "codex", + api: "openai-chatgpt-responses", compat: luna?.compat, } as never) ?.levels.map((level) => level.id); @@ -1708,6 +1712,7 @@ describe("buildOpenAIProvider", () => { provider: "openai", modelId: "gpt-5.6-luna", agentRuntime: "codex", + api: "openai-responses", compat: { supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], }, @@ -1716,6 +1721,7 @@ describe("buildOpenAIProvider", () => { provider: "openai", modelId: "gpt-5.6-sol", agentRuntime: "codex", + api: "openai-responses", compat: { supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], }, @@ -1724,6 +1730,7 @@ describe("buildOpenAIProvider", () => { provider: "openai", modelId: "gpt-5.6-sol", agentRuntime: "codex", + api: "openai-chatgpt-responses", compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], }, diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts index c79b7aca7fb5..a02e5a72a5a7 100644 --- a/extensions/openai/openai-provider.ts +++ b/extensions/openai/openai-provider.ts @@ -1007,9 +1007,9 @@ export function buildOpenAIProvider(): ProviderPlugin { /content_filter.*(?:prompt|input).*(?:too long|exceed)/i.test(errorMessage), classifyFailoverReason: ({ code }) => classifyOpenAiFailoverCode(code), resolveReasoningOutputMode: () => "native", - resolveThinkingProfile: ({ provider, modelId, agentRuntime, compat }) => + resolveThinkingProfile: ({ provider, modelId, agentRuntime, api, compat }) => normalizeProviderId(provider) === PROVIDER_ID - ? resolveUnifiedOpenAIThinkingProfile(modelId, agentRuntime, compat) + ? resolveUnifiedOpenAIThinkingProfile(modelId, agentRuntime, compat, api) : null, isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_PROVIDER_MODERN_MODEL_IDS), diff --git a/extensions/openai/provider-policy-api.test.ts b/extensions/openai/provider-policy-api.test.ts index 047510addc75..1fe2cc5fe8d5 100644 --- a/extensions/openai/provider-policy-api.test.ts +++ b/extensions/openai/provider-policy-api.test.ts @@ -124,8 +124,9 @@ describe("OpenAI provider policy artifact", () => { provider: "openai", modelId, agentRuntime: "codex", + api: "openai-responses", compat: { - supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], + supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"], }, })?.levels.map((level) => level.id); @@ -151,12 +152,14 @@ describe("OpenAI provider policy artifact", () => { provider: "openai", modelId: "gpt-5.6-sol", agentRuntime: "codex", + api: "openai-chatgpt-responses", compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }, })?.levels.map((level) => level.id); const terraLevels = resolveThinkingProfile({ provider: "openai", modelId: "gpt-5.6-terra", agentRuntime: "codex", + api: "openai-chatgpt-responses", compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], }, @@ -174,6 +177,7 @@ describe("OpenAI provider policy artifact", () => { provider: "openai", modelId: "gpt-5.6-sol", agentRuntime: "codex", + api: "openai-chatgpt-responses", compat: { supportedReasoningEfforts: efforts }, }); @@ -186,6 +190,7 @@ describe("OpenAI provider policy artifact", () => { provider: "openai", modelId: "gpt-5.6-luna", agentRuntime: "codex", + api: "openai-responses", compat: { supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"], }, diff --git a/extensions/openai/provider-policy-api.ts b/extensions/openai/provider-policy-api.ts index 92632a5c0d74..95a3fa32c39c 100644 --- a/extensions/openai/provider-policy-api.ts +++ b/extensions/openai/provider-policy-api.ts @@ -545,6 +545,7 @@ export function resolveThinkingProfile(params: ProviderDefaultThinkingPolicyCont params.modelId, params.agentRuntime, params.compat, + params.api, ); default: return null; diff --git a/extensions/openai/thinking-policy.test.ts b/extensions/openai/thinking-policy.test.ts new file mode 100644 index 000000000000..b7952ea1d57e --- /dev/null +++ b/extensions/openai/thinking-policy.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { resolveUnifiedOpenAIThinkingProfile } from "./thinking-policy.js"; + +function levelIds(params: { + api: "openai-responses" | "openai-chatgpt-responses"; + efforts: string[]; +}) { + return resolveUnifiedOpenAIThinkingProfile( + "gpt-5.6-sol", + "codex", + { supportedReasoningEfforts: params.efforts }, + params.api, + ).levels.map((level) => level.id); +} + +describe("OpenAI thinking route provenance", () => { + it("keeps native fallback capabilities for a direct OpenAI route", () => { + expect( + levelIds({ + api: "openai-responses", + efforts: ["low", "medium", "high", "xhigh", "max"], + }), + ).toContain("ultra"); + }); + + it("uses ChatGPT model/list metadata as authoritative", () => { + expect( + levelIds({ + api: "openai-chatgpt-responses", + efforts: ["low", "medium", "high", "xhigh", "max"], + }), + ).not.toContain("ultra"); + }); +}); diff --git a/extensions/openai/thinking-policy.ts b/extensions/openai/thinking-policy.ts index 6c03f267868d..8fa47ac3811f 100644 --- a/extensions/openai/thinking-policy.ts +++ b/extensions/openai/thinking-policy.ts @@ -5,6 +5,7 @@ import type { } from "openclaw/plugin-sdk/plugin-entry"; type OpenAIThinkingCompat = ProviderDefaultThinkingPolicyContext["compat"]; +type OpenAIThinkingApi = ProviderDefaultThinkingPolicyContext["api"]; const OPENAI_THINKING_BASE_LEVELS = [ { id: "off" }, @@ -80,6 +81,7 @@ function buildOpenAIThinkingProfile(params: { modelId: string; xhighModelIds: readonly string[]; agentRuntime?: string | null; + api?: OpenAIThinkingApi; compat?: OpenAIThinkingCompat; }): ProviderThinkingProfile { const modelId = normalizeModelId(params.modelId); @@ -89,8 +91,8 @@ function buildOpenAIThinkingProfile(params: { const isTerra = modelId === "gpt-5.6-terra"; const isLuna = modelId === "gpt-5.6-luna"; const codexEfforts = params.compat?.supportedReasoningEfforts?.map(normalizeModelId); - const hasDirectOpenAICompat = codexEfforts?.includes("none") === true; - const authoritativeCodexEfforts = hasDirectOpenAICompat ? undefined : codexEfforts; + const authoritativeCodexEfforts = + params.api === "openai-chatgpt-responses" ? codexEfforts : undefined; const fallbackCodexMax = isSol || isTerra || isLuna; const codexSupportsMax = authoritativeCodexEfforts ? authoritativeCodexEfforts.includes("max") @@ -101,8 +103,8 @@ function buildOpenAIThinkingProfile(params: { const codexSupportsUltra = authoritativeCodexEfforts ? authoritativeCodexEfforts.includes("ultra") : fallbackCodexUltra; - // OpenClaw owns its logical Ultra orchestration. Native Codex owns its Ultra - // catalog; direct API metadata must not erase the known native fallback. + // OpenClaw owns its logical Ultra orchestration. Native Codex capabilities + // come only from the selected ChatGPT route's catalog metadata. const supportsUltra = (isBare || isSol || isTerra || isLuna) && (agentRuntime === "openclaw" || @@ -132,11 +134,13 @@ export function resolveOpenAICodexThinkingProfile( modelId: string, agentRuntime?: string | null, compat?: OpenAIThinkingCompat, + api?: OpenAIThinkingApi, ): ProviderThinkingProfile { return buildOpenAIThinkingProfile({ modelId, xhighModelIds: OPENAI_CODEX_XHIGH_MODEL_IDS, agentRuntime, + api, compat, }); } @@ -145,11 +149,13 @@ export function resolveUnifiedOpenAIThinkingProfile( modelId: string, agentRuntime?: string | null, compat?: OpenAIThinkingCompat, + api?: OpenAIThinkingApi, ): ProviderThinkingProfile { return buildOpenAIThinkingProfile({ modelId, xhighModelIds: OPENAI_UNIFIED_XHIGH_MODEL_IDS, agentRuntime, + api, compat, }); } diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index 7e8c6526204d..1367538c99c5 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -89,10 +89,16 @@ describe("ModelsListResultSchema", () => { id: "gpt-image", name: "GPT Image", provider: "openai", + agentRuntime: { id: "codex", fallback: "openclaw", source: "model" }, input: ["text", "image", "audio", "video", "document"], }; expect(Value.Check(ModelsListResultSchema, { models: [model] })).toBe(true); + expect( + Value.Check(ModelsListResultSchema, { + models: [{ ...model, agentRuntime: { id: "codex", source: "unknown" } }], + }), + ).toBe(false); expect( Value.Check(ModelsListResultSchema, { models: [{ ...model, input: ["text", "binary"] }], diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 31c72a171581..7bcfb3e46239 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -14,6 +14,21 @@ import { NonEmptyString } from "./primitives.js"; */ /** Model option shown in selectors and model catalog results. */ +export const GatewayAgentRuntimeSchema = closedObject({ + id: NonEmptyString, + fallback: Type.Optional(Type.Union([Type.Literal("openclaw"), Type.Literal("none")])), + source: Type.Union([ + Type.Literal("env"), + Type.Literal("agent"), + Type.Literal("defaults"), + Type.Literal("model"), + Type.Literal("provider"), + Type.Literal("implicit"), + Type.Literal("session"), + Type.Literal("session-key"), + ]), +}); + export const ModelChoiceSchema = closedObject({ id: NonEmptyString, name: NonEmptyString, @@ -22,6 +37,7 @@ export const ModelChoiceSchema = closedObject({ available: Type.Optional(Type.Boolean()), contextWindow: Type.Optional(Type.Integer({ minimum: 1 })), reasoning: Type.Optional(Type.Boolean()), + agentRuntime: Type.Optional(GatewayAgentRuntimeSchema), apiKeySupported: Type.Optional(Type.Boolean()), input: Type.Optional( Type.Array( @@ -57,20 +73,7 @@ export const AgentSummarySchema = closedObject({ fallbacks: Type.Optional(Type.Array(NonEmptyString)), }), ), - agentRuntime: Type.Optional( - closedObject({ - id: NonEmptyString, - fallback: Type.Optional(Type.Union([Type.Literal("openclaw"), Type.Literal("none")])), - source: Type.Union([ - Type.Literal("env"), - Type.Literal("agent"), - Type.Literal("defaults"), - Type.Literal("model"), - Type.Literal("provider"), - Type.Literal("implicit"), - ]), - }), - ), + agentRuntime: Type.Optional(GatewayAgentRuntimeSchema), thinkingLevels: Type.Optional( Type.Array( closedObject({ @@ -893,6 +896,7 @@ export const ToolsInvokeResultSchema = closedObject({ // Wire types derive directly from local schema consts so public d.ts graphs never // pull in the ProtocolSchemas registry. export type AgentSummary = Static; +export type GatewayAgentRuntime = Static; export type AgentsFileEntry = Static; export type AgentsCreateParams = Static; export type AgentsCreateResult = Static; diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 241b4968c217..caf02ea9a03e 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -977,6 +977,18 @@ describe("runAgentHarnessAttempt", () => { }); describe("selectAgentHarness", () => { + it("does not select Codex from a non-OpenAI model name", () => { + registerSuccessfulCodexHarness(); + + expect(resolveAgentHarnessPolicy({ provider: "custom", modelId: "gpt-5.4-codex" })).toEqual({ + runtime: "auto", + runtimeSource: "implicit", + }); + expect(selectAgentHarness({ provider: "custom", modelId: "gpt-5.4-codex" }).id).toBe( + "openclaw", + ); + }); + it("auto-selects plugin support by default", () => { const supports = vi.fn(() => ({ supported: true as const, priority: 100 })); registerAgentHarness({ diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index fa78c0c331b8..dd8dafa5f54c 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -188,6 +188,7 @@ export function resolveThinkingProfile(params: { provider: context.normalizedProvider, modelId: context.modelId, agentRuntime: params.agentRuntime, + api: context.api, reasoning: context.reasoning, ...(context.params ? { params: context.params } : {}), compat: context.compat, diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 483e64721a3f..bab209e1bc89 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -12,6 +12,7 @@ import { } from "./doctor-config-analysis.js"; import { runDoctorConfigPreflight } from "./doctor-config-preflight.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; +import { cronCodexRuntimePolicyTargetKey } from "./doctor/cron/store-migration.js"; import { emitDoctorNotes, sanitizeDoctorNote } from "./doctor/emit-notes.js"; import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js"; import { @@ -149,6 +150,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { let candidate = structuredClone(baseCfg); let pendingChanges = false; let fixHints: string[] = []; + let shouldRepairCronCodexModelRefsAfterConfigWrite = false; const doctorFixCommand = formatCliCommand("openclaw doctor --fix"); const sourceMeta = (snapshot.sourceConfig as { meta?: { lastTouchedVersion?: unknown } })?.meta; const sourceLastTouchedVersion = @@ -165,6 +167,35 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { pendingChanges = pendingChanges || legacyStep.state.pendingChanges; fixHints = legacyStep.state.fixHints; const legacyMigrationPartiallyValid = legacyStep.partiallyValid === true; + const { collectBlockedLegacyOpenAICodexProviderPlan } = + await import("./doctor/shared/legacy-config-migrations.runtime.models.js"); + const blockedCodexProviderPlan = collectBlockedLegacyOpenAICodexProviderPlan(candidate); + const blockedCodexModelIdentities = new Set(blockedCodexProviderPlan.blockedModelIdentities); + if (preflight.cronCodexRuntimePolicyTargets?.length) { + const { repairCronCodexRuntimePolicies } = + await import("./doctor/cron/runtime-policy-migration.js"); + const cronRuntimeRepair = repairCronCodexRuntimePolicies({ + cfg: candidate, + targets: preflight.cronCodexRuntimePolicyTargets, + blockedModelIdentities: blockedCodexModelIdentities, + }); + emitDoctorChangesPanel(cronRuntimeRepair.changes, shouldRepair); + if (cronRuntimeRepair.warnings.length > 0) { + emitDoctorNotes({ note, warningNotes: cronRuntimeRepair.warnings }); + } + const blockedTargets = new Set( + cronRuntimeRepair.blockedTargets.map(cronCodexRuntimePolicyTargetKey), + ); + shouldRepairCronCodexModelRefsAfterConfigWrite = preflight.cronCodexRuntimePolicyTargets.some( + (target) => !blockedTargets.has(cronCodexRuntimePolicyTargetKey(target)), + ); + ({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({ + state: { cfg, candidate, pendingChanges, fixHints }, + mutation: cronRuntimeRepair, + shouldRepair, + fixHint: `Run "${doctorFixCommand}" to preserve migrated cron runtime policy.`, + })); + } const pluginLegacyIssues = await (async () => { if (snapshot.parsed === snapshot.sourceConfig) { return []; @@ -217,7 +248,9 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { note(sanitizeDoctorNote(unsupportedInternalHookEntryWarnings.join("\n")), "Doctor warnings"); } - const normalized = normalizeCompatibilityConfigValues(candidate); + const normalized = normalizeCompatibilityConfigValues(candidate, { + blockedModelIdentities: blockedCodexModelIdentities, + }); if (normalized.changes.length > 0) { emitDoctorChangesPanel(normalized.changes, shouldRepair); ({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({ @@ -303,6 +336,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { state: { cfg, candidate, pendingChanges, fixHints }, doctorFixCommand, env: process.env, + blockedCodexProviderPlan, }); ({ cfg, candidate, pendingChanges, fixHints } = repairSequence.state); if (repairSequence.authProfilesRepaired) { @@ -321,6 +355,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { doctorFixCommand, env: process.env, allowExec: params.options.allowExec === true, + blockedCodexProviderPlan, }); emitDoctorNotes({ note, @@ -378,5 +413,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { preservedLegacyRootKeys: ["defaultModel"], ...(sourceLastTouchedVersion ? { sourceLastTouchedVersion } : {}), ...(legacyMigrationPartiallyValid ? { skipPluginValidationOnWrite: true } : {}), + ...(shouldRepairCronCodexModelRefsAfterConfigWrite + ? { shouldRepairCronCodexModelRefsAfterConfigWrite: true } + : {}), + ...(blockedCodexProviderPlan.blockedModelIdentities.length > 0 + ? { blockedCodexModelIdentities: blockedCodexProviderPlan.blockedModelIdentities } + : {}), }; } diff --git a/src/commands/doctor-config-preflight.state-migration.test.ts b/src/commands/doctor-config-preflight.state-migration.test.ts index bd407704c7cf..bf470545d842 100644 --- a/src/commands/doctor-config-preflight.state-migration.test.ts +++ b/src/commands/doctor-config-preflight.state-migration.test.ts @@ -65,7 +65,16 @@ const autoMigrateLegacyTaskStateSidecars = vi.hoisted(() => ), ); const repairLegacyCronStoreWithoutPrompt = vi.hoisted(() => - vi.fn(async () => ({ changes: ["cron-imported"], warnings: [] })), + vi.fn( + async (): Promise<{ + changes: string[]; + warnings: string[]; + codexRuntimePolicyTargets?: Array<{ modelRef: string }>; + }> => ({ changes: ["cron-imported"], warnings: [] }), + ), +); +const collectCronCodexRuntimePolicyTargetsReadOnly = vi.hoisted(() => + vi.fn(async () => ({ targets: [] as Array<{ modelRef: string }>, warnings: [] as string[] })), ); const needsStartupMigrationCheckpoint = vi.hoisted(() => vi.fn(() => false)); const startupMigrationLeaseHeartbeat = vi.hoisted(() => vi.fn()); @@ -134,6 +143,7 @@ vi.mock("./doctor-state-migrations.js", () => ({ })); vi.mock("./doctor/cron/index.js", () => ({ + collectCronCodexRuntimePolicyTargetsReadOnly, repairLegacyCronStoreWithoutPrompt, })); @@ -203,6 +213,8 @@ describe("runDoctorConfigPreflight state migration", () => { changes: ["cron-imported"], warnings: [], }); + collectCronCodexRuntimePolicyTargetsReadOnly.mockReset(); + collectCronCodexRuntimePolicyTargetsReadOnly.mockResolvedValue({ targets: [], warnings: [] }); }); it("runs the startup guard immediately before the first state mutation", async () => { @@ -356,6 +368,7 @@ describe("runDoctorConfigPreflight state migration", () => { expect(readConfigFileSnapshot).toHaveBeenCalledOnce(); expect(repairLegacyCronStoreWithoutPrompt).toHaveBeenCalledWith({ cfg: { gateway: { mode: "local", port: 19091 } }, + migrateCodexModelRefs: false, }); expect(autoMigrateLegacyState).toHaveBeenCalledWith({ cfg: { gateway: { mode: "local", port: 19091 } }, @@ -366,6 +379,28 @@ describe("runDoctorConfigPreflight state migration", () => { expect(note).toHaveBeenCalledWith("- imported", "Doctor changes"); }); + it("carries cron Codex runtime policy targets only during repair", async () => { + collectCronCodexRuntimePolicyTargetsReadOnly.mockResolvedValueOnce({ + targets: [{ modelRef: "openai/gpt-5.6-sol" }], + warnings: [], + }); + + const result = await runDoctorConfigPreflight({ + migrateLegacyConfig: false, + invalidConfigNote: false, + repairPrefixedConfig: true, + }); + + expect(repairLegacyCronStoreWithoutPrompt).toHaveBeenCalledWith({ + cfg: { gateway: { mode: "local", port: 19091 } }, + migrateCodexModelRefs: false, + }); + expect(collectCronCodexRuntimePolicyTargetsReadOnly).toHaveBeenCalledWith({ + cfg: { gateway: { mode: "local", port: 19091 } }, + }); + expect(result.cronCodexRuntimePolicyTargets).toEqual([{ modelRef: "openai/gpt-5.6-sol" }]); + }); + it("records the startup migration checkpoint after clean startup migrations", async () => { needsStartupMigrationCheckpoint.mockReturnValue(true); @@ -681,6 +716,7 @@ describe("runDoctorConfigPreflight state migration", () => { list: [{ id: "main" }], }), }), + migrateCodexModelRefs: false, }); expect(autoMigrateLegacyState).toHaveBeenCalledWith({ cfg: expect.objectContaining({ diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 643e6f6c9d05..6d8537c79fca 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -16,6 +16,7 @@ import type { StartupMigrationLease } from "../infra/startup-migration-checkpoin import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { resolveHomeDir } from "../utils.js"; import { noteIncludeConfinementWarning } from "./doctor-config-analysis.js"; +import type { CronCodexRuntimePolicyTarget } from "./doctor/cron/store-migration.js"; import { findDoctorLegacyConfigIssues } from "./doctor/shared/legacy-config-issues.js"; import { resolveStateMigrationConfigInput } from "./doctor/shared/legacy-config-state-migration-input.js"; @@ -88,6 +89,7 @@ async function maybeMigrateLegacyConfig(): Promise { export type DoctorConfigPreflightResult = { snapshot: Awaited>; baseConfig: OpenClawConfig; + cronCodexRuntimePolicyTargets?: CronCodexRuntimePolicyTarget[]; }; function collectDoctorLegacyIssues( @@ -245,6 +247,7 @@ export async function runDoctorConfigPreflight( let startupMigrationHeartbeat: ReturnType | undefined; let startupMigrationHeartbeatError: unknown; const startupMigrationWarnings: string[] = []; + const cronCodexRuntimePolicyTargets: CronCodexRuntimePolicyTarget[] = []; const noteStartupStateMigrationResult = (result: { changes: string[]; warnings: string[]; @@ -391,11 +394,22 @@ export async function runDoctorConfigPreflight( }), ); } else if (stateMigrationInput.cfg) { - const { repairLegacyCronStoreWithoutPrompt } = await loadDoctorCron(); + const { + collectCronCodexRuntimePolicyTargetsReadOnly, + repairLegacyCronStoreWithoutPrompt, + } = await loadDoctorCron(); const cronResult = await repairLegacyCronStoreWithoutPrompt({ cfg: stateMigrationInput.cfg, + migrateCodexModelRefs: false, }); noteStartupStateMigrationResult(cronResult); + if (options.repairPrefixedConfig === true) { + const cronCodexPlan = await collectCronCodexRuntimePolicyTargetsReadOnly({ + cfg: stateMigrationInput.cfg, + }); + cronCodexRuntimePolicyTargets.push(...cronCodexPlan.targets); + noteStartupStateMigrationResult({ changes: [], warnings: cronCodexPlan.warnings }); + } noteStartupStateMigrationResult( await autoMigrateLegacyState({ cfg: stateMigrationInput.cfg, @@ -460,6 +474,7 @@ export async function runDoctorConfigPreflight( return { snapshot, baseConfig, + ...(cronCodexRuntimePolicyTargets.length > 0 ? { cronCodexRuntimePolicyTargets } : {}), }; } finally { if (startupMigrationHeartbeat) { diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index d3afd8b2ad21..b9e64ab13802 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -4,7 +4,11 @@ import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { validateConfigObject } from "../config/validation.js"; +import { maybeRepairCodexRoutes } from "./doctor/shared/codex-route-warnings.js"; import { normalizeCompatibilityConfigValues } from "./doctor/shared/legacy-config-core-migrate.js"; +import { LEGACY_CONFIG_MIGRATIONS } from "./doctor/shared/legacy-config-migrations.js"; +import { collectBlockedLegacyOpenAICodexProviderPlan } from "./doctor/shared/legacy-config-migrations.runtime.models.js"; vi.mock("../plugins/setup-registry.js", () => ({ resolvePluginSetupCliBackend: () => undefined, @@ -759,57 +763,66 @@ describe("normalizeCompatibilityConfigValues", () => { expect(res.changes).toStrictEqual([]); }); - it("migrates legacy Codex primary refs to OpenAI refs without agent runtime pins", () => { - const res = normalizeCompatibilityConfigValues({ + it("migrates shipped Codex refs to canonical OpenAI refs with model runtime pins", () => { + const normalized = normalizeCompatibilityConfigValues({ agents: { defaults: { agentRuntime: { id: "auto" }, model: { - primary: "codex/gpt-5.5", + primary: "codex/gpt-5.6-sol", fallbacks: ["anthropic/claude-sonnet-4-6", "codex/gpt-5.4-mini"], }, models: { - "codex/gpt-5.5": { alias: "legacy-codex" }, - "openai/gpt-5.5": { alias: "gpt", params: { temperature: 0.2 } }, + "codex/gpt-5.6-sol": { alias: "legacy-codex" }, + "openai/gpt-5.6-sol": { alias: "gpt", params: { temperature: 0.2 } }, "codex/gpt-5.4-mini": {}, }, }, list: [ { id: "reviewer", - model: "codex/gpt-5.4-mini", + model: "codex/gpt-5.6-sol", }, ], }, } as unknown as OpenClawConfig); + const repaired = maybeRepairCodexRoutes({ + cfg: normalized.config, + shouldRepair: true, + }); - expect(res.config.agents?.defaults?.model).toEqual({ - primary: "openai/gpt-5.5", + expect(repaired.cfg.agents?.defaults?.model).toEqual({ + primary: "openai/gpt-5.6-sol", fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4-mini"], }); - expect(res.config.agents?.defaults?.agentRuntime).toEqual({ id: "auto" }); - expect(res.config.agents?.defaults?.models).toEqual({ - "codex/gpt-5.5": { alias: "legacy-codex" }, - "openai/gpt-5.5": { alias: "gpt", params: { temperature: 0.2 } }, - "codex/gpt-5.4-mini": {}, - "openai/gpt-5.4-mini": {}, + expect(repaired.cfg.agents?.defaults?.agentRuntime).toBeUndefined(); + expect(repaired.cfg.agents?.defaults?.models).toEqual({ + "openai/gpt-5.6-sol": { + alias: "gpt", + params: { temperature: 0.2 }, + agentRuntime: { id: "codex" }, + }, + "openai/gpt-5.4-mini": { agentRuntime: { id: "codex" } }, }); - expect(res.config.agents?.list?.[0]).toEqual({ + expect(repaired.cfg.agents?.list?.[0]).toEqual({ id: "reviewer", - model: "openai/gpt-5.4-mini", + model: "openai/gpt-5.6-sol", + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + }, }); - expect(res.changes).toContain( + expect(normalized.changes).toContain( "Moved agents.defaults.model legacy runtime primary refs to canonical provider refs and selected codex runtime.", ); - expect(res.changes).toContain( + expect(normalized.changes).toContain( "Moved agents.defaults.models legacy runtime keys to canonical provider keys.", ); - expect(res.changes).toContain( + expect(normalized.changes).toContain( "Moved agents.list.reviewer.model legacy runtime primary refs to canonical provider refs and selected codex runtime.", ); }); - it("does not force Codex harness for legacy fallback-only refs", () => { + it("migrates fallback-only Codex refs through the complete route repair", () => { const input = { agents: { defaults: { @@ -824,10 +837,148 @@ describe("normalizeCompatibilityConfigValues", () => { }, } as unknown as OpenClawConfig; - const res = normalizeCompatibilityConfigValues(input); + const repaired = maybeRepairCodexRoutes({ + cfg: input, + shouldRepair: true, + }); - expect(res.config).toEqual(input); - expect(res.changes).toStrictEqual([]); + expect(repaired.cfg.agents?.defaults?.model).toEqual({ + primary: "openai/gpt-5.5", + fallbacks: ["openai/gpt-5.4-mini"], + }); + expect(repaired.cfg.agents?.defaults?.models).toEqual({ + "openai/gpt-5.4-mini": { + alias: "legacy-codex", + agentRuntime: { id: "codex" }, + }, + }); + expect(repaired.cfg.agents?.defaults?.models?.["codex/gpt-5.4-mini"]).toBeUndefined(); + }); + + it("keeps the whole provider-conflicted Codex namespace legacy", () => { + const migrated = { + models: { + providers: { + openai: { + models: [ + { + id: "gpt-5.6-sol", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ], + }, + codex: { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.6-sol" }, { id: "gpt-5.4-mini" }], + }, + }, + }, + agents: { + defaults: { + model: { + primary: "codex/gpt-5.6-sol", + fallbacks: ["codex/gpt-5.3-mini"], + }, + models: { + "codex/gpt-5.6-sol": { alias: "blocked" }, + "codex/gpt-5.3-mini": { alias: "unlisted" }, + }, + }, + }, + } as unknown as OpenClawConfig; + const migrationChanges: string[] = []; + for (const migration of LEGACY_CONFIG_MIGRATIONS) { + migration.apply(migrated as unknown as Record, migrationChanges); + } + + const blockedProviderPlan = collectBlockedLegacyOpenAICodexProviderPlan(migrated); + const normalized = normalizeCompatibilityConfigValues(migrated, { + blockedModelIdentities: new Set(blockedProviderPlan.blockedModelIdentities), + }); + const repaired = maybeRepairCodexRoutes({ + cfg: normalized.config, + shouldRepair: true, + blockedProviderPlan, + }); + + expect(repaired.cfg.models?.providers).toHaveProperty("codex"); + expect(repaired.cfg.agents?.defaults?.model).toEqual({ + primary: "codex/gpt-5.6-sol", + fallbacks: ["codex/gpt-5.3-mini"], + }); + expect(repaired.cfg.agents?.defaults?.models).toEqual({ + "codex/gpt-5.6-sol": { alias: "blocked" }, + "codex/gpt-5.3-mini": { alias: "unlisted" }, + }); + expect(repaired.warnings).toHaveLength(1); + expect(repaired.warnings[0]).toContain( + "Legacy Codex provider routes require manual reconciliation", + ); + expect(repaired.warnings[0]).toContain( + "Doctor retained matching legacy refs in config, sessions, and cron", + ); + }); + + it("migrates a 2026.6 wizard-shaped Codex config into gateway-loadable canonical state", () => { + const raw = { + models: { + providers: { + codex: { + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-chatgpt-responses", + models: [ + { + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400_000, + maxTokens: 128_000, + }, + ], + }, + }, + }, + agents: { + defaults: { + model: { + primary: "codex/gpt-5.6-sol", + fallbacks: ["codex/gpt-5.4-mini"], + }, + models: { + "codex/gpt-5.6-sol": { alias: "codex" }, + "codex/gpt-5.4-mini": {}, + }, + }, + }, + plugins: { entries: { codex: { enabled: true } } }, + }; + const migrated = structuredClone(raw) as Record; + const migrationChanges: string[] = []; + for (const migration of LEGACY_CONFIG_MIGRATIONS) { + migration.apply(migrated, migrationChanges); + } + const normalized = normalizeCompatibilityConfigValues(migrated as OpenClawConfig); + const repaired = maybeRepairCodexRoutes({ cfg: normalized.config, shouldRepair: true }); + + expect(repaired.cfg.agents?.defaults?.model).toEqual({ + primary: "openai/gpt-5.6-sol", + fallbacks: ["openai/gpt-5.4-mini"], + }); + expect(repaired.cfg.agents?.defaults?.models).toEqual({ + "openai/gpt-5.6-sol": { alias: "codex", agentRuntime: { id: "codex" } }, + "openai/gpt-5.4-mini": { agentRuntime: { id: "codex" } }, + }); + expect(repaired.cfg.models?.providers).not.toHaveProperty("codex"); + expect(repaired.cfg.models?.providers?.openai?.models?.[0]).toMatchObject({ + id: "gpt-5.6-sol", + agentRuntime: { id: "codex" }, + }); + expect(JSON.stringify(repaired.cfg)).not.toContain('"codex/'); + expect(validateConfigObject(repaired.cfg).ok).toBe(true); }); it("migrates legacy Claude CLI primary refs to Anthropic refs plus model runtime", () => { diff --git a/src/commands/doctor-session-transcripts.test.ts b/src/commands/doctor-session-transcripts.test.ts index 410c7dbe524f..e15a8b8f9185 100644 --- a/src/commands/doctor-session-transcripts.test.ts +++ b/src/commands/doctor-session-transcripts.test.ts @@ -608,6 +608,32 @@ describe("doctor session transcript repair", () => { expect(assistant.message.api).toBe("openai-chatgpt-responses"); }); + it("rewrites shipped codex transcript provider metadata", async () => { + const filePath = await writeTranscript([ + { type: "session", version: 3, id: "session-1", timestamp: "2026-04-25T00:00:00Z" }, + { + type: "message", + id: "legacy-assistant", + parentId: null, + message: { + role: "assistant", + provider: "codex", + api: "openai-chatgpt-responses", + content: [{ type: "text", text: "hello" }], + }, + }, + ]); + + const result = await repairBrokenSessionTranscriptFile({ filePath, shouldRepair: true }); + + expect(result.repaired).toBe(true); + expect(result.legacyOpenAICodexEntries).toBe(1); + const lines = (await fs.readFile(filePath, "utf-8")).trim().split(/\r?\n/); + const assistant = JSON.parse(expectDefined(lines[1], "lines[1] test invariant")); + expect(assistant.message.provider).toBe("openai"); + expect(assistant.message.api).toBe("openai-chatgpt-responses"); + }); + it("ignores ordinary branch history without internal runtime context", async () => { const filePath = await writeTranscript([ { type: "session", version: 3, id: "session-1", timestamp: "2026-04-25T00:00:00Z" }, diff --git a/src/commands/doctor-session-transcripts.ts b/src/commands/doctor-session-transcripts.ts index 1f60bfb0ce1d..00534e5b286b 100644 --- a/src/commands/doctor-session-transcripts.ts +++ b/src/commands/doctor-session-transcripts.ts @@ -20,6 +20,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { shortenHomePath } from "../utils.js"; import { withDoctorSqliteMaintenanceLock } from "./doctor-sqlite-maintenance-lock.js"; +import { isLegacyCodexProviderId } from "./doctor/shared/codex-route-model-ref.js"; const SESSION_TRANSCRIPTS_CHECK_ID = "core/doctor/session-transcripts"; @@ -52,7 +53,6 @@ type ActiveTranscriptPath = { appendParentId: string | null; }; -const LEGACY_OPENAI_CODEX_PROVIDER_ID = "openai-codex"; const OPENAI_PROVIDER_ID = "openai"; const LEGACY_OPENAI_CODEX_RESPONSES_API = "openai-codex-responses"; const OPENAI_CHATGPT_RESPONSES_API = "openai-chatgpt-responses"; @@ -101,7 +101,7 @@ function normalizeLegacyOpenAICodexTranscriptMetadata(entries: TranscriptEntry[] continue; } let touched = false; - if (message.provider === LEGACY_OPENAI_CODEX_PROVIDER_ID) { + if (isLegacyCodexProviderId(message.provider)) { message.provider = OPENAI_PROVIDER_ID; touched = true; } diff --git a/src/commands/doctor/cron/index.ts b/src/commands/doctor/cron/index.ts index 0ad7e350042b..df5b4af86336 100644 --- a/src/commands/doctor/cron/index.ts +++ b/src/commands/doctor/cron/index.ts @@ -17,6 +17,7 @@ import type { CronJob } from "../../../cron/types.js"; import type { HealthFinding } from "../../../flows/health-checks.js"; import { shortenHomePath } from "../../../utils.js"; import type { DoctorPrompter, DoctorOptions } from "../../doctor-prompter.js"; +import type { LegacyCodexModelIdentity } from "../shared/codex-route-model-ref.js"; import { countStaleDreamingJobs, migrateLegacyDreamingPayloadShape, @@ -47,7 +48,12 @@ import { mergeRuntimeEntryIntoConfigJob, needsSqliteProjectionBackfill, } from "./repair-plan.js"; -import { normalizeStoredCronJobs } from "./store-migration.js"; +import { planCronCodexRefRewriteAgainstPersistedConfig } from "./runtime-policy-migration.js"; +import { normalizeStoredCronJobs, type CronCodexRuntimePolicyTarget } from "./store-migration.js"; +import { + collectStoredCronCodexRuntimePolicyTargets, + cronCodexRuntimePolicyTargetKey, +} from "./store-migration.js"; import { noteCronDeliveryTargetAdvisory, noteCronModelOverrides } from "./warnings.js"; export { @@ -132,6 +138,7 @@ type LegacyCronRepairState = { export type LegacyCronRepairResult = { changes: string[]; warnings: string[]; + codexRuntimePolicyTargets?: CronCodexRuntimePolicyTarget[]; }; const LEGACY_CRON_STORE_CHECK_ID = "core/doctor/legacy-cron-store"; @@ -227,11 +234,31 @@ async function applyLegacyCronStoreRepair(params: { cfg: OpenClawConfig; state: LegacyCronRepairState; normalized?: ReturnType; + migrateCodexModelRefs?: boolean; + blockedModelIdentities?: ReadonlySet; }): Promise { const { state } = params; const changes: string[] = []; const warnings: string[] = []; - const normalized = params.normalized ?? normalizeStoredCronJobs(state.rawJobs); + const runtimePolicyPlan = + params.migrateCodexModelRefs === true + ? planCronCodexRefRewriteAgainstPersistedConfig({ + cfg: params.cfg, + targets: collectStoredCronCodexRuntimePolicyTargets(state.rawJobs), + blockedModelIdentities: params.blockedModelIdentities, + }) + : undefined; + warnings.push(...(runtimePolicyPlan?.warnings ?? [])); + const blockedRuntimePolicyTargets = new Set( + (runtimePolicyPlan?.blockedTargets ?? []).map(cronCodexRuntimePolicyTargetKey), + ); + const normalized = + params.normalized ?? + normalizeStoredCronJobs(state.rawJobs, { + migrateCodexModelRefs: params.migrateCodexModelRefs, + shouldMigrateCodexRuntimePolicyTarget: (target) => + !blockedRuntimePolicyTargets.has(cronCodexRuntimePolicyTargetKey(target)), + }); const legacyWebhook = normalizeOptionalString(params.cfg.cron?.webhook); const notifyMigration = migrateLegacyNotifyFallback({ jobs: state.rawJobs, @@ -340,7 +367,11 @@ async function applyLegacyCronStoreRepair(params: { ); } - return { changes, warnings }; + return { + changes, + warnings, + codexRuntimePolicyTargets: normalized.codexRuntimePolicyTargets, + }; } export async function collectLegacyCronStoreHealthFindings(params: { @@ -476,6 +507,8 @@ export async function collectLegacyCronStoreHealthFindings(params: { export async function repairLegacyCronStoreWithoutPrompt(params: { cfg: OpenClawConfig; + migrateCodexModelRefs?: boolean; + blockedModelIdentities?: ReadonlySet; }): Promise { const storePath = resolveCronJobsStorePath(normalizeOptionalString(params.cfg.cron?.store)); let state: LegacyCronRepairState | null; @@ -495,7 +528,54 @@ export async function repairLegacyCronStoreWithoutPrompt(params: { if (!state) { return { changes: [], warnings: [] }; } - return await applyLegacyCronStoreRepair({ cfg: params.cfg, state }); + return await applyLegacyCronStoreRepair({ ...params, state }); +} + +/** Read legacy Codex cron targets without changing either cron storage or config. */ +export async function collectCronCodexRuntimePolicyTargetsReadOnly(params: { + cfg: OpenClawConfig; +}): Promise<{ targets: CronCodexRuntimePolicyTarget[]; warnings: string[] }> { + const storePath = resolveCronJobsStorePath(normalizeOptionalString(params.cfg.cron?.store)); + try { + const state = await loadLegacyCronRepairState({ cfg: params.cfg, readOnly: true }); + return { + targets: state ? collectStoredCronCodexRuntimePolicyTargets(state.rawJobs) : [], + warnings: [], + }; + } catch (err) { + return { + targets: [], + warnings: [ + `Failed reading cron storage at ${shortenHomePath(storePath)} while planning Codex model migration: ${errorMessage(err)}`, + ], + }; + } +} + +/** Commit Codex cron refs only after their model-scoped config policy is durable. */ +export async function repairCronCodexModelRefsAfterConfigWrite(params: { + cfg: OpenClawConfig; + blockedModelIdentities?: ReadonlySet; +}): Promise { + const storePath = resolveCronJobsStorePath(normalizeOptionalString(params.cfg.cron?.store)); + try { + const state = await loadLegacyCronRepairState({ cfg: params.cfg }); + return state + ? await applyLegacyCronStoreRepair({ + cfg: params.cfg, + state, + migrateCodexModelRefs: true, + blockedModelIdentities: params.blockedModelIdentities, + }) + : { changes: [], warnings: [] }; + } catch (err) { + return { + changes: [], + warnings: [ + `Failed reading cron storage at ${shortenHomePath(storePath)} while committing Codex model migration: ${errorMessage(err)}`, + ], + }; + } } function noteLegacyCronRepairResult(result: LegacyCronRepairResult): void { diff --git a/src/commands/doctor/cron/payload-migration.ts b/src/commands/doctor/cron/payload-migration.ts index d1fc69810e86..a120999b3b01 100644 --- a/src/commands/doctor/cron/payload-migration.ts +++ b/src/commands/doctor/cron/payload-migration.ts @@ -3,6 +3,7 @@ import { normalizeOptionalLowercaseString, readStringValue as readString, } from "../../../../packages/normalization-core/src/string-coerce.js"; +import { toCanonicalOpenAIModelRef } from "../shared/codex-route-model-ref.js"; type UnknownRecord = Record; @@ -41,22 +42,46 @@ function hasShellToolAccess(toolsAllow: unknown): boolean { }); } -function toCanonicalOpenAIModelRef(value: unknown): string | undefined { - const raw = readString(value); - if (typeof raw !== "string") { - return undefined; +export type LegacyOpenAICodexCronModelRoute = { + legacyModelRef: string; + canonicalModelRef: string; +}; + +function readLegacyOpenAICodexCronModelRoute( + value: unknown, +): LegacyOpenAICodexCronModelRoute | undefined { + const legacyModelRef = readString(value)?.trim(); + const canonicalModelRef = legacyModelRef ? toCanonicalOpenAIModelRef(legacyModelRef) : undefined; + return legacyModelRef && canonicalModelRef ? { legacyModelRef, canonicalModelRef } : undefined; +} + +/** Legacy and canonical route pairs retained for namespace-specific migration blockers. */ +export function collectLegacyOpenAICodexCronModelRoutes( + payload: UnknownRecord, +): LegacyOpenAICodexCronModelRoute[] { + const routes = new Map(); + const add = (value: unknown) => { + const route = readLegacyOpenAICodexCronModelRoute(value); + if (route) { + routes.set(`${route.legacyModelRef}\u0000${route.canonicalModelRef}`, route); + } + }; + add(payload.model); + if (Array.isArray(payload.fallbacks)) { + for (const fallback of payload.fallbacks) { + add(fallback); + } } - const trimmed = raw.trim(); - const slash = trimmed.indexOf("/"); - if (slash <= 0) { - return undefined; - } - const provider = trimmed.slice(0, slash).trim().toLowerCase(); - if (provider !== "openai-codex") { - return undefined; - } - const model = trimmed.slice(slash + 1).trim(); - return model ? `openai/${model}` : undefined; + return [...routes.values()]; +} + +/** Canonical OpenAI refs whose legacy cron shape implied the Codex runtime. */ +export function collectLegacyOpenAICodexCronModelRefs(payload: UnknownRecord): string[] { + return [ + ...new Set( + collectLegacyOpenAICodexCronModelRoutes(payload).map((route) => route.canonicalModelRef), + ), + ]; } function normalizeChannel(value: string): string { @@ -114,29 +139,35 @@ function parseLegacyAgentTurnCommandMessage(message: string): LegacyAgentTurnCom }; } -/** Return true when a cron payload contains legacy `openai-codex/*` model refs. */ +/** Return true when a cron payload contains legacy Codex-route model refs. */ export function hasLegacyOpenAICodexCronModelRef(payload: UnknownRecord): boolean { - if (toCanonicalOpenAIModelRef(payload.model)) { - return true; - } - const fallbacks = payload.fallbacks; - return ( - Array.isArray(fallbacks) && fallbacks.some((fallback) => toCanonicalOpenAIModelRef(fallback)) - ); + return collectLegacyOpenAICodexCronModelRefs(payload).length > 0; } -function migrateLegacyOpenAICodexModelRefs(payload: UnknownRecord): boolean { +function migrateLegacyOpenAICodexModelRefs( + payload: UnknownRecord, + shouldMigrate: (modelRef: string, legacyModelRef: string) => boolean, +): boolean { let mutated = false; - const model = toCanonicalOpenAIModelRef(payload.model); - if (model && payload.model !== model) { - payload.model = model; + const model = readLegacyOpenAICodexCronModelRoute(payload.model); + if ( + model && + shouldMigrate(model.canonicalModelRef, model.legacyModelRef) && + payload.model !== model.canonicalModelRef + ) { + payload.model = model.canonicalModelRef; mutated = true; } const fallbacks = payload.fallbacks; if (Array.isArray(fallbacks)) { - const next = fallbacks.map((fallback) => toCanonicalOpenAIModelRef(fallback) ?? fallback); + const next = fallbacks.map((fallback) => { + const route = readLegacyOpenAICodexCronModelRoute(fallback); + return route && shouldMigrate(route.canonicalModelRef, route.legacyModelRef) + ? route.canonicalModelRef + : fallback; + }); if (next.some((fallback, index) => fallback !== fallbacks[index])) { payload.fallbacks = next; mutated = true; @@ -147,7 +178,13 @@ function migrateLegacyOpenAICodexModelRefs(payload: UnknownRecord): boolean { } /** Normalize legacy cron payload channel/provider and model reference fields in place. */ -export function migrateLegacyCronPayload(payload: UnknownRecord): boolean { +export function migrateLegacyCronPayload( + payload: UnknownRecord, + options: { + migrateCodexModelRefs?: boolean; + shouldMigrateCodexModelRef?: (modelRef: string, legacyModelRef: string) => boolean; + } = {}, +): boolean { let mutated = false; const channelValue = readString(payload.channel); @@ -172,7 +209,11 @@ export function migrateLegacyCronPayload(payload: UnknownRecord): boolean { mutated = true; } - if (migrateLegacyOpenAICodexModelRefs(payload)) { + const shouldMigrateCodexModelRef = + options.migrateCodexModelRefs === true + ? (options.shouldMigrateCodexModelRef ?? (() => true)) + : () => false; + if (migrateLegacyOpenAICodexModelRefs(payload, shouldMigrateCodexModelRef)) { mutated = true; } diff --git a/src/commands/doctor/cron/runtime-policy-migration.ts b/src/commands/doctor/cron/runtime-policy-migration.ts new file mode 100644 index 000000000000..e9544ed5c9a8 --- /dev/null +++ b/src/commands/doctor/cron/runtime-policy-migration.ts @@ -0,0 +1,165 @@ +// Doctor-only runtime policy repair for migrated cron Codex model refs. +import { asOptionalRecord } from "@openclaw/normalization-core/record-coerce"; +import { resolveDefaultAgentId } from "../../../agents/agent-scope-config.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { normalizeAgentId } from "../../../routing/session-key.js"; +import { + isBlockedLegacyCodexModelRef, + normalizeRuntimeString, + type LegacyCodexModelIdentity, +} from "../shared/codex-route-model-ref.js"; +import type { CronCodexRuntimePolicyTarget } from "./store-migration.js"; + +type MutableRecord = Record; + +function ensureRecord(container: MutableRecord, key: string): MutableRecord { + const existing = asOptionalRecord(container[key]); + if (existing) { + return existing; + } + const created: MutableRecord = {}; + container[key] = created; + return created; +} + +function resolvePolicyOwner(params: { + cfg: OpenClawConfig; + target: CronCodexRuntimePolicyTarget; +}): { owner: MutableRecord; path: string } | undefined { + const root = params.cfg as unknown as MutableRecord; + const agents = ensureRecord(root, "agents"); + const requestedAgentId = params.target.agentId + ? normalizeAgentId(params.target.agentId) + : undefined; + const defaultAgentId = resolveDefaultAgentId(params.cfg); + const effectiveAgentId = requestedAgentId ?? defaultAgentId; + const list = Array.isArray(agents.list) ? agents.list : []; + const owner = list.find((entry) => { + const record = asOptionalRecord(entry); + return normalizeAgentId(typeof record?.id === "string" ? record.id : "") === effectiveAgentId; + }); + const record = asOptionalRecord(owner); + if (record) { + return { owner: record, path: `agents.list.${effectiveAgentId}` }; + } + return !requestedAgentId || requestedAgentId === defaultAgentId + ? { owner: ensureRecord(agents, "defaults"), path: "agents.defaults" } + : undefined; +} + +/** Install model-scoped Codex runtime intent for canonical refs migrated out of cron payloads. */ +export function repairCronCodexRuntimePolicies(params: { + cfg: OpenClawConfig; + targets: ReadonlyArray; + blockedModelIdentities?: ReadonlySet; +}): { + config: OpenClawConfig; + changes: string[]; + warnings: string[]; + blockedTargets: CronCodexRuntimePolicyTarget[]; + changedTargets: CronCodexRuntimePolicyTarget[]; +} { + if (params.targets.length === 0) { + return { + config: params.cfg, + changes: [], + warnings: [], + blockedTargets: [], + changedTargets: [], + }; + } + const next = structuredClone(params.cfg); + const changes: string[] = []; + const warnings: string[] = []; + const blockedTargets: CronCodexRuntimePolicyTarget[] = []; + const changedTargets: CronCodexRuntimePolicyTarget[] = []; + // Distinct stored identities (agentId omitted vs the default agent named) + // can resolve to one policy owner; every equivalent target must inherit the + // first decision or the deferred rewrite filter misses blocked siblings. + const decisions = new Map(); + + for (const target of params.targets) { + if ( + isBlockedLegacyCodexModelRef({ + modelRef: target.legacyModelRef ?? target.modelRef, + blockedModelIdentities: params.blockedModelIdentities, + }) + ) { + blockedTargets.push(target); + continue; + } + const owner = resolvePolicyOwner({ cfg: next, target }); + const targetLabel = target.agentId ? `agent ${target.agentId}` : "the default agent"; + if (!owner) { + blockedTargets.push(target); + warnings.push( + `Cron model ${target.modelRef} was migrated to openai/*, but ${targetLabel} has no configured agent entry; set its model-scoped agentRuntime.id to "codex" manually.`, + ); + continue; + } + const key = `${owner.path}\u0000${target.modelRef}`; + const priorDecision = decisions.get(key); + if (priorDecision) { + if (priorDecision === "blocked") { + blockedTargets.push(target); + } else if (priorDecision === "changed") { + changedTargets.push(target); + } + continue; + } + const models = ensureRecord(owner.owner, "models"); + const modelEntry = ensureRecord(models, target.modelRef); + const priorRuntime = asOptionalRecord(modelEntry.agentRuntime); + const priorRuntimeId = normalizeRuntimeString(priorRuntime?.id); + // "auto" carries no conflicting intent: on the legacy codex provider it + // selected the codex harness, so replace it like an unset runtime. + if (priorRuntimeId && priorRuntimeId !== "codex" && priorRuntimeId !== "auto") { + decisions.set(key, "blocked"); + blockedTargets.push(target); + warnings.push( + `Retained ${owner.path}.models.${target.modelRef}.agentRuntime.id="${priorRuntimeId}": it conflicts with migrated cron Codex runtime intent; repair the cron model or runtime policy manually.`, + ); + continue; + } + if (priorRuntimeId === "codex") { + decisions.set(key, "noop"); + continue; + } + decisions.set(key, "changed"); + modelEntry.agentRuntime = { ...priorRuntime, id: "codex" }; + changedTargets.push(target); + changes.push( + `Set ${owner.path}.models.${target.modelRef}.agentRuntime.id to "codex" for migrated cron runtime intent.`, + ); + } + + return { + config: changes.length > 0 ? next : params.cfg, + changes, + warnings, + blockedTargets, + changedTargets, + }; +} + +/** Restrict a post-config-write cron rewrite to runtime policies already on disk. */ +export function planCronCodexRefRewriteAgainstPersistedConfig(params: { + cfg: OpenClawConfig; + targets: ReadonlyArray; + blockedModelIdentities?: ReadonlySet; +}): { warnings: string[]; blockedTargets: CronCodexRuntimePolicyTarget[] } { + const policyPlan = repairCronCodexRuntimePolicies(params); + // Keep every raw stored identity: the downstream filter matches on the raw + // (agentId, modelRef) key, so collapsing identities that merely normalize to + // the same agent would let the sibling job bypass the block. + return { + warnings: [ + ...policyPlan.warnings, + ...policyPlan.changedTargets.map( + (target) => + `Retained the legacy cron route for ${target.modelRef} because its model-scoped agentRuntime.id="codex" policy is not present in persisted config; rerun doctor --fix.`, + ), + ], + blockedTargets: [...policyPlan.blockedTargets, ...policyPlan.changedTargets], + }; +} diff --git a/src/commands/doctor/cron/store-migration.test.ts b/src/commands/doctor/cron/store-migration.test.ts index 226cf4e69650..eaedc9c10d44 100644 --- a/src/commands/doctor/cron/store-migration.test.ts +++ b/src/commands/doctor/cron/store-migration.test.ts @@ -1,7 +1,17 @@ // Cron store migration tests cover doctor migration of persisted cron stores. import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; -import { normalizeStoredCronJobs } from "./store-migration.js"; +import { resolveAgentHarnessPolicy } from "../../../agents/harness/policy.js"; +import { legacyCodexProviderIdentityKey } from "../shared/codex-route-model-ref.js"; +import { + planCronCodexRefRewriteAgainstPersistedConfig, + repairCronCodexRuntimePolicies, +} from "./runtime-policy-migration.js"; +import { + collectStoredCronCodexRuntimePolicyTargets, + cronCodexRuntimePolicyTargetKey, + normalizeStoredCronJobs, +} from "./store-migration.js"; const DEFAULT_TOP_OF_HOUR_STAGGER_MS = 5 * 60 * 1000; @@ -26,9 +36,12 @@ function makeLegacyJob(overrides: Record): Record) { +function normalizeOneJob( + job: Record, + options: Parameters[1] = {}, +) { const jobs = [job]; - const result = normalizeStoredCronJobs(jobs); + const result = normalizeStoredCronJobs(jobs, options); return { job: jobs[0], result }; } @@ -113,6 +126,7 @@ describe("normalizeStoredCronJobs", () => { fallbacks: ["anthropic/claude-opus-4.6", "openai-codex/gpt-5.4-mini"], }, }), + { migrateCodexModelRefs: true }, ); expect(result.mutated).toBe(true); @@ -124,6 +138,331 @@ describe("normalizeStoredCronJobs", () => { expect(payload.fallbacks).toEqual(["anthropic/claude-opus-4.6", "openai/gpt-5.4-mini"]); }); + it("rewrites shipped codex model refs in cron payloads", () => { + const { job, result } = normalizeOneJob( + makeLegacyJob({ + id: "shipped-codex-cron-model", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + model: "codex/gpt-5.6-sol", + fallbacks: ["codex/gpt-5.4-mini"], + }, + }), + { migrateCodexModelRefs: true }, + ); + + expect(result.mutated).toBe(true); + expect(result.issues.legacyPayloadCodexModel).toBe(1); + const payload = expectDefined(job, "job test invariant").payload as Record; + expect(payload.model).toBe("openai/gpt-5.6-sol"); + expect(payload.fallbacks).toEqual(["openai/gpt-5.4-mini"]); + const runtimeRepair = repairCronCodexRuntimePolicies({ + cfg: {}, + targets: result.codexRuntimePolicyTargets, + }); + expect(runtimeRepair.warnings).toStrictEqual([]); + expect(runtimeRepair.config.agents?.defaults?.models).toMatchObject({ + "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + "openai/gpt-5.4-mini": { agentRuntime: { id: "codex" } }, + }); + expect( + resolveAgentHarnessPolicy({ + provider: "openai", + modelId: "gpt-5.6-sol", + config: runtimeRepair.config, + }).runtime, + ).toBe("codex"); + }); + + it("keeps the whole provider-conflicted cron namespace legacy", () => { + const jobs = [ + makeLegacyJob({ + id: "provider-conflicted-codex-model", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + model: "codex/gpt-5.6-sol", + fallbacks: ["codex/gpt-5.3-mini"], + }, + }), + ]; + const blockedNamespace = expectDefined( + legacyCodexProviderIdentityKey("codex"), + "blocked cron namespace test invariant", + ); + const policyPlan = repairCronCodexRuntimePolicies({ + cfg: {}, + targets: collectStoredCronCodexRuntimePolicyTargets(jobs), + blockedModelIdentities: new Set([blockedNamespace]), + }); + const blockedTargets = new Set(policyPlan.blockedTargets.map(cronCodexRuntimePolicyTargetKey)); + + normalizeStoredCronJobs(jobs, { + migrateCodexModelRefs: true, + shouldMigrateCodexRuntimePolicyTarget: (target) => + !blockedTargets.has(cronCodexRuntimePolicyTargetKey(target)), + }); + + const payload = expectDefined(jobs[0], "job test invariant").payload as Record; + expect(payload.model).toBe("codex/gpt-5.6-sol"); + expect(payload.fallbacks).toEqual(["codex/gpt-5.3-mini"]); + expect(policyPlan.config.agents?.defaults?.models).toBeUndefined(); + }); + + it("retains a legacy cron ref when canonical runtime policy conflicts", () => { + const jobs = [ + makeLegacyJob({ + id: "blocked-codex-cron-model", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + model: "codex/gpt-5.6-sol", + }, + }), + ]; + const policyPlan = repairCronCodexRuntimePolicies({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + }, + targets: collectStoredCronCodexRuntimePolicyTargets(jobs), + }); + const blocked = new Set(policyPlan.blockedTargets.map(cronCodexRuntimePolicyTargetKey)); + + const result = normalizeStoredCronJobs(jobs, { + migrateCodexModelRefs: true, + shouldMigrateCodexRuntimePolicyTarget: (target) => + !blocked.has(cronCodexRuntimePolicyTargetKey(target)), + }); + + expect(policyPlan.warnings.join("\n")).toContain("conflicts with migrated cron Codex runtime"); + expect(result.issues.legacyPayloadCodexModel).toBe(1); + expect(result.codexRuntimePolicyTargets).toStrictEqual([]); + const job = expectDefined(jobs[0], "job test invariant"); + expect((job.payload as Record).model).toBe("codex/gpt-5.6-sol"); + }); + + it("retains a default-agent cron ref when its list-entry runtime conflicts", () => { + const jobs = [ + makeLegacyJob({ + id: "default-agent-shadowed-codex-model", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + model: "codex/gpt-5.6-sol", + }, + }), + ]; + const rewritePlan = planCronCodexRefRewriteAgainstPersistedConfig({ + cfg: { + agents: { + list: [ + { + id: "primary", + default: true, + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, + }, + }, + ], + }, + }, + targets: collectStoredCronCodexRuntimePolicyTargets(jobs), + }); + const blocked = new Set(rewritePlan.blockedTargets.map(cronCodexRuntimePolicyTargetKey)); + + normalizeStoredCronJobs(jobs, { + migrateCodexModelRefs: true, + shouldMigrateCodexRuntimePolicyTarget: (target) => + !blocked.has(cronCodexRuntimePolicyTargetKey(target)), + }); + + expect(rewritePlan.warnings.join("\n")).toContain( + 'Retained agents.list.primary.models.openai/gpt-5.6-sol.agentRuntime.id="openclaw"', + ); + const job = expectDefined(jobs[0], "job test invariant"); + expect((job.payload as Record).model).toBe("codex/gpt-5.6-sol"); + }); + + it("blocks every stored identity that resolves to one conflicted policy owner", () => { + // agentId omitted and the default agent named explicitly are distinct + // stored identities resolving to the same owner; both must stay legacy. + const jobs = [ + makeLegacyJob({ + id: "implicit-default-agent", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { kind: "agentTurn", message: "ping", model: "codex/gpt-5.6-sol" }, + }), + makeLegacyJob({ + id: "explicit-default-agent", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + model: "codex/gpt-5.6-sol", + agentId: "primary", + }, + }), + ]; + const rewritePlan = planCronCodexRefRewriteAgainstPersistedConfig({ + cfg: { + agents: { + list: [ + { + id: "primary", + default: true, + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, + }, + }, + ], + }, + }, + targets: collectStoredCronCodexRuntimePolicyTargets(jobs), + }); + const blocked = new Set(rewritePlan.blockedTargets.map(cronCodexRuntimePolicyTargetKey)); + + normalizeStoredCronJobs(jobs, { + migrateCodexModelRefs: true, + shouldMigrateCodexRuntimePolicyTarget: (target) => + !blocked.has(cronCodexRuntimePolicyTargetKey(target)), + }); + + for (const job of jobs) { + expect( + (expectDefined(job, "job test invariant").payload as Record).model, + ).toBe("codex/gpt-5.6-sol"); + } + }); + + it("writes a named default agent policy to its list entry before rewriting cron", () => { + const jobs = [ + makeLegacyJob({ + id: "default-agent-list-codex-model", + agentId: "primary", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + model: "codex/gpt-5.6-sol", + }, + }), + ]; + const targets = collectStoredCronCodexRuntimePolicyTargets(jobs); + const policyRepair = repairCronCodexRuntimePolicies({ + cfg: { + agents: { + list: [{ id: "primary", default: true }], + }, + }, + targets, + }); + + expect(policyRepair.config.agents?.list?.[0]?.models).toMatchObject({ + "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + }); + expect(policyRepair.config.agents?.defaults?.models).toBeUndefined(); + const rewritePlan = planCronCodexRefRewriteAgainstPersistedConfig({ + cfg: policyRepair.config, + targets, + }); + expect(rewritePlan).toStrictEqual({ warnings: [], blockedTargets: [] }); + const blocked = new Set(rewritePlan.blockedTargets.map(cronCodexRuntimePolicyTargetKey)); + + normalizeStoredCronJobs(jobs, { + migrateCodexModelRefs: true, + shouldMigrateCodexRuntimePolicyTarget: (target) => + !blocked.has(cronCodexRuntimePolicyTargetKey(target)), + }); + const job = expectDefined(jobs[0], "job test invariant"); + expect((job.payload as Record).model).toBe("openai/gpt-5.6-sol"); + }); + + it("writes an implicit default agent policy to defaults when no list entry exists", () => { + const jobs = [ + makeLegacyJob({ + id: "implicit-default-codex-model", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + model: "codex/gpt-5.6-sol", + }, + }), + ]; + const policyRepair = repairCronCodexRuntimePolicies({ + cfg: {}, + targets: collectStoredCronCodexRuntimePolicyTargets(jobs), + }); + + expect(policyRepair.config.agents?.defaults?.models).toMatchObject({ + "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } }, + }); + }); + + it("retains a post-snapshot Codex ref until its runtime policy is persisted", () => { + const jobs = [ + makeLegacyJob({ + id: "post-snapshot-codex-cron-model", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + model: "codex/gpt-5.6-sol", + }, + }), + ]; + const rewritePlan = planCronCodexRefRewriteAgainstPersistedConfig({ + cfg: {}, + targets: collectStoredCronCodexRuntimePolicyTargets(jobs), + }); + const blocked = new Set(rewritePlan.blockedTargets.map(cronCodexRuntimePolicyTargetKey)); + + const result = normalizeStoredCronJobs(jobs, { + migrateCodexModelRefs: true, + shouldMigrateCodexRuntimePolicyTarget: (target) => + !blocked.has(cronCodexRuntimePolicyTargetKey(target)), + }); + + expect(rewritePlan.warnings).toEqual([ + expect.stringContaining("policy is not present in persisted config"), + ]); + expect(result.issues.legacyPayloadCodexModel).toBe(1); + expect(result.codexRuntimePolicyTargets).toStrictEqual([]); + const job = expectDefined(jobs[0], "job test invariant"); + expect((job.payload as Record).model).toBe("codex/gpt-5.6-sol"); + }); + + it("does not rewrite Codex refs during an ordinary cron normalization pass", () => { + const { job, result } = normalizeOneJob( + makeLegacyJob({ + id: "deferred-codex-cron-model", + schedule: { kind: "every", everyMs: 60_000 }, + payload: { + kind: "agentTurn", + message: "ping", + model: "codex/gpt-5.6-sol", + }, + }), + ); + + expect(result.issues.legacyPayloadCodexModel).toBe(1); + expect(result.codexRuntimePolicyTargets).toStrictEqual([]); + expect( + (expectDefined(job, "job test invariant").payload as Record).model, + ).toBe("codex/gpt-5.6-sol"); + }); + it("converts legacy agent command prompts into command cron payloads", () => { const command = "cd /home/openclaw/.razor/quant && ./scripts/system/run_position_control.sh --write-card --silent-token NO_REPLY"; diff --git a/src/commands/doctor/cron/store-migration.ts b/src/commands/doctor/cron/store-migration.ts index f6c52ff1ee18..9522d9c2b0cb 100644 --- a/src/commands/doctor/cron/store-migration.ts +++ b/src/commands/doctor/cron/store-migration.ts @@ -12,10 +12,15 @@ import { getInvalidPersistedCronJobReason } from "../../../cron/persisted-shape. import { coerceFiniteScheduleNumber } from "../../../cron/schedule.js"; import { inferCronJobName } from "../../../cron/service/normalize.js"; import { normalizeCronStaggerMs, resolveDefaultCronStaggerMs } from "../../../cron/stagger.js"; +import { + isBlockedLegacyCodexModelRef, + type LegacyCodexModelIdentity, +} from "../shared/codex-route-model-ref.js"; import { normalizeLegacyDeliveryInput } from "./legacy-delivery.js"; import { resolveLegacyCronMigrationId } from "./legacy-store-migration.js"; import { classifyUnresolvedAgentTurnShellToolPrompt, + collectLegacyOpenAICodexCronModelRoutes, hasLegacyOpenAICodexCronModelRef, migrateLegacyAgentTurnCommandPayload, migrateLegacyCronPayload, @@ -40,7 +45,53 @@ type CronStoreIssueKey = type CronStoreIssues = Partial>; +export type CronCodexRuntimePolicyTarget = { + agentId?: string; + modelRef: string; + legacyModelRef?: string; +}; + +export function cronCodexRuntimePolicyTargetKey(target: CronCodexRuntimePolicyTarget): string { + return `${target.agentId ?? ""}\u0000${target.modelRef}\u0000${target.legacyModelRef ?? ""}`; +} + +export function collectStoredCronCodexRuntimePolicyTargets( + jobs: ReadonlyArray>, + blockedModelIdentities?: ReadonlySet, +): CronCodexRuntimePolicyTarget[] { + const targets = new Map(); + for (const job of jobs) { + const agentId = normalizeOptionalString(job.agentId); + const payload = + job.payload && typeof job.payload === "object" && !Array.isArray(job.payload) + ? (job.payload as Record) + : {}; + const routes = [ + ...collectLegacyOpenAICodexCronModelRoutes(payload), + ...collectLegacyOpenAICodexCronModelRoutes({ model: job.model }), + ]; + for (const route of routes) { + if ( + isBlockedLegacyCodexModelRef({ + modelRef: route.legacyModelRef, + blockedModelIdentities, + }) + ) { + continue; + } + const target = { + ...(agentId ? { agentId } : {}), + modelRef: route.canonicalModelRef, + legacyModelRef: route.legacyModelRef, + }; + targets.set(cronCodexRuntimePolicyTargetKey(target), target); + } + } + return [...targets.values()]; +} + type NormalizeCronStoreJobsResult = { + codexRuntimePolicyTargets: CronCodexRuntimePolicyTarget[]; issues: CronStoreIssues; unresolvedAgentTurnCommandPromptJobs: string[]; unresolvedAgentTurnShellToolPromptJobs: string[]; @@ -247,6 +298,10 @@ function stripLegacyTopLevelFields(raw: Record) { /** Normalize persisted cron jobs in place and report issues plus rows to quarantine. */ export function normalizeStoredCronJobs( jobs: Array>, + options: { + migrateCodexModelRefs?: boolean; + shouldMigrateCodexRuntimePolicyTarget?: (target: CronCodexRuntimePolicyTarget) => boolean; + } = {}, ): NormalizeCronStoreJobsResult { const issues: CronStoreIssues = {}; const unresolvedAgentTurnCommandPromptJobs: string[] = []; @@ -258,6 +313,7 @@ export function normalizeStoredCronJobs( let mutated = false; const keptJobs: Array> = []; const removedJobs: NormalizeCronStoreJobsResult["removedJobs"] = []; + const codexRuntimePolicyTargets = new Map(); for (const [sourceIndex, raw] of jobs.entries()) { const jobIssues = new Set(); @@ -417,15 +473,40 @@ export function normalizeStoredCronJobs( if (payloadRecord) { const hadLegacyPayloadProvider = Boolean(normalizeOptionalString(payloadRecord.provider)); const hadLegacyPayloadCodexModel = hasLegacyOpenAICodexCronModelRef(payloadRecord); - if (migrateLegacyCronPayload(payloadRecord)) { + const legacyCodexModelRoutes = collectLegacyOpenAICodexCronModelRoutes(payloadRecord); + const agentId = normalizeOptionalString(raw.agentId); + const shouldMigrateCodexModelRef = (modelRef: string, legacyModelRef: string) => + options.shouldMigrateCodexRuntimePolicyTarget?.({ + ...(agentId ? { agentId } : {}), + modelRef, + legacyModelRef, + }) !== false; + if (hadLegacyPayloadCodexModel) { + trackIssue("legacyPayloadCodexModel"); + } + if ( + migrateLegacyCronPayload(payloadRecord, { + migrateCodexModelRefs: options.migrateCodexModelRefs, + shouldMigrateCodexModelRef, + }) + ) { mutated = true; - if (hadLegacyPayloadCodexModel) { - trackIssue("legacyPayloadCodexModel"); - } if (hadLegacyPayloadProvider) { trackIssue("legacyPayloadProvider"); } } + if (hadLegacyPayloadCodexModel && options.migrateCodexModelRefs === true) { + for (const route of legacyCodexModelRoutes) { + const target = { + ...(agentId ? { agentId } : {}), + modelRef: route.canonicalModelRef, + legacyModelRef: route.legacyModelRef, + }; + if (shouldMigrateCodexModelRef(route.canonicalModelRef, route.legacyModelRef)) { + codexRuntimePolicyTargets.set(cronCodexRuntimePolicyTargetKey(target), target); + } + } + } if (migrateLegacyAgentTurnCommandPayload(payloadRecord)) { mutated = true; trackIssue("legacyAgentTurnCommandPayload"); @@ -638,6 +719,7 @@ export function normalizeStoredCronJobs( } return { + codexRuntimePolicyTargets: [...codexRuntimePolicyTargets.values()], issues, unresolvedAgentTurnCommandPromptJobs, unresolvedAgentTurnShellToolPromptJobs, diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index b06134c6cf30..47009a82b2d7 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -33,6 +33,7 @@ import { maybeRepairContextEngineHostCompatibility } from "./shared/context-engi import { scanEmptyAllowlistPolicyWarnings } from "./shared/empty-allowlist-scan.js"; import { maybeRepairExecSafeBinProfiles } from "./shared/exec-safe-bins.js"; import { maybeRepairInvalidPluginConfig } from "./shared/invalid-plugin-config.js"; +import type { BlockedLegacyOpenAICodexProviderPlan } from "./shared/legacy-config-migrations.runtime.models.js"; import { maybeRepairLegacyToolsBySenderKeys } from "./shared/legacy-tools-by-sender.js"; import { repairMissingConfiguredPluginInstalls } from "./shared/missing-configured-plugin-install.js"; import { maybeRepairOpenPolicyAllowFrom } from "./shared/open-policy-allowfrom.js"; @@ -48,6 +49,7 @@ export async function runDoctorRepairSequence(params: { state: DoctorConfigMutationState; doctorFixCommand: string; env?: NodeJS.ProcessEnv; + blockedCodexProviderPlan?: BlockedLegacyOpenAICodexProviderPlan; }): Promise<{ state: DoctorConfigMutationState; changeNotes: string[]; @@ -100,6 +102,7 @@ export async function runDoctorRepairSequence(params: { cfg: state.candidate, env, shouldRepair: true, + blockedProviderPlan: params.blockedCodexProviderPlan, }); applyMutation({ config: codexRouteRepair.cfg, diff --git a/src/commands/doctor/shared/codex-route-compaction-repair.ts b/src/commands/doctor/shared/codex-route-compaction-repair.ts index b0b110dda4d4..bea00bebb108 100644 --- a/src/commands/doctor/shared/codex-route-compaction-repair.ts +++ b/src/commands/doctor/shared/codex-route-compaction-repair.ts @@ -14,6 +14,7 @@ import { agentUsesCodexRuntimeForCompaction, isOpenAICodexModelRef, toCanonicalOpenAIModelRef, + type LegacyCodexModelIdentity, } from "./codex-route-model-ref.js"; import { rewriteStringModelSlot } from "./codex-route-model-slots.js"; import { @@ -45,6 +46,7 @@ export function rewriteAgentCompactionRefs(params: { rewrittenInheritedCompactionModels?: Map; runtimePolicyChanges: string[]; unsupportedCompactionChanges: string[]; + blockedModelIdentities?: ReadonlySet; env?: NodeJS.ProcessEnv; }): void { const compaction = asMutableRecord(params.agent.compaction); @@ -65,6 +67,7 @@ export function rewriteAgentCompactionRefs(params: { container: compaction, key: "model", path: `${params.path}.compaction.model`, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); rewriteCompactionMemoryFlushModel(params, compaction); @@ -89,6 +92,7 @@ export function rewriteAgentCompactionRefs(params: { container: compaction, key: "model", path: `${params.path}.compaction.model`, + blockedModelIdentities: params.blockedModelIdentities, }); } } @@ -106,6 +110,7 @@ function rewriteLosslessCompactionModel( container: compaction, key: "model", path: `${params.path}.compaction.model`, + blockedModelIdentities: params.blockedModelIdentities, }); preserveCodexRuntimePolicyForHits(params, start); @@ -126,6 +131,7 @@ function rewriteLosslessCompactionModel( container: inheritedCompaction, key: "model", path: inheritedModelPath, + blockedModelIdentities: params.blockedModelIdentities, }); const inheritedHit = params.hits[inheritedStart]; const inheritedCanonicalModel = @@ -180,6 +186,7 @@ function rewriteCompactionMemoryFlushModel( container: asMutableRecord(compaction?.memoryFlush), key: "model", path: `${params.path}.compaction.memoryFlush.model`, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); } diff --git a/src/commands/doctor/shared/codex-route-config-repair.ts b/src/commands/doctor/shared/codex-route-config-repair.ts index b0867ce5d4ee..3269ed6b1cb0 100644 --- a/src/commands/doctor/shared/codex-route-config-repair.ts +++ b/src/commands/doctor/shared/codex-route-config-repair.ts @@ -14,6 +14,7 @@ import { readAgentPrimaryModelRef, readLegacyDefaultsRuntime, resolveRuntime, + type LegacyCodexModelIdentity, } from "./codex-route-model-ref.js"; import { rewriteModelConfigSlot, rewriteModelsMap } from "./codex-route-model-slots.js"; import { @@ -48,6 +49,7 @@ function rewriteAgentModelRefs(params: { rewrittenInheritedCompactionModels?: Map; runtimePolicyChanges: string[]; unsupportedCompactionChanges: string[]; + blockedModelIdentities?: ReadonlySet; env?: NodeJS.ProcessEnv; }): void { if (!params.agent) { @@ -78,6 +80,7 @@ function rewriteAgentModelRefs(params: { key, path: `${params.path}.${key}`, runtime: params.currentRuntime, + blockedModelIdentities: params.blockedModelIdentities, }); preserveCodexRuntimePolicyForNewHits(start); } else { @@ -88,6 +91,7 @@ function rewriteAgentModelRefs(params: { container: params.agent, key, path: `${params.path}.${key}`, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); } @@ -99,6 +103,7 @@ function rewriteAgentModelRefs(params: { container: asMutableRecord(params.agent.heartbeat), key: "model", path: `${params.path}.heartbeat.model`, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); rewriteModelConfigSlotIfCanonicalCodexRuntime({ @@ -108,6 +113,7 @@ function rewriteAgentModelRefs(params: { container: asMutableRecord(params.agent.subagents), key: "model", path: `${params.path}.subagents.model`, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); rewriteAgentCompactionRefs({ @@ -126,6 +132,7 @@ function rewriteAgentModelRefs(params: { rewrittenInheritedCompactionModels: params.rewrittenInheritedCompactionModels, runtimePolicyChanges: params.runtimePolicyChanges, unsupportedCompactionChanges: params.unsupportedCompactionChanges, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); for (const key of AGENT_MEDIA_MODEL_CONFIG_KEYS) { @@ -134,6 +141,7 @@ function rewriteAgentModelRefs(params: { container: params.agent, key, path: `${params.path}.${key}`, + blockedModelIdentities: params.blockedModelIdentities, }); } if (params.rewriteModelsMap) { @@ -142,6 +150,7 @@ function rewriteAgentModelRefs(params: { hits: params.hits, models: asMutableRecord(params.agent.models), path: `${params.path}.models`, + blockedModelIdentities: params.blockedModelIdentities, }); preserveCodexRuntimePolicyForNewHits(start); } @@ -151,6 +160,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { cfg: OpenClawConfig; preserveSharedDefaultCompactionOverrides: SharedDefaultCompactionOverrideConsumers; ignoreLegacyAgentRuntimePins?: boolean; + blockedModelIdentities?: ReadonlySet; env?: NodeJS.ProcessEnv; }): ConfigRouteRepairResult { const nextConfig = structuredClone(params.cfg); @@ -159,7 +169,11 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { const unsupportedCompactionChanges: string[] = []; const ignoreLegacyAgentRuntimePins = params.ignoreLegacyAgentRuntimePins ?? - configRepairWouldClearLegacyRuntimePins({ cfg: nextConfig, env: params.env }); + configRepairWouldClearLegacyRuntimePins({ + cfg: nextConfig, + blockedModelIdentities: params.blockedModelIdentities, + env: params.env, + }); unsupportedCompactionChanges.push( ...maybeMigrateLegacyLosslessCompactionConfig({ cfg: nextConfig, @@ -191,6 +205,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { rewrittenInheritedCompactionModels, runtimePolicyChanges, unsupportedCompactionChanges, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); const inheritedModelRef = readAgentPrimaryModelRef(nextConfig.agents?.defaults); @@ -222,11 +237,20 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { rewrittenInheritedCompactionModels, runtimePolicyChanges, unsupportedCompactionChanges, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); } - rewriteNonAgentModelRefs({ cfg: nextConfig, hits, env: params.env }); - const shouldClearRuntimePins = hits.some((hit) => !isCompactionOnlyRouteHit(hit)); + rewriteNonAgentModelRefs({ + cfg: nextConfig, + hits, + blockedModelIdentities: params.blockedModelIdentities, + env: params.env, + }); + // A retained legacy provider can still own config, session, or cron refs that need these pins. + // Keep global pins intact until the manual provider conflict is reconciled as one unit. + const shouldClearRuntimePins = + !params.blockedModelIdentities?.size && hits.some((hit) => !isCompactionOnlyRouteHit(hit)); const runtimePinChanges = shouldClearRuntimePins ? clearConfigLegacyAgentRuntimePolicies(nextConfig) : []; @@ -248,6 +272,7 @@ function rewriteConfigModelRefsWithCompactionPolicy(params: { function rewriteNonAgentModelRefs(params: { cfg: OpenClawConfig; hits: CodexRouteHit[]; + blockedModelIdentities?: ReadonlySet; env?: NodeJS.ProcessEnv; }): void { const channelsModelByChannel = asMutableRecord(params.cfg.channels?.modelByChannel); @@ -263,6 +288,7 @@ function rewriteNonAgentModelRefs(params: { container: targets, key: targetId, path: `channels.modelByChannel.${channelId}.${targetId}`, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); } @@ -274,6 +300,7 @@ function rewriteNonAgentModelRefs(params: { container: mapping as MutableRecord, key: "model", path: `hooks.mappings.${index}.model`, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); } @@ -283,6 +310,7 @@ function rewriteNonAgentModelRefs(params: { container: asMutableRecord(params.cfg.hooks?.gmail), key: "model", path: "hooks.gmail.model", + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); rewriteStringModelSlotIfCanonicalCodexRuntime({ @@ -291,6 +319,7 @@ function rewriteNonAgentModelRefs(params: { container: asMutableRecord(params.cfg.messages?.tts), key: "summaryModel", path: "messages.tts.summaryModel", + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); rewriteStringModelSlotIfCanonicalCodexRuntime({ @@ -299,26 +328,30 @@ function rewriteNonAgentModelRefs(params: { container: asMutableRecord(asMutableRecord(params.cfg.channels?.discord)?.voice), key: "model", path: "channels.discord.voice.model", + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); } export function configRepairWouldClearLegacyRuntimePins(params: { cfg: OpenClawConfig; + blockedModelIdentities?: ReadonlySet; env?: NodeJS.ProcessEnv; }): boolean { const dryRun = rewriteConfigModelRefsWithCompactionPolicy({ cfg: params.cfg, preserveSharedDefaultCompactionOverrides: { model: true, provider: true }, ignoreLegacyAgentRuntimePins: false, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); - return dryRun.changes.some((hit) => !isCompactionOnlyRouteHit(hit)); + return dryRun.runtimePinChanges.length > 0; } export function rewriteConfigModelRefs(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; + blockedModelIdentities?: ReadonlySet; }): ConfigRouteRepairResult { const preserveSharedDefaultCompactionOverrides = getSharedDefaultCompactionOverrideConsumers({ cfg: params.cfg, @@ -328,6 +361,7 @@ export function rewriteConfigModelRefs(params: { return rewriteConfigModelRefsWithCompactionPolicy({ cfg: params.cfg, preserveSharedDefaultCompactionOverrides, + blockedModelIdentities: params.blockedModelIdentities, env: params.env, }); } diff --git a/src/commands/doctor/shared/codex-route-config-scan.ts b/src/commands/doctor/shared/codex-route-config-scan.ts index ee64ac0ee1d0..c1041fc4827c 100644 --- a/src/commands/doctor/shared/codex-route-config-scan.ts +++ b/src/commands/doctor/shared/codex-route-config-scan.ts @@ -12,6 +12,7 @@ import { resolveImplicitDefaultAgentModelRef, resolveRuntime, resolveRuntimeModelRef, + type LegacyCodexModelIdentity, } from "./codex-route-model-ref.js"; import { collectCodexRuntimeModelPolicyRefs, @@ -34,6 +35,7 @@ function collectModelsMapRefs(params: { hits: CodexRouteHit[]; path: string; models: unknown; + blockedModelIdentities?: ReadonlySet; }): void { const record = asMutableRecord(params.models); if (!record) { @@ -47,6 +49,7 @@ function collectModelsMapRefs(params: { hits: params.hits, path: `${params.path}.${modelRef}`, model: modelRef, + blockedModelIdentities: params.blockedModelIdentities, }); } } @@ -57,6 +60,7 @@ function collectAgentModelRefs(params: { path: string; runtime?: string; collectModelsMap?: boolean; + blockedModelIdentities?: ReadonlySet; }): void { const agent = asMutableRecord(params.agent); if (!agent) { @@ -68,6 +72,7 @@ function collectAgentModelRefs(params: { path: `${params.path}.${key}`, value: agent[key], runtime: key === "model" ? params.runtime : undefined, + blockedModelIdentities: params.blockedModelIdentities, }); } for (const key of AGENT_MEDIA_MODEL_CONFIG_KEYS) { @@ -75,39 +80,48 @@ function collectAgentModelRefs(params: { hits: params.hits, path: `${params.path}.${key}`, value: agent[key], + blockedModelIdentities: params.blockedModelIdentities, }); } collectStringModelSlot({ hits: params.hits, path: `${params.path}.heartbeat.model`, value: asMutableRecord(agent.heartbeat)?.model, + blockedModelIdentities: params.blockedModelIdentities, }); collectModelConfigSlot({ hits: params.hits, path: `${params.path}.subagents.model`, value: asMutableRecord(agent.subagents)?.model, + blockedModelIdentities: params.blockedModelIdentities, }); const compaction = asMutableRecord(agent.compaction); collectStringModelSlot({ hits: params.hits, path: `${params.path}.compaction.model`, value: compaction?.model, + blockedModelIdentities: params.blockedModelIdentities, }); collectStringModelSlot({ hits: params.hits, path: `${params.path}.compaction.memoryFlush.model`, value: asMutableRecord(compaction?.memoryFlush)?.model, + blockedModelIdentities: params.blockedModelIdentities, }); if (params.collectModelsMap) { collectModelsMapRefs({ hits: params.hits, path: `${params.path}.models`, models: agent.models, + blockedModelIdentities: params.blockedModelIdentities, }); } } -export function collectConfigModelRefs(cfg: OpenClawConfig): CodexRouteHit[] { +export function collectConfigModelRefs( + cfg: OpenClawConfig, + blockedModelIdentities?: ReadonlySet, +): CodexRouteHit[] { const hits: CodexRouteHit[] = []; const defaults = cfg.agents?.defaults; const defaultsRuntime = readLegacyDefaultsRuntime(defaults); @@ -117,6 +131,7 @@ export function collectConfigModelRefs(cfg: OpenClawConfig): CodexRouteHit[] { path: "agents.defaults", runtime: resolveRuntime({ defaultsRuntime }), collectModelsMap: true, + blockedModelIdentities, }); const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; @@ -134,6 +149,7 @@ export function collectConfigModelRefs(cfg: OpenClawConfig): CodexRouteHit[] { agentRuntime: asAgentRuntimePolicyConfig(agentRecord.agentRuntime), defaultsRuntime, }), + blockedModelIdentities, }); } @@ -148,6 +164,7 @@ export function collectConfigModelRefs(cfg: OpenClawConfig): CodexRouteHit[] { hits, path: `channels.modelByChannel.${channelId}.${targetId}`, value: model, + blockedModelIdentities, }); } } @@ -157,18 +174,26 @@ export function collectConfigModelRefs(cfg: OpenClawConfig): CodexRouteHit[] { hits, path: `hooks.mappings.${index}.model`, value: mapping.model, + blockedModelIdentities, }); } - collectStringModelSlot({ hits, path: "hooks.gmail.model", value: cfg.hooks?.gmail?.model }); + collectStringModelSlot({ + hits, + path: "hooks.gmail.model", + value: cfg.hooks?.gmail?.model, + blockedModelIdentities, + }); collectStringModelSlot({ hits, path: "messages.tts.summaryModel", value: cfg.messages?.tts?.summaryModel, + blockedModelIdentities, }); collectStringModelSlot({ hits, path: "channels.discord.voice.model", value: asMutableRecord(asMutableRecord(cfg.channels?.discord)?.voice)?.model, + blockedModelIdentities, }); return hits; } diff --git a/src/commands/doctor/shared/codex-route-model-ref.ts b/src/commands/doctor/shared/codex-route-model-ref.ts index d4665da95245..c5699445797f 100644 --- a/src/commands/doctor/shared/codex-route-model-ref.ts +++ b/src/commands/doctor/shared/codex-route-model-ref.ts @@ -25,12 +25,123 @@ export function readLegacyDefaultsRuntime(defaults: unknown): AgentRuntimePolicy return asAgentRuntimePolicyConfig(asMutableRecord(defaults)?.agentRuntime); } +const LEGACY_CODEX_PROVIDER_IDS = new Set(["codex", "openai-codex"]); + +export type LegacyCodexModelIdentity = string; + +// A namespace block ends at the separator; exact model blocks append the model id. +// This lets one retained dynamic provider stop every downstream route rewrite. +export function legacyCodexProviderIdentityKey( + providerId: unknown, +): LegacyCodexModelIdentity | undefined { + const normalized = normalizeString(providerId); + return normalized && LEGACY_CODEX_PROVIDER_IDS.has(normalized) + ? `${normalized}\u0000` + : undefined; +} + +export function legacyCodexModelIdentityKey(params: { + providerId: unknown; + modelId: unknown; +}): LegacyCodexModelIdentity | undefined { + const providerId = normalizeString(params.providerId); + if ( + !providerId || + !LEGACY_CODEX_PROVIDER_IDS.has(providerId) || + typeof params.modelId !== "string" + ) { + return undefined; + } + const modelId = splitTrailingAuthProfile(params.modelId).model.trim(); + if (!modelId) { + return undefined; + } + const slash = modelId.indexOf("/"); + const unscopedModelId = + slash > 0 && LEGACY_CODEX_PROVIDER_IDS.has(normalizeString(modelId.slice(0, slash)) ?? "") + ? modelId.slice(slash + 1).trim() + : modelId; + return unscopedModelId ? `${providerId}\u0000${unscopedModelId}` : undefined; +} + +export function legacyCodexModelRefIdentityKey( + modelRef: unknown, +): LegacyCodexModelIdentity | undefined { + if (typeof modelRef !== "string") { + return undefined; + } + const model = splitTrailingAuthProfile(modelRef).model.trim(); + const slash = model.indexOf("/"); + if (slash <= 0) { + return undefined; + } + return legacyCodexModelIdentityKey({ + providerId: model.slice(0, slash), + modelId: model.slice(slash + 1), + }); +} + +export function isBlockedLegacyCodexModelRef(params: { + modelRef: unknown; + blockedModelIdentities?: ReadonlySet; +}): boolean { + const identity = legacyCodexModelRefIdentityKey(params.modelRef); + if (!identity || !params.blockedModelIdentities) { + return false; + } + const separator = identity.indexOf("\u0000"); + const providerIdentity = separator >= 0 ? identity.slice(0, separator + 1) : undefined; + return ( + params.blockedModelIdentities.has(identity) || + Boolean(providerIdentity && params.blockedModelIdentities.has(providerIdentity)) + ); +} + +export function isBlockedLegacyCodexModelPair(params: { + providerId: unknown; + modelId: unknown; + blockedModelIdentities?: ReadonlySet; +}): boolean { + if (!params.blockedModelIdentities) { + return false; + } + const providerIdentity = legacyCodexProviderIdentityKey(params.providerId); + const modelIdentity = legacyCodexModelIdentityKey(params); + return ( + Boolean(providerIdentity && params.blockedModelIdentities.has(providerIdentity)) || + Boolean(modelIdentity && params.blockedModelIdentities.has(modelIdentity)) + ); +} + +export function isLegacyCodexProviderId(provider: unknown): boolean { + const normalized = normalizeString(provider); + return normalized ? LEGACY_CODEX_PROVIDER_IDS.has(normalized) : false; +} + +function readLegacyCodexModelId(model: unknown): string | undefined { + if (typeof model !== "string") { + return undefined; + } + const trimmed = model.trim(); + const slash = trimmed.indexOf("/"); + if ( + slash <= 0 || + !LEGACY_CODEX_PROVIDER_IDS.has(normalizeString(trimmed.slice(0, slash)) ?? "") + ) { + return undefined; + } + const modelId = trimmed.slice(slash + 1).trim(); + return modelId || undefined; +} + export function isOpenAICodexModelRef(model: string | undefined): model is string { - return normalizeString(model)?.startsWith("openai-codex/") === true; + return readLegacyCodexModelId(model) !== undefined; } export function isOpenAICodexAuthProfileRef(profile: unknown): boolean { - return normalizeString(profile)?.startsWith("openai-codex:") === true; + const normalized = normalizeString(profile); + const separator = normalized?.indexOf(":") ?? -1; + return separator > 0 && LEGACY_CODEX_PROVIDER_IDS.has(normalized?.slice(0, separator) ?? ""); } export function isProviderlessModelRef(model: unknown): model is string { @@ -39,19 +150,12 @@ export function isProviderlessModelRef(model: unknown): model is string { } export function toCanonicalOpenAIModelRef(model: string): string | undefined { - if (!isOpenAICodexModelRef(model)) { - return undefined; - } - const modelId = model.slice("openai-codex/".length).trim(); + const modelId = readLegacyCodexModelId(model); return modelId ? `openai/${modelId}` : undefined; } export function toOpenAIModelId(model: string): string | undefined { - if (!isOpenAICodexModelRef(model)) { - return undefined; - } - const modelId = model.slice("openai-codex/".length).trim(); - return modelId || undefined; + return readLegacyCodexModelId(model); } export function resolveRuntime(params: { diff --git a/src/commands/doctor/shared/codex-route-model-slots.ts b/src/commands/doctor/shared/codex-route-model-slots.ts index a91e399c962f..ed34e351f54b 100644 --- a/src/commands/doctor/shared/codex-route-model-slots.ts +++ b/src/commands/doctor/shared/codex-route-model-slots.ts @@ -1,9 +1,11 @@ import { asOptionalRecord as asMutableRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalLowercaseString as normalizeString } from "@openclaw/normalization-core/string-coerce"; import { + isBlockedLegacyCodexModelRef, isOpenAICodexModelRef, normalizeRuntimeString, toCanonicalOpenAIModelRef, + type LegacyCodexModelIdentity, } from "./codex-route-model-ref.js"; import type { CodexRouteHit, MutableRecord } from "./codex-route-types.js"; @@ -12,7 +14,16 @@ export function recordCodexModelHit(params: { path: string; model: string; runtime?: string; + blockedModelIdentities?: ReadonlySet; }): string | undefined { + if ( + isBlockedLegacyCodexModelRef({ + modelRef: params.model, + blockedModelIdentities: params.blockedModelIdentities, + }) + ) { + return undefined; + } const canonicalModel = toCanonicalOpenAIModelRef(params.model); if (!canonicalModel) { return undefined; @@ -31,6 +42,7 @@ export function collectStringModelSlot(params: { path: string; value: unknown; runtime?: string; + blockedModelIdentities?: ReadonlySet; }): boolean { if (typeof params.value !== "string") { return false; @@ -45,6 +57,7 @@ export function collectStringModelSlot(params: { path: params.path, model, runtime: params.runtime, + blockedModelIdentities: params.blockedModelIdentities, }), ); } @@ -54,6 +67,7 @@ export function collectModelConfigSlot(params: { path: string; value: unknown; runtime?: string; + blockedModelIdentities?: ReadonlySet; }): boolean { if (typeof params.value === "string") { return collectStringModelSlot(params); @@ -67,6 +81,7 @@ export function collectModelConfigSlot(params: { path: `${params.path}.primary`, value: record.primary, runtime: params.runtime, + blockedModelIdentities: params.blockedModelIdentities, }); if (Array.isArray(record.fallbacks)) { for (const [index, entry] of record.fallbacks.entries()) { @@ -74,6 +89,7 @@ export function collectModelConfigSlot(params: { hits: params.hits, path: `${params.path}.fallbacks.${index}`, value: entry, + blockedModelIdentities: params.blockedModelIdentities, }); } } @@ -165,6 +181,7 @@ export function rewriteStringModelSlot(params: { key: string; path: string; runtime?: string; + blockedModelIdentities?: ReadonlySet; }): boolean { if (!params.container) { return false; @@ -179,6 +196,7 @@ export function rewriteStringModelSlot(params: { path: params.path, model, runtime: params.runtime, + blockedModelIdentities: params.blockedModelIdentities, }); if (!canonicalModel) { return false; @@ -193,6 +211,7 @@ export function rewriteModelConfigSlot(params: { key: string; path: string; runtime?: string; + blockedModelIdentities?: ReadonlySet; }): boolean { if (!params.container) { return false; @@ -211,6 +230,7 @@ export function rewriteModelConfigSlot(params: { key: "primary", path: `${params.path}.primary`, runtime: params.runtime, + blockedModelIdentities: params.blockedModelIdentities, }); if (Array.isArray(record.fallbacks)) { record.fallbacks = record.fallbacks.map((entry, index) => { @@ -222,6 +242,7 @@ export function rewriteModelConfigSlot(params: { hits: params.hits, path: `${params.path}.fallbacks.${index}`, model, + blockedModelIdentities: params.blockedModelIdentities, }); return canonicalModel ?? entry; }); @@ -233,6 +254,7 @@ export function rewriteModelsMap(params: { hits: CodexRouteHit[]; models: MutableRecord | undefined; path: string; + blockedModelIdentities?: ReadonlySet; }): void { if (!params.models) { return; @@ -242,11 +264,15 @@ export function rewriteModelsMap(params: { if (!canonicalModel) { continue; } - recordCodexModelHit({ + const recorded = recordCodexModelHit({ hits: params.hits, path: `${params.path}.${legacyRef}`, model: legacyRef, + blockedModelIdentities: params.blockedModelIdentities, }); + if (!recorded) { + continue; + } const legacyEntry = params.models[legacyRef] ?? {}; const canonicalEntry = params.models[canonicalModel]; const legacyRecord = asMutableRecord(legacyEntry); diff --git a/src/commands/doctor/shared/codex-route-runtime-policy.ts b/src/commands/doctor/shared/codex-route-runtime-policy.ts index 2db176f41776..2ba081748612 100644 --- a/src/commands/doctor/shared/codex-route-runtime-policy.ts +++ b/src/commands/doctor/shared/codex-route-runtime-policy.ts @@ -7,10 +7,12 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { normalizeAgentId } from "../../../routing/session-key.js"; import { canonicalOpenAIModelUsesCodexRuntime, + isBlockedLegacyCodexModelRef, isOpenAICodexModelRef, normalizeRuntimeString, parseModelRef, toCanonicalOpenAIModelRef, + type LegacyCodexModelIdentity, } from "./codex-route-model-ref.js"; import { modelConfigContainsRef, rewriteStringModelSlot } from "./codex-route-model-slots.js"; import type { CodexRouteHit, MutableRecord } from "./codex-route-types.js"; @@ -373,6 +375,7 @@ export function rewriteStringModelSlotIfCanonicalCodexRuntime(params: { container: MutableRecord | undefined; key: string; path: string; + blockedModelIdentities?: ReadonlySet; env?: NodeJS.ProcessEnv; }): void { const value = params.container?.[params.key]; @@ -396,6 +399,7 @@ export function rewriteStringModelSlotIfCanonicalCodexRuntime(params: { container: params.container, key: params.key, path: params.path, + blockedModelIdentities: params.blockedModelIdentities, }); } @@ -406,6 +410,7 @@ export function rewriteModelConfigSlotIfCanonicalCodexRuntime(params: { container: MutableRecord | undefined; key: string; path: string; + blockedModelIdentities?: ReadonlySet; env?: NodeJS.ProcessEnv; }): void { const value = params.container?.[params.key]; @@ -434,6 +439,10 @@ export function rewriteModelConfigSlotIfCanonicalCodexRuntime(params: { const canonicalModel = toCanonicalOpenAIModelRef(entry.trim()); if ( !canonicalModel || + isBlockedLegacyCodexModelRef({ + modelRef: entry, + blockedModelIdentities: params.blockedModelIdentities, + }) || !canonicalOpenAIModelUsesCodexRuntime({ cfg: params.cfg, modelRef: canonicalModel, diff --git a/src/commands/doctor/shared/codex-route-session-repair.test-support.ts b/src/commands/doctor/shared/codex-route-session-repair.test-support.ts index 5ca9e1d293f1..d3150922d9b5 100644 --- a/src/commands/doctor/shared/codex-route-session-repair.test-support.ts +++ b/src/commands/doctor/shared/codex-route-session-repair.test-support.ts @@ -1,4 +1,5 @@ import type { SessionEntry } from "../../../config/sessions/types.js"; +import type { LegacyCodexModelIdentity } from "./codex-route-model-ref.js"; import type { SessionRouteRepairResult } from "./codex-route-types.js"; import "./codex-route-session-repair.js"; @@ -6,6 +7,7 @@ type TestApi = { repairCodexSessionStoreRoutes(params: { store: Record; now?: number; + blockedModelIdentities?: ReadonlySet; }): SessionRouteRepairResult; }; diff --git a/src/commands/doctor/shared/codex-route-session-repair.ts b/src/commands/doctor/shared/codex-route-session-repair.ts index 524170192dd1..03ca47642ca5 100644 --- a/src/commands/doctor/shared/codex-route-session-repair.ts +++ b/src/commands/doctor/shared/codex-route-session-repair.ts @@ -7,11 +7,15 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { isValidAgentHarnessSessionStoreEntry } from "../../../sessions/agent-harness-session-key.js"; import { isOpenAICodexAuthProfileRef, + isBlockedLegacyCodexModelPair, + isBlockedLegacyCodexModelRef, isOpenAICodexModelRef, + isLegacyCodexProviderId, isProviderlessModelRef, normalizeRuntimeString, toCanonicalOpenAIModelRef, toOpenAIModelId, + type LegacyCodexModelIdentity, } from "./codex-route-model-ref.js"; import type { CodexSessionRouteRepairSummary, @@ -22,12 +26,32 @@ function rewriteSessionModelPair(params: { entry: SessionEntry; providerKey: "modelProvider" | "providerOverride"; modelKey: "model" | "modelOverride"; + blockedModelIdentities?: ReadonlySet; }): boolean { let changed = false; const provider = normalizeString(params.entry[params.providerKey]); const model = typeof params.entry[params.modelKey] === "string" ? params.entry[params.modelKey] : undefined; - if (provider === "openai-codex") { + const legacyProviderModelRef = + sessionProviderAllowsScopedModelRef(provider) && isOpenAICodexModelRef(model) + ? model + : undefined; + const blockedIdentity = + isBlockedLegacyCodexModelPair({ + providerId: provider, + modelId: model, + blockedModelIdentities: params.blockedModelIdentities, + }) || + (legacyProviderModelRef + ? isBlockedLegacyCodexModelRef({ + modelRef: legacyProviderModelRef, + blockedModelIdentities: params.blockedModelIdentities, + }) + : false); + if (blockedIdentity) { + return false; + } + if (isLegacyCodexProviderId(provider)) { params.entry[params.providerKey] = "openai"; if (model) { const modelId = toOpenAIModelId(model); @@ -37,8 +61,11 @@ function rewriteSessionModelPair(params: { } return true; } - if (model && isOpenAICodexModelRef(model)) { - const canonicalModel = toCanonicalOpenAIModelRef(model); + if (legacyProviderModelRef) { + const canonicalModel = + provider === "openai" + ? toOpenAIModelId(legacyProviderModelRef) + : toCanonicalOpenAIModelRef(legacyProviderModelRef); if (canonicalModel) { params.entry[params.modelKey] = canonicalModel; changed = true; @@ -47,11 +74,34 @@ function rewriteSessionModelPair(params: { return changed; } -function clearStaleCodexFallbackNotice(entry: SessionEntry): boolean { - if ( - !isOpenAICodexModelRef(entry.fallbackNoticeSelectedModel) && - !isOpenAICodexModelRef(entry.fallbackNoticeActiveModel) - ) { +function sessionProviderAllowsScopedModelRef(provider: string | undefined): boolean { + // Canonical "openai" pairs keep raw model ids untouched: a configured + // OpenAI-compatible model may legitimately be ID'd "codex/". Only an + // absent or legacy provider field marks the model string as a scoped ref. + return !provider || isLegacyCodexProviderId(provider); +} + +function sessionModelPairHasLegacyRoute(provider: unknown, model: unknown): boolean { + const normalizedProvider = normalizeString(provider); + return ( + isLegacyCodexProviderId(normalizedProvider) || + (sessionProviderAllowsScopedModelRef(normalizedProvider) && + typeof model === "string" && + isOpenAICodexModelRef(model)) + ); +} + +function clearStaleCodexFallbackNotice( + entry: SessionEntry, + blockedModelIdentities?: ReadonlySet, +): boolean { + const endpoints = [entry.fallbackNoticeSelectedModel, entry.fallbackNoticeActiveModel]; + const hasBlockedEndpoint = endpoints.some( + (modelRef) => + isOpenAICodexModelRef(modelRef) && + isBlockedLegacyCodexModelRef({ modelRef, blockedModelIdentities }), + ); + if (hasBlockedEndpoint || !endpoints.some(isOpenAICodexModelRef)) { return false; } delete entry.fallbackNoticeSelectedModel; @@ -60,7 +110,7 @@ function clearStaleCodexFallbackNotice(entry: SessionEntry): boolean { return true; } -function clearStaleSessionRuntimePins(entry: SessionEntry): boolean { +function preserveRepairedSessionRuntimeIntent(entry: SessionEntry): boolean { const harnessRuntime = normalizeRuntimeString(entry.agentHarnessId); const overrideRuntime = normalizeRuntimeString(entry.agentRuntimeOverride); let changed = false; @@ -68,14 +118,17 @@ function clearStaleSessionRuntimePins(entry: SessionEntry): boolean { delete entry.agentHarnessId; changed = true; } - if (entry.agentRuntimeOverride !== undefined && overrideRuntime !== "openclaw") { - delete entry.agentRuntimeOverride; + if (overrideRuntime !== "openclaw" && entry.agentRuntimeOverride !== "codex") { + entry.agentRuntimeOverride = "codex"; changed = true; } return changed; } -function repairProviderlessCodexSessionOverride(entry: SessionEntry): boolean { +function repairProviderlessCodexSessionOverride( + entry: SessionEntry, + blockedModelIdentities?: ReadonlySet, +): boolean { if ( !isProviderlessModelRef(entry.modelOverride) || !isOpenAICodexAuthProfileRef(entry.authProfileOverride) || @@ -85,6 +138,16 @@ function repairProviderlessCodexSessionOverride(entry: SessionEntry): boolean { ) { return false; } + const authProvider = normalizeString(entry.authProfileOverride)?.split(":", 1)[0]; + if ( + isBlockedLegacyCodexModelPair({ + providerId: authProvider, + modelId: entry.modelOverride, + blockedModelIdentities, + }) + ) { + return false; + } entry.providerOverride = "openai"; if (entry.model !== undefined || entry.modelProvider !== undefined) { @@ -104,6 +167,7 @@ function repairProviderlessCodexSessionOverride(entry: SessionEntry): boolean { function repairCodexSessionStoreRoutes(params: { store: Record; now?: number; + blockedModelIdentities?: ReadonlySet; }): SessionRouteRepairResult { const now = params.now ?? Date.now(); const sessionKeys: string[] = []; @@ -115,18 +179,27 @@ function repairCodexSessionStoreRoutes(params: { entry, providerKey: "modelProvider", modelKey: "model", + blockedModelIdentities: params.blockedModelIdentities, }); const changedOverrideModelRoute = rewriteSessionModelPair({ entry, providerKey: "providerOverride", modelKey: "modelOverride", + blockedModelIdentities: params.blockedModelIdentities, }); - const changedProviderlessOverride = repairProviderlessCodexSessionOverride(entry); + const changedProviderlessOverride = repairProviderlessCodexSessionOverride( + entry, + params.blockedModelIdentities, + ); const changedModelRoute = changedRuntimeModelRoute || changedOverrideModelRoute || changedProviderlessOverride; - const changedFallbackNotice = clearStaleCodexFallbackNotice(entry); - const changedRuntimePins = - changedModelRoute || changedFallbackNotice ? clearStaleSessionRuntimePins(entry) : false; + const changedFallbackNotice = clearStaleCodexFallbackNotice( + entry, + params.blockedModelIdentities, + ); + const changedRuntimePins = changedModelRoute + ? preserveRepairedSessionRuntimeIntent(entry) + : false; if (!changedModelRoute && !changedFallbackNotice && !changedRuntimePins) { continue; } @@ -145,23 +218,61 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") { ] = { repairCodexSessionStoreRoutes }; } -function scanCodexSessionStoreRoutes(store: Record): string[] { +function scanCodexSessionStoreRoutes( + store: Record, + blockedModelIdentities?: ReadonlySet, +): string[] { return Object.entries(store).flatMap(([sessionKey, entry]) => { if (!entry || isValidAgentHarnessSessionStoreEntry(sessionKey, entry)) { return []; } + const isBlockedPair = (provider: unknown, model: unknown) => { + const normalizedProvider = normalizeString(provider); + const legacyProviderModelRef = + sessionProviderAllowsScopedModelRef(normalizedProvider) && + typeof model === "string" && + isOpenAICodexModelRef(model) + ? model + : undefined; + return ( + isBlockedLegacyCodexModelPair({ + providerId: provider, + modelId: model, + blockedModelIdentities, + }) || + (legacyProviderModelRef && + isBlockedLegacyCodexModelRef({ + modelRef: legacyProviderModelRef, + blockedModelIdentities, + })) + ); + }; + const fallbackNoticeEndpoints = [ + entry.fallbackNoticeSelectedModel, + entry.fallbackNoticeActiveModel, + ]; + const hasBlockedFallbackNoticeEndpoint = fallbackNoticeEndpoints.some( + (modelRef) => + isOpenAICodexModelRef(modelRef) && + isBlockedLegacyCodexModelRef({ modelRef, blockedModelIdentities }), + ); + const hasRewritableFallbackNotice = + !hasBlockedFallbackNoticeEndpoint && fallbackNoticeEndpoints.some(isOpenAICodexModelRef); const hasLegacyRoute = - normalizeString(entry.modelProvider) === "openai-codex" || - normalizeString(entry.providerOverride) === "openai-codex" || - isOpenAICodexModelRef(entry.model) || - isOpenAICodexModelRef(entry.modelOverride) || + (sessionModelPairHasLegacyRoute(entry.modelProvider, entry.model) && + !isBlockedPair(entry.modelProvider, entry.model)) || + (sessionModelPairHasLegacyRoute(entry.providerOverride, entry.modelOverride) && + !isBlockedPair(entry.providerOverride, entry.modelOverride)) || (isProviderlessModelRef(entry.modelOverride) && isOpenAICodexAuthProfileRef(entry.authProfileOverride) && entry.authProfileOverrideSource === "auto" && entry.modelOverrideSource === "auto" && - !normalizeString(entry.providerOverride)) || - isOpenAICodexModelRef(entry.fallbackNoticeSelectedModel) || - isOpenAICodexModelRef(entry.fallbackNoticeActiveModel); + !normalizeString(entry.providerOverride) && + !isBlockedPair( + normalizeString(entry.authProfileOverride)?.split(":", 1)[0], + entry.modelOverride, + )) || + hasRewritableFallbackNotice; return hasLegacyRoute ? [sessionKey] : []; }); } @@ -172,6 +283,7 @@ export async function maybeRepairCodexSessionRoutes(params: { env?: NodeJS.ProcessEnv; shouldRepair: boolean; codexRuntimeReady?: boolean; + blockedModelIdentities?: ReadonlySet; }): Promise { const targets = resolveAllAgentSessionStoreTargetsSync(params.cfg, { env: params.env ?? process.env, @@ -183,6 +295,7 @@ export async function maybeRepairCodexSessionRoutes(params: { const stale = targets.flatMap((target) => { const sessionKeys = scanCodexSessionStoreRoutes( loadSessionStore(target.storePath, { skipCache: true, clone: false }), + params.blockedModelIdentities, ); return sessionKeys.map((sessionKey) => `${target.agentId}:${sessionKey}`); }); @@ -194,7 +307,7 @@ export async function maybeRepairCodexSessionRoutes(params: { stale.length > 0 ? [ [ - "- Legacy `openai-codex/*` session route state detected.", + "- Legacy `codex/*` or `openai-codex/*` session route state detected.", `- Affected sessions: ${stale.length}.`, "- Run `openclaw doctor --fix` to rewrite stale session model/provider pins across all agent session stores.", ].join("\n"), @@ -208,13 +321,18 @@ export async function maybeRepairCodexSessionRoutes(params: { for (const target of targets) { const staleSessionKeys = scanCodexSessionStoreRoutes( loadSessionStore(target.storePath, { skipCache: true, clone: false }), + params.blockedModelIdentities, ); if (staleSessionKeys.length === 0) { continue; } const result = await updateSessionStore( target.storePath, - (store) => repairCodexSessionStoreRoutes({ store }), + (store) => + repairCodexSessionStoreRoutes({ + store, + blockedModelIdentities: params.blockedModelIdentities, + }), { skipMaintenance: true }, ); if (!result.changed) { diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index fa39df1ba78a..106da7a01a68 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -33,6 +33,10 @@ vi.mock("../../../plugins/installed-plugin-index.js", async (importOriginal) => loadInstalledPluginIndex: mocks.loadInstalledPluginIndex, })); +import { + legacyCodexModelIdentityKey, + legacyCodexProviderIdentityKey, +} from "./codex-route-model-ref.js"; import { repairCodexSessionStoreRoutes } from "./codex-route-session-repair.test-support.js"; import { collectCodexRouteWarnings, maybeRepairCodexRoutes } from "./codex-route-warnings.js"; @@ -67,7 +71,7 @@ describe("collectCodexRouteWarnings", () => { expect(warnings).toStrictEqual([ [ - "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", + "- Legacy `codex/*` and `openai-codex/*` model refs should be rewritten to `openai/*`.", "- agents.defaults.model: openai-codex/gpt-5.5 should become openai/gpt-5.5.", "- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*`, moves Codex intent to provider/model runtime policy, and clears old whole-agent runtime pins.", ].join("\n"), @@ -149,7 +153,7 @@ describe("collectCodexRouteWarnings", () => { expect(warnings).toStrictEqual([ [ - "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", + "- Legacy `codex/*` and `openai-codex/*` model refs should be rewritten to `openai/*`.", '- agents.defaults.model: openai-codex/gpt-5.5 should become openai/gpt-5.5; current runtime is "codex".', "- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*`, moves Codex intent to provider/model runtime policy, and clears old whole-agent runtime pins.", ].join("\n"), @@ -172,7 +176,7 @@ describe("collectCodexRouteWarnings", () => { expect(warnings).toStrictEqual([ [ - "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", + "- Legacy `codex/*` and `openai-codex/*` model refs should be rewritten to `openai/*`.", "- agents.defaults.model: openai-codex/gpt-5.5 should become openai/gpt-5.5.", "- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*`, moves Codex intent to provider/model runtime policy, and clears old whole-agent runtime pins.", ].join("\n"), @@ -1286,7 +1290,7 @@ describe("collectCodexRouteWarnings", () => { expect(collectCodexRouteWarnings({ cfg })).toStrictEqual([ [ - "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", + "- Legacy `codex/*` and `openai-codex/*` model refs should be rewritten to `openai/*`.", "- hooks.gmail.model: openai-codex/gpt-5.4 should become openai/gpt-5.4.", "- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*`, moves Codex intent to provider/model runtime policy, and clears old whole-agent runtime pins.", ].join("\n"), @@ -1766,7 +1770,7 @@ describe("collectCodexRouteWarnings", () => { expect(collectCodexRouteWarnings({ cfg })).toStrictEqual([ [ - "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", + "- Legacy `codex/*` and `openai-codex/*` model refs should be rewritten to `openai/*`.", "- hooks.gmail.model: openai-codex/gpt-5.4 should become openai/gpt-5.4.", "- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*`, moves Codex intent to provider/model runtime policy, and clears old whole-agent runtime pins.", ].join("\n"), @@ -1792,6 +1796,42 @@ describe("collectCodexRouteWarnings", () => { expect(result.cfg.hooks?.gmail?.model).toBe("openai-codex/gpt-5.4"); }); + it("keeps global runtime pins while a blocked namespace remains", () => { + const result = maybeRepairCodexRoutes({ + cfg: { + models: { + providers: { + openai: { + models: [{ id: "gpt-5.6-sol", api: "openai-responses" }], + }, + "openai-codex": { + models: [{ id: "gpt-5.6-sol", api: "openai-chatgpt-responses" }], + }, + }, + }, + agents: { + defaults: { + model: "openai-codex/gpt-5.6-sol", + agentRuntime: { id: "codex" }, + }, + }, + hooks: { + mappings: [{ model: "codex/gpt-5.4-mini" }], + }, + } as unknown as OpenClawConfig, + shouldRepair: true, + }); + + expect(result.cfg.agents?.defaults?.model).toBe("openai-codex/gpt-5.6-sol"); + expect(result.cfg.agents?.defaults?.agentRuntime).toEqual({ id: "codex" }); + expect(result.cfg.hooks?.mappings?.[0]?.model).toBe("openai/gpt-5.4-mini"); + expect(result.changes.join("\n")).not.toContain("Removed agents.defaults.agentRuntime"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain( + "Legacy Codex provider routes require manual reconciliation", + ); + }); + it("keeps default compaction overrides when route repair clears the default Codex pin", () => { const result = maybeRepairCodexRoutes({ cfg: { @@ -3877,7 +3917,7 @@ describe("collectCodexRouteWarnings", () => { expect(result.changes).toStrictEqual([]); expect(result.warnings).toStrictEqual([ [ - "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", + "- Legacy `codex/*` and `openai-codex/*` model refs should be rewritten to `openai/*`.", "- agents.defaults.heartbeat.model: openai-codex/gpt-5.4 should become openai/gpt-5.4.", "- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*`, moves Codex intent to provider/model runtime policy, and clears old whole-agent runtime pins.", ].join("\n"), @@ -3958,7 +3998,7 @@ describe("collectCodexRouteWarnings", () => { expect(result.changes).toStrictEqual([]); expect(result.warnings).toStrictEqual([ [ - "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", + "- Legacy `codex/*` and `openai-codex/*` model refs should be rewritten to `openai/*`.", "- hooks.gmail.model: openai-codex/gpt-5.4 should become openai/gpt-5.4.", "- Run `openclaw doctor --fix`: it rewrites configured model refs and stale sessions to `openai/*`, moves Codex intent to provider/model runtime policy, and clears old whole-agent runtime pins.", ].join("\n"), @@ -4013,9 +4053,9 @@ describe("collectCodexRouteWarnings", () => { expectDefined(store.main, "store.main test invariant").authProfileOverrideCompactionCount, ).toBe(2); expect(expectDefined(store.main, "store.main test invariant").agentHarnessId).toBeUndefined(); - expect( - expectDefined(store.main, "store.main test invariant").agentRuntimeOverride, - ).toBeUndefined(); + expect(expectDefined(store.main, "store.main test invariant").agentRuntimeOverride).toBe( + "codex", + ); expect( expectDefined(store.main, "store.main test invariant").fallbackNoticeSelectedModel, ).toBeUndefined(); @@ -4029,6 +4069,201 @@ describe("collectCodexRouteWarnings", () => { expect(expectDefined(store.other, "store.other test invariant").agentHarnessId).toBe("codex"); }); + it("repairs shipped codex namespace session route refs", () => { + const store: Record = { + main: { + sessionId: "s1", + updatedAt: 1, + modelProvider: "codex", + model: "codex/gpt-5.6-sol", + providerOverride: "codex", + modelOverride: "codex/gpt-5.6-sol", + authProfileOverride: "codex:default", + authProfileOverrideSource: "auto", + fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", + agentRuntimeOverride: "codex", + }, + }; + + const result = repairCodexSessionStoreRoutes({ store, now: 123 }); + + expect(result).toEqual({ changed: true, sessionKeys: ["main"] }); + expect(store.main).toMatchObject({ + modelProvider: "openai", + model: "gpt-5.6-sol", + providerOverride: "openai", + modelOverride: "gpt-5.6-sol", + authProfileOverride: "codex:default", + updatedAt: 123, + }); + expect(store.main?.fallbackNoticeSelectedModel).toBeUndefined(); + expect(store.main?.agentRuntimeOverride).toBe("codex"); + }); + + it("treats slash model ids as raw for custom providers while migrating legacy pairs", () => { + const store: Record = { + custom: { + sessionId: "s-custom", + updatedAt: 1, + modelProvider: "custom", + model: "codex/foo", + providerOverride: "custom", + modelOverride: "openai-codex/bar", + agentRuntimeOverride: "openclaw", + }, + legacy: { + sessionId: "s-legacy", + updatedAt: 2, + modelProvider: "codex", + model: "codex/foo", + }, + }; + + const result = repairCodexSessionStoreRoutes({ store, now: 123 }); + + expect(result).toEqual({ changed: true, sessionKeys: ["legacy"] }); + expect(store.custom).toMatchObject({ + modelProvider: "custom", + model: "codex/foo", + providerOverride: "custom", + modelOverride: "openai-codex/bar", + agentRuntimeOverride: "openclaw", + updatedAt: 1, + }); + expect(store.legacy).toMatchObject({ + modelProvider: "openai", + model: "foo", + agentRuntimeOverride: "codex", + updatedAt: 123, + }); + }); + + it("keeps the whole provider-conflicted session namespace legacy", () => { + const store: Record = { + blocked: { + sessionId: "s-blocked", + updatedAt: 1, + modelProvider: "codex", + model: "gpt-5.6-sol", + providerOverride: "codex", + modelOverride: "codex/gpt-5.6-sol", + }, + migrate: { + sessionId: "s-migrate", + updatedAt: 2, + modelProvider: "codex", + model: "gpt-5.3-mini", + }, + providerOnly: { + sessionId: "s-provider-only", + updatedAt: 3, + modelProvider: "codex", + }, + }; + const blockedNamespace = expectDefined( + legacyCodexProviderIdentityKey("codex"), + "blocked session namespace test invariant", + ); + + const result = repairCodexSessionStoreRoutes({ + store, + now: 123, + blockedModelIdentities: new Set([blockedNamespace]), + }); + + expect(result).toEqual({ changed: false, sessionKeys: [] }); + expect(store.blocked).toMatchObject({ + modelProvider: "codex", + model: "gpt-5.6-sol", + providerOverride: "codex", + modelOverride: "codex/gpt-5.6-sol", + updatedAt: 1, + }); + expect(store.migrate).toMatchObject({ + modelProvider: "codex", + model: "gpt-5.3-mini", + updatedAt: 2, + }); + expect(store.providerOnly).toMatchObject({ + modelProvider: "codex", + updatedAt: 3, + }); + }); + + it("clears mixed legacy and canonical fallback notices atomically", () => { + const store: Record = { + main: { + sessionId: "s1", + updatedAt: 1, + modelProvider: "openai", + model: "gpt-5.6-sol", + fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", + fallbackNoticeActiveModel: "openai/gpt-5.6-sol", + fallbackNoticeReason: "rate-limit", + }, + }; + + const result = repairCodexSessionStoreRoutes({ store, now: 123 }); + + expect(result).toEqual({ changed: true, sessionKeys: ["main"] }); + expect(store.main?.fallbackNoticeSelectedModel).toBeUndefined(); + expect(store.main?.fallbackNoticeActiveModel).toBeUndefined(); + expect(store.main?.fallbackNoticeReason).toBeUndefined(); + }); + + it("retains a fallback notice atomically when one legacy endpoint is blocked", () => { + const store: Record = { + main: { + sessionId: "s1", + updatedAt: 1, + modelProvider: "openai", + model: "gpt-5.6-sol", + fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", + fallbackNoticeActiveModel: "openai/gpt-5.6-sol", + fallbackNoticeReason: "rate-limit", + }, + }; + const blockedIdentity = expectDefined( + legacyCodexModelIdentityKey({ providerId: "codex", modelId: "gpt-5.6-sol" }), + "blocked fallback notice model identity test invariant", + ); + + const result = repairCodexSessionStoreRoutes({ + store, + now: 123, + blockedModelIdentities: new Set([blockedIdentity]), + }); + + expect(result).toEqual({ changed: false, sessionKeys: [] }); + expect(store.main).toMatchObject({ + updatedAt: 1, + fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", + fallbackNoticeActiveModel: "openai/gpt-5.6-sol", + fallbackNoticeReason: "rate-limit", + }); + }); + + it("leaves session runtime intent untouched for fallback-notice-only cleanup", () => { + const store: Record = { + main: { + sessionId: "s1", + updatedAt: 1, + modelProvider: "openai", + model: "gpt-5.6-sol", + fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", + fallbackNoticeReason: "rate-limit", + }, + }; + + const result = repairCodexSessionStoreRoutes({ store, now: 123 }); + + expect(result).toEqual({ changed: true, sessionKeys: ["main"] }); + expect(store.main?.fallbackNoticeSelectedModel).toBeUndefined(); + expect(store.main?.fallbackNoticeReason).toBeUndefined(); + expect(store.main?.agentRuntimeOverride).toBeUndefined(); + expect(store.main?.agentHarnessId).toBeUndefined(); + }); + it("skips valid locked agent-harness rows while repairing ordinary legacy routes", () => { const supervisedKey = "agent:main:harness:codex:supervision:abc123"; const ordinaryLockedKey = "agent:main:ordinary-locked"; @@ -4105,7 +4340,7 @@ describe("collectCodexRouteWarnings", () => { ); }); - it("clears stale Codex overrides while preserving explicit OpenClaw session pins", () => { + it("preserves Codex runtime intent alongside explicit OpenClaw harness pins", () => { const store: Record = { main: { sessionId: "s1", @@ -4126,12 +4361,12 @@ describe("collectCodexRouteWarnings", () => { expect(expectDefined(store.main, "store.main test invariant").modelProvider).toBe("openai"); expect(expectDefined(store.main, "store.main test invariant").model).toBe("gpt-5.5"); expect(expectDefined(store.main, "store.main test invariant").agentHarnessId).toBe("pi"); - expect( - expectDefined(store.main, "store.main test invariant").agentRuntimeOverride, - ).toBeUndefined(); + expect(expectDefined(store.main, "store.main test invariant").agentRuntimeOverride).toBe( + "codex", + ); }); - it("keeps Codex session auth pins while leaving runtime unpinned", () => { + it("installs Codex runtime intent for a session-only legacy route", () => { const store: Record = { main: { sessionId: "s1", @@ -4159,9 +4394,9 @@ describe("collectCodexRouteWarnings", () => { "auto", ); expect(expectDefined(store.main, "store.main test invariant").agentHarnessId).toBeUndefined(); - expect( - expectDefined(store.main, "store.main test invariant").agentRuntimeOverride, - ).toBeUndefined(); + expect(expectDefined(store.main, "store.main test invariant").agentRuntimeOverride).toBe( + "codex", + ); }); it("repairs Telegram direct session routes while preserving canonical OpenAI auth pins", () => { @@ -4203,7 +4438,7 @@ describe("collectCodexRouteWarnings", () => { expect(entry.authProfileOverride).toBe("openai:work"); expect(entry.authProfileOverrideSource).toBe("auto"); expect(entry.agentHarnessId).toBeUndefined(); - expect(entry.agentRuntimeOverride).toBeUndefined(); + expect(entry.agentRuntimeOverride).toBe("codex"); }); it("repairs providerless auto Codex session overrides", () => { diff --git a/src/commands/doctor/shared/codex-route-warnings.ts b/src/commands/doctor/shared/codex-route-warnings.ts index 5fb1cbe4026c..c898bc59d8b2 100644 --- a/src/commands/doctor/shared/codex-route-warnings.ts +++ b/src/commands/doctor/shared/codex-route-warnings.ts @@ -30,6 +30,10 @@ import type { LegacyLosslessCompactionConfig, UnsupportedCodexCompactionOverride, } from "./codex-route-types.js"; +import { + collectBlockedLegacyOpenAICodexProviderPlan, + type BlockedLegacyOpenAICodexProviderPlan, +} from "./legacy-config-migrations.runtime.models.js"; function formatCodexRouteChange(hit: CodexRouteHit): string { return `${hit.path}: ${hit.model} -> ${hit.canonicalModel}.`; @@ -163,12 +167,17 @@ function collectCodexComputerUseWarnings(cfg: OpenClawConfig): string[] { export function collectCodexRouteWarnings(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; + blockedProviderPlan?: BlockedLegacyOpenAICodexProviderPlan; }): string[] { const env = params.env ?? process.env; - const hits = collectConfigModelRefs(params.cfg); + const blockedProviderPlan = + params.blockedProviderPlan ?? collectBlockedLegacyOpenAICodexProviderPlan(params.cfg); + const blockedModelIdentities = new Set(blockedProviderPlan.blockedModelIdentities); + const hits = collectConfigModelRefs(params.cfg, blockedModelIdentities); const disabledCodexPluginHits = collectDisabledCodexPluginRouteHits(params.cfg, env); const ignoreLegacyAgentRuntimePins = configRepairWouldClearLegacyRuntimePins({ cfg: params.cfg, + blockedModelIdentities, env, }); const legacyLosslessCompactionConfigs = collectLegacyLosslessCompactionConfigs({ @@ -198,13 +207,14 @@ export function collectCodexRouteWarnings(params: { env, }); const warnings = [ + ...(blockedProviderPlan.warning ? [blockedProviderPlan.warning] : []), ...collectCodexAppServerCommandWarnings(params.cfg), ...collectCodexComputerUseWarnings(params.cfg), ]; if (hits.length > 0) { warnings.push( [ - "- Legacy `openai-codex/*` model refs should be rewritten to `openai/*`.", + "- Legacy `codex/*` and `openai-codex/*` model refs should be rewritten to `openai/*`.", ...hits.map( (hit) => `- ${hit.path}: ${hit.model} should become ${hit.canonicalModel}${ @@ -276,12 +286,17 @@ export function maybeRepairCodexRoutes(params: { env?: NodeJS.ProcessEnv; shouldRepair: boolean; codexRuntimeReady?: boolean; + blockedProviderPlan?: BlockedLegacyOpenAICodexProviderPlan; }): { cfg: OpenClawConfig; warnings: string[]; changes: string[] } { const env = params.env ?? process.env; - const hits = collectConfigModelRefs(params.cfg); + const blockedProviderPlan = + params.blockedProviderPlan ?? collectBlockedLegacyOpenAICodexProviderPlan(params.cfg); + const blockedModelIdentities = new Set(blockedProviderPlan.blockedModelIdentities); + const hits = collectConfigModelRefs(params.cfg, blockedModelIdentities); const disabledCodexPluginHits = collectDisabledCodexPluginRouteHits(params.cfg, env); const ignoreLegacyAgentRuntimePins = configRepairWouldClearLegacyRuntimePins({ cfg: params.cfg, + blockedModelIdentities, env, }); const unsupportedCompactionOverrides = collectUnsupportedCodexCompactionOverrides({ @@ -298,23 +313,36 @@ export function maybeRepairCodexRoutes(params: { hits.length === 0 && disabledCodexPluginHits.length === 0 && unsupportedCompactionOverrides.length === 0 && - legacyLosslessCompactionConfigs.length === 0 + legacyLosslessCompactionConfigs.length === 0 && + !blockedProviderPlan.warning ) { return { cfg: params.cfg, warnings: [], changes: [] }; } if (!params.shouldRepair) { return { cfg: params.cfg, - warnings: collectCodexRouteWarnings({ cfg: params.cfg, env }), + warnings: collectCodexRouteWarnings({ + cfg: params.cfg, + env, + blockedProviderPlan, + }), changes: [], }; } - const repaired = rewriteConfigModelRefs({ cfg: params.cfg, env }); + const repaired = rewriteConfigModelRefs({ + cfg: params.cfg, + env, + blockedModelIdentities, + }); const codexPluginRepair = enableCodexPluginForRequiredRoutes({ cfg: repaired.cfg, routeHits: collectDisabledCodexPluginRouteHits(repaired.cfg, env), }); - const warnings = collectCodexRouteWarnings({ cfg: codexPluginRepair.cfg, env }); + const warnings = collectCodexRouteWarnings({ + cfg: codexPluginRepair.cfg, + env, + blockedProviderPlan, + }); const routeChanges = repaired.changes.length > 0 ? [ diff --git a/src/commands/doctor/shared/legacy-config-compatibility-base.ts b/src/commands/doctor/shared/legacy-config-compatibility-base.ts index 774561b58d7f..3cc9f11d26fe 100644 --- a/src/commands/doctor/shared/legacy-config-compatibility-base.ts +++ b/src/commands/doctor/shared/legacy-config-compatibility-base.ts @@ -1,5 +1,6 @@ // Shared base compatibility normalizers reused by core and plugin setup migrations. import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { LegacyCodexModelIdentity } from "./codex-route-model-ref.js"; import { normalizeLegacyBrowserConfig, normalizeLegacyCrossContextMessageConfig, @@ -21,6 +22,7 @@ export function normalizeBaseCompatibilityConfigValues( cfg: OpenClawConfig, changes: string[], afterBrowser?: (config: OpenClawConfig) => OpenClawConfig, + blockedModelIdentities?: ReadonlySet, ): OpenClawConfig { let next = seedMissingDefaultAccountsFromSingleAccountBase(cfg, changes); next = normalizeLegacyBrowserConfig(next, changes); @@ -42,7 +44,7 @@ export function normalizeBaseCompatibilityConfigValues( next = normalizeLegacyNanoBananaSkill(next, changes); next = normalizeLegacyTalkConfig(next, changes); next = normalizeLegacyOpenAIModelProviderApi(next, changes); - next = normalizeLegacyRuntimeModelRefs(next, changes); + next = normalizeLegacyRuntimeModelRefs(next, changes, blockedModelIdentities); next = normalizeLegacyCrossContextMessageConfig(next, changes); next = normalizeLegacyMediaProviderOptions(next, changes); next = normalizeLegacyOllamaNativeNumCtxParams(next, changes); diff --git a/src/commands/doctor/shared/legacy-config-core-migrate.ts b/src/commands/doctor/shared/legacy-config-core-migrate.ts index 32d10a42a9b7..7aad83927e0a 100644 --- a/src/commands/doctor/shared/legacy-config-core-migrate.ts +++ b/src/commands/doctor/shared/legacy-config-core-migrate.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { runPluginSetupConfigMigrations } from "../../../plugins/setup-registry.js"; import { migrateLegacySecretRefEnvMarkers } from "../../../secrets/legacy-secretref-env-marker.js"; import { applyChannelDoctorCompatibilityMigrations } from "./channel-legacy-config-migrate.js"; +import type { LegacyCodexModelIdentity } from "./codex-route-model-ref.js"; import { pruneBindingsForMissingAgents } from "./legacy-config-binding-repair.js"; import { normalizeBaseCompatibilityConfigValues } from "./legacy-config-compatibility-base.js"; import { @@ -49,21 +50,31 @@ function repairNullAgentWorkspaces(cfg: OpenClawConfig, changes: string[]): Open } /** Normalize current config through core, plugin setup, channel, and secret-ref migrations. */ -export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { +export function normalizeCompatibilityConfigValues( + cfg: OpenClawConfig, + options: { + blockedModelIdentities?: ReadonlySet; + } = {}, +): { config: OpenClawConfig; changes: string[]; } { const changes: string[] = []; - let next = normalizeBaseCompatibilityConfigValues(cfg, changes, (config) => { - const setupMigration = runPluginSetupConfigMigrations({ - config, - }); - if (setupMigration.changes.length === 0) { - return config; - } - changes.push(...setupMigration.changes); - return setupMigration.config; - }); + let next = normalizeBaseCompatibilityConfigValues( + cfg, + changes, + (config) => { + const setupMigration = runPluginSetupConfigMigrations({ + config, + }); + if (setupMigration.changes.length === 0) { + return config; + } + changes.push(...setupMigration.changes); + return setupMigration.config; + }, + options.blockedModelIdentities, + ); const channelMigrations = applyChannelDoctorCompatibilityMigrations(next); if (channelMigrations.changes.length > 0) { next = channelMigrations.next; diff --git a/src/commands/doctor/shared/legacy-config-core-normalizers.ts b/src/commands/doctor/shared/legacy-config-core-normalizers.ts index 5c9a567caf11..a7ab8a44790e 100644 --- a/src/commands/doctor/shared/legacy-config-core-normalizers.ts +++ b/src/commands/doctor/shared/legacy-config-core-normalizers.ts @@ -10,6 +10,10 @@ import { resolveNormalizedProviderModelMaxTokens } from "../../../config/default import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import { DEFAULT_GOOGLE_API_BASE_URL } from "../../../infra/google-api-base-url.js"; import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js"; +import { + isBlockedLegacyCodexModelRef, + type LegacyCodexModelIdentity, +} from "./codex-route-model-ref.js"; import { hasOwnKey, isRecord } from "./legacy-config-record-shared.js"; import { isLegacyModelsAddCodexMetadataModel } from "./legacy-models-add-metadata.js"; import { @@ -292,7 +296,10 @@ function normalizeLegacyCodexCliAgentRuntimePolicy(raw: unknown): { }; } -function normalizeLegacyRuntimeAgentModelConfig(raw: unknown): { +function normalizeLegacyRuntimeAgentModelConfig( + raw: unknown, + blockedModelIdentities?: ReadonlySet, +): { value?: unknown; changed: boolean; selectedRuntime?: string; @@ -300,7 +307,9 @@ function normalizeLegacyRuntimeAgentModelConfig(raw: unknown): { selectedRefs: SelectedRuntimeRef[]; } { if (typeof raw === "string") { - const migrated = migrateLegacyRuntimeModelRef(raw); + const migrated = isBlockedLegacyCodexModelRef({ modelRef: raw, blockedModelIdentities }) + ? null + : migrateLegacyRuntimeModelRef(raw); return migrated ? { value: migrated.ref, @@ -322,7 +331,10 @@ function normalizeLegacyRuntimeAgentModelConfig(raw: unknown): { } const migratedPrimary = - typeof raw.primary === "string" ? migrateLegacyRuntimeModelRef(raw.primary) : null; + typeof raw.primary === "string" && + !isBlockedLegacyCodexModelRef({ modelRef: raw.primary, blockedModelIdentities }) + ? migrateLegacyRuntimeModelRef(raw.primary) + : null; let changed = false; const next: Record = { ...raw }; const selectedRefs: SelectedRuntimeRef[] = []; @@ -343,7 +355,12 @@ function normalizeLegacyRuntimeAgentModelConfig(raw: unknown): { if (typeof fallback !== "string") { return fallback; } - const migratedFallback = migrateLegacyRuntimeModelRef(fallback); + const migratedFallback = isBlockedLegacyCodexModelRef({ + modelRef: fallback, + blockedModelIdentities, + }) + ? null + : migrateLegacyRuntimeModelRef(fallback); if ( migratedFallback && (migratedFallback.runtime === selectedRuntime || @@ -408,6 +425,7 @@ function normalizeLegacyRuntimeAllowlistModels( rawModels: unknown, selectedRuntime: string | undefined, selectedRuntimeRequiresPolicy: boolean, + blockedModelIdentities?: ReadonlySet, ): { value?: unknown; changed: boolean; @@ -425,7 +443,12 @@ function normalizeLegacyRuntimeAllowlistModels( requiresRuntimePolicy: boolean; }> = []; for (const [rawKey, entry] of Object.entries(rawModels)) { - const migrated = migrateLegacyRuntimeModelRef(rawKey); + const migrated = isBlockedLegacyCodexModelRef({ + modelRef: rawKey, + blockedModelIdentities, + }) + ? null + : migrateLegacyRuntimeModelRef(rawKey); if ( migrated && (migrated.runtime === selectedRuntime || @@ -546,12 +569,13 @@ function normalizeLegacyRuntimeAgentContainer( raw: Record, path: string, changes: string[], + blockedModelIdentities?: ReadonlySet, ): { value: Record; changed: boolean } { let changed = false; const next: Record = { ...raw }; const legacyWholeAgentRuntime = resolveLegacyWholeAgentRuntimePolicy(raw.agentRuntime); - const model = normalizeLegacyRuntimeAgentModelConfig(raw.model); + const model = normalizeLegacyRuntimeAgentModelConfig(raw.model, blockedModelIdentities); if (model.changed) { next.model = model.value; changed = true; @@ -567,6 +591,7 @@ function normalizeLegacyRuntimeAgentContainer( raw.models, model.selectedRuntime, model.selectedRuntimeRequiresPolicy, + blockedModelIdentities, ); if (models.changed) { next.models = models.value; @@ -684,6 +709,7 @@ function normalizeLegacyCodexCliProviderRuntimePins( export function normalizeLegacyRuntimeModelRefs( cfg: OpenClawConfig, changes: string[], + blockedModelIdentities?: ReadonlySet, ): OpenClawConfig { const providerPinned = normalizeLegacyCodexCliProviderRuntimePins(cfg, changes); const cfgWithProviders = providerPinned.config; @@ -699,6 +725,7 @@ export function normalizeLegacyRuntimeModelRefs( rawAgents.defaults, "agents.defaults", changes, + blockedModelIdentities, ); if (defaults.changed) { nextAgents.defaults = defaults.value; @@ -713,7 +740,12 @@ export function normalizeLegacyRuntimeModelRefs( } const agentId = normalizeOptionalString(entry.id); const path = agentId ? `agents.list.${sanitizeForLog(agentId)}` : `agents.list[${index}]`; - const agent = normalizeLegacyRuntimeAgentContainer(entry, path, changes); + const agent = normalizeLegacyRuntimeAgentContainer( + entry, + path, + changes, + blockedModelIdentities, + ); if (agent.changed) { changed = true; return agent.value; diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index ad451a5caf7a..5dec2c8776de 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -4,8 +4,13 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it } from "vitest"; import { findLegacyConfigIssues } from "../../../config/legacy.js"; import type { OpenClawConfig } from "../../../config/types.js"; +import { legacyCodexProviderIdentityKey } from "./codex-route-model-ref.js"; import { pruneBindingsForMissingAgents } from "./legacy-config-binding-repair.js"; import { LEGACY_CONFIG_MIGRATIONS } from "./legacy-config-migrations.js"; +import { + collectBlockedLegacyOpenAICodexProviderPlan, + collectBlockedLegacyOpenAICodexProviderWarnings, +} from "./legacy-config-migrations.runtime.models.js"; function repairBindingsForTest(config: OpenClawConfig) { const changes: string[] = []; @@ -157,6 +162,235 @@ describe("legacy memory search config migrate", () => { ]); }); + it("merges shipped codex provider config into openai with model-scoped Codex runtime", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + models: [{ id: "text-embedding-3-small" }], + }, + codex: { + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-chatgpt-responses", + models: [{ id: "gpt-5.6-sol" }], + }, + }, + }, + }); + + expect(res.config?.models?.providers).not.toHaveProperty("codex"); + expect(res.config?.models?.providers?.openai?.models).toEqual([ + { id: "text-embedding-3-small" }, + { + id: "gpt-5.6-sol", + baseUrl: "https://chatgpt.com/backend-api", + api: "openai-chatgpt-responses", + agentRuntime: { id: "codex" }, + }, + ]); + expect(res.changes).toContain( + "Merged 1 model(s) from models.providers.codex into models.providers.openai: gpt-5.6-sol.", + ); + }); + + it("normalizes moved shipped codex model auto runtime and preserves explicit overrides", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + codex: { + models: [ + { id: "gpt-missing" }, + { id: "gpt-auto", agentRuntime: { id: "auto" } }, + { id: "gpt-openclaw", agentRuntime: { id: "openclaw" } }, + ], + }, + }, + }, + }); + + expect(res.config?.models?.providers?.openai?.models).toEqual([ + { id: "gpt-missing", agentRuntime: { id: "codex" } }, + { id: "gpt-auto", agentRuntime: { id: "codex" } }, + { id: "gpt-openclaw", agentRuntime: { id: "openclaw" } }, + ]); + expect(res.config?.models?.providers).not.toHaveProperty("codex"); + }); + + it("normalizes merged shipped codex model auto runtime and preserves explicit overrides", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { models: [{ id: "text-embedding-3-small" }] }, + codex: { + models: [ + { id: "gpt-auto", agentRuntime: { id: "auto" } }, + { id: "gpt-openclaw", agentRuntime: { id: "openclaw" } }, + ], + }, + }, + }, + }); + + expect(res.config?.models?.providers?.openai?.models).toEqual([ + { id: "text-embedding-3-small" }, + { id: "gpt-auto", agentRuntime: { id: "codex" } }, + { id: "gpt-openclaw", agentRuntime: { id: "openclaw" } }, + ]); + expect(res.config?.models?.providers).not.toHaveProperty("codex"); + }); + + it("keeps conflicting shipped codex provider config for manual review", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + apiKey: "placeholder", + models: [{ id: "text-embedding-3-small" }], + }, + codex: { + auth: "oauth", + api: "openai-codex-responses", + models: [{ id: "gpt-5.6-sol", api: "openai-codex-responses" }], + }, + }, + }, + }); + + expect(res.config?.models?.providers?.codex).toEqual({ + auth: "oauth", + api: "openai-chatgpt-responses", + models: [{ id: "gpt-5.6-sol", api: "openai-chatgpt-responses" }], + }); + expect(res.config?.models?.providers?.openai).toEqual({ + apiKey: "placeholder", + models: [{ id: "text-embedding-3-small" }], + }); + expect(res.changes).toContain( + "Skipped merging models.providers.codex into models.providers.openai because provider-level defaults cannot be represented safely on merged models: models.providers.codex.auth, models.providers.openai.apiKey.", + ); + expect(collectBlockedLegacyOpenAICodexProviderWarnings(res.config)).toEqual([ + expect.stringContaining("models.providers.codex cannot be merged automatically"), + ]); + expect(collectBlockedLegacyOpenAICodexProviderPlan(res.config).blockedModelIdentities).toEqual([ + expectDefined( + legacyCodexProviderIdentityKey("codex"), + "provider-default blocked namespace test invariant", + ), + ]); + }); + + it("blocks a retained openai-codex provider namespace wholesale", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + apiKey: "placeholder", + models: [{ id: "text-embedding-3-small" }], + }, + "openai-codex": { + auth: "oauth", + api: "openai-codex-responses", + models: [{ id: "gpt-5.5" }], + }, + }, + }, + }); + + expect(res.config?.models?.providers).toHaveProperty("openai-codex"); + expect(collectBlockedLegacyOpenAICodexProviderPlan(res.config).blockedModelIdentities).toEqual([ + expectDefined( + legacyCodexProviderIdentityKey("openai-codex"), + "openai-codex blocked namespace test invariant", + ), + ]); + }); + + it("keeps non-equivalent same-id shipped codex models for manual review", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + apiKey: "placeholder", + models: [ + { + id: "gpt-5.6-sol", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ], + }, + codex: { + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.6-sol" }, { id: "gpt-5.4-mini" }], + }, + }, + }, + }); + + expect(res.config?.models?.providers?.codex).toEqual({ + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.6-sol" }, { id: "gpt-5.4-mini" }], + }); + expect(res.config?.models?.providers?.openai?.models).toEqual([ + { + id: "gpt-5.6-sol", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + ]); + expect(res.changes).toContain( + "Skipped merging models.providers.codex into models.providers.openai because colliding model definitions differ for: gpt-5.6-sol.", + ); + expect(collectBlockedLegacyOpenAICodexProviderWarnings(res.config)).toEqual([ + expect.stringContaining("colliding model definitions differ for: gpt-5.6-sol"), + ]); + expect(collectBlockedLegacyOpenAICodexProviderPlan(res.config).blockedModelIdentities).toEqual([ + expectDefined( + legacyCodexProviderIdentityKey("codex"), + "blocked provider namespace test invariant", + ), + ]); + }); + + it("removes equivalent same-id shipped codex models", () => { + const res = migrateLegacyConfigForTest({ + models: { + providers: { + openai: { + models: [ + { + id: "gpt-5.6-sol", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + agentRuntime: { id: "codex" }, + }, + ], + }, + codex: { + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + models: [{ id: "gpt-5.6-sol" }], + }, + }, + }, + }); + + expect(res.config?.models?.providers).not.toHaveProperty("codex"); + expect(res.config?.models?.providers?.openai?.models).toEqual([ + { + id: "gpt-5.6-sol", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + agentRuntime: { id: "codex" }, + }, + ]); + expect(res.changes).toContain( + "Removed models.providers.codex because models.providers.openai already exists.", + ); + }); + it("records removal when canonical OpenAI provider already exists", () => { const res = migrateLegacyConfigForTest({ models: { @@ -226,7 +460,13 @@ describe("legacy memory search config migrate", () => { openai: { api: "openai-chatgpt-responses", baseUrl: "https://api.openai.com/v1", - models: [{ id: "gpt-5.5" }], + models: [ + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + ], }, "openai-codex": { api: "openai-codex-responses", @@ -242,7 +482,11 @@ describe("legacy memory search config migrate", () => { expect((openai?.models as unknown[])?.length).toBe(2); expect(openai?.models).toEqual( expect.arrayContaining([ - { id: "gpt-5.5" }, + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, { id: "gpt-5.4", api: "openai-chatgpt-responses", @@ -577,7 +821,18 @@ describe("legacy memory search config migrate", () => { openai: { api: "openai-chatgpt-responses", baseUrl: "https://api.openai.com/v1", - models: [{ id: "gpt-5.5" }, { id: "gpt-5.4" }], + models: [ + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + { + id: "gpt-5.4", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + ], }, "openai-codex": { api: "openai-codex-responses", @@ -590,7 +845,18 @@ describe("legacy memory search config migrate", () => { const openai = res.config?.models?.providers?.openai as Record | undefined; // All legacy models are already present; canonical provider unchanged - expect(openai?.models).toEqual([{ id: "gpt-5.5" }, { id: "gpt-5.4" }]); + expect(openai?.models).toEqual([ + { + id: "gpt-5.5", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + { + id: "gpt-5.4", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api", + }, + ]); expect(res.config?.models?.providers).not.toHaveProperty("openai-codex"); expect(res.changes).toContain( "Removed models.providers.openai-codex because models.providers.openai already exists.", diff --git a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts index 62d05c84095b..cf24434969a7 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.runtime.models.ts @@ -1,6 +1,7 @@ // Legacy model runtime config migrations for stale model refs, compat fields, and catalog data. import { isDeepStrictEqual } from "node:util"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { normalizeOptionalAgentRuntimeId } from "../../../agents/agent-runtime-id.js"; import { splitTrailingAuthProfile } from "../../../agents/model-ref-profile.js"; import { defineLegacyConfigMigration, @@ -11,6 +12,11 @@ import { } from "../../../config/legacy.shared.js"; import { isModelThinkingFormat, type ModelDefinitionConfig } from "../../../config/types.models.js"; import { isBlockedObjectKey } from "../../../infra/prototype-keys.js"; +import { + isLegacyCodexProviderId, + legacyCodexProviderIdentityKey, + type LegacyCodexModelIdentity, +} from "./codex-route-model-ref.js"; import { isLegacyModelsAddCodexMetadataModel } from "./legacy-models-add-metadata.js"; const STALE_CONTEXT_WINDOW_FIXES: Record = { @@ -1043,7 +1049,6 @@ function rewriteKnownModelRefs( const RETIRED_MODEL_REF_MESSAGE = 'Configured retired model refs are no longer in the bundled catalogs; run "openclaw doctor --fix" to upgrade them.'; -const LEGACY_OPENAI_CODEX_PROVIDER_ID = "openai-codex"; const LEGACY_OPENAI_CODEX_RESPONSES_API = "openai-codex-responses"; const OPENAI_PROVIDER_ID = "openai"; const OPENAI_CHATGPT_RESPONSES_API = "openai-chatgpt-responses"; @@ -1128,11 +1133,12 @@ function hasOwnDefinedProperty(record: Record, key: string): bo function collectModelMergeBlockers(params: { canonical: Record; legacy: Record; + legacyProviderId: string; }): string[] { const blockers: string[] = []; for (const key of MODEL_UNSCOPED_PROVIDER_DEFAULT_KEYS) { if (hasOwnDefinedProperty(params.legacy, key)) { - blockers.push(`models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID}.${key}`); + blockers.push(`models.providers.${params.legacyProviderId}.${key}`); } } for (const key of CANONICAL_PROVIDER_MODEL_LEAK_KEYS) { @@ -1194,13 +1200,21 @@ function hasAutoFixableLegacyOpenAICodexProvider(providersValue: unknown): boole const canonicalEntry = getCanonicalOpenAIProviderEntry(providers); for (const [providerId, providerValue] of Object.entries(providers)) { const provider = getRecord(providerValue); - if (!provider || normalizeProviderId(providerId) !== LEGACY_OPENAI_CODEX_PROVIDER_ID) { + if (!provider || !isLegacyCodexProviderId(providerId)) { continue; } const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, []); if (normalized.changed || !canonicalEntry) { return true; } + const modelCollisions = collectNonEquivalentLegacyOpenAIModelCollisions({ + canonical: canonicalEntry.value, + legacy: normalized.value, + legacyProviderId: providerId, + }); + if (modelCollisions.length > 0) { + continue; + } const modelsToMerge = getMergeableLegacyOpenAIModels({ canonical: canonicalEntry.value, legacy: normalized.value, @@ -1211,6 +1225,7 @@ function hasAutoFixableLegacyOpenAICodexProvider(providersValue: unknown): boole const mergeBlockers = collectModelMergeBlockers({ canonical: canonicalEntry.value, legacy: normalized.value, + legacyProviderId: providerId, }); if (mergeBlockers.length === 0) { return true; @@ -1219,22 +1234,43 @@ function hasAutoFixableLegacyOpenAICodexProvider(providersValue: unknown): boole return false; } -export function collectBlockedLegacyOpenAICodexProviderWarnings(raw: unknown): string[] { +export type BlockedLegacyOpenAICodexProviderPlan = { + blockedModelIdentities: LegacyCodexModelIdentity[]; + warning?: string; +}; + +/** Compute the provider-merge blockers once so every doctor state repair shares the decision. */ +export function collectBlockedLegacyOpenAICodexProviderPlan( + raw: unknown, +): BlockedLegacyOpenAICodexProviderPlan { const models = getRecord(getRecord(raw)?.models); const providers = getRecord(models?.providers); const canonicalEntry = providers ? getCanonicalOpenAIProviderEntry(providers) : undefined; if (!providers || !canonicalEntry) { - return []; + return { blockedModelIdentities: [] }; } - const warnings: string[] = []; + const blockedModelIdentities = new Set(); + const warningLines: string[] = []; for (const [providerId, providerValue] of Object.entries(providers)) { const provider = getRecord(providerValue); - if (!provider || normalizeProviderId(providerId) !== LEGACY_OPENAI_CODEX_PROVIDER_ID) { + if (!provider || !isLegacyCodexProviderId(providerId)) { continue; } const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, []); - if (normalized.changed) { + const modelCollisions = collectNonEquivalentLegacyOpenAIModelCollisions({ + canonical: canonicalEntry.value, + legacy: normalized.value, + legacyProviderId: providerId, + }); + if (modelCollisions.length > 0) { + const identity = legacyCodexProviderIdentityKey(providerId); + if (identity) { + blockedModelIdentities.add(identity); + } + warningLines.push( + `- models.providers.${providerId} cannot be merged automatically into models.providers.${canonicalEntry.key} because colliding model definitions differ for: ${modelCollisions.join(", ")}.`, + ); continue; } const modelsToMerge = getMergeableLegacyOpenAIModels({ @@ -1247,20 +1283,71 @@ export function collectBlockedLegacyOpenAICodexProviderWarnings(raw: unknown): s const mergeBlockers = collectModelMergeBlockers({ canonical: canonicalEntry.value, legacy: normalized.value, + legacyProviderId: providerId, }); if (mergeBlockers.length === 0) { continue; } - warnings.push( - `models.providers.${providerId} cannot be merged automatically into models.providers.${canonicalEntry.key} because provider-level defaults cannot be represented safely on merged models: ${mergeBlockers.join(", ")}. Move the affected model/provider defaults manually before removing models.providers.${providerId}.`, + const identity = legacyCodexProviderIdentityKey(providerId); + if (identity) { + blockedModelIdentities.add(identity); + } + warningLines.push( + `- models.providers.${providerId} cannot be merged automatically into models.providers.${canonicalEntry.key} because provider-level defaults cannot be represented safely on merged models: ${mergeBlockers.join(", ")}.`, ); } - return warnings; + // Intentionally fail closed: retained legacy refs are NOT executable until + // reconciled (the live codex provider is gone, and a hidden resolver/auth + // shim is forbidden by policy). Only hand-authored models.providers.codex + // definitions can reach this state; the warning names the exact repair. + return { + blockedModelIdentities: [...blockedModelIdentities], + ...(warningLines.length > 0 + ? { + warning: [ + "Legacy Codex provider routes require manual reconciliation before matching refs can migrate.", + ...warningLines, + "- Doctor retained matching legacy refs in config, sessions, and cron. These refs will not execute until reconciled: fix the model route/auth metadata, remove the legacy provider entry, then rerun `openclaw doctor --fix`.", + ].join("\n"), + } + : {}), + }; +} + +export function collectBlockedLegacyOpenAICodexProviderWarnings(raw: unknown): string[] { + const warning = collectBlockedLegacyOpenAICodexProviderPlan(raw).warning; + return warning ? [warning] : []; +} + +function resolveMovedCodexModelRuntime(params: { + legacyProviderId: string; + legacyProvider: Record; + model: Record; +}): Record | undefined { + if (normalizeProviderId(params.legacyProviderId) !== "codex") { + return undefined; + } + const modelRuntime = getRecord(params.model.agentRuntime); + const modelRuntimeId = normalizeOptionalAgentRuntimeId(modelRuntime?.id); + if (modelRuntimeId && modelRuntimeId !== "auto") { + return undefined; + } + if (modelRuntimeId === "auto") { + return { ...modelRuntime, id: "codex" }; + } + const providerRuntime = getRecord(params.legacyProvider.agentRuntime); + const providerRuntimeId = normalizeOptionalAgentRuntimeId(providerRuntime?.id); + // Converting provider-level auto must keep its sibling policy fields + // (e.g. fallback: "none"), matching the model-level branch above. + return providerRuntimeId && providerRuntimeId !== "auto" + ? providerRuntime + : { ...providerRuntime, id: "codex" }; } function buildMergedLegacyOpenAIModel( model: unknown, legacyProvider: Record, + legacyProviderId: string, ): unknown { const modelRecord = getRecord(model); if (!modelRecord) { @@ -1273,6 +1360,11 @@ function buildMergedLegacyOpenAIModel( const legacyApi = typeof legacyProvider.api === "string" ? legacyProvider.api : undefined; const legacyParams = getRecord(legacyProvider.params); const legacyAgentRuntime = getRecord(legacyProvider.agentRuntime); + const movedCodexRuntime = resolveMovedCodexModelRuntime({ + legacyProviderId, + legacyProvider, + model: modelRecord, + }); if (legacyBaseUrl && !modelRecord.baseUrl) { patch.baseUrl = legacyBaseUrl; @@ -1293,13 +1385,15 @@ function buildMergedLegacyOpenAIModel( patch.params = legacyParams; } } - if (legacyAgentRuntime && modelRecord.agentRuntime === undefined) { + if (movedCodexRuntime) { + patch.agentRuntime = movedCodexRuntime; + } else if (legacyAgentRuntime && modelRecord.agentRuntime === undefined) { patch.agentRuntime = legacyAgentRuntime; } if ( modelRecord.metadataSource === undefined && isLegacyModelsAddCodexMetadataModel({ - provider: LEGACY_OPENAI_CODEX_PROVIDER_ID, + provider: legacyProviderId, model: modelRecord as Partial, }) ) { @@ -1308,6 +1402,79 @@ function buildMergedLegacyOpenAIModel( return Object.keys(patch).length > 0 ? Object.assign({}, modelRecord, patch) : model; } +function collectNonEquivalentLegacyOpenAIModelCollisions(params: { + canonical: Record; + legacy: Record; + legacyProviderId: string; +}): string[] { + const canonicalModels = Array.isArray(params.canonical.models) ? params.canonical.models : []; + const legacyModels = Array.isArray(params.legacy.models) ? params.legacy.models : []; + const conflicts = new Set(); + + for (const legacyModel of legacyModels) { + const legacyRecord = getRecord(legacyModel); + const legacyId = typeof legacyRecord?.id === "string" ? legacyRecord.id : undefined; + const legacyName = typeof legacyRecord?.name === "string" ? legacyRecord.name : undefined; + if (!legacyRecord || (!legacyId && !legacyName)) { + continue; + } + const collisions = canonicalModels.filter((canonicalModel) => { + const canonicalRecord = getRecord(canonicalModel); + return legacyId ? canonicalRecord?.id === legacyId : canonicalRecord?.name === legacyName; + }); + if (collisions.length === 0) { + continue; + } + const legacyEffective = buildMergedLegacyOpenAIModel( + legacyModel, + params.legacy, + params.legacyProviderId, + ); + const definitionsMatch = collisions.every((canonicalModel) => { + const canonicalEffective = buildMergedLegacyOpenAIModel( + canonicalModel, + params.canonical, + OPENAI_PROVIDER_ID, + ); + if (!isDeepStrictEqual(canonicalEffective, legacyEffective)) { + return false; + } + return MODEL_UNSCOPED_PROVIDER_DEFAULT_KEYS.every((key) => + isDeepStrictEqual(params.canonical[key], params.legacy[key]), + ); + }); + if (!definitionsMatch) { + conflicts.add(legacyId ?? legacyName ?? "unknown"); + } + } + + return [...conflicts]; +} + +function prepareLegacyCodexProviderForCanonicalMove( + providerId: string, + provider: Record, +): Record { + if (normalizeProviderId(providerId) !== "codex" || !Array.isArray(provider.models)) { + return provider; + } + return { + ...provider, + models: provider.models.map((model) => { + const record = getRecord(model); + if (!record) { + return model; + } + const agentRuntime = resolveMovedCodexModelRuntime({ + legacyProviderId: providerId, + legacyProvider: provider, + model: record, + }); + return agentRuntime ? { ...record, agentRuntime } : model; + }), + }; +} + function migrateLegacyOpenAICodexProvider(raw: Record, changes: string[]): void { const models = getRecord(raw.models); const providers = getRecord(models?.providers); @@ -1323,7 +1490,7 @@ function migrateLegacyOpenAICodexProvider(raw: Record, changes: } const normalized = normalizeLegacyOpenAIResponsesApi(providerId, provider, changes); - if (normalizeProviderId(providerId) !== LEGACY_OPENAI_CODEX_PROVIDER_ID) { + if (!isLegacyCodexProviderId(providerId)) { if (normalized.changed) { providers[providerId] = normalized.value; providersChanged = true; @@ -1332,9 +1499,12 @@ function migrateLegacyOpenAICodexProvider(raw: Record, changes: } if (!hasCanonicalOpenAIProvider(providers)) { - providers[OPENAI_PROVIDER_ID] = normalized.value; + providers[OPENAI_PROVIDER_ID] = prepareLegacyCodexProviderForCanonicalMove( + providerId, + normalized.value, + ); changes.push( - `Moved models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} → models.providers.${OPENAI_PROVIDER_ID}.`, + `Moved models.providers.${providerId} → models.providers.${OPENAI_PROVIDER_ID}.`, ); } else { // Canonical openai provider already exists. Merge non-conflicting model @@ -1347,20 +1517,31 @@ function migrateLegacyOpenAICodexProvider(raw: Record, changes: const canonicalModels: unknown[] = Array.isArray(canonical.models) ? (canonical.models as unknown[]) : []; + const modelCollisions = collectNonEquivalentLegacyOpenAIModelCollisions({ + canonical, + legacy: normalized.value, + legacyProviderId: providerId, + }); const modelsToMerge = getMergeableLegacyOpenAIModels({ canonical, legacy: normalized.value, }); const mergeBlockers = - modelsToMerge.length > 0 - ? collectModelMergeBlockers({ canonical, legacy: normalized.value }) + modelCollisions.length === 0 && modelsToMerge.length > 0 + ? collectModelMergeBlockers({ + canonical, + legacy: normalized.value, + legacyProviderId: providerId, + }) : []; - if (mergeBlockers.length > 0) { + if (modelCollisions.length > 0 || mergeBlockers.length > 0) { if (normalized.changed) { providers[providerId] = normalized.value; providersChanged = true; changes.push( - `Skipped merging models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} into models.providers.${OPENAI_PROVIDER_ID} because provider-level defaults cannot be represented safely on merged models: ${mergeBlockers.join(", ")}.`, + modelCollisions.length > 0 + ? `Skipped merging models.providers.${providerId} into models.providers.${OPENAI_PROVIDER_ID} because colliding model definitions differ for: ${modelCollisions.join(", ")}.` + : `Skipped merging models.providers.${providerId} into models.providers.${OPENAI_PROVIDER_ID} because provider-level defaults cannot be represented safely on merged models: ${mergeBlockers.join(", ")}.`, ); } continue; @@ -1368,7 +1549,9 @@ function migrateLegacyOpenAICodexProvider(raw: Record, changes: // Stamp model-scoped legacy provider defaults onto each merged model so it // keeps the Codex endpoint and runtime metadata instead of inheriting the // canonical provider's OpenAI platform defaults. - const stamped = modelsToMerge.map((m) => buildMergedLegacyOpenAIModel(m, normalized.value)); + const stamped = modelsToMerge.map((m) => + buildMergedLegacyOpenAIModel(m, normalized.value, providerId), + ); if (stamped.length > 0) { providers[canonicalKey] = { ...canonical, models: [...canonicalModels, ...stamped] }; const mergedIds = stamped @@ -1382,11 +1565,11 @@ function migrateLegacyOpenAICodexProvider(raw: Record, changes: }) .join(", "); changes.push( - `Merged ${stamped.length} model(s) from models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} into models.providers.${OPENAI_PROVIDER_ID}: ${mergedIds}.`, + `Merged ${stamped.length} model(s) from models.providers.${providerId} into models.providers.${OPENAI_PROVIDER_ID}: ${mergedIds}.`, ); } else { changes.push( - `Removed models.providers.${LEGACY_OPENAI_CODEX_PROVIDER_ID} because models.providers.${OPENAI_PROVIDER_ID} already exists.`, + `Removed models.providers.${providerId} because models.providers.${OPENAI_PROVIDER_ID} already exists.`, ); } } @@ -1416,13 +1599,13 @@ const RETIRED_MODEL_REF_RULES: LegacyConfigRule[] = [ /** Legacy config migration specs for model/provider runtime config compatibility. */ export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_MODELS: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ - id: "models.providers.openai-codex->models.providers.openai", - describe: "Move legacy OpenAI Codex provider config to canonical OpenAI provider config", + id: "models.providers.codex-routes->models.providers.openai", + describe: "Move legacy Codex-route provider config to canonical OpenAI provider config", legacyRules: [ { path: ["models", "providers"], message: - 'models.providers.openai-codex is legacy; run "openclaw doctor --fix" to move it to models.providers.openai.', + 'models.providers.codex and models.providers.openai-codex are legacy; run "openclaw doctor --fix" to move them to models.providers.openai.', match: (value) => hasAutoFixableLegacyOpenAICodexProvider(value), }, { diff --git a/src/commands/doctor/shared/legacy-models-add-metadata.ts b/src/commands/doctor/shared/legacy-models-add-metadata.ts index 0eb70c69dffa..e7b59565f42c 100644 --- a/src/commands/doctor/shared/legacy-models-add-metadata.ts +++ b/src/commands/doctor/shared/legacy-models-add-metadata.ts @@ -14,7 +14,8 @@ export function isLegacyModelsAddCodexMetadataModel(params: { model: Partial | undefined; }): boolean { const model = params.model; - if (normalizeProviderId(params.provider) !== "openai-codex" || !model) { + const provider = normalizeProviderId(params.provider); + if ((provider !== "codex" && provider !== "openai-codex") || !model) { return false; } const id = model.id?.trim().toLowerCase(); diff --git a/src/commands/doctor/shared/legacy-runtime-model-providers.ts b/src/commands/doctor/shared/legacy-runtime-model-providers.ts index 45c19e7b02be..a3075557075e 100644 --- a/src/commands/doctor/shared/legacy-runtime-model-providers.ts +++ b/src/commands/doctor/shared/legacy-runtime-model-providers.ts @@ -21,7 +21,7 @@ const LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES = [ provider: "openai", runtime: "codex", cli: false, - requiresRuntimePolicy: false, + requiresRuntimePolicy: true, }, { legacyProvider: "codex-cli", diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 4bcfb005453b..9f7db927886f 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -21,6 +21,7 @@ import type { import { collectChannelRouteTargets } from "../../../routing/channel-route-targets.js"; import { createLazyImportLoader } from "../../../shared/lazy-promise.js"; import { VERSION_BOUND_RUNTIME_PLUGIN_POLICY_IDS_BY_SURFACE } from "./configured-runtime-plugin-installs.js"; +import type { BlockedLegacyOpenAICodexProviderPlan } from "./legacy-config-migrations.runtime.models.js"; import { resolveDoctorPrimaryModelRef } from "./primary-model-ref.js"; type ChannelDoctorModule = typeof import("./channel-doctor.js"); @@ -708,6 +709,7 @@ export async function collectDoctorPreviewNotes(params: { doctorFixCommand: string; env?: NodeJS.ProcessEnv; allowExec?: boolean; + blockedCodexProviderPlan?: BlockedLegacyOpenAICodexProviderPlan; }): Promise { const infoNotes: string[] = []; const warnings: string[] = []; @@ -718,10 +720,6 @@ export async function collectDoctorPreviewNotes(params: { warnings.push(...collectVisibleReplyToolPolicyWarnings(params.cfg)); warnings.push(...collectChannelBoundMessageToolPolicyWarnings(params.cfg)); warnings.push(...collectProfileConfiguredToolSectionWarnings(params.cfg)); - const { collectBlockedLegacyOpenAICodexProviderWarnings } = - await import("./legacy-config-migrations.runtime.models.js"); - warnings.push(...collectBlockedLegacyOpenAICodexProviderWarnings(params.cfg)); - const { collectActiveToolSchemaProjectionWarnings } = await import("./active-tool-schema-warnings.js"); warnings.push(...collectActiveToolSchemaProjectionWarnings({ cfg: params.cfg, env })); @@ -790,10 +788,16 @@ export async function collectDoctorPreviewNotes(params: { } } - if (hasPluginConfig) { - const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js"); - warnings.push(...collectCodexRouteWarnings({ cfg: params.cfg, env })); + const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js"); + warnings.push( + ...collectCodexRouteWarnings({ + cfg: params.cfg, + env, + blockedProviderPlan: params.blockedCodexProviderPlan, + }), + ); + if (hasPluginConfig) { const { collectContextEngineHostCompatibilityWarnings } = await import("./context-engine-host-compat.js"); warnings.push( diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index 3ac6e247e1a7..23eed4ce8d57 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -250,57 +250,6 @@ describe("appendConfiguredRows", () => { }); describe("appendProviderCatalogRows", () => { - it("can skip runtime model-suppression hooks for provider-catalog fast paths", async () => { - const rows: ModelRow[] = []; - - await appendProviderCatalogRows({ - rows, - seenKeys: new Set(), - catalogModels: [ - { - id: "gpt-5.5", - name: "gpt-5.5", - provider: "codex", - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api", - input: ["text"], - reasoning: false, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 8192, - maxTokens: 4096, - }, - ], - context: { - cfg: { - agents: { defaults: { model: { primary: "codex/gpt-5.5" } } }, - models: { providers: {} }, - }, - agentDir: "/tmp/openclaw-agent", - authIndex, - configuredByKey: new Map(), - discoveredKeys: new Set(), - filter: { provider: "codex", local: false }, - skipRuntimeModelSuppression: true, - }, - }); - - expect(mocks.shouldSuppressBuiltInModel).not.toHaveBeenCalled(); - expect(mocks.shouldSuppressBuiltInModelFromManifest).toHaveBeenCalledWith({ - provider: "codex", - id: "gpt-5.5", - baseUrl: "https://chatgpt.com/backend-api", - config: { - agents: { defaults: { model: { primary: "codex/gpt-5.5" } } }, - models: { providers: {} }, - }, - }); - expect(mocks.normalizeProviderResolvedModelWithPlugin).not.toHaveBeenCalled(); - const row = requireOnlyRow(rows); - expect(row.key).toBe("codex/gpt-5.5"); - expect(row.available).toBe(true); - expect(row.missing).toBe(false); - }); - it("applies manifest suppression when runtime model-suppression hooks are skipped", async () => { mocks.shouldSuppressBuiltInModelFromManifest.mockReturnValueOnce(true); const rows: ModelRow[] = []; diff --git a/src/config/plugin-auto-enable.core.test.ts b/src/config/plugin-auto-enable.core.test.ts index 3d3d7e24f74b..9eaef8a3440e 100644 --- a/src/config/plugin-auto-enable.core.test.ts +++ b/src/config/plugin-auto-enable.core.test.ts @@ -512,24 +512,6 @@ describe("applyPluginAutoEnable core", () => { expect(result.changes).toContain("firecrawl web fetch configured, enabled automatically."); }); - it("auto-enables an opt-in provider plugin when an explicit provider model is configured", () => { - const result = applyPluginAutoEnable({ - config: { - agents: { - defaults: { - model: "codex/gpt-5.4", - }, - }, - }, - env, - manifestRegistry: makeRegistry([{ id: "codex", channels: [], providers: ["codex"] }]), - }); - - expect(result.config.plugins?.entries?.codex?.enabled).toBe(true); - expect(result.config.plugins?.allow).toBeUndefined(); - expect(result.changes).toContain("codex/gpt-5.4 model configured, enabled automatically."); - }); - it("auto-enables provider plugins referenced by media generation model fallbacks", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index cb548d7d8831..5e74fb4657c1 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -112,6 +112,10 @@ const mocks = vi.hoisted(() => ({ async (): Promise => undefined, ), maybeRepairLegacyCronStore: vi.fn().mockResolvedValue(undefined), + repairCronCodexModelRefsAfterConfigWrite: vi.fn().mockResolvedValue({ + changes: [], + warnings: [], + }), noteLegacyWhatsAppCrontabHealthCheck: vi.fn().mockResolvedValue(undefined), scanConfiguredChannelPluginBlockers: vi.fn( (): Array<{ channelId: string; pluginId: string; reason: string }> => [], @@ -404,6 +408,7 @@ vi.mock("../commands/doctor/cron/index.js", () => ({ collectLegacyCronStoreHealthFindings: mocks.collectLegacyCronStoreHealthFindings, collectLegacyWhatsAppCrontabHealthWarning: mocks.collectLegacyWhatsAppCrontabHealthWarning, maybeRepairLegacyCronStore: mocks.maybeRepairLegacyCronStore, + repairCronCodexModelRefsAfterConfigWrite: mocks.repairCronCodexModelRefsAfterConfigWrite, noteLegacyWhatsAppCrontabHealthCheck: mocks.noteLegacyWhatsAppCrontabHealthCheck, })); @@ -662,6 +667,11 @@ describe("doctor health contributions", () => { mocks.collectLegacyWhatsAppCrontabHealthWarning.mockResolvedValue(undefined); mocks.maybeRepairLegacyCronStore.mockReset(); mocks.maybeRepairLegacyCronStore.mockResolvedValue(undefined); + mocks.repairCronCodexModelRefsAfterConfigWrite.mockReset(); + mocks.repairCronCodexModelRefsAfterConfigWrite.mockResolvedValue({ + changes: [], + warnings: [], + }); mocks.noteLegacyWhatsAppCrontabHealthCheck.mockReset(); mocks.noteLegacyWhatsAppCrontabHealthCheck.mockResolvedValue(undefined); mocks.scanConfiguredChannelPluginBlockers.mockReset(); @@ -3281,6 +3291,63 @@ describe("doctor health contributions", () => { expect(runtime.log).not.toHaveBeenCalled(); }); + it("does not commit deferred cron migration when the config write fails", async () => { + const cfg = { agents: { defaults: { models: {} } } } as OpenClawConfig; + mocks.replaceConfigFile.mockRejectedValueOnce(new Error("config write failed")); + const ctx = { + cfg, + cfgForPersistence: cfg, + configResult: { + cfg, + shouldWriteConfig: true, + shouldRepairCronCodexModelRefsAfterConfigWrite: true, + blockedCodexModelIdentities: ["codex\u0000gpt-5.6-sol"], + }, + configPath: "/tmp/fake-openclaw.json", + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + env: {}, + } as DoctorContributionRunContext; + + await expect(requireDoctorContribution("doctor:write-config").run(ctx)).rejects.toThrow( + "config write failed", + ); + expect(mocks.repairCronCodexModelRefsAfterConfigWrite).not.toHaveBeenCalled(); + }); + + it("commits deferred cron migration after the config write succeeds", async () => { + const cfg = { agents: { defaults: { models: {} } } } as OpenClawConfig; + const ctx = { + cfg, + cfgForPersistence: cfg, + configResult: { + cfg, + shouldWriteConfig: true, + shouldRepairCronCodexModelRefsAfterConfigWrite: true, + blockedCodexModelIdentities: ["codex\u0000gpt-5.6-sol"], + }, + configPath: "/tmp/fake-openclaw.json", + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: {}, + env: {}, + } as DoctorContributionRunContext; + + await requireDoctorContribution("doctor:write-config").run(ctx); + + expect(mocks.replaceConfigFile).toHaveBeenCalledOnce(); + expect(mocks.repairCronCodexModelRefsAfterConfigWrite).toHaveBeenCalledWith({ + cfg, + blockedModelIdentities: new Set(["codex\u0000gpt-5.6-sol"]), + }); + expect(mocks.replaceConfigFile.mock.invocationCallOrder[0]).toBeLessThan( + mocks.repairCronCodexModelRefsAfterConfigWrite.mock.invocationCallOrder[0] ?? 0, + ); + }); + describe("config size drops during update", () => { beforeEach(() => { mocks.replaceConfigFile.mockReset(); diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 57ec76cb123c..67f9ed437ff0 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -29,6 +29,8 @@ type DoctorConfigResult = { sourceLastTouchedVersion?: string; skipPluginValidationOnWrite?: boolean; preservedLegacyRootKeys?: readonly string[]; + shouldRepairCronCodexModelRefsAfterConfigWrite?: boolean; + blockedCodexModelIdentities?: readonly string[]; }; export type DoctorHealthFlowContext = { @@ -652,6 +654,9 @@ async function runCodexSessionRouteHealth(ctx: DoctorHealthFlowContext): Promise cfg: ctx.cfg, env: ctx.env ?? process.env, shouldRepair: ctx.prompter.shouldRepair, + ...(ctx.configResult.blockedCodexModelIdentities?.length + ? { blockedModelIdentities: new Set(ctx.configResult.blockedCodexModelIdentities) } + : {}), }); if (result.changes.length > 0) { note(result.changes.join("\n"), "Doctor changes"); @@ -1329,6 +1334,28 @@ async function runWriteConfigHealth(ctx: DoctorHealthFlowContext): Promise ); } } + if (ctx.configResult.shouldRepairCronCodexModelRefsAfterConfigWrite === true) { + // Two-phase safety: replaceConfigFile above persists ctx.cfg itself + // (cfgForPersistence is only the flow-start change-detection clone), and + // when no write was scheduled ctx.cfg still mirrors disk because the + // required runtime policy was already persisted. Either way ctx.cfg is the + // durable config, so the cron rewrite below validates against real state. + const { repairCronCodexModelRefsAfterConfigWrite } = + await import("../commands/doctor/cron/index.js"); + const result = await repairCronCodexModelRefsAfterConfigWrite({ + cfg: ctx.cfg, + ...(ctx.configResult.blockedCodexModelIdentities?.length + ? { blockedModelIdentities: new Set(ctx.configResult.blockedCodexModelIdentities) } + : {}), + }); + const { note } = await loadNoteModule(); + if (result.changes.length > 0) { + note(result.changes.join("\n"), "Doctor changes"); + } + if (result.warnings.length > 0) { + note(result.warnings.join("\n"), "Doctor warnings"); + } + } } async function collectWriteConfigHealthFindings( diff --git a/src/gateway/server-methods/models-list-result.openai-routes.test.ts b/src/gateway/server-methods/models-list-result.openai-routes.test.ts index 8364e3b60574..6bd26765a95f 100644 --- a/src/gateway/server-methods/models-list-result.openai-routes.test.ts +++ b/src/gateway/server-methods/models-list-result.openai-routes.test.ts @@ -15,6 +15,8 @@ const WITHOUT_OPENAI_ENV_AUTH = { OPENAI_OAUTH_TOKEN: undefined, CHATGPT_OAUTH_TOKEN: undefined, } as const; +const IMPLICIT_CODEX_RUNTIME = { id: "codex", source: "implicit" } as const; +const IMPLICIT_OPENCLAW_RUNTIME = { id: "openclaw", source: "implicit" } as const; function catalogEntry(id: string, api: ModelCatalogEntry["api"]): ModelCatalogEntry { return { id, name: id, provider: "openai", api }; @@ -45,18 +47,21 @@ describe("models.list OpenAI routes", () => { it("keeps route-aware default browse indeterminate without the provider artifact", async () => { const resolveRoutes = vi.fn(() => null); const createResolver = vi.fn(() => resolveRoutes); - await withEnvAsync({ ...WITHOUT_OPENAI_ENV_AUTH, OPENAI_API_KEY: "test-key" }, async () => { - await expect( - listModels({ - view: "default", - catalog: [ - catalogEntry("gpt-5.5", "openai-responses"), - catalogEntry("gpt-5.6", "openai-responses"), - ], - routeResolverFactory: createResolver, - }), - ).resolves.toEqual({ models: [] }); - }); + await withEnvAsync( + { ...WITHOUT_OPENAI_ENV_AUTH, OPENAI_API_KEY: "test-token-placeholder" }, + async () => { + await expect( + listModels({ + view: "default", + catalog: [ + catalogEntry("gpt-5.5", "openai-responses"), + catalogEntry("gpt-5.6", "openai-responses"), + ], + routeResolverFactory: createResolver, + }), + ).resolves.toEqual({ models: [] }); + }, + ); expect(createResolver).toHaveBeenCalledOnce(); expect(resolveRoutes).toHaveBeenCalledTimes(2); }); @@ -92,6 +97,7 @@ describe("models.list OpenAI routes", () => { id: "gpt-5.4-codex", name: "gpt-5.4-codex", provider: "openai", + agentRuntime: IMPLICIT_CODEX_RUNTIME, available: false, }, ], @@ -119,7 +125,15 @@ describe("models.list OpenAI routes", () => { } as ModelCatalogEntry; await expect(listModels({ catalog: [row], routeResolverFactory })).resolves.toEqual({ - models: [{ id: "gpt-5.6", name: "gpt-5.6", provider: "openai", available: false }], + models: [ + { + id: "gpt-5.6", + name: "gpt-5.6", + provider: "openai", + agentRuntime: IMPLICIT_CODEX_RUNTIME, + available: false, + }, + ], }); }); @@ -160,6 +174,7 @@ describe("models.list OpenAI routes", () => { id: "gpt-5.4-nano", name: "GPT-5.4 Nano", provider: "openai", + agentRuntime: IMPLICIT_OPENCLAW_RUNTIME, contextWindow: 1_000_000, reasoning: true, available: true, @@ -194,8 +209,20 @@ describe("models.list OpenAI routes", () => { }), ).resolves.toEqual({ models: [ - { id: "chat-latest", name: "chat-latest", provider: "openai", available: false }, - { id: "gpt-5.6", name: "GPT-5.6", provider: "openai", available: false }, + { + id: "chat-latest", + name: "chat-latest", + provider: "openai", + agentRuntime: IMPLICIT_OPENCLAW_RUNTIME, + available: false, + }, + { + id: "gpt-5.6", + name: "GPT-5.6", + provider: "openai", + agentRuntime: IMPLICIT_OPENCLAW_RUNTIME, + available: false, + }, ], }); @@ -206,7 +233,15 @@ describe("models.list OpenAI routes", () => { catalog: [catalogEntry("gpt-5.6", "openai-chatgpt-responses"), incompatibleRow], }), ).resolves.toEqual({ - models: [{ id: "gpt-5.6", name: "GPT-5.6", provider: "openai", available: false }], + models: [ + { + id: "gpt-5.6", + name: "GPT-5.6", + provider: "openai", + agentRuntime: IMPLICIT_OPENCLAW_RUNTIME, + available: false, + }, + ], }); }); it("uses auth.order to project one logical route and its capabilities", async () => { @@ -251,6 +286,7 @@ describe("models.list OpenAI routes", () => { id: "gpt-5.5", name: "gpt-5.5", provider: "openai", + agentRuntime: IMPLICIT_CODEX_RUNTIME, available: true, }, ], @@ -271,6 +307,7 @@ describe("models.list OpenAI routes", () => { id: "gpt-5.5", name: "gpt-5.5", provider: "openai", + agentRuntime: IMPLICIT_CODEX_RUNTIME, contextWindow: 400_000, reasoning: true, available: true, @@ -309,6 +346,7 @@ describe("models.list OpenAI routes", () => { id: "gpt-5.5", name: "GPT-5.5", provider: "openai", + agentRuntime: IMPLICIT_CODEX_RUNTIME, contextWindow: 400_000, reasoning: true, input: ["text", "video"], @@ -330,6 +368,7 @@ describe("models.list OpenAI routes", () => { id: "gpt-5.5", name: "gpt-5.5", provider: "openai", + agentRuntime: IMPLICIT_CODEX_RUNTIME, contextWindow: 1_000_000, reasoning: true, available: true, @@ -366,6 +405,7 @@ describe("models.list OpenAI routes", () => { id: "gpt-5.6", name: "GPT-5.6", provider: "openai", + agentRuntime: IMPLICIT_OPENCLAW_RUNTIME, available: false, }, ], @@ -411,6 +451,7 @@ describe("models.list OpenAI routes", () => { id: "chat-latest", name: "chat-latest", provider: "openai", + agentRuntime: IMPLICIT_OPENCLAW_RUNTIME, available: false, }); }, @@ -456,10 +497,42 @@ describe("models.list OpenAI routes", () => { name: "chat-latest", provider: "openai", alias: "fast", + agentRuntime: IMPLICIT_OPENCLAW_RUNTIME, available: false, }, ], }); }); }); + + it("exposes configured runtime intent independently of route execution", async () => { + const cfg = { + agents: { + defaults: { + models: { + "openai/gpt-5.4-nano": { + agentRuntime: { id: "codex" }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + await withEnvAsync( + { ...WITHOUT_OPENAI_ENV_AUTH, OPENAI_API_KEY: "test-token-placeholder" }, + async () => { + const result = await listModels({ + cfg, + catalog: [catalogEntry("gpt-5.4-nano", "openai-responses")], + }); + + expect(result.models).toContainEqual( + expect.objectContaining({ + id: "gpt-5.4-nano", + agentRuntime: { id: "codex", source: "model" }, + }), + ); + }, + ); + }); }); diff --git a/src/gateway/server-methods/models-list-result.ts b/src/gateway/server-methods/models-list-result.ts index ddc3a6122f18..a05abbc77d68 100644 --- a/src/gateway/server-methods/models-list-result.ts +++ b/src/gateway/server-methods/models-list-result.ts @@ -9,6 +9,7 @@ import { } from "../../agents/agent-scope.js"; import { loadAuthProfileStoreWithoutExternalProfiles } from "../../agents/auth-profiles.js"; import { DEFAULT_PROVIDER } from "../../agents/defaults.js"; +import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; import { createModelAuthAvailabilityResolver, type ModelAuthAvailability, @@ -47,6 +48,7 @@ import { getRuntimeConfigSourceSnapshot } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadPluginRegistrySnapshotWithMetadata } from "../../plugins/plugin-registry.js"; import { resolveManifestProviderAuthChoices } from "../../plugins/provider-auth-choices.js"; +import type { GatewayAgentRuntime } from "../../shared/session-types.js"; import type { GatewayRequestContext } from "./types.js"; type ModelsListView = ModelCatalogBrowseView; @@ -54,7 +56,10 @@ type ModelsListEntry = Pick< ModelCatalogEntry, "alias" | "contextWindow" | "id" | "input" | "name" | "provider" | "reasoning" > & { available?: boolean }; -type ModelsListEntryWithCapabilities = ModelsListEntry & { apiKeySupported?: boolean }; +type ModelsListEntryWithCapabilities = ModelsListEntry & { + agentRuntime?: GatewayAgentRuntime; + apiKeySupported?: boolean; +}; type ApiKeyProviderCapabilities = { providers: ReadonlyMap; resolveProvider(provider: string): string; @@ -75,8 +80,8 @@ function resolvePositiveSafeInteger(value: unknown): number | undefined { return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; } -// Project explicitly onto the public protocol shape. Route, base URL, auth, -// runtime, and cost facts stay private to server-side selection. +// Project explicitly onto the public protocol shape. Concrete route, base URL, +// auth, and cost facts stay private; runtime intent is attached separately. function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry { const contextWindow = resolvePositiveSafeInteger(entry.contextWindow); return { @@ -89,6 +94,28 @@ function buildPublicModelProjection(entry: ModelCatalogEntry): ModelsListEntry { }; } +function resolveModelChoiceAgentRuntime(params: { + cfg: OpenClawConfig; + agentId: string; + entry: ModelCatalogEntry; +}): GatewayAgentRuntime | undefined { + const harnessPolicy = resolveAgentHarnessPolicy({ + provider: params.entry.provider, + modelId: params.entry.id, + modelApi: params.entry.api, + modelBaseUrl: params.entry.baseUrl, + config: params.cfg, + agentId: params.agentId, + }); + if (harnessPolicy.runtime === "auto") { + return undefined; + } + return { + id: harnessPolicy.runtime, + source: harnessPolicy.runtimeSource ?? "implicit", + }; +} + function listEnabledSyntheticAuthProviderRefs(params: { cfg: OpenClawConfig; workspaceDir: string; @@ -349,6 +376,7 @@ export function createGatewayAgentModelCatalogProjector(params: { async function buildPublicModelsListEntries(params: { catalog: ModelCatalogEntry[]; cfg: OpenClawConfig; + agentId: string; evaluateEntry(entry: ModelCatalogEntry): Promise; includeInput?: boolean; preserveUnknownAvailability?: boolean; @@ -367,8 +395,14 @@ async function buildPublicModelsListEntries(params: { // Legacy views keep emitting a boolean because existing clients treat // omission as selectable. Inventory consumers preserve unknown state. const capabilityProvider = params.apiKeyCapabilities?.resolveProvider(entry.provider); + const agentRuntime = resolveModelChoiceAgentRuntime({ + cfg: params.cfg, + agentId: params.agentId, + entry, + }); return { ...publicEntry, + ...(agentRuntime ? { agentRuntime } : {}), ...(capabilityProvider && params.apiKeyCapabilities?.providers.has(capabilityProvider) ? { apiKeySupported: params.apiKeyCapabilities.providers.get(capabilityProvider) === true, @@ -472,6 +506,7 @@ export async function buildModelsListResult(params: { models: await buildPublicModelsListEntries({ catalog: inventory, cfg, + agentId, evaluateEntry: inventoryProjector.evaluateEntry, includeInput: true, preserveUnknownAvailability: true, @@ -534,6 +569,7 @@ export async function buildModelsListResult(params: { models: await buildPublicModelsListEntries({ catalog: models, cfg, + agentId, evaluateEntry, ...(capableProviders ? { apiKeyCapabilities: capableProviders } : {}), }), diff --git a/src/gateway/server-methods/models.test.ts b/src/gateway/server-methods/models.test.ts index 120eeb9c0d38..dd44f133411b 100644 --- a/src/gateway/server-methods/models.test.ts +++ b/src/gateway/server-methods/models.test.ts @@ -313,6 +313,7 @@ describe("models.list", () => { id: "gpt-test", name: "GPT Test", provider: "openai", + agentRuntime: { id: "openclaw", source: "implicit" }, available: false, }, ], @@ -409,7 +410,15 @@ describe("models.list", () => { expect(respond).toHaveBeenCalledWith( true, { - models: [{ id: "gpt-test", name: "GPT Test", provider: "openai", available: false }], + models: [ + { + id: "gpt-test", + name: "GPT Test", + provider: "openai", + agentRuntime: { id: "codex", source: "implicit" }, + available: false, + }, + ], }, undefined, ); @@ -489,8 +498,20 @@ describe("models.list", () => { true, { models: [ - { id: "gpt-5.4", name: "GPT-5.4 Codex", provider: "openai", available: true }, - { id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai", available: true }, + { + id: "gpt-5.4", + name: "GPT-5.4 Codex", + provider: "openai", + agentRuntime: { id: "codex", source: "implicit" }, + available: true, + }, + { + id: "gpt-codex-test", + name: "GPT Codex Test", + provider: "openai", + agentRuntime: { id: "codex", source: "implicit" }, + available: true, + }, { id: "llama-local", name: "Llama Local", provider: "vllm", available: true }, { id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true }, ], @@ -517,8 +538,20 @@ describe("models.list", () => { provider: "anthropic", available: false, }, - { id: "gpt-5.4", name: "GPT-5.4 Codex", provider: "openai", available: true }, - { id: "gpt-codex-test", name: "GPT Codex Test", provider: "openai", available: true }, + { + id: "gpt-5.4", + name: "GPT-5.4 Codex", + provider: "openai", + agentRuntime: { id: "codex", source: "implicit" }, + available: true, + }, + { + id: "gpt-codex-test", + name: "GPT Codex Test", + provider: "openai", + agentRuntime: { id: "codex", source: "implicit" }, + available: true, + }, { id: "llama-local", name: "Llama Local", provider: "vllm", available: true }, { id: "qwen-local", name: "Qwen Local", provider: "vllm", available: true }, ], @@ -645,6 +678,7 @@ describe("models.list", () => { id: "gpt-5.4", name: "GPT-5.4 Codex", provider: "openai", + agentRuntime: { id: "codex", source: "implicit" }, available: true, }, ], @@ -713,6 +747,7 @@ describe("models.list", () => { id: "claude-opus-4-8", name: "Claude Opus 4.8", provider: "anthropic", + agentRuntime: { id: "claude-cli", source: "model" }, available: true, }, ], diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 789cd237e3a0..bda8af2a95b3 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -820,6 +820,33 @@ describe("gateway session utils", () => { expect(lockedCodex.thinkingLevels?.map((level) => level.id)).not.toContain("ultra"); }); + test("reports observed locked runtime from agentHarnessId instead of configured intent", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.6-sol" }, + models: { + "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + } as OpenClawConfig; + + const row = buildGatewaySessionRow({ + cfg, + storePath: "", + store: {}, + key: "agent:main:main", + entry: { + sessionId: "observed-codex", + agentHarnessId: "codex", + modelSelectionLocked: true, + } as SessionEntry, + }); + + expect(row.agentRuntime).toEqual({ id: "codex", source: "session" }); + }); + test.each(["xhigh", "max"] as const)( "preserves catalog-less persisted %s in session change projections", (thinkingLevel) => { diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 3e1826430091..4113c2e71336 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -82,11 +82,6 @@ const BUNDLED_LIVE_CONFIG_PROVIDER_GUARDS = { "const startupPluginConfig = (api.pluginConfig ?? {})", "const currentPluginConfig = resolveCurrentPluginConfig(ctx.config);", ], - "extensions/codex/provider.ts": [ - "resolvePluginConfigObject(", - "const runtimePluginConfig = resolvePluginConfigObject(ctx.config, CODEX_PROVIDER_ID);", - "const pluginConfig = runtimePluginConfig ?? (ctx.config ? undefined : options.pluginConfig);", - ], "extensions/github-copilot/index.ts": [ "resolvePluginConfigObject(", 'const runtimePluginConfig = resolvePluginConfigObject(config, "github-copilot");', diff --git a/src/plugins/provider-thinking.types.ts b/src/plugins/provider-thinking.types.ts index 6792916e9cdb..e0b444169092 100644 --- a/src/plugins/provider-thinking.types.ts +++ b/src/plugins/provider-thinking.types.ts @@ -29,6 +29,8 @@ type ProviderThinkingModelCompat = { export type ProviderDefaultThinkingPolicyContext = ProviderThinkingPolicyContext & { /** Effective agent runtime selected for this model, when known. */ agentRuntime?: string | null; + /** API adapter id from the selected catalog route, when known. */ + api?: string | null; reasoning?: boolean; params?: Record; compat?: ProviderThinkingModelCompat | null; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 88b21d1cb437..1ad9928f8b62 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -860,6 +860,7 @@ export type ModelCatalogEntry = { available?: boolean; contextWindow?: number; reasoning?: boolean; + agentRuntime?: import("../../../packages/gateway-protocol/src/schema.js").GatewayAgentRuntime; input?: Array<"text" | "image" | "document">; apiKeySupported?: boolean; }; diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index db6bea9f0641..aa7e68db7c43 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -4488,34 +4488,6 @@ describe("chat model controls", () => { }); }); - it("shows canonical OpenAI names for legacy Codex model references", () => { - const { state } = createChatHeaderState({ - model: "gpt-5.5", - modelProvider: "codex", - thinkingDefault: "high", - models: [ - { - id: "gpt-5.5", - name: "GPT-5.5", - alias: "codex", - provider: "codex", - }, - ], - }); - const container = document.createElement("div"); - render( - renderChatModelControls({ - ...createChatModelControlsProps(state), - modelOverrides: { main: "codex/gpt-5.5" }, - }), - container, - ); - - expect( - container.querySelector(".chat-controls__inline-select-label")?.textContent?.trim(), - ).toBe("GPT-5.5 · High"); - }); - it("uses the session provider for slash-containing raw model ids without metadata", () => { const { state } = createChatHeaderState(); state.chatModelCatalog = [