diff --git a/CHANGELOG.md b/CHANGELOG.md index 1029a315489..d010466dd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Models: suppress explicitly configured openai-codex/gpt-5.4-mini inline entries so a stale models config written by `openclaw doctor --fix` cannot bypass the manifest capability block and cause repeated assistant-turn failures when the runtime switches to that model on ChatGPT-backed Codex accounts. Conditional suppressions (e.g. qwen Coding Plan endpoint guards) remain bypassable by explicit user configuration. (#74451) Thanks @0xCyda, @hclsys, and @Marvae. - Dependencies: refresh workspace runtime, plugin, and tooling packages, including ACP, Pi, AWS SDK, TypeBox, pnpm, oxlint, oxfmt, jsdom, pdfjs, ciao, and tokenjuice, while keeping patched ACP behavior and lint gates current. Thanks @mariozechner. - Messages/queue: make `steer` drain all pending Pi steering messages at the next model boundary, keep legacy one-at-a-time steering as `queue`, and add a dedicated steering queue docs page. Thanks @vincentkoc. - Messages/queue: default active-run queueing to `steer` with a 500ms followup fallback debounce, and document the queue modes, precedence, and drop policies on the command queue page. Thanks @vincentkoc. diff --git a/src/agents/model-suppression.ts b/src/agents/model-suppression.ts index d95be529f90..5e355c63a80 100644 --- a/src/agents/model-suppression.ts +++ b/src/agents/model-suppression.ts @@ -11,6 +11,7 @@ function resolveBuiltInModelSuppressionFromManifest(params: { id?: string | null; baseUrl?: string | null; config?: OpenClawConfig; + unconditionalOnly?: boolean; }) { const provider = normalizeProviderId(params.provider ?? ""); const modelId = normalizeLowercaseStringOrEmpty(params.id); @@ -22,6 +23,7 @@ function resolveBuiltInModelSuppressionFromManifest(params: { id: modelId, ...(params.config ? { config: params.config } : {}), ...(params.baseUrl ? { baseUrl: params.baseUrl } : {}), + unconditionalOnly: params.unconditionalOnly, env: process.env, }); } @@ -61,6 +63,20 @@ export function shouldSuppressBuiltInModel(params: { return resolveBuiltInModelSuppression(params)?.suppress ?? false; } +// Checks only unconditional suppressions (no `when` clause). Used for inline +// model entries where user configuration may override conditional suppressions +// (e.g. custom endpoint overrides) but not absolute provider capability blocks. +export function shouldUnconditionallySuppress(params: { + provider?: string | null; + id?: string | null; + config?: OpenClawConfig; +}): boolean { + return ( + resolveBuiltInModelSuppressionFromManifest({ ...params, unconditionalOnly: true })?.suppress ?? + false + ); +} + export function buildSuppressedBuiltInModelError(params: { provider?: string | null; id?: string | null; diff --git a/src/agents/pi-embedded-runner/model.test.ts b/src/agents/pi-embedded-runner/model.test.ts index 8f61c605130..2a91f0ee8eb 100644 --- a/src/agents/pi-embedded-runner/model.test.ts +++ b/src/agents/pi-embedded-runner/model.test.ts @@ -69,6 +69,20 @@ vi.mock("../model-suppression.js", () => { isQwenCodingPlanBaseUrl(baseUrl ?? resolveConfiguredQwenBaseUrl(config)) ); }, + shouldUnconditionallySuppress: ({ provider, id }: { provider?: string; id?: string }) => { + if ( + (provider === "openai" || + provider === "azure-openai-responses" || + provider === "openai-codex") && + id?.trim().toLowerCase() === "gpt-5.3-codex-spark" + ) { + return true; + } + if (provider === "openai-codex" && id?.trim().toLowerCase() === "gpt-5.4-mini") { + return true; + } + return false; + }, buildSuppressedBuiltInModelError: ({ provider, id, @@ -355,6 +369,34 @@ describe("resolveModel", () => { ); }); + it("#74451: suppresses explicitly configured openai-codex/gpt-5.4-mini despite inline entry", () => { + const cfg = { + models: { + providers: { + "openai-codex": { + api: "openai-codex-responses", + models: [ + { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", + api: "openai-codex-responses", + contextWindow: 400_000, + maxTokens: 128_000, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + const result = resolveModelForTest("openai-codex", "gpt-5.4-mini", "/tmp/agent", cfg); + + expect(result.model).toBeUndefined(); + expect(result.error).toBe( + "Unknown model: openai-codex/gpt-5.4-mini. gpt-5.4-mini is not supported by the OpenAI Codex OAuth route. Use openai/gpt-5.4-mini with an OpenAI API key or openai-codex/gpt-5.5 with Codex OAuth.", + ); + }); + it("normalizes Google fallback baseUrls for custom providers", () => { const cfg = { models: { diff --git a/src/agents/pi-embedded-runner/model.ts b/src/agents/pi-embedded-runner/model.ts index a3a98a2c85a..4ace0982c56 100644 --- a/src/agents/pi-embedded-runner/model.ts +++ b/src/agents/pi-embedded-runner/model.ts @@ -25,6 +25,7 @@ import { findNormalizedProviderValue, normalizeProviderId } from "../model-selec import { buildSuppressedBuiltInModelError, shouldSuppressBuiltInModel, + shouldUnconditionallySuppress, } from "../model-suppression.js"; import { isLegacyModelsAddCodexMetadataModel } from "../openai-codex-models-add-legacy.js"; import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js"; @@ -614,6 +615,14 @@ function resolveExplicitModelWithRegistry(params: { modelId, }); if (inlineMatch?.api) { + // Unconditional suppressions (no `when` clause) represent absolute provider + // capability blocks that cannot be overridden by inline user configuration. + // Conditional suppressions (e.g. baseUrlHosts-gated qwen restrictions) are + // intentionally bypassable when the user has explicitly configured the model. + // (#74451) + if (shouldUnconditionallySuppress({ provider, id: modelId, config: cfg })) { + return { kind: "suppressed" }; + } const resolvedParams = mergeConfiguredRuntimeModelParams({ cfg, provider, diff --git a/src/plugins/manifest-model-suppression.ts b/src/plugins/manifest-model-suppression.ts index 3aa2df28cf4..19283349d78 100644 --- a/src/plugins/manifest-model-suppression.ts +++ b/src/plugins/manifest-model-suppression.ts @@ -118,6 +118,7 @@ export function buildManifestBuiltInModelSuppressionResolver(params: { provider?: string | null; id?: string | null; baseUrl?: string | null; + unconditionalOnly?: boolean; }) => { const provider = normalizeLowercaseStringOrEmpty(input.provider); const modelId = normalizeLowercaseStringOrEmpty(input.id); @@ -128,6 +129,7 @@ export function buildManifestBuiltInModelSuppressionResolver(params: { const suppression = suppressions.find( (entry) => entry.mergeKey === mergeKey && + (!input.unconditionalOnly || !entry.when) && manifestSuppressionMatchesConditions({ suppression: entry, provider, @@ -163,6 +165,7 @@ export function resolveManifestBuiltInModelSuppression(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; baseUrl?: string | null; + unconditionalOnly?: boolean; }) { const resolver = buildManifestBuiltInModelSuppressionResolver({ config: params.config, @@ -173,5 +176,6 @@ export function resolveManifestBuiltInModelSuppression(params: { provider: params.provider, id: params.id, baseUrl: params.baseUrl, + unconditionalOnly: params.unconditionalOnly, }); }