From af4ffb2e8ed0be505209c45c9ba90019ba587d29 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 11:23:32 -0700 Subject: [PATCH] fix: exact dated models work after gateway ready (#121119) * fix(agents): publish exact configured model facts * fix(agents): keep prepared model completion internal * fix(agents): scope prepared model plugin lookup * refactor(agents): split prepared model configured facts * fix(agents): keep configured catalog types acyclic --- .../forward-compat-generation.test.ts | 22 ++- extensions/anthropic/register.runtime.ts | 74 +++++++--- src/agents/prepared-model-runtime.build.ts | 2 +- ...epared-model-runtime.configured-catalog.ts | 89 ++++++++++++ ...red-model-runtime.configured-completion.ts | 42 ++++++ .../prepared-model-runtime.configured.ts | 25 ++-- src/agents/prepared-model-runtime.facts.ts | 131 ++++++++---------- ...pared-model-runtime.startup-static.test.ts | 101 ++++++++++++++ 8 files changed, 376 insertions(+), 110 deletions(-) create mode 100644 src/agents/prepared-model-runtime.configured-catalog.ts create mode 100644 src/agents/prepared-model-runtime.configured-completion.ts diff --git a/extensions/anthropic/forward-compat-generation.test.ts b/extensions/anthropic/forward-compat-generation.test.ts index 8f224d9b1360..2568e3a24c59 100644 --- a/extensions/anthropic/forward-compat-generation.test.ts +++ b/extensions/anthropic/forward-compat-generation.test.ts @@ -40,13 +40,33 @@ describe("unreleased Claude generations", () => { expect(resolveModel("claude-opus-4-8")?.params?.canonicalModelId).toBeUndefined(); }); - it("leaves pre-4.6 and snapshot-dated ids alone", () => { + it("does not mistake snapshot dates for minor versions", () => { // claude-opus-4-20250514 is 4.0; a naive parse reads 4.20 and would treat it // as newer than every released generation. expect(resolveModel("claude-opus-4-20250514")?.params?.canonicalModelId).toBeUndefined(); expect(supportsClaudeAdaptiveThinking({ id: "claude-haiku-4-5-20251001" })).toBe(false); }); + it("clones released snapshot ids from their dateless manifest template", () => { + expect(resolveModel("claude-haiku-4-5-20251001")).toEqual({ + id: "claude-haiku-4-5-20251001", + name: "claude-haiku-4-5-20251001", + provider: "anthropic", + api: "anthropic-messages", + baseUrl: "https://api.anthropic.com", + reasoning: true, + input: ["text", "image"], + mediaInput: { + image: { maxSidePx: 1568, preferredSidePx: 1568, tokenMode: "provider" }, + }, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 64_000, + compat: { codeMode: "preferred" }, + }); + expect(resolveModel("claude-haiku-4-9-20251001")).toBeUndefined(); + }); + it("carries manifest catalog compat onto hand-built modern rows", () => { // The hand-built forward-compat row replaces the catalog row when the // runtime prefers plugin-resolved modern models. Dropping compat here diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 9f7385bf5926..4e9b1410b06f 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -384,6 +384,33 @@ function resolveAnthropic46ForwardCompatModel(params: { }); } +function resolveAnthropicSnapshotModel( + ctx: ProviderResolveDynamicModelContext, +): ProviderRuntimeModel | undefined { + const modelId = ctx.modelId.trim(); + const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId); + const match = /^(claude-[a-z0-9]+(?:-[a-z0-9]+)*)-\d{8}$/.exec(normalizedModelId); + if ( + modelId !== normalizedModelId || + normalizeLowercaseStringOrEmpty(ctx.provider) !== PROVIDER_ID || + !match + ) { + return undefined; + } + const templateId = match[1]!; + const captured = cloneFirstTemplateModel({ + providerId: PROVIDER_ID, + modelId, + templateIds: [templateId], + ctx, + }); + if (captured) { + return captured; + } + const template = resolveAnthropicManifestModel(templateId); + return template ? { ...template, id: modelId, name: modelId } : undefined; +} + /** Newest Claude generation whose request contract this plugin encodes. */ const ANTHROPIC_NEWEST_KNOWN_GENERATION = { major: 5, minor: 0 } as const; @@ -436,28 +463,40 @@ function resolveAnthropicUnreleasedCanonicalModelId(modelId: string): string { return /(?:^|-)claude-sonnet-/.test(modelId) ? "claude-sonnet-5" : "claude-opus-5"; } -// Lazily indexed manifest compat per provider so hand-built dynamic rows keep -// catalog capability metadata even when the run's model registry is empty -// (for example env-key-only runs without a generated models.json). -let anthropicManifestCompatIndex: Map | undefined; +// Dynamic rows use the manifest as the provider-owned offline contract when a lifecycle registry +// has no template yet. Keeping one normalized index avoids reparsing catalog metadata per run. +let anthropicManifestModelIndex: Map | undefined; + +function resolveAnthropicManifestModel(modelId: string): ProviderRuntimeModel | undefined { + if (!anthropicManifestModelIndex) { + anthropicManifestModelIndex = new Map(); + const catalog = buildAnthropicCatalogProvider(); + for (const model of catalog.models ?? []) { + const api = model.api ?? catalog.api; + const baseUrl = model.baseUrl ?? catalog.baseUrl; + if (api && baseUrl) { + anthropicManifestModelIndex.set(model.id, { + ...model, + input: model.input.filter( + (item): item is "text" | "image" => item === "text" || item === "image", + ), + provider: PROVIDER_ID, + api, + baseUrl, + }); + } + } + } + return anthropicManifestModelIndex.get(modelId); +} function resolveAnthropicManifestCompat( provider: string, modelId: string, ): ModelCompatConfig | undefined { - if (!anthropicManifestCompatIndex) { - anthropicManifestCompatIndex = new Map(); - const providers = manifest.modelCatalog?.providers ?? {}; - for (const [providerId, catalog] of Object.entries(providers)) { - for (const model of catalog.models ?? []) { - const compat = (model as { compat?: ModelCompatConfig }).compat; - if (compat) { - anthropicManifestCompatIndex.set(`${providerId}/${model.id}`, compat); - } - } - } - } - return anthropicManifestCompatIndex.get(`${provider}/${modelId}`); + return normalizeLowercaseStringOrEmpty(provider) === PROVIDER_ID + ? resolveAnthropicManifestModel(modelId)?.compat + : undefined; } function buildAnthropicForwardCompatModel( @@ -528,6 +567,7 @@ function resolveAnthropicForwardCompatModel( ctx: ProviderResolveDynamicModelContext, ): ProviderRuntimeModel | undefined { return ( + resolveAnthropicSnapshotModel(ctx) ?? resolveAnthropic46ForwardCompatModel({ ctx, dashModelId: ANTHROPIC_OPUS_48_MODEL_ID, diff --git a/src/agents/prepared-model-runtime.build.ts b/src/agents/prepared-model-runtime.build.ts index e8daba458ddf..ad7e9dd294b0 100644 --- a/src/agents/prepared-model-runtime.build.ts +++ b/src/agents/prepared-model-runtime.build.ts @@ -363,7 +363,7 @@ async function buildSnapshotBatch( } const registryMs = performance.now() - registryStartedAt; const preparedAgentFacts = [...preparedInputs.values()]; - const configuredRuntimeModelCount = preparedAgentFacts.reduce( + const configuredRuntimeModelCount = [...preparedCatalogs.values()].reduce( (count, facts) => count + facts.configuredRuntimeModels.length, 0, ); diff --git a/src/agents/prepared-model-runtime.configured-catalog.ts b/src/agents/prepared-model-runtime.configured-catalog.ts new file mode 100644 index 000000000000..9749b1840100 --- /dev/null +++ b/src/agents/prepared-model-runtime.configured-catalog.ts @@ -0,0 +1,89 @@ +import type { ConfiguredModelRef } from "@openclaw/model-catalog-core/configured-model-refs"; +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import type { InlineModelEntry } from "./embedded-agent-runner/model.inline-provider.js"; +import type { ModelCatalogEntry } from "./model-catalog.js"; +import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; +import { + toStaticCatalogEntry, + type PreparedConfiguredRuntimeModel, +} from "./prepared-model-runtime.configured.js"; +import type { ModelRegistry } from "./sessions/model-registry.js"; + +type ConfiguredCatalogAgentFacts = { + configuredModelRefs: readonly ConfiguredModelRef[]; +}; + +type ConfiguredCatalogWorkspaceFacts = { + configuredCatalogEntries: readonly ModelCatalogEntry[]; + inlineProviderModels: readonly InlineModelEntry[]; +}; + +type ConfiguredRuntimeFacts = { + templateModelRegistry: ModelRegistry; + modelCatalog: ModelCatalogSnapshot; + configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[]; + inlineProviderModels: readonly InlineModelEntry[]; +}; + +export function modelCatalogEntryKey(entry: Pick): string { + return `${normalizeProviderId(entry.provider)}\0${entry.id.trim().toLowerCase()}`; +} + +function createConfiguredModelCatalogSnapshot(params: { + agentFacts: ConfiguredCatalogAgentFacts; + workspaceFacts: ConfiguredCatalogWorkspaceFacts; + templateModelRegistry: ModelRegistry; + configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[]; +}): ModelCatalogSnapshot { + const entries = new Map(); + const addEntry = (entry: ModelCatalogEntry) => { + const key = modelCatalogEntryKey(entry); + if (!entries.has(key)) { + entries.set(key, entry); + } + }; + for (const entry of params.workspaceFacts.configuredCatalogEntries) { + addEntry(entry); + } + for (const configured of params.configuredRuntimeModels) { + addEntry(toStaticCatalogEntry(configured.model)); + } + for (const { value } of params.agentFacts.configuredModelRefs) { + const separator = value.indexOf("/"); + if (separator <= 0 || separator >= value.length - 1) { + continue; + } + const provider = normalizeProviderId(value.slice(0, separator)); + const modelId = value.slice(separator + 1).trim(); + if (!provider || !modelId) { + continue; + } + const model = params.templateModelRegistry.find(provider, modelId); + if (model) { + addEntry(toStaticCatalogEntry(model)); + } + } + const configuredEntries = [...entries.values()]; + const staticEntries = params.configuredRuntimeModels.map(({ model }) => + toStaticCatalogEntry(model), + ); + return { + entries: configuredEntries, + routeVariants: configuredEntries, + ...(staticEntries.length > 0 ? { staticEntries } : {}), + }; +} + +export function prepareConfiguredRuntimeFacts(params: { + agentFacts: ConfiguredCatalogAgentFacts; + workspaceFacts: ConfiguredCatalogWorkspaceFacts; + templateModelRegistry: ModelRegistry; + configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[]; +}): ConfiguredRuntimeFacts { + return { + templateModelRegistry: params.templateModelRegistry, + modelCatalog: createConfiguredModelCatalogSnapshot(params), + configuredRuntimeModels: params.configuredRuntimeModels, + inlineProviderModels: params.workspaceFacts.inlineProviderModels, + }; +} diff --git a/src/agents/prepared-model-runtime.configured-completion.ts b/src/agents/prepared-model-runtime.configured-completion.ts new file mode 100644 index 000000000000..3a3bc85c4cf0 --- /dev/null +++ b/src/agents/prepared-model-runtime.configured-completion.ts @@ -0,0 +1,42 @@ +import type { ConfiguredModelRef } from "@openclaw/model-catalog-core/configured-model-refs"; +import { + buildModelCatalogMergeKey, + parseModelCatalogRef, +} from "@openclaw/model-catalog-core/model-catalog-refs"; +import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; +import type { PreparedConfiguredRuntimeModel } from "./prepared-model-runtime.configured.js"; + +export function completeConfiguredRuntimeModels(params: { + configuredModelRefs: readonly ConfiguredModelRef[]; + configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[]; + resolveDynamicModel: (lookup: { + provider: string; + modelId: string; + }) => ProviderRuntimeModel | undefined; +}): PreparedConfiguredRuntimeModel[] { + const existing = new Map( + params.configuredRuntimeModels.map((configured) => [ + buildModelCatalogMergeKey(configured.provider, configured.modelId), + configured, + ]), + ); + const completed: PreparedConfiguredRuntimeModel[] = []; + const seen = new Set(); + for (const { value } of params.configuredModelRefs) { + const parsed = parseModelCatalogRef(value); + if (!parsed) { + continue; + } + const key = buildModelCatalogMergeKey(parsed.provider, parsed.modelId); + if (seen.has(key)) { + continue; + } + seen.add(key); + const prepared = existing.get(key); + const model = prepared?.model ?? params.resolveDynamicModel(parsed); + if (model) { + completed.push({ provider: parsed.provider, modelId: parsed.modelId, model }); + } + } + return completed; +} diff --git a/src/agents/prepared-model-runtime.configured.ts b/src/agents/prepared-model-runtime.configured.ts index 46a5bd2c5095..000c5b488f26 100644 --- a/src/agents/prepared-model-runtime.configured.ts +++ b/src/agents/prepared-model-runtime.configured.ts @@ -2,6 +2,10 @@ import { collectConfiguredModelRefs, type ConfiguredModelRef, } from "@openclaw/model-catalog-core/configured-model-refs"; +import { + buildModelCatalogMergeKey, + parseModelCatalogRef, +} from "@openclaw/model-catalog-core/model-catalog-refs"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { MODEL_APIS } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -125,15 +129,12 @@ export function collectConfiguredProviderIdsNeedingStaticCatalog(params: { }): string[] { const providerIds = new Set(); for (const { value } of params.configuredModelRefs ?? collectConfiguredModelRefs(params.config)) { - const separator = value.indexOf("/"); - if (separator <= 0 || separator >= value.length - 1) { + const parsed = parseModelCatalogRef(value); + if (!parsed) { continue; } - const provider = normalizeProviderId(value.slice(0, separator)); - const modelId = value.slice(separator + 1).trim(); + const { provider, modelId } = parsed; if ( - !provider || - !modelId || hasConfiguredInlineProviderModel( params.config, provider, @@ -164,16 +165,12 @@ export function prepareConfiguredRuntimeModels(params: { const prepared: PreparedConfiguredRuntimeModel[] = []; const seen = new Set(); for (const { value } of params.configuredModelRefs ?? collectConfiguredModelRefs(params.config)) { - const separator = value.indexOf("/"); - if (separator <= 0 || separator >= value.length - 1) { + const parsed = parseModelCatalogRef(value); + if (!parsed) { continue; } - const provider = normalizeProviderId(value.slice(0, separator)); - const modelId = value.slice(separator + 1).trim(); - if (!provider || !modelId) { - continue; - } - const key = `${provider}\0${modelId.toLowerCase()}`; + const { modelId, provider } = parsed; + const key = buildModelCatalogMergeKey(provider, modelId); if (seen.has(key)) { continue; } diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index 095030faf116..0800cab58a7a 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import path from "node:path"; import { performance } from "node:perf_hooks"; import type { ConfiguredModelRef } from "@openclaw/model-catalog-core/configured-model-refs"; -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import { + findNormalizedProviderValue, + normalizeProviderId, +} from "@openclaw/model-catalog-core/provider-id"; import { stableStringify } from "@openclaw/normalization-core"; import type { PreparedMessageToolCatalog } from "../channels/plugins/message-action-discovery.js"; import { sha256Base64Url } from "../infra/crypto-digest.js"; @@ -13,6 +16,7 @@ import { getPreparedMessageToolCatalogForRegistry, } from "../plugins/prepared-message-tool-catalog.js"; import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js"; +import { resolveLoadedProviderRuntimePlugin } from "../plugins/provider-hook-runtime.js"; import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { resolveRuntimeSyntheticAuthProviderRefs } from "../plugins/synthetic-auth.runtime.js"; @@ -42,6 +46,11 @@ import { resolvePluginModelCatalogOwnerPluginId, type PersistedPluginModelCatalog, } from "./plugin-model-catalog.js"; +import { + modelCatalogEntryKey, + prepareConfiguredRuntimeFacts, +} from "./prepared-model-runtime.configured-catalog.js"; +import { completeConfiguredRuntimeModels } from "./prepared-model-runtime.configured-completion.js"; import { collectPreparedModelRuntimeConfiguredRefs, collectConfiguredProviderIdsNeedingStaticCatalog, @@ -465,76 +474,6 @@ export function isPreparedModelCatalogFull(snapshot: ModelCatalogSnapshot): bool return fullModelCatalogSnapshots.has(snapshot); } -function modelCatalogEntryKey(entry: Pick): string { - return `${normalizeProviderId(entry.provider)}\0${entry.id.trim().toLowerCase()}`; -} - -function createConfiguredModelCatalogSnapshot(params: { - agentFacts: PreparedModelRuntimeAgentFacts; - workspaceFacts: PreparedModelRuntimeWorkspaceFacts; - templateModelRegistry: ModelRegistry; - configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[]; -}): ModelCatalogSnapshot { - const entries = new Map(); - const addEntry = (entry: ModelCatalogEntry) => { - const key = modelCatalogEntryKey(entry); - if (!entries.has(key)) { - entries.set(key, entry); - } - }; - for (const entry of params.workspaceFacts.configuredCatalogEntries) { - addEntry(entry); - } - for (const configured of params.configuredRuntimeModels) { - addEntry(toStaticCatalogEntry(configured.model)); - } - for (const { value } of params.agentFacts.configuredModelRefs) { - const separator = value.indexOf("/"); - if (separator <= 0 || separator >= value.length - 1) { - continue; - } - const provider = normalizeProviderId(value.slice(0, separator)); - const modelId = value.slice(separator + 1).trim(); - if (!provider || !modelId) { - continue; - } - const model = params.templateModelRegistry.find(provider, modelId); - if (model) { - addEntry(toStaticCatalogEntry(model)); - } - } - const configuredEntries = [...entries.values()]; - const staticEntries = params.configuredRuntimeModels.map(({ model }) => - toStaticCatalogEntry(model), - ); - return { - entries: configuredEntries, - routeVariants: configuredEntries, - ...(staticEntries.length > 0 ? { staticEntries } : {}), - }; -} - -function prepareConfiguredRuntimeFacts( - agentFacts: PreparedModelRuntimeAgentFacts, - workspaceFacts: PreparedModelRuntimeWorkspaceFacts, - sharedTemplateModelRegistry: ModelRegistry, -): PreparedModelRuntimeCatalogFacts { - const { configuredRuntimeModels } = agentFacts; - const { inlineProviderModels } = workspaceFacts; - const templateModelRegistry = sharedTemplateModelRegistry; - return { - templateModelRegistry, - modelCatalog: createConfiguredModelCatalogSnapshot({ - agentFacts, - workspaceFacts, - templateModelRegistry, - configuredRuntimeModels, - }), - configuredRuntimeModels, - inlineProviderModels, - }; -} - function captureModelsJsonContents(agentDir: string): string | null { try { return fs.readFileSync(path.join(agentDir, "models.json"), "utf8"); @@ -641,12 +580,50 @@ export function prepareConfiguredRuntimeFactsBatch(params: { }, ); registryCount += 1; - for (const facts of group.agentFacts) { - catalogs.set( - facts.input, - prepareConfiguredRuntimeFacts(facts, params.workspaceFacts, templateModelRegistry), - ); - } + // The captured registry exists only after agent-owned catalog parsing. Complete static misses + // here so turn facts stay within this lifecycle generation without starting live discovery. + withPluginRuntimeRegistryScope(params.workspaceFacts.pluginRegistry, () => { + for (const facts of group.agentFacts) { + const { input } = facts; + const configuredRuntimeModels = params.workspaceFacts.pluginRegistry + ? completeConfiguredRuntimeModels({ + configuredModelRefs: facts.configuredModelRefs, + configuredRuntimeModels: facts.configuredRuntimeModels, + resolveDynamicModel: ({ provider, modelId }) => { + const providerConfig = + input.config.models?.providers?.[provider] ?? + findNormalizedProviderValue(input.config.models?.providers, provider); + return ( + resolveLoadedProviderRuntimePlugin({ + provider, + modelId, + config: input.config, + workspaceDir: input.workspaceDir, + env: facts.env, + })?.resolveDynamicModel?.({ + config: input.config, + agentDir: input.agentDir, + workspaceDir: input.workspaceDir, + provider, + modelId, + modelRegistry: templateModelRegistry, + providerConfig, + }) ?? undefined + ); + }, + }) + : facts.configuredRuntimeModels; + catalogs.set( + input, + prepareConfiguredRuntimeFacts({ + agentFacts: facts, + workspaceFacts: params.workspaceFacts, + templateModelRegistry, + configuredRuntimeModels, + }), + ); + } + }); } return { catalogs, registryCount }; } diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index 766ee3a9cdb4..80829739dc0f 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -409,6 +409,107 @@ describe("prepared model runtime Gateway catalog mode", () => { expect(mocks.discoverModels).toHaveBeenCalledTimes(3); }); + it("publishes exact dynamic configured models without building a live catalog", async () => { + const provider = "fixture-provider"; + const modelId = "fixture-model-2026-08-09"; + const registry = createEmptyPluginRegistry(); + const resolveDynamicModel = vi.fn( + (context: { provider: string; modelId: string; modelRegistry: unknown }) => ({ + id: context.modelId, + name: "Fixture dated model", + provider: context.provider, + api: "openai-responses" as const, + baseUrl: "https://fixture.invalid/v1", + reasoning: false, + input: ["text" as const], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 64_000, + maxTokens: 8_192, + }), + ); + registry.providers.push({ + pluginId: provider, + provider: { id: provider, label: "Fixture provider", auth: [], resolveDynamicModel }, + source: "test", + }); + mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(registry); + const providerConfig = { + api: "openai-responses" as const, + baseUrl: "https://configured.fixture.invalid/v1", + models: [], + }; + const config = { + models: { providers: { [provider]: providerConfig } }, + agents: { + defaults: { + model: { + primary: `${provider}/${modelId}`, + fallbacks: ["openai/gpt-5.5", `${provider}/${modelId}`], + }, + }, + }, + }; + + await refreshPreparedModelRuntimeSnapshots(config, { + gatewayLifecycle: true, + catalogMode: "static", + }); + + expect(resolveDynamicModel).toHaveBeenCalledOnce(); + expect(mocks.discoverModels.mock.invocationCallOrder[0]).toBeLessThan( + resolveDynamicModel.mock.invocationCallOrder[0]!, + ); + expect(resolveDynamicModel).toHaveBeenCalledWith({ + config, + agentDir: "/tmp/prepared-static-agent", + workspaceDir: "/tmp/prepared-static-workspace", + provider, + modelId, + modelRegistry: mocks.modelRegistry, + providerConfig, + }); + const snapshot = getPreparedModelRuntimeSnapshot({ + agentId: "default", + config, + agentDir: "/tmp/prepared-static-agent", + inheritedAuthDir: "/tmp/prepared-static-agent", + workspaceDir: "/tmp/prepared-static-workspace", + }); + expect( + snapshot?.configuredRuntimeModels.map( + (configured) => `${configured.provider}/${configured.modelId}`, + ), + ).toEqual([`${provider}/${modelId}`, "openai/gpt-5.5"]); + expect(snapshot?.configuredRuntimeModels[0]?.model).toMatchObject({ + provider, + id: modelId, + name: "Fixture dated model", + api: "openai-responses", + baseUrl: "https://fixture.invalid/v1", + }); + for (const entries of [ + snapshot?.modelCatalog.entries, + snapshot?.modelCatalog.routeVariants, + snapshot?.modelCatalog.staticEntries, + ]) { + expect(entries?.map((entry) => `${entry.provider}/${entry.id}`)).toEqual([ + `${provider}/${modelId}`, + "openai/gpt-5.5", + ]); + } + expect(mocks.prepareStaticCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + providerDiscoveryProviderIds: [provider, "openai"], + staticCatalogProviderIds: [provider, "openai"], + }), + ); + expect(mocks.discoverModels).toHaveBeenCalledOnce(); + expect(mocks.buildPreparedModelCatalogSnapshot).not.toHaveBeenCalled(); + expect(mocks.loadStaticCatalog).not.toHaveBeenCalled(); + expect(mocks.planOpenClawModelsJsonSource).not.toHaveBeenCalled(); + expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); + }); + it("does not request a static provider hook when manifest facts resolve the configured model", async () => { mocks.resolveStaticCatalogModel.mockReturnValue({ id: "gpt-5.5",