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
This commit is contained in:
Peter Steinberger
2026-08-09 11:23:32 -07:00
committed by GitHub
parent dd5b4d0d30
commit af4ffb2e8e
8 changed files with 376 additions and 110 deletions
@@ -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
+57 -17
View File
@@ -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<string, ModelCompatConfig> | 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<string, ProviderRuntimeModel> | 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,
+1 -1
View File
@@ -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,
);
@@ -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<ModelCatalogEntry, "id" | "provider">): 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<string, ModelCatalogEntry>();
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,
};
}
@@ -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<string>();
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;
}
+11 -14
View File
@@ -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<string>();
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<string>();
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;
}
+54 -77
View File
@@ -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<ModelCatalogEntry, "id" | "provider">): 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<string, ModelCatalogEntry>();
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 };
}
@@ -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",