mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -545,6 +545,7 @@ export function resolveThinkingProfile(params: ProviderDefaultThinkingPolicyCont
|
||||
params.modelId,
|
||||
params.agentRuntime,
|
||||
params.compat,
|
||||
params.api,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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");',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user