mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
fix(agents): avoid runtime model hydration on hot paths
This commit is contained in:
parent
c2b56ded61
commit
7951cc0c8a
14 changed files with 399 additions and 17 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { loadModelCatalog } from "./model-catalog.js";
|
||||
export { loadManifestModelCatalog, loadModelCatalog } from "./model-catalog.js";
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}".`);
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue