refactor(codex)!: fold the codex text provider into openai with a doctor migration

The live codex text provider was a redundant projection of the openai
catalog (exclusive provider ownership; the openai plugin's ChatGPT OAuth
discovery already serves gpt-5.6-* route-aware). Folding it:

- extensions/codex no longer registers a text provider, catalog entry, or
  synthetic text auth; provider.ts/provider-catalog.ts/provider-discovery.ts
  and the route-blind model-name heuristics are deleted; the narrow
  post-harness reasoning fallback moves to an app-server-owned module
- openai thinking policy keys on explicit selected-route provenance
  (api === openai-chatgpt-responses) instead of value-shape inference
- models.list gains an optional additive agentRuntime field (configured
  intent); session agentHarnessId remains the execution proof
- doctor --fix migrates the shipped codex/* config shape end to end:
  every model slot, provider-config merge with blocker-aware conflict
  handling, sessions, cron payloads (two-phase: runtime policy persists
  before cron refs rewrite), transcripts; migrated refs carry model-scoped
  agentRuntime.id=codex preserving the shipped wizard semantics; auto
  runtime policies normalize to codex with sibling fields preserved;
  blocked provider conflicts retain the whole legacy namespace fail-closed
  with an actionable warning
- the stale openai:default profile cleanup (#91352) was deliberately
  deferred to a follow-up after review showed it needs per-agent identity
  proofs; doctor keeps warning about unusable profiles

Fixes #105561
Fixes #84637
Fixes #90420
This commit is contained in:
Peter Steinberger
2026-07-15 22:44:08 +01:00
parent 154d53c4f6
commit 3c8269ca52
75 changed files with 2845 additions and 1986 deletions
+1 -1
View File
@@ -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.<provider>` 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.
+2 -2
View File
@@ -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
{
+7 -5
View File
@@ -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.
<Note>
Fresh OpenAI setup applies a GPT-5.6 primary only when no primary model is
+2
View File
@@ -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 = [
+36 -9
View File
@@ -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<string, unknown>;
const agentHarnessRegistration = mockCallArg(registerAgentHarness) as Record<string, unknown>;
const mediaProviderRegistration = mockCallArg(registerMediaUnderstandingProvider) as
| Record<string, unknown>
@@ -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", () => {
+3 -6
View File
@@ -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<string> = 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 }),
);
@@ -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(
{
+1 -5
View File
@@ -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"],
@@ -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("<persona_latch>");
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("<persona_latch>");
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();
});
});
-20
View File
@@ -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<typeof resolveGpt5SystemPromptContribution>[0],
) {
return resolveGpt5SystemPromptContribution(params);
}
-121
View File
@@ -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<Record<string, number>> = 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<CodexAppServerModel & { contextWindow?: number }>;
/**
* 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")
);
}
-50
View File
@@ -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<string, { config?: unknown } | undefined>)?.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;
-819
View File
@@ -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<ReturnType<typeof buildCodexProviderCatalog>>,
) {
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<typeof listCodexAppServerModels>[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<string, unknown>) {
if (!value || typeof value !== "object") {
throw new Error("Expected record");
}
const actual = value as Record<string, unknown>;
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<unknown>,
): Promise<unknown> => 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<never>(() => {}),
);
const withCodexAppServerJsonClient = vi.fn(
async (
_params: unknown,
run: (request: typeof scopedRequest) => Promise<unknown>,
): Promise<unknown> => 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();
});
});
-497
View File
@@ -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<string, CodexReasoningEffort>([
["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<CodexAppServerModelListResult>;
type CodexUsageRead = {
rateLimits: unknown;
accountEmail?: string;
};
type CodexUsageReader = (options: {
timeoutMs: number;
agentDir?: string;
authProfileId?: string;
config?: Parameters<typeof requestCodexAppServerUsageLazy>[0]["config"];
startOptions?: CodexAppServerStartOptions;
}) => Promise<CodexUsageRead>;
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<CodexAppServerModel[]> {
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<CodexAppServerModelListResult> {
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<unknown>,
deadline: number,
): Promise<string | undefined> {
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<typeof setTimeout> | undefined;
const timeout = new Promise<undefined>((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);
}
@@ -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;
}
@@ -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,
@@ -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,
@@ -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,
@@ -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 {
@@ -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",
@@ -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,
});
}
@@ -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 {
+2 -2
View File
@@ -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) => {
@@ -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"],
},
+2 -2
View File
@@ -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),
@@ -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"],
},
+1
View File
@@ -545,6 +545,7 @@ export function resolveThinkingProfile(params: ProviderDefaultThinkingPolicyCont
params.modelId,
params.agentRuntime,
params.compat,
params.api,
);
default:
return null;
+34
View File
@@ -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");
});
});
+10 -4
View File
@@ -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,
});
}
@@ -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"] }],
@@ -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<typeof AgentSummarySchema>;
export type GatewayAgentRuntime = Static<typeof GatewayAgentRuntimeSchema>;
export type AgentsFileEntry = Static<typeof AgentsFileEntrySchema>;
export type AgentsCreateParams = Static<typeof AgentsCreateParamsSchema>;
export type AgentsCreateResult = Static<typeof AgentsCreateResultSchema>;
+12
View File
@@ -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({
+1
View File
@@ -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,
+42 -1
View File
@@ -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 }
: {}),
};
}
@@ -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({
+16 -1
View File
@@ -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<string[]> {
export type DoctorConfigPreflightResult = {
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
baseConfig: OpenClawConfig;
cronCodexRuntimePolicyTargets?: CronCodexRuntimePolicyTarget[];
};
function collectDoctorLegacyIssues(
@@ -245,6 +247,7 @@ export async function runDoctorConfigPreflight(
let startupMigrationHeartbeat: ReturnType<typeof setInterval> | 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) {
@@ -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<string, unknown>, 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<string, unknown>;
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", () => {
@@ -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" },
+2 -2
View File
@@ -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;
}
+84 -4
View File
@@ -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<typeof normalizeStoredCronJobs>;
migrateCodexModelRefs?: boolean;
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>;
}): Promise<LegacyCronRepairResult> {
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<LegacyCodexModelIdentity>;
}): Promise<LegacyCronRepairResult> {
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<LegacyCodexModelIdentity>;
}): Promise<LegacyCronRepairResult> {
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 {
+71 -30
View File
@@ -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<string, unknown>;
@@ -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<string, LegacyOpenAICodexCronModelRoute>();
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;
}
@@ -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<string, unknown>;
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<CronCodexRuntimePolicyTarget>;
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>;
}): {
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<string, "blocked" | "changed" | "noop">();
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<CronCodexRuntimePolicyTarget>;
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>;
}): { 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],
};
}
@@ -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<string, unknown>): Record<string, unkno
};
}
function normalizeOneJob(job: Record<string, unknown>) {
function normalizeOneJob(
job: Record<string, unknown>,
options: Parameters<typeof normalizeStoredCronJobs>[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<string, unknown>;
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<string, unknown>;
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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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";
+86 -4
View File
@@ -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<Record<CronStoreIssueKey, number>>;
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<Record<string, unknown>>,
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>,
): CronCodexRuntimePolicyTarget[] {
const targets = new Map<string, CronCodexRuntimePolicyTarget>();
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<string, unknown>)
: {};
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<string, unknown>) {
/** Normalize persisted cron jobs in place and report issues plus rows to quarantine. */
export function normalizeStoredCronJobs(
jobs: Array<Record<string, unknown>>,
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<Record<string, unknown>> = [];
const removedJobs: NormalizeCronStoreJobsResult["removedJobs"] = [];
const codexRuntimePolicyTargets = new Map<string, CronCodexRuntimePolicyTarget>();
for (const [sourceIndex, raw] of jobs.entries()) {
const jobIssues = new Set<CronStoreIssueKey>();
@@ -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,
+3
View File
@@ -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,
@@ -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<string, string>;
runtimePolicyChanges: string[];
unsupportedCompactionChanges: string[];
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>;
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,
});
}
@@ -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<string, string>;
runtimePolicyChanges: string[];
unsupportedCompactionChanges: string[];
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>;
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<LegacyCodexModelIdentity>;
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<LegacyCodexModelIdentity>;
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<LegacyCodexModelIdentity>;
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<LegacyCodexModelIdentity>;
}): 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,
});
}
@@ -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<LegacyCodexModelIdentity>;
}): 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<LegacyCodexModelIdentity>;
}): 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<LegacyCodexModelIdentity>,
): 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;
}
@@ -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<LegacyCodexModelIdentity>;
}): 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<LegacyCodexModelIdentity>;
}): 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: {
@@ -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<LegacyCodexModelIdentity>;
}): 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<LegacyCodexModelIdentity>;
}): 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<LegacyCodexModelIdentity>;
}): 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<LegacyCodexModelIdentity>;
}): 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<LegacyCodexModelIdentity>;
}): 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<LegacyCodexModelIdentity>;
}): 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);
@@ -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<LegacyCodexModelIdentity>;
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<LegacyCodexModelIdentity>;
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,
@@ -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<string, SessionEntry>;
now?: number;
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>;
}): SessionRouteRepairResult;
};
@@ -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<LegacyCodexModelIdentity>;
}): 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/<x>". 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<LegacyCodexModelIdentity>,
): 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<LegacyCodexModelIdentity>,
): 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<string, SessionEntry>;
now?: number;
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>;
}): 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, SessionEntry>): string[] {
function scanCodexSessionStoreRoutes(
store: Record<string, SessionEntry>,
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>,
): 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<LegacyCodexModelIdentity>;
}): Promise<CodexSessionRouteRepairSummary> {
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) {
@@ -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<string, SessionEntry> = {
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<string, SessionEntry> = {
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<string, SessionEntry> = {
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<string, SessionEntry> = {
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<string, SessionEntry> = {
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<string, SessionEntry> = {
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<string, SessionEntry> = {
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<string, SessionEntry> = {
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", () => {
@@ -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
? [
@@ -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<LegacyCodexModelIdentity>,
): 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);
@@ -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<LegacyCodexModelIdentity>;
} = {},
): {
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;
@@ -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<LegacyCodexModelIdentity>,
): {
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<string, unknown> = { ...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<LegacyCodexModelIdentity>,
): {
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<string, unknown>,
path: string,
changes: string[],
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>,
): { value: Record<string, unknown>; changed: boolean } {
let changed = false;
const next: Record<string, unknown> = { ...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<LegacyCodexModelIdentity>,
): 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;
@@ -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<string, unknown> | 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.",
@@ -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<string, { stale: number; correct: number }> = {
@@ -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<string, unknown>, key: string): bo
function collectModelMergeBlockers(params: {
canonical: Record<string, unknown>;
legacy: Record<string, unknown>;
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<LegacyCodexModelIdentity>();
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<string, unknown>;
model: Record<string, unknown>;
}): Record<string, unknown> | 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<string, unknown>,
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<ModelDefinitionConfig>,
})
) {
@@ -1308,6 +1402,79 @@ function buildMergedLegacyOpenAIModel(
return Object.keys(patch).length > 0 ? Object.assign({}, modelRecord, patch) : model;
}
function collectNonEquivalentLegacyOpenAIModelCollisions(params: {
canonical: Record<string, unknown>;
legacy: Record<string, unknown>;
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<string>();
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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>, changes: string[]): void {
const models = getRecord(raw.models);
const providers = getRecord(models?.providers);
@@ -1323,7 +1490,7 @@ function migrateLegacyOpenAICodexProvider(raw: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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),
},
{
@@ -14,7 +14,8 @@ export function isLegacyModelsAddCodexMetadataModel(params: {
model: Partial<ModelDefinitionConfig> | 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();
@@ -21,7 +21,7 @@ const LEGACY_RUNTIME_MODEL_PROVIDER_ALIASES = [
provider: "openai",
runtime: "codex",
cli: false,
requiresRuntimePolicy: false,
requiresRuntimePolicy: true,
},
{
legacyProvider: "codex-cli",
+11 -7
View File
@@ -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<DoctorPreviewNotes> {
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(
-51
View File
@@ -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[] = [];
@@ -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: {
@@ -112,6 +112,10 @@ const mocks = vi.hoisted(() => ({
async (): Promise<string | undefined> => 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();
+27
View File
@@ -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<void>
);
}
}
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(
@@ -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" },
}),
);
},
);
});
});
@@ -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<string, boolean>;
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<ModelsListEntryEvaluation>;
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 } : {}),
}),
+40 -5
View File
@@ -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,
},
],
+27
View File
@@ -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) => {
@@ -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");',
+2
View File
@@ -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<string, unknown>;
compat?: ProviderThinkingModelCompat | null;
+1
View File
@@ -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;
};
-28
View File
@@ -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 = [