fix(agents): avoid runtime model hydration on hot paths

This commit is contained in:
Keshav's Bot 2026-05-26 17:06:23 +01:00 committed by Peter Steinberger
parent c2b56ded61
commit 7951cc0c8a
No known key found for this signature in database
14 changed files with 399 additions and 17 deletions

View file

@ -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;

View file

@ -1 +1 @@
export { loadModelCatalog } from "./model-catalog.js";
export { loadManifestModelCatalog, loadModelCatalog } from "./model-catalog.js";

View file

@ -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", () => {

View file

@ -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<string, string> = {
"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;
}

View file

@ -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<string, ModelCatalogEntry>();
@ -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<string>;
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 }) => {

View file

@ -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);
});
});

View file

@ -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,
});
}

View file

@ -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}".`);

View file

@ -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 };
}

View file

@ -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({

View file

@ -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<OpenClawConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
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();

View file

@ -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 = {

View file

@ -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) {

View file

@ -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)) {