From 7951cc0c8aad0bd2b09487875d0a8a5291d0334d Mon Sep 17 00:00:00 2001 From: Keshav's Bot Date: Tue, 26 May 2026 17:06:23 +0100 Subject: [PATCH] fix(agents): avoid runtime model hydration on hot paths --- src/agents/agent-command.ts | 4 + src/agents/model-catalog.runtime.ts | 2 +- src/agents/model-ref-shared.test.ts | 16 +++ src/agents/model-ref-shared.ts | 15 +++ src/agents/model-selection-shared.ts | 22 ++++ .../model-selection.plugin-runtime.test.ts | 114 ++++++++++++++++++ src/agents/model-visibility-policy.ts | 7 ++ src/agents/tools/session-status-tool.ts | 16 +++ .../reply/directive-handling.defaults.ts | 4 + .../reply/get-reply-run.media-only.test.ts | 33 +++++ src/auto-reply/reply/get-reply-run.ts | 58 +++++++-- src/auto-reply/reply/model-selection.test.ts | 41 ++++++- src/auto-reply/reply/model-selection.ts | 67 ++++++++-- src/gateway/http-utils.ts | 17 ++- 14 files changed, 399 insertions(+), 17 deletions(-) diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 20cb78d9e8c..d6c8c6d49cf 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -929,6 +929,8 @@ async function agentCommandInternal( catalog: [], defaultProvider, defaultModel, + allowManifestNormalization: true, + allowPluginNormalization: true, ...modelManifestContext, }); @@ -940,6 +942,8 @@ async function agentCommandInternal( defaultProvider, defaultModel, agentId: sessionAgentId, + allowManifestNormalization: true, + allowPluginNormalization: true, ...modelManifestContext, }); allowedModelCatalog = visibilityPolicy.allowedCatalog; diff --git a/src/agents/model-catalog.runtime.ts b/src/agents/model-catalog.runtime.ts index c5799cb640f..0bf33317808 100644 --- a/src/agents/model-catalog.runtime.ts +++ b/src/agents/model-catalog.runtime.ts @@ -1 +1 @@ -export { loadModelCatalog } from "./model-catalog.js"; +export { loadManifestModelCatalog, loadModelCatalog } from "./model-catalog.js"; diff --git a/src/agents/model-ref-shared.test.ts b/src/agents/model-ref-shared.test.ts index 0ef1b4be9ea..e6dc3813eec 100644 --- a/src/agents/model-ref-shared.test.ts +++ b/src/agents/model-ref-shared.test.ts @@ -16,6 +16,22 @@ describe("normalizeStaticProviderModelId", () => { "nvidia/nemotron-3-super-120b-a12b", ); }); + + it("keeps OpenRouter bare compatibility ids provider-qualified without manifest lookup", () => { + expect( + normalizeStaticProviderModelId("openrouter", "auto", { + allowManifestNormalization: false, + }), + ).toBe("openrouter/auto"); + }); + + it("normalizes retired XAI beta ids without manifest lookup", () => { + expect( + normalizeStaticProviderModelId("xai", "grok-4.20-experimental-beta-0304-reasoning", { + allowManifestNormalization: false, + }), + ).toBe("grok-4.20-beta-latest-reasoning"); + }); }); describe("normalizeConfiguredProviderCatalogModelId", () => { diff --git a/src/agents/model-ref-shared.ts b/src/agents/model-ref-shared.ts index b3ed200d101..0bbb1dbf00b 100644 --- a/src/agents/model-ref-shared.ts +++ b/src/agents/model-ref-shared.ts @@ -55,6 +55,21 @@ function normalizeBuiltInProviderModelId(provider: string, model: string): strin if (provider === "google" || provider === "google-gemini-cli" || provider === "google-vertex") { return normalizeGooglePreviewModelId(model); } + if (provider === "openrouter") { + const trimmed = model.trim(); + return trimmed && !trimmed.includes("/") ? `openrouter/${trimmed}` : model; + } + if (provider === "xai") { + const xaiAliases: Record = { + "grok-4-fast-reasoning": "grok-4-fast", + "grok-4-1-fast-reasoning": "grok-4-1-fast", + "grok-4.20-experimental-beta-0304-reasoning": "grok-4.20-beta-latest-reasoning", + "grok-4.20-experimental-beta-0304-non-reasoning": "grok-4.20-beta-latest-non-reasoning", + "grok-4.20-reasoning": "grok-4.20-beta-latest-reasoning", + "grok-4.20-non-reasoning": "grok-4.20-beta-latest-non-reasoning", + }; + return xaiAliases[normalizeLowercaseStringOrEmpty(model)] ?? model; + } return model; } diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 6584971a820..0c0eb5a6add 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -438,12 +438,16 @@ export function resolveAllowlistModelKey( cfg?: OpenClawConfig; raw: string; defaultProvider: string; + allowManifestNormalization?: boolean; + allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, ): string | null { const parsed = parseModelRefWithCompatAlias({ cfg: params.cfg, raw: params.raw, defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, }); if (!parsed) { @@ -540,6 +544,8 @@ function buildModelCatalogMetadata( params: { cfg: OpenClawConfig; defaultProvider: string; + allowManifestNormalization?: boolean; + allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, ): ModelCatalogMetadata { const configuredByKey = new Map(); @@ -564,6 +570,8 @@ function buildModelCatalogMetadata( cfg: params.cfg, raw: rawKey, defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, }); if (!key) { @@ -800,6 +808,8 @@ export function buildAllowedModelSetWithFallbacks( const metadata = buildModelCatalogMetadata({ cfg: params.cfg, defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, }); const configuredCatalog = buildConfiguredModelCatalog({ @@ -1276,9 +1286,13 @@ export function resolveAllowedModelSelection( allowAny: boolean; allowedKeys: ReadonlySet; allowedCatalog: readonly ModelCatalogEntry[]; + allowManifestNormalization?: boolean; + allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, ): ModelRef | null { const current = normalizeModelRef(params.provider, params.model, { + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, }); if ( @@ -1292,6 +1306,8 @@ export function resolveAllowedModelSelection( return null; } return normalizeModelRef(fallback.provider, fallback.id, { + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, }); } @@ -1335,6 +1351,8 @@ export function createModelVisibilityPolicyWithFallbacks( defaultProvider: string; defaultModel?: string; fallbackModels: readonly string[]; + allowManifestNormalization?: boolean; + allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, ): ModelVisibilityPolicy { const visibility = parseConfiguredModelVisibilityEntries({ cfg: params.cfg }); @@ -1347,6 +1365,8 @@ export function createModelVisibilityPolicyWithFallbacks( cfg: params.cfg, raw, defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, }); if (key) { @@ -1370,6 +1390,8 @@ export function createModelVisibilityPolicyWithFallbacks( allowAny: allowed.allowAny, allowedKeys: allowed.allowedKeys, allowedCatalog: allowed.allowedCatalog, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, manifestPlugins: params.manifestPlugins, }), visibleCatalog: ({ catalog, defaultVisibleCatalog, view }) => { diff --git a/src/agents/model-selection.plugin-runtime.test.ts b/src/agents/model-selection.plugin-runtime.test.ts index dbdaa4c9b59..bc1ac8d343a 100644 --- a/src/agents/model-selection.plugin-runtime.test.ts +++ b/src/agents/model-selection.plugin-runtime.test.ts @@ -106,4 +106,118 @@ describe("model-selection plugin runtime normalization", () => { model: "custom-modern-model", }); }); + + it("keeps model visibility policy construction off plugin runtime hooks by default", async () => { + normalizeProviderModelIdWithPluginMock.mockImplementation(({ provider, context }) => { + if ( + provider === "custom-provider" && + (context as { modelId?: string }).modelId === "custom-legacy-model" + ) { + return "custom-modern-model"; + } + return undefined; + }); + + const { createModelVisibilityPolicy } = await import("./model-visibility-policy.js"); + + const policy = createModelVisibilityPolicy({ + cfg: { + agents: { + defaults: { + models: { + "custom-provider/custom-legacy-model": {}, + }, + }, + }, + }, + catalog: [], + defaultProvider: "custom-provider", + defaultModel: "custom-legacy-model", + }); + + expect(policy.allowedKeys.has("custom-provider/custom-legacy-model")).toBe(true); + expect(policy.allowedKeys.has("custom-provider/custom-modern-model")).toBe(false); + expect(normalizeProviderModelIdWithPluginMock).not.toHaveBeenCalled(); + }); + + it("propagates explicit plugin runtime normalization opt-in through model visibility policy", async () => { + normalizeProviderModelIdWithPluginMock.mockImplementation(({ provider, context }) => { + if ( + provider === "custom-provider" && + (context as { modelId?: string }).modelId === "custom-legacy-model" + ) { + return "custom-modern-model"; + } + return undefined; + }); + + const { createModelVisibilityPolicy } = await import("./model-visibility-policy.js"); + + const policy = createModelVisibilityPolicy({ + cfg: { + agents: { + defaults: { + models: { + "custom-provider/custom-legacy-model": {}, + }, + }, + }, + }, + catalog: [], + defaultProvider: "custom-provider", + defaultModel: "custom-legacy-model", + allowPluginNormalization: true, + }); + + expect(policy.allowedKeys.has("custom-provider/custom-modern-model")).toBe(true); + expect(normalizeProviderModelIdWithPluginMock).toHaveBeenCalled(); + }); + + it("keeps plugin-normalized stored overrides allowed in auto-reply runtime selection", async () => { + normalizeProviderModelIdWithPluginMock.mockImplementation(({ provider, context }) => { + if ( + provider === "custom-provider" && + (context as { modelId?: string }).modelId === "custom-legacy-model" + ) { + return "custom-modern-model"; + } + return undefined; + }); + + const { createModelSelectionState } = await import("../auto-reply/reply/model-selection.js"); + const cfg = { + agents: { + defaults: { + models: { + "custom-provider/custom-legacy-model": {}, + }, + }, + }, + }; + const sessionKey = "agent:main:discord:channel:c1"; + const sessionEntry = { + sessionId: sessionKey, + updatedAt: 1, + providerOverride: "custom-provider", + modelOverride: "custom-legacy-model", + }; + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "custom-provider", + defaultModel: "custom-legacy-model", + provider: "custom-provider", + model: "custom-legacy-model", + hasModelDirective: false, + }); + + expect(state.provider).toBe("custom-provider"); + expect(state.model).toBe("custom-modern-model"); + expect(state.resetModelOverride).toBe(false); + }); }); diff --git a/src/agents/model-visibility-policy.ts b/src/agents/model-visibility-policy.ts index 3452efcfcef..aacc90d19b9 100644 --- a/src/agents/model-visibility-policy.ts +++ b/src/agents/model-visibility-policy.ts @@ -25,6 +25,8 @@ export function createModelVisibilityPolicy( defaultProvider: string; defaultModel?: string; agentId?: string; + allowManifestNormalization?: boolean; + allowPluginNormalization?: boolean; } & ModelManifestNormalizationContext, ): ModelVisibilityPolicy { return createModelVisibilityPolicyWithFallbacks({ @@ -36,6 +38,11 @@ export function createModelVisibilityPolicy( cfg: params.cfg, agentId: params.agentId, }), + // Model visibility is used by lightweight status/list paths. Keep plugin + // manifest normalization opt-in so those paths do not load plugin runtime + // metadata unless a caller explicitly needs it. + allowManifestNormalization: params.allowManifestNormalization ?? false, + allowPluginNormalization: params.allowPluginNormalization ?? false, manifestPlugins: params.manifestPlugins, }); } diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 60fa11bc001..b19a47b3c8d 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -16,6 +16,7 @@ import { import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; import { resolveSessionModelIdentityRef } from "../../gateway/session-utils.js"; +import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import { buildAgentMainSessionKey, DEFAULT_AGENT_ID, @@ -289,18 +290,33 @@ async function resolveModelOverride(params: { defaultProvider: currentProvider, }); const catalog = await loadModelCatalog({ config: params.cfg }); + const manifestMetadataSnapshot = loadManifestMetadataSnapshot({ + config: params.cfg, + workspaceDir: params.sessionEntry?.spawnedWorkspaceDir, + env: process.env, + }); + const modelManifestContext = { + manifestPlugins: manifestMetadataSnapshot.plugins, + }; const policy = createModelVisibilityPolicy({ cfg: params.cfg, catalog, defaultProvider: currentProvider, defaultModel: currentModel, agentId: params.agentId, + allowManifestNormalization: true, + allowPluginNormalization: true, + ...modelManifestContext, }); const resolved = resolveModelRefFromString({ + cfg: params.cfg, raw, defaultProvider: currentProvider, aliasIndex, + allowManifestNormalization: true, + allowPluginNormalization: true, + ...modelManifestContext, }); if (!resolved) { throw new Error(`Unrecognized model "${raw}".`); diff --git a/src/auto-reply/reply/directive-handling.defaults.ts b/src/auto-reply/reply/directive-handling.defaults.ts index 2781492d5ff..4892cbb75f8 100644 --- a/src/auto-reply/reply/directive-handling.defaults.ts +++ b/src/auto-reply/reply/directive-handling.defaults.ts @@ -13,12 +13,16 @@ export function resolveDefaultModel(params: { cfg: OpenClawConfig; agentId?: str const mainModel = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId, + // Default-model lookup is on every reply; plugin runtime normalization can + // cold-load plugins, so keep this to static/configured model aliases here. + allowPluginNormalization: false, }); const defaultProvider = mainModel.provider; const defaultModel = mainModel.model; const aliasIndex = buildModelAliasIndex({ cfg: params.cfg, defaultProvider, + allowPluginNormalization: false, }); return { defaultProvider, defaultModel, aliasIndex }; } diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index e50791c8914..211409826f2 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -338,6 +338,39 @@ describe("runPreparedReply media-only handling", () => { expect(call?.followupRun.run.allowEmptyAssistantReplyAsSilent).toBe(true); }); + it("hydrates runtime thinking metadata before trusting static provider support", async () => { + const resolveThinkingCatalog = vi.fn(async () => [ + { + provider: "openai", + id: "chat-latest", + reasoning: false, + }, + ]); + + await runPreparedReply( + baseParams({ + provider: "openai", + model: "chat-latest", + resolvedThinkLevel: "high", + modelState: { + resolveDefaultThinkingLevel: async () => "high", + resolveThinkingCatalog, + allowedModelCatalog: [ + { + provider: "openai", + id: "chat-latest", + name: "Chat Latest", + }, + ], + } as never, + }), + ); + + expect(resolveThinkingCatalog).toHaveBeenCalledOnce(); + const call = requireRunReplyAgentCall(); + expect(call.followupRun.run.thinkLevel).toBe("off"); + }); + it("keeps empty-assistant silence disabled for direct runs by default", async () => { await runPreparedReply( baseParams({ diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index aaf9777aab1..3a382dca7de 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -11,6 +11,7 @@ import { resolveAgentHarnessPolicy } from "../../agents/harness/selection.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-codex-routing.js"; import { resolveEmbeddedFullAccessState } from "../../agents/pi-embedded-runner/sandbox-info.js"; import type { EmbeddedFullAccessBlockedReason } from "../../agents/pi-embedded-runner/types.js"; +import { normalizeProviderId } from "../../agents/provider-id.js"; import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../../agents/spawned-context.js"; import type { SilentReplyPromptMode } from "../../agents/system-prompt.types.js"; import { normalizeChatType } from "../../channels/chat-type.js"; @@ -46,6 +47,7 @@ import { normalizeThinkLevel, type ReasoningLevel, resolveSupportedThinkingLevel, + type ThinkingCatalogEntry, type ThinkLevel, type VerboseLevel, } from "../thinking.js"; @@ -110,6 +112,23 @@ type InternalGetReplyOptions = GetReplyOptions & { type AgentDefaults = NonNullable["defaults"]; type ExecOverrides = Pick; +function hasResolvedThinkingCatalogEntry(params: { + catalog?: readonly ThinkingCatalogEntry[]; + provider: string; + model: string; +}): boolean { + const modelId = normalizeOptionalString(params.model); + if (!modelId) { + return false; + } + const normalizedProvider = normalizeProviderId(params.provider); + const entry = params.catalog?.find( + (candidate) => + normalizeProviderId(candidate.provider) === normalizedProvider && candidate.id === modelId, + ); + return entry?.reasoning !== undefined; +} + export function resolvePromptSilentReplyConversationType(params: { ctx: Pick< MsgContext, @@ -708,7 +727,11 @@ export async function runPreparedReply( if (!resolvedThinkLevel && prefixedBodyBase) { const parts = prefixedBodyBase.split(/\s+/); const maybeLevel = normalizeThinkLevel(parts[0]); - const thinkingCatalog = maybeLevel ? await modelState.resolveThinkingCatalog() : undefined; + const thinkingCatalog = maybeLevel + ? await traceRunPhase("reply.resolve_thinking_catalog_for_hint", () => + modelState.resolveThinkingCatalog(), + ) + : undefined; if ( maybeLevel && isThinkingLevelSupported({ provider, model, level: maybeLevel, catalog: thinkingCatalog }) @@ -789,17 +812,38 @@ export async function runPreparedReply( await traceRunPhase("reply.build_prompt_bodies", () => rebuildPromptBodies()); const isRoomEvent = inboundEventKind === "room_event"; if (!resolvedThinkLevel) { - resolvedThinkLevel = await modelState.resolveDefaultThinkingLevel(); + resolvedThinkLevel = await traceRunPhase("reply.resolve_default_thinking", () => + modelState.resolveDefaultThinkingLevel(), + ); } - const thinkingCatalog = await modelState.resolveThinkingCatalog(); - if ( - !isThinkingLevelSupported({ + const allowedThinkingCatalog = modelState.allowedModelCatalog ?? []; + let thinkingCatalog = allowedThinkingCatalog.length > 0 ? allowedThinkingCatalog : undefined; + let thinkingLevelSupported = isThinkingLevelSupported({ + provider, + model, + level: resolvedThinkLevel, + catalog: thinkingCatalog, + }); + const shouldHydrateThinkingCatalog = + !thinkingLevelSupported || + (resolvedThinkLevel !== "off" && + !hasResolvedThinkingCatalogEntry({ catalog: thinkingCatalog, provider, model })); + if (shouldHydrateThinkingCatalog) { + // Hydrate the runtime model catalog only when the lightweight catalog cannot + // prove support or lacks reasoning metadata for the selected model. The full + // catalog load was a 14s+ reply-blocking cost for known Codex models that + // already publish authoritative thinking metadata. + thinkingCatalog = await traceRunPhase("reply.resolve_thinking_catalog", () => + modelState.resolveThinkingCatalog(), + ); + thinkingLevelSupported = isThinkingLevelSupported({ provider, model, level: resolvedThinkLevel, catalog: thinkingCatalog, - }) - ) { + }); + } + if (!thinkingLevelSupported) { const explicitThink = directives.hasThinkDirective && directives.thinkLevel !== undefined; if (explicitThink) { typing.cleanup(); diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index a894e007216..e98eb44b4fc 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { MODEL_CONTEXT_TOKEN_CACHE } from "../../agents/context-cache.js"; -import { loadModelCatalog } from "../../agents/model-catalog.runtime.js"; +import { loadManifestModelCatalog, loadModelCatalog } from "../../agents/model-catalog.runtime.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { createModelSelectionState, resolveContextTokens } from "./model-selection.js"; vi.mock("../../agents/model-catalog.runtime.js", () => ({ + loadManifestModelCatalog: vi.fn(() => []), loadModelCatalog: vi.fn(async () => [ { provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus 4.5" }, { provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" }, @@ -53,6 +54,8 @@ vi.mock("../../agents/auth-profiles.runtime.js", () => ({ afterEach(() => { MODEL_CONTEXT_TOKEN_CACHE.clear(); + vi.mocked(loadManifestModelCatalog).mockReturnValue([]); + vi.mocked(loadManifestModelCatalog).mockClear(); authProfileStoreMock.reset(); }); @@ -176,6 +179,42 @@ describe("createModelSelectionState catalog loading", () => { expect(loadModelCatalog).toHaveBeenCalledOnce(); }); + it("uses manifest metadata before hydrating the runtime thinking catalog", async () => { + vi.mocked(loadModelCatalog).mockClear(); + vi.mocked(loadManifestModelCatalog).mockClear(); + vi.mocked(loadManifestModelCatalog).mockReturnValueOnce([ + { provider: "openai", id: "gpt-5.5", name: "GPT-5.5", reasoning: true }, + ]); + const cfg = { + agents: { + defaults: { + models: { + "openai/gpt-5.5": {}, + }, + }, + }, + } as OpenClawConfig; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + defaultProvider: "openai", + defaultModel: "gpt-5.5", + provider: "openai", + model: "gpt-5.5", + hasModelDirective: false, + }); + + await expect(state.resolveThinkingCatalog()).resolves.toEqual([ + expect.objectContaining({ provider: "openai", id: "gpt-5.5", reasoning: true }), + ]); + expect(loadManifestModelCatalog).toHaveBeenCalledWith({ + config: cfg, + fallbackToMetadataScan: false, + }); + expect(loadModelCatalog).not.toHaveBeenCalled(); + }); + it("prefers per-agent thinkingDefault over model and global defaults", async () => { vi.mocked(loadModelCatalog).mockClear(); const cfg = { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index efe38fd378a..3bad9be6555 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -78,6 +78,14 @@ const modelCatalogRuntimeLoader = createLazyImportLoader( const sessionStoreRuntimeLoader = createLazyImportLoader( () => import("../../config/sessions/store.runtime.js"), ); +const RUNTIME_MODEL_VISIBILITY_NORMALIZATION = { + allowManifestNormalization: true, + allowPluginNormalization: true, +} as const; + +function normalizeRuntimeModelRef(provider: string, model: string) { + return normalizeModelRef(provider, model, RUNTIME_MODEL_VISIBILITY_NORMALIZATION); +} function loadModelCatalogRuntime() { return modelCatalogRuntimeLoader.load(); @@ -87,6 +95,18 @@ function loadSessionStoreRuntime() { return sessionStoreRuntimeLoader.load(); } +function findSelectedCatalogEntry(params: { + catalog?: readonly ModelCatalogEntry[]; + provider: string; + model: string; +}): ModelCatalogEntry | undefined { + const normalizedProvider = normalizeProviderId(params.provider); + return params.catalog?.find( + (entry) => + normalizeProviderId(entry.provider) === normalizedProvider && entry.id === params.model, + ); +} + export async function createModelSelectionState(params: { cfg: OpenClawConfig; agentId?: string; @@ -157,6 +177,7 @@ export async function createModelSelectionState(params: { defaultProvider, defaultModel, agentId: params.agentId, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); let modelCatalog: ModelCatalog | null = null; let resetModelOverride = false; @@ -190,6 +211,7 @@ export async function createModelSelectionState(params: { defaultProvider, defaultModel, agentId: params.agentId, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); allowedModelCatalog = visibilityPolicy.allowedCatalog; allowedModelKeys = visibilityPolicy.allowedKeys; @@ -204,6 +226,7 @@ export async function createModelSelectionState(params: { defaultProvider, defaultModel, agentId: params.agentId, + ...RUNTIME_MODEL_VISIBILITY_NORMALIZATION, }); allowedModelCatalog = visibilityPolicy.allowedCatalog; allowedModelKeys = visibilityPolicy.allowedKeys; @@ -216,7 +239,7 @@ export async function createModelSelectionState(params: { } if (sessionEntry && sessionStore && sessionKey && directStoredOverride) { - const normalizedOverride = normalizeModelRef( + const normalizedOverride = normalizeRuntimeModelRef( directStoredOverride.provider, directStoredOverride.model, ); @@ -244,13 +267,13 @@ export async function createModelSelectionState(params: { } } if (staleHeartbeatAutoFallbackOverride) { - const normalizedCurrentSelection = normalizeModelRef(provider, model); + const normalizedCurrentSelection = normalizeRuntimeModelRef(provider, model); const currentSelectionKey = modelKey( normalizedCurrentSelection.provider, normalizedCurrentSelection.model, ); const normalizedDirectOverride = directStoredOverride - ? normalizeModelRef(directStoredOverride.provider, directStoredOverride.model) + ? normalizeRuntimeModelRef(directStoredOverride.provider, directStoredOverride.model) : null; const directStoredOverrideKey = normalizedDirectOverride ? modelKey(normalizedDirectOverride.provider, normalizedDirectOverride.model) @@ -278,7 +301,7 @@ export async function createModelSelectionState(params: { (staleHeartbeatAutoFallbackOverride && storedOverride?.source === "session"); if (storedOverride?.model && !skipStoredOverride) { - const normalizedStoredOverride = normalizeModelRef( + const normalizedStoredOverride = normalizeRuntimeModelRef( storedOverride.provider || defaultProvider, storedOverride.model, ); @@ -340,15 +363,45 @@ export async function createModelSelectionState(params: { } let thinkingCatalog: ModelCatalog | undefined; + let manifestModelCatalog: ModelCatalog | null = null; + const loadManifestCatalogForThinking = async () => { + if (manifestModelCatalog) { + return manifestModelCatalog; + } + const { loadManifestModelCatalog } = await loadModelCatalogRuntime(); + manifestModelCatalog = loadManifestModelCatalog({ + config: cfg, + fallbackToMetadataScan: false, + }); + logStage("manifest-catalog-loaded-for-thinking", `entries=${manifestModelCatalog.length}`); + return manifestModelCatalog; + }; const resolveThinkingCatalog = async () => { if (thinkingCatalog) { return thinkingCatalog; } let catalogForThinking = modelCatalog && modelCatalog.length > 0 ? modelCatalog : allowedModelCatalog; - const selectedCatalogEntry = catalogForThinking?.find( - (entry) => entry.provider === provider && entry.id === model, - ); + let selectedCatalogEntry = findSelectedCatalogEntry({ + catalog: catalogForThinking, + provider, + model, + }); + // Prefer static manifest rows before cold runtime discovery. Synthetic + // allowlist rows know only provider/id; manifest rows can prove reasoning + // support without opening the Pi auth-backed model registry. + if (!modelCatalog && selectedCatalogEntry?.reasoning === undefined) { + const manifestCatalog = await loadManifestCatalogForThinking(); + const manifestSelectedEntry = findSelectedCatalogEntry({ + catalog: manifestCatalog, + provider, + model, + }); + if (manifestSelectedEntry?.reasoning !== undefined) { + catalogForThinking = manifestCatalog; + selectedCatalogEntry = manifestSelectedEntry; + } + } const shouldHydrateRuntimeCatalog = !modelCatalog && (!selectedCatalogEntry || selectedCatalogEntry.reasoning === undefined); if (shouldHydrateRuntimeCatalog) { diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index 4ebaf00d54f..a723837e6c4 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -4,6 +4,7 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { modelKey, parseModelRef, resolveDefaultModelForAgent } from "../agents/model-selection.js"; import { createModelVisibilityPolicy } from "../agents/model-visibility-policy.js"; import { getRuntimeConfig } from "../config/io.js"; +import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import { normalizeLowercaseStringOrEmpty, @@ -87,7 +88,18 @@ export async function resolveOpenAiCompatModelOverride(params: { const cfg = getRuntimeConfig(); const defaultModelRef = resolveDefaultModelForAgent({ cfg, agentId: params.agentId }); const defaultProvider = defaultModelRef.provider; - const parsed = parseModelRef(raw, defaultProvider); + const manifestMetadataSnapshot = loadManifestMetadataSnapshot({ + config: cfg, + env: process.env, + }); + const modelManifestContext = { + manifestPlugins: manifestMetadataSnapshot.plugins, + }; + const parsed = parseModelRef(raw, defaultProvider, { + allowManifestNormalization: true, + allowPluginNormalization: true, + ...modelManifestContext, + }); if (!parsed) { return { errorMessage: "Invalid `x-openclaw-model`." }; } @@ -98,6 +110,9 @@ export async function resolveOpenAiCompatModelOverride(params: { catalog, defaultProvider, agentId: params.agentId, + allowManifestNormalization: true, + allowPluginNormalization: true, + ...modelManifestContext, }); const normalized = modelKey(parsed.provider, parsed.model); if (!policy.allowsKey(normalized)) {