From 6e210a63c25d8b83c5c0c413feaa66e3ce312180 Mon Sep 17 00:00:00 2001 From: Nyvo <75425811+Nyvo-io@users.noreply.github.com> Date: Mon, 17 Aug 2026 10:19:07 +0800 Subject: [PATCH 1/6] feat(core,storage): add user-overridable model facts Generated-by: Codex --- packages/core/package.json | 1 + .../core/src/__tests__/model-catalog.test.ts | 45 ++++ .../core/src/__tests__/model-facts.test.ts | 101 ++++++++ .../__tests__/runtime-policy-codec.test.ts | 27 ++ packages/core/src/model-catalog.ts | 128 +++++++--- packages/core/src/model-facts.ts | 231 ++++++++++++++++++ .../connection-catalog-codec.ts | 66 ++++- .../src/__tests__/model-facts-store.test.ts | 44 ++++ .../runtime-policy-model-facts.test.ts | 69 ++++++ .../__tests__/runtime-policy-stores.test.ts | 2 + packages/storage/src/index.ts | 1 + packages/storage/src/model-facts-store.ts | 73 ++++++ packages/storage/src/runtime-policy-stores.ts | 19 ++ .../storage/src/runtime-policy/coordinator.ts | 125 ++++++++-- .../storage/src/runtime-policy/document-io.ts | 2 +- 15 files changed, 878 insertions(+), 56 deletions(-) create mode 100644 packages/core/src/__tests__/model-facts.test.ts create mode 100644 packages/core/src/model-facts.ts create mode 100644 packages/storage/src/__tests__/model-facts-store.test.ts create mode 100644 packages/storage/src/__tests__/runtime-policy-model-facts.test.ts create mode 100644 packages/storage/src/model-facts-store.ts diff --git a/packages/core/package.json b/packages/core/package.json index ffc9eec2e1..3c1894a6af 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -81,6 +81,7 @@ "./llm-connections": "./dist/llm-connections.js", "./provider-registry": "./dist/provider-registry.js", "./model-catalog": "./dist/model-catalog.js", + "./model-facts": "./dist/model-facts.js", "./model-metadata": "./dist/model-metadata.js", "./model-web-search": "./dist/model-web-search.js", "./model-thinking": "./dist/model-thinking.js", diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 3a6c57a9ac..f7e7c01db4 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -174,3 +174,48 @@ test('Alibaba Token Plan catalogs the formal Qwen3.8 model instead of its retire assert.equal(model?.canUseAsChatDefault, true, providerType); } }); + +test('saved model choices can materialize a user fact override without exposing unrelated entries', () => { + const entries = buildConnectionModelCatalogEntries({ + connection: { + slug: 'zai-live', + providerType: 'zai-coding-plan', + defaultModel: 'glm-4.7', + models: [{ id: 'glm-4.7' }], + modelSource: 'fetched', + }, + savedModelIds: [{ id: 'saved-custom', source: 'session_model' }], + modelFactOverrides: { + 'zai-coding-plan:saved-custom': { + displayName: 'Saved Custom', + contextWindow: 88_000, + knowledgeCutoff: '2026-01-01', + structuredOutput: true, + lastUpdated: '2026-02-01', + modalities: { input: ['text'], output: ['text'] }, + }, + 'zai-coding-plan:hidden': { contextWindow: 1_000 }, + }, + }); + const saved = entries.find((entry) => entry.id === 'saved-custom'); + assert.equal(saved?.displayName, 'Saved Custom'); + assert.equal(saved?.contextWindow, 88_000); + assert.equal(saved?.knowledgeCutoff, '2026-01-01'); + assert.equal(saved?.structuredOutput, true); + assert.equal(saved?.lastUpdated, '2026-02-01'); + assert.deepEqual(saved?.modalities, { input: ['text'], output: ['text'] }); + assert.equal( + entries.some((entry) => entry.id === 'hidden'), + false, + ); +}); + +test('catalog capability merges retain provider web search facts alongside overrides', () => { + const entries = buildModelCatalogEntries({ + providerType: 'deepseek', + models: [{ id: 'deepseek-v4-flash', capabilities: { webSearch: true } }], + modelSource: 'fetched', + modelFactOverrides: { 'deepseek:deepseek-v4-flash': { capabilities: { chat: true } } }, + }); + assert.equal(entries[0]?.capabilities.webSearch, true); +}); diff --git a/packages/core/src/__tests__/model-facts.test.ts b/packages/core/src/__tests__/model-facts.test.ts new file mode 100644 index 0000000000..0706cd14f5 --- /dev/null +++ b/packages/core/src/__tests__/model-facts.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyModelFactOverride, + applyModelFactOverridesToConnection, + decodeModelFactsDocument, + modelFactKey, +} from '../model-facts.js'; + +test('model facts use provider:model keys and merge fields without replacing provider facts', () => { + const key = modelFactKey('openai', 'o4-mini'); + const document = decodeModelFactsDocument({ + schemaVersion: 1, + overrides: { [key]: { contextWindow: 200_000, capabilities: { vision: false } } }, + }); + const model = applyModelFactOverride( + { + id: 'o4-mini', + displayName: 'Provider name', + maxOutputTokens: 4_000, + capabilities: { chat: true, vision: true }, + }, + document.overrides[key], + ); + assert.equal(model.displayName, 'Provider name'); + assert.equal(model.contextWindow, 200_000); + assert.deepEqual(model.capabilities, { chat: true, vision: false }); +}); + +test('malformed and unknown model fact fields are rejected', () => { + assert.throws(() => + decodeModelFactsDocument({ schemaVersion: 1, overrides: { 'openai:o4-mini': { nope: true } } }), + ); + assert.throws(() => + decodeModelFactsDocument({ schemaVersion: 1, overrides: { 'o4-mini': { contextWindow: 1 } } }), + ); + assert.throws(() => + decodeModelFactsDocument({ + schemaVersion: 1, + overrides: { 'openai:o4-mini': { contextWindow: 0 } }, + }), + ); + assert.throws(() => + decodeModelFactsDocument({ + schemaVersion: 1, + overrides: { 'openai:o4-mini': { capabilities: { toString: true } } }, + }), + ); +}); + +test('model fact keys preserve colons in provider model ids', () => { + const key = modelFactKey('ollama-cloud', 'gpt-oss:120b'); + assert.equal(key, 'ollama-cloud:gpt-oss:120b'); + const document = decodeModelFactsDocument({ + schemaVersion: 1, + overrides: { [key]: { contextWindow: 131_072 } }, + }); + assert.equal(document.overrides[key]?.contextWindow, 131_072); +}); + +test('override-only models are projected only when enabled', () => { + const connection = { + slug: 'openai', + providerType: 'openai' as const, + defaultModel: 'custom', + enabledModelIds: ['custom'], + models: [{ id: 'provider-model' }], + }; + const result = applyModelFactOverridesToConnection(connection, { + 'openai:custom': { contextWindow: 64_000 }, + 'openai:hidden': { contextWindow: 1_000 }, + }); + assert.deepEqual(result.models, [ + { id: 'provider-model' }, + { id: 'custom', contextWindow: 64_000 }, + ]); +}); + +test('overrides replace fields on discovered models while preserving untouched provider facts', () => { + const result = applyModelFactOverridesToConnection( + { + providerType: 'openai', + defaultModel: 'provider-model', + enabledModelIds: ['provider-model'], + models: [ + { id: 'provider-model', contextWindow: 8_000, capabilities: { chat: true, vision: true } }, + ], + }, + { 'openai:provider-model': { contextWindow: 64_000, capabilities: { vision: false } } }, + ); + assert.equal(result.models?.[0]?.contextWindow, 64_000); + assert.deepEqual(result.models?.[0]?.capabilities, { chat: true, vision: false }); +}); + +test('catalog capabilities preserve web search facts from metadata and overrides', () => { + const result = applyModelFactOverride( + { id: 'web-model', capabilities: { webSearch: true } }, + { capabilities: { chat: true } }, + ); + assert.deepEqual(result.capabilities, { webSearch: true, chat: true }); +}); diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index 491bb2d340..a51e830e7a 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -373,6 +373,33 @@ test('normalizes exact bounded model discovery results', () => { } }); +test('normalizes extended model facts used by the runtime host catalog', () => { + const result = normalizeConnectionModelDiscoveryResult({ + models: [ + { + id: 'custom-model', + description: 'A custom model', + inputLimit: 120_000, + knowledgeCutoff: '2025-01', + structuredOutput: true, + lastUpdated: '2026-01-01', + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + ], + source: 'fetched', + fetchedAt: 42, + }); + assert.deepEqual(result.models[0], { + id: 'custom-model', + description: 'A custom model', + inputLimit: 120_000, + knowledgeCutoff: '2025-01', + structuredOutput: true, + lastUpdated: '2026-01-01', + modalities: { input: ['text', 'image'], output: ['text'] }, + }); +}); + test('credential domain validation requires material but leaves capacity to callers', () => { const input = normalizeSetCredentialInput({ locator: { diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index bbb789cb0b..680544f0e3 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -8,6 +8,11 @@ import { PROVIDER_DEFAULTS, providerSupportsModelDiscovery } from './llm-connect import type { PricingConfig } from './usage-stats/types.js'; import { curatedCatalogFallbackModelsForProvider, lookupModelMetadata } from './model-metadata.js'; import { pricingModelKey } from './usage-stats/pricing.js'; +import { + applyModelFactOverride, + lookupModelFactOverride, + type ModelFactOverrides, +} from './model-facts.js'; export type ModelCapabilitySource = 'provider_api' | 'static_catalog' | 'user_override' | 'unknown'; @@ -34,6 +39,7 @@ export interface KnownModelCapabilities { reasoning?: true; functionCalling?: true; imageGeneration?: true; + webSearch?: true; } export interface ModelCatalogPricing { @@ -109,6 +115,7 @@ export interface BuildConnectionModelCatalogInput { authOk?: boolean; pricing?: Iterable; pricingSource?: 'builtin' | 'user_override'; + modelFactOverrides?: ModelFactOverrides; } export interface BuildModelCatalogInput { @@ -126,6 +133,7 @@ export interface BuildModelCatalogInput { pricing?: Iterable; pricingSource?: 'builtin' | 'user_override'; savedModelIds?: Iterable; + modelFactOverrides?: ModelFactOverrides; } const DEFAULT_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; @@ -158,7 +166,10 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa .map((model) => makeEntry( input, - model, + applyModelFactOverride( + model, + lookupModelFactOverride(input.modelFactOverrides, input.providerType, model.id.trim()), + ), source, modelSource, savedChoiceSources, @@ -228,6 +239,7 @@ export function buildConnectionModelCatalogEntries( pricing: input.pricing, pricingSource: input.pricingSource, savedModelIds: input.savedModelIds, + modelFactOverrides: input.modelFactOverrides, }); } @@ -290,11 +302,17 @@ function makeEntry( providerType: input.providerType, ...(input.connectionSlug ? { connectionSlug: input.connectionSlug } : {}), source, - capabilitySource: normalizedModel.capabilities - ? source - : metadata.capabilities - ? 'static_catalog' - : 'unknown', + capabilitySource: lookupModelFactOverride( + input.modelFactOverrides, + input.providerType, + normalizedModel.id, + )?.capabilities + ? 'user_override' + : normalizedModel.capabilities + ? source + : metadata.capabilities + ? 'static_catalog' + : 'unknown', unavailableReason, availability: availabilityOf(unavailableReason), canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), @@ -340,6 +358,7 @@ function mergeCapabilities( reasoning: providerCapabilities.reasoning ?? metadataCapabilities.reasoning, functionCalling: providerCapabilities.functionCalling ?? metadataCapabilities.functionCalling, imageGeneration: providerCapabilities.imageGeneration ?? metadataCapabilities.imageGeneration, + webSearch: providerCapabilities.webSearch ?? metadataCapabilities.webSearch, }; } @@ -353,36 +372,56 @@ function makeMissingDefaultEntry( ): ModelCatalogEntry { const unavailableReason = missingEntryUnavailableReason(input, modelSource); const metadata = lookupModelMetadata(input.providerType, id); + const model = applyModelFactOverride( + { id }, + lookupModelFactOverride(input.modelFactOverrides, input.providerType, id), + ); const recommendedRank = recommendedRanks.get(id); return { id, - ...displayNameForKnownModel(input.providerType, id), - ...(metadata.description !== undefined ? { description: metadata.description } : {}), + ...displayNameForModel(input.providerType, model), + ...((model.description ?? metadata.description) + ? { description: model.description ?? metadata.description } + : {}), providerType: input.providerType, ...(input.connectionSlug ? { connectionSlug: input.connectionSlug } : {}), source: 'unknown', - capabilitySource: metadata.capabilities ? 'static_catalog' : 'unknown', + capabilitySource: model.capabilities + ? 'user_override' + : metadata.capabilities + ? 'static_catalog' + : 'unknown', unavailableReason, availability: availabilityOf(unavailableReason), canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), isDefault: true, - capabilities: normalizeCapabilities(metadata.capabilities), + capabilities: normalizeCapabilities( + mergeCapabilities(model.capabilities, metadata.capabilities), + ), lifecycle: metadata.lifecycle ?? 'unknown', ...(recommendedRank ? { recommendedRank } : {}), ...(metadata.docsUrl ? { docsUrl: metadata.docsUrl } : {}), - ...(metadata.contextWindow !== undefined ? { contextWindow: metadata.contextWindow } : {}), - ...(metadata.inputLimit !== undefined ? { inputLimit: metadata.inputLimit } : {}), - ...(metadata.maxOutputTokens !== undefined - ? { maxOutputTokens: metadata.maxOutputTokens } + ...((model.contextWindow ?? metadata.contextWindow) !== undefined + ? { contextWindow: model.contextWindow ?? metadata.contextWindow } + : {}), + ...((model.inputLimit ?? metadata.inputLimit) !== undefined + ? { inputLimit: model.inputLimit ?? metadata.inputLimit } + : {}), + ...((model.maxOutputTokens ?? metadata.maxOutputTokens) !== undefined + ? { maxOutputTokens: model.maxOutputTokens ?? metadata.maxOutputTokens } + : {}), + ...((model.knowledgeCutoff ?? metadata.knowledgeCutoff) !== undefined + ? { knowledgeCutoff: model.knowledgeCutoff ?? metadata.knowledgeCutoff } : {}), - ...(metadata.knowledgeCutoff !== undefined - ? { knowledgeCutoff: metadata.knowledgeCutoff } + ...((model.structuredOutput ?? metadata.structuredOutput) !== undefined + ? { structuredOutput: model.structuredOutput ?? metadata.structuredOutput } : {}), - ...(metadata.structuredOutput !== undefined - ? { structuredOutput: metadata.structuredOutput } + ...((model.lastUpdated ?? metadata.lastUpdated) !== undefined + ? { lastUpdated: model.lastUpdated ?? metadata.lastUpdated } + : {}), + ...((model.modalities ?? metadata.modalities) !== undefined + ? { modalities: model.modalities ?? metadata.modalities } : {}), - ...(metadata.lastUpdated !== undefined ? { lastUpdated: metadata.lastUpdated } : {}), - ...(metadata.modalities !== undefined ? { modalities: metadata.modalities } : {}), provenance: { modelSource, ...(input.modelsFetchedAt ? { modelsFetchedAt: input.modelsFetchedAt } : {}), @@ -401,36 +440,56 @@ function makeMissingUserChoiceEntry( ): ModelCatalogEntry { const unavailableReason = missingEntryUnavailableReason(input, modelSource); const metadata = lookupModelMetadata(input.providerType, id); + const model = applyModelFactOverride( + { id }, + lookupModelFactOverride(input.modelFactOverrides, input.providerType, id), + ); const recommendedRank = recommendedRanks.get(id); return { id, - ...displayNameForKnownModel(input.providerType, id), - ...(metadata.description !== undefined ? { description: metadata.description } : {}), + ...displayNameForModel(input.providerType, model), + ...((model.description ?? metadata.description) + ? { description: model.description ?? metadata.description } + : {}), providerType: input.providerType, ...(input.connectionSlug ? { connectionSlug: input.connectionSlug } : {}), source: 'unknown', - capabilitySource: metadata.capabilities ? 'static_catalog' : 'unknown', + capabilitySource: model.capabilities + ? 'user_override' + : metadata.capabilities + ? 'static_catalog' + : 'unknown', unavailableReason, availability: availabilityOf(unavailableReason), canUseAsChatDefault: canUseUnavailableReasonAsDefault(unavailableReason), isDefault: id === normalizedDefaultModel, - capabilities: normalizeCapabilities(metadata.capabilities), + capabilities: normalizeCapabilities( + mergeCapabilities(model.capabilities, metadata.capabilities), + ), lifecycle: metadata.lifecycle ?? 'unknown', ...(recommendedRank ? { recommendedRank } : {}), ...(metadata.docsUrl ? { docsUrl: metadata.docsUrl } : {}), - ...(metadata.contextWindow !== undefined ? { contextWindow: metadata.contextWindow } : {}), - ...(metadata.inputLimit !== undefined ? { inputLimit: metadata.inputLimit } : {}), - ...(metadata.maxOutputTokens !== undefined - ? { maxOutputTokens: metadata.maxOutputTokens } + ...((model.contextWindow ?? metadata.contextWindow) !== undefined + ? { contextWindow: model.contextWindow ?? metadata.contextWindow } + : {}), + ...((model.inputLimit ?? metadata.inputLimit) !== undefined + ? { inputLimit: model.inputLimit ?? metadata.inputLimit } + : {}), + ...((model.maxOutputTokens ?? metadata.maxOutputTokens) !== undefined + ? { maxOutputTokens: model.maxOutputTokens ?? metadata.maxOutputTokens } + : {}), + ...((model.knowledgeCutoff ?? metadata.knowledgeCutoff) !== undefined + ? { knowledgeCutoff: model.knowledgeCutoff ?? metadata.knowledgeCutoff } + : {}), + ...((model.structuredOutput ?? metadata.structuredOutput) !== undefined + ? { structuredOutput: model.structuredOutput ?? metadata.structuredOutput } : {}), - ...(metadata.knowledgeCutoff !== undefined - ? { knowledgeCutoff: metadata.knowledgeCutoff } + ...((model.lastUpdated ?? metadata.lastUpdated) !== undefined + ? { lastUpdated: model.lastUpdated ?? metadata.lastUpdated } : {}), - ...(metadata.structuredOutput !== undefined - ? { structuredOutput: metadata.structuredOutput } + ...((model.modalities ?? metadata.modalities) !== undefined + ? { modalities: model.modalities ?? metadata.modalities } : {}), - ...(metadata.lastUpdated !== undefined ? { lastUpdated: metadata.lastUpdated } : {}), - ...(metadata.modalities !== undefined ? { modalities: metadata.modalities } : {}), provenance: { modelSource, ...(input.modelsFetchedAt ? { modelsFetchedAt: input.modelsFetchedAt } : {}), @@ -565,6 +624,7 @@ function normalizeCapabilities(caps: ModelInfo['capabilities']): KnownModelCapab ...(caps.reasoning === true ? { reasoning: true as const } : {}), ...(caps.functionCalling === true ? { functionCalling: true as const } : {}), ...(caps.imageGeneration === true ? { imageGeneration: true as const } : {}), + ...(caps.webSearch === true ? { webSearch: true as const } : {}), }; } diff --git a/packages/core/src/model-facts.ts b/packages/core/src/model-facts.ts new file mode 100644 index 0000000000..e10a2c6491 --- /dev/null +++ b/packages/core/src/model-facts.ts @@ -0,0 +1,231 @@ +import type { ModelInfo, ProviderType } from './llm-connections.js'; +import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from './runtime-policy.js'; + +export const MODEL_FACTS_SCHEMA_VERSION = 1 as const; +export const MODEL_FACT_KEY_MAX_LENGTH = 512; +export const MODEL_FACTS_MAX_OVERRIDES = 512; + +export type ModelFactOverride = Readonly>>; +export type ModelFactOverrides = Readonly>; + +export interface ModelFactsDocument { + readonly schemaVersion: typeof MODEL_FACTS_SCHEMA_VERSION; + readonly overrides: ModelFactOverrides; +} + +const PROVIDER_ID_PATTERN = /^[^:\s]{1,128}$/; +// Model ids may contain colons (for example, Ollama's `gpt-oss:120b`). The +// provider is the only component that is constrained to the first separator. +const MODEL_ID_PATTERN = /^[^\s]{1,256}$/; +const PROVIDER_MODEL_KEY_PATTERN = /^([^:\s]{1,128}):([^\s]{1,256})$/; +const MAX_FACT_NUMBER = 10_000_000_000; + +export function modelFactKey(providerType: ProviderType | string, modelId: string): string { + const provider = providerType.trim(); + const model = modelId.trim(); + if (!provider || !model || !PROVIDER_ID_PATTERN.test(provider) || !MODEL_ID_PATTERN.test(model)) { + throw new Error('Model fact keys must use a non-empty provider:model identifier'); + } + const key = `${provider}:${model}`; + if (key.length > MODEL_FACT_KEY_MAX_LENGTH) throw new Error('Model fact key is too long'); + return key; +} + +export function lookupModelFactOverride( + overrides: ModelFactOverrides | undefined, + providerType: ProviderType | string, + modelId: string, +): ModelFactOverride | undefined { + if (!overrides) return undefined; + try { + return overrides[modelFactKey(providerType, modelId)]; + } catch { + return undefined; + } +} + +export function decodeModelFactsDocument(value: unknown): ModelFactsDocument { + if (!isRecord(value) || value.schemaVersion !== MODEL_FACTS_SCHEMA_VERSION) { + throw new Error('model-facts.json has an unsupported schema'); + } + if (!isRecord(value.overrides)) throw new Error('model-facts.json.overrides must be an object'); + const keys = Object.keys(value.overrides); + if (keys.length > MODEL_FACTS_MAX_OVERRIDES) + throw new Error('model-facts.json has too many overrides'); + const overrides: Record = {}; + for (const key of keys) { + const match = PROVIDER_MODEL_KEY_PATTERN.exec(key); + if (!match || key.length > MODEL_FACT_KEY_MAX_LENGTH) throw new Error('Invalid model fact key'); + overrides[key] = normalizeModelFactOverride(value.overrides[key]); + } + return { schemaVersion: MODEL_FACTS_SCHEMA_VERSION, overrides }; +} + +export function normalizeModelFactOverride(value: unknown): ModelFactOverride { + if (!isRecord(value)) throw new Error('Model fact override must be an object'); + const allowed = new Set([ + 'displayName', + 'description', + 'apiProtocol', + 'contextWindow', + 'inputLimit', + 'maxOutputTokens', + 'knowledgeCutoff', + 'structuredOutput', + 'lastUpdated', + 'capabilities', + 'modalities', + ]); + for (const key of Object.keys(value)) + if (!allowed.has(key)) throw new Error(`Unknown model fact field: ${key}`); + const result: Record = {}; + for (const key of ['displayName', 'description', 'knowledgeCutoff', 'lastUpdated'] as const) { + if (key in value) { + if (typeof value[key] !== 'string' || value[key].length > 2048) + throw new Error(`Invalid ${key}`); + result[key] = value[key]; + } + } + if ('apiProtocol' in value) { + if ( + value.apiProtocol !== 'openai-chat' && + value.apiProtocol !== 'openai-responses' && + value.apiProtocol !== 'anthropic-messages' + ) + throw new Error('Invalid apiProtocol'); + result.apiProtocol = value.apiProtocol; + } + for (const key of ['contextWindow', 'inputLimit', 'maxOutputTokens'] as const) { + if (key in value) { + const number = value[key]; + if (!isPositiveBoundedInteger(number)) throw new Error(`Invalid ${key}`); + result[key] = number; + } + } + for (const key of ['structuredOutput'] as const) { + if (key in value) { + if (typeof value[key] !== 'boolean') throw new Error(`Invalid ${key}`); + result[key] = value[key]; + } + } + if ('capabilities' in value) result.capabilities = normalizeCapabilities(value.capabilities); + if ('modalities' in value) result.modalities = normalizeModalities(value.modalities); + return result as ModelFactOverride; +} + +function normalizeCapabilities(value: unknown): NonNullable { + if (!isRecord(value)) throw new Error('Invalid capabilities'); + const result: Record = {}; + const allowed = [ + 'chat', + 'vision', + 'reasoning', + 'functionCalling', + 'imageGeneration', + 'webSearch', + ] as const; + for (const key of allowed) { + if (key in value) { + if (typeof value[key] !== 'boolean') throw new Error(`Invalid capability: ${key}`); + result[key] = value[key]; + } + } + for (const key of Object.keys(value)) + if (!allowed.includes(key as (typeof allowed)[number])) { + throw new Error(`Unknown capability: ${key}`); + } + return result; +} + +function normalizeModalities(value: unknown): NonNullable { + if (!isRecord(value) || !Array.isArray(value.input) || !Array.isArray(value.output)) + throw new Error('Invalid modalities'); + const inputs = value.input.filter(isModality); + const outputs = value.output.filter(isOutputModality); + if (inputs.length !== value.input.length || outputs.length !== value.output.length) + throw new Error('Invalid modality value'); + return { input: [...new Set(inputs)], output: [...new Set(outputs)] }; +} + +function isModality(value: unknown): value is 'text' | 'image' | 'audio' | 'pdf' { + return value === 'text' || value === 'image' || value === 'audio' || value === 'pdf'; +} +function isOutputModality(value: unknown): value is 'text' | 'image' | 'audio' { + return value === 'text' || value === 'image' || value === 'audio'; +} +function isPositiveBoundedInteger(value: unknown): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + value > 0 && + value <= MAX_FACT_NUMBER + ); +} +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function applyModelFactOverride( + model: ModelInfo, + override: ModelFactOverride | undefined, +): ModelInfo { + if (!override) return { ...model }; + return { + ...model, + ...override, + id: model.id, + ...(override.capabilities === undefined + ? {} + : { capabilities: { ...model.capabilities, ...override.capabilities } }), + ...(override.modalities === undefined ? {} : { modalities: override.modalities }), + }; +} + +type ModelFactConnectionLike = { + readonly providerType: ProviderType; + readonly defaultModel?: string; + readonly models?: readonly ModelInfo[]; + readonly enabledModelIds?: readonly string[]; +}; + +export function applyModelFactOverridesToConnection( + connection: T, + overrides: ModelFactOverrides, +): T { + const models = (connection.models ?? []).map((model) => + applyModelFactOverride( + model, + lookupModelFactOverride(overrides, connection.providerType, model.id), + ), + ); + const existing = new Set(models.map((model) => model.id)); + const enabled = new Set( + connection.enabledModelIds ?? + (connection.defaultModel === undefined ? [] : [connection.defaultModel]), + ); + for (const modelId of enabled) { + if (existing.has(modelId)) continue; + const override = lookupModelFactOverride(overrides, connection.providerType, modelId); + if (override) { + models.push(applyModelFactOverride({ id: modelId }, override)); + existing.add(modelId); + } + } + return { ...connection, models } as T; +} + +export function applyModelFactOverridesToCatalogSnapshot( + snapshot: ConnectionCatalogSnapshot, + overrides: ModelFactOverrides, +): ConnectionCatalogSnapshot { + return { + ...snapshot, + connections: snapshot.connections.map( + (connection) => + applyModelFactOverridesToConnection( + connection, + overrides, + ) as unknown as ConnectionCatalogEntry, + ), + }; +} diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 9164336979..95726fba5f 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -415,7 +415,20 @@ export function decodeConnectionModel(value: unknown): ConnectionModel { const item = exactRecord( value, 'connection model', - ['id', 'displayName', 'apiProtocol', 'contextWindow', 'maxOutputTokens', 'capabilities'], + [ + 'id', + 'displayName', + 'description', + 'apiProtocol', + 'contextWindow', + 'inputLimit', + 'maxOutputTokens', + 'knowledgeCutoff', + 'structuredOutput', + 'lastUpdated', + 'capabilities', + 'modalities', + ], ['id'], ); if ( @@ -442,11 +455,16 @@ export function decodeConnectionModel(value: unknown): ConnectionModel { ); } } + const modalities = + item.modalities === undefined ? undefined : decodeModelModalities(item.modalities); return { id: decodeConnectionModelId(item.id), ...(item.displayName === undefined ? {} : { displayName: stringValue(item.displayName, 'model display name', 512) }), + ...(item.description === undefined + ? {} + : { description: stringValue(item.description, 'model description', 2048) }), ...(item.apiProtocol === undefined ? {} : { apiProtocol: item.apiProtocol }), ...(item.contextWindow === undefined ? {} @@ -458,6 +476,16 @@ export function decodeConnectionModel(value: unknown): ConnectionModel { Number.MAX_SAFE_INTEGER, ), }), + ...(item.inputLimit === undefined + ? {} + : { + inputLimit: integerValue( + item.inputLimit, + 'model input limit', + 1, + Number.MAX_SAFE_INTEGER, + ), + }), ...(item.maxOutputTokens === undefined ? {} : { @@ -468,10 +496,46 @@ export function decodeConnectionModel(value: unknown): ConnectionModel { Number.MAX_SAFE_INTEGER, ), }), + ...(item.knowledgeCutoff === undefined + ? {} + : { knowledgeCutoff: stringValue(item.knowledgeCutoff, 'model knowledge cutoff', 2048) }), + ...(item.structuredOutput === undefined + ? {} + : { structuredOutput: booleanValue(item.structuredOutput, 'model structured output') }), + ...(item.lastUpdated === undefined + ? {} + : { lastUpdated: stringValue(item.lastUpdated, 'model last updated', 2048) }), ...(capabilities === undefined ? {} : { capabilities }), + ...(modalities === undefined ? {} : { modalities }), }; } +function decodeModelModalities(value: unknown): NonNullable { + const item = exactRecord(value, 'connection model modalities', ['input', 'output']); + if (!Array.isArray(item.input) || !Array.isArray(item.output)) { + throw domainError('connection model modalities must contain input and output arrays'); + } + const input = item.input.map((entry) => decodeModelInputModality(entry)); + const output = item.output.map((entry) => decodeModelOutputModality(entry)); + return { input, output }; +} + +function decodeModelInputModality(value: unknown): 'text' | 'image' | 'audio' | 'pdf' { + const modality = stringValue(value, 'connection model input modality', 16); + if (modality !== 'text' && modality !== 'image' && modality !== 'audio' && modality !== 'pdf') { + throw domainError('connection model input modality is invalid'); + } + return modality; +} + +function decodeModelOutputModality(value: unknown): 'text' | 'image' | 'audio' { + const modality = stringValue(value, 'connection model output modality', 16); + if (modality !== 'text' && modality !== 'image' && modality !== 'audio') { + throw domainError('connection model output modality is invalid'); + } + return modality; +} + export function decodeConnectionTestSummary(value: unknown): ConnectionTestSummary { const item = exactRecord( value, diff --git a/packages/storage/src/__tests__/model-facts-store.test.ts b/packages/storage/src/__tests__/model-facts-store.test.ts new file mode 100644 index 0000000000..553be1cb0c --- /dev/null +++ b/packages/storage/src/__tests__/model-facts-store.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { ModelFactsDocumentOwner } from '../model-facts-store.js'; +import { cleanupRuntimePolicyDocumentTemps } from '../runtime-policy/document-io.js'; + +test('model facts persist and malformed documents fail closed with a bounded diagnostic', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-facts-')); + try { + const owner = new ModelFactsDocumentOwner(); + assert.deepEqual((await owner.read(root)).overrides, {}); + await owner.replace(root, { 'openai:o4-mini': { contextWindow: 200_000 } }); + assert.equal((await owner.read(root)).overrides['openai:o4-mini']?.contextWindow, 200_000); + await writeFile(join(root, 'model-facts.json'), '{not-json}', 'utf8'); + const result = await owner.readWithDiagnostics(root); + assert.equal(result.diagnostic, 'malformed'); + assert.deepEqual(result.document.overrides, {}); + await writeFile( + join(root, 'model-facts.json'), + JSON.stringify({ schemaVersion: 1, overrides: { 'openai:o4-mini': { unknown: true } } }), + 'utf8', + ); + assert.equal((await owner.readWithDiagnostics(root)).diagnostic, 'malformed'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('model facts temporary writes are removed by runtime policy recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-facts-recovery-')); + try { + await writeFile( + join(root, 'model-facts.json.00000000-0000-4000-8000-000000000000.tmp'), + '{}', + 'utf8', + ); + await cleanupRuntimePolicyDocumentTemps(root); + assert.deepEqual(await readdir(root), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts new file mode 100644 index 0000000000..473fb4107c --- /dev/null +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { RuntimePolicyCoordinator } from '../runtime-policy/coordinator.js'; + +test('runtime policy catalog overlays enabled custom model facts without changing the raw catalog', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const created = await coordinator.createConnection({ + expectedCatalogRevision: 0, + connection: { + slug: 'custom-openai', + name: 'Custom OpenAI', + providerType: 'ollama', + enabled: true, + enabledModelIds: ['custom-model'], + }, + }); + assert.equal(created.kind, 'committed'); + assert.equal(Object.isFrozen(created), true); + if (created.kind === 'committed') assert.equal(Object.isFrozen(created.snapshot), true); + const replaced = await coordinator.replaceModelFacts({ + 'ollama:custom-model': { contextWindow: 64_000 }, + }); + assert.equal(Object.isFrozen(replaced), true); + assert.equal(Object.isFrozen(replaced.overrides), true); + const facts = await coordinator.getModelFacts(); + assert.equal(Object.isFrozen(facts), true); + assert.equal(Object.isFrozen(facts.document), true); + assert.equal(Object.isFrozen(facts.document.overrides), true); + const snapshot = await coordinator.getCatalogSnapshot(); + const model = snapshot.connections[0]?.models.find( + (candidate) => candidate.id === 'custom-model', + ); + assert.equal(model?.contextWindow, 64_000); + const prepared = await coordinator.beginConnectionTest( + snapshot.connections[0]!.connectionId, + null, + ); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind === 'ready') { + const tested = await coordinator.completeConnectionTest(prepared.ticket, { + status: 'verified', + checkedAt: '2026-08-01T00:00:00.000Z', + }); + assert.equal(tested.kind, 'committed'); + } + assert.equal( + (await coordinator.getCatalogSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + await coordinator.replaceModelFacts({ + 'ollama:custom-model': { contextWindow: 65_000, apiProtocol: 'openai-responses' }, + }); + assert.equal((await coordinator.getCatalogSnapshot()).connections[0]?.lastTest, undefined); + const restarted = new RuntimePolicyCoordinator((operation) => operation(root)); + const persisted = await restarted.getCatalogSnapshot(); + assert.equal( + persisted.connections[0]?.models.find((candidate) => candidate.id === 'custom-model') + ?.contextWindow, + 65_000, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 64cc2a356d..a5ec5b02f3 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -953,6 +953,7 @@ describe('runtime policy stores', () => { ); const fetch = await stores.operations.beginModelFetch(connection.connectionId); + await stores.modelFacts.replace({ 'openai:gpt-5': { apiProtocol: 'openai-responses' } }); const testTicket = await stores.operations.beginConnectionTest( connection.connectionId, 'gpt-5', @@ -961,6 +962,7 @@ describe('runtime policy stores', () => { assert.equal(testTicket.kind, 'ready'); if (fetch.kind !== 'ready' || testTicket.kind !== 'ready') return; assert.equal(testTicket.modelId, 'gpt-5'); + assert.equal(testTicket.connection.models?.[0]?.apiProtocol, 'openai-responses'); assert.equal(fetch.secretMaterial.connection?.secret, 'effect-secret'); await assert.rejects( diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 1ff5b9a193..a9fcf0ae08 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -140,6 +140,7 @@ export type { } from './operational-state-store.js'; export * from './operational-state-backup.js'; export * from './mcp-config-store.js'; +export * from './model-facts-store.js'; export * from './workspace-identity.js'; export * from './memory-bundle-store.js'; export * from './long-term-memory-store.js'; diff --git a/packages/storage/src/model-facts-store.ts b/packages/storage/src/model-facts-store.ts new file mode 100644 index 0000000000..771e40f022 --- /dev/null +++ b/packages/storage/src/model-facts-store.ts @@ -0,0 +1,73 @@ +import { + decodeModelFactsDocument, + MODEL_FACTS_MAX_OVERRIDES, + MODEL_FACTS_SCHEMA_VERSION, + normalizeModelFactOverride, + type ModelFactOverrides, + type ModelFactsDocument, +} from '@maka/core/model-facts'; +import { RuntimePolicyStoreError } from './runtime-policy/errors.js'; +import { readBoundedJsonDocument, writeJsonDocument } from './runtime-policy/document-io.js'; + +export const MODEL_FACTS_DOCUMENT_MAX_BYTES = 256 * 1024; +const FILE = 'model-facts.json'; + +export interface ModelFactsReadResult { + readonly document: ModelFactsDocument; + readonly diagnostic?: 'malformed' | 'oversized' | 'io_failed'; +} + +export class ModelFactsDocumentOwner { + async read(root: string): Promise { + return (await this.readWithDiagnostics(root)).document; + } + + async readWithDiagnostics(root: string): Promise { + let value: unknown | undefined; + try { + value = await readBoundedJsonDocument(root, FILE, MODEL_FACTS_DOCUMENT_MAX_BYTES); + } catch (error) { + if (error instanceof RuntimePolicyStoreError && error.code === 'invalid_document') { + const diagnostic = error.message.includes('exceeds') ? 'oversized' : 'malformed'; + return { document: emptyDocument(), diagnostic }; + } + throw error; + } + if (value === undefined) return { document: emptyDocument() }; + try { + return { document: decodeModelFactsDocument(value) }; + } catch { + return { document: emptyDocument(), diagnostic: 'malformed' }; + } + } + + async replace(root: string, overrides: ModelFactOverrides): Promise { + const entries = Object.entries(overrides); + if (entries.length > MODEL_FACTS_MAX_OVERRIDES) { + throw new RuntimePolicyStoreError('invalid_policy_input', 'Too many model fact overrides'); + } + let validated: ModelFactsDocument; + try { + const normalized: Record> = {}; + for (const [key, value] of entries) normalized[key] = normalizeModelFactOverride(value); + const document: ModelFactsDocument = { + schemaVersion: MODEL_FACTS_SCHEMA_VERSION, + overrides: normalized, + }; + // Reuse the canonical decoder for key grammar and exact bounded shape. + validated = decodeModelFactsDocument(document); + } catch (error) { + throw new RuntimePolicyStoreError( + 'invalid_policy_input', + error instanceof Error ? error.message : 'Invalid model fact overrides', + { cause: error }, + ); + } + await writeJsonDocument(root, FILE, validated, MODEL_FACTS_DOCUMENT_MAX_BYTES); + return validated; + } +} + +function emptyDocument(): ModelFactsDocument { + return { schemaVersion: MODEL_FACTS_SCHEMA_VERSION, overrides: {} }; +} diff --git a/packages/storage/src/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts index 7eb93556e4..160c3c9813 100644 --- a/packages/storage/src/runtime-policy-stores.ts +++ b/packages/storage/src/runtime-policy-stores.ts @@ -14,6 +14,8 @@ import type { SetDefaultConnectionTargetInput, UpdateCatalogConnectionInput, } from '@maka/core/runtime-policy'; +import type { ModelFactOverrides, ModelFactsDocument } from '@maka/core/model-facts'; +import type { ModelFactsReadResult } from './model-facts-store.js'; import { assertStorageRootLease, runWithStorageRootLease, @@ -89,6 +91,14 @@ export interface ConnectionCatalogWriter extends ConnectionCatalogReader { ): Promise; } +export interface ModelFactsReader { + get(): Promise; +} + +export interface ModelFactsWriter extends ModelFactsReader { + replace(overrides: ModelFactOverrides): Promise; +} + export interface CredentialVaultReader { getSnapshot(): Promise; getStatus(locator: CredentialLocator): Promise; @@ -105,6 +115,7 @@ export interface RuntimePolicyStoresReader { readonly [readerBrand]: true; readonly runtimePolicy: Readonly; readonly connectionCatalog: Readonly; + readonly modelFacts: Readonly; readonly credentialVault: Readonly; } @@ -114,6 +125,7 @@ export interface RuntimePolicyStoresWriter { readonly [writerBrand]: true; readonly runtimePolicy: Readonly; readonly connectionCatalog: Readonly; + readonly modelFacts: Readonly; readonly credentialVault: Readonly; readonly operations: Readonly; } @@ -145,6 +157,7 @@ export async function openInteractiveRuntimePolicyStoresForRead( [readerBrand]: true, runtimePolicy: { getSnapshot: () => coordinator.getPolicySnapshot() }, connectionCatalog: { getSnapshot: () => coordinator.getCatalogSnapshot() }, + modelFacts: { get: () => coordinator.getModelFacts() }, credentialVault: { getSnapshot: () => coordinator.getVaultSnapshot(), getStatus: (locator) => coordinator.getCredentialStatus(locator), @@ -201,6 +214,10 @@ function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolic remove: (input) => coordinator.removeConnection(input), setDefaultTarget: (input) => coordinator.setDefaultTarget(input), }, + modelFacts: { + get: () => coordinator.getModelFacts(), + replace: (overrides) => coordinator.replaceModelFacts(overrides), + }, credentialVault: { getSnapshot: () => coordinator.getVaultSnapshot(), getStatus: (locator) => coordinator.getCredentialStatus(locator), @@ -247,11 +264,13 @@ function invalidFacade(access: 'read' | 'write'): StorageRootAuthorityError { function freezeFacade(stores: { readonly runtimePolicy: object; readonly connectionCatalog: object; + readonly modelFacts: object; readonly credentialVault: object; readonly operations?: object; }): void { Object.freeze(stores.runtimePolicy); Object.freeze(stores.connectionCatalog); + Object.freeze(stores.modelFacts); Object.freeze(stores.credentialVault); if (stores.operations) Object.freeze(stores.operations); Object.freeze(stores); diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index b63bcc7bf1..6f572f38ad 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -32,6 +32,11 @@ import { type SetDefaultConnectionTargetInput, type UpdateCatalogConnectionInput, } from '@maka/core/runtime-policy'; +import { + applyModelFactOverridesToConnection, + applyModelFactOverridesToCatalogSnapshot, + type ModelFactOverrides, +} from '@maka/core/model-facts'; import { deriveProviderAuthContract, type ProviderAuthAction } from '@maka/core/provider-auth'; import { deriveConnectionSlug, @@ -103,6 +108,7 @@ import { } from './onboarding-transaction.js'; import { policySnapshot, RuntimePolicyDocumentOwner } from './policy-document.js'; import { SerializedOperationLane } from '../serialized-operation-lane.js'; +import { ModelFactsDocumentOwner } from '../model-facts-store.js'; type RootExecutor = (operation: (root: string) => Promise) => Promise; @@ -175,6 +181,7 @@ export class RuntimePolicyCoordinator { private readonly policy = new RuntimePolicyDocumentOwner(); private readonly catalog = new ConnectionCatalogDocumentOwner(); private readonly vault = new CredentialVaultDocumentOwner(); + private readonly modelFacts = new ModelFactsDocumentOwner(); private readonly tickets = new WeakMap(); private onboardingRecoveryRequired = false; @@ -201,7 +208,28 @@ export class RuntimePolicyCoordinator { } getCatalogSnapshot() { - return this.inLane(async (root) => catalogSnapshot(await this.catalog.read(root))); + return this.inLane(async (root) => + this.projectCatalogSnapshot(root, catalogSnapshot(await this.catalog.read(root))), + ); + } + + getModelFacts() { + return this.inLane(async (root) => deepFreeze(await this.modelFacts.readWithDiagnostics(root))); + } + + replaceModelFacts(overrides: ModelFactOverrides) { + return this.inLane(async (root) => { + const document = await this.modelFacts.replace(root, overrides); + try { + await this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)); + } catch (error) { + throw commitOutcomeUnknown( + 'Model facts were replaced before connection verification could be cleared', + error, + ); + } + return deepFreeze(document); + }); } getVaultSnapshot() { @@ -249,11 +277,15 @@ export class RuntimePolicyCoordinator { } createConnection(input: CreateCatalogConnectionInput) { - return this.inLane((root) => this.catalog.create(root, input)); + return this.inLane(async (root) => + this.projectCatalogMutation(root, await this.catalog.create(root, input)), + ); } updateConnection(input: UpdateCatalogConnectionInput) { - return this.inLane((root) => this.catalog.update(root, input)); + return this.inLane(async (root) => + this.projectCatalogMutation(root, await this.catalog.update(root, input)), + ); } removeConnection(rawInput: RemoveCatalogConnectionInput) { @@ -274,7 +306,10 @@ export class RuntimePolicyCoordinator { const vault = await this.vault.read(root); if (!connection) { await this.vault.deleteConnectionCredentials(root, vault, expected.connectionId); - return deepFreeze({ kind: 'committed' as const, snapshot: catalogSnapshot(catalog) }); + return deepFreeze({ + kind: 'committed' as const, + snapshot: await this.projectCatalogSnapshot(root, catalogSnapshot(catalog)), + }); } const result = await this.catalog.remove(root, { expected }); if (result.kind === 'committed') { @@ -287,12 +322,14 @@ export class RuntimePolicyCoordinator { ); } } - return result; + return this.projectCatalogMutation(root, result); }); } setDefaultTarget(input: SetDefaultConnectionTargetInput) { - return this.inLane((root) => this.catalog.setDefaultTarget(root, input)); + return this.inLane(async (root) => + this.projectCatalogMutation(root, await this.catalog.setDefaultTarget(root, input)), + ); } setCredential(rawInput: SetCredentialInput) { @@ -581,7 +618,10 @@ export class RuntimePolicyCoordinator { if (prepared.kind !== 'ready') return prepared; return deepFreeze({ kind: 'ready' as const, - connection: structuredClone(connection), + connection: applyModelFactOverridesToConnection( + structuredClone(connection), + (await this.modelFacts.read(root)).overrides, + ), secretMaterial: prepared.secretMaterial, networkProxy: structuredClone(prepared.networkProxy), }); @@ -879,7 +919,10 @@ export class RuntimePolicyCoordinator { connectionBasis(checked.connection), result, ); - return deepFreeze({ kind: 'committed' as const, snapshot }); + return deepFreeze({ + kind: 'committed' as const, + snapshot: await this.projectCatalogSnapshot(root, snapshot), + }); }), ); } @@ -947,7 +990,11 @@ export class RuntimePolicyCoordinator { const result = await this.applyConnectionOnboarding(root, intent); await clearConnectionOnboardingIntent(root); this.onboardingRecoveryRequired = false; - return deepFreeze({ kind: 'committed' as const, ...result }); + return deepFreeze({ + kind: 'committed' as const, + ...result, + snapshot: await this.projectCatalogSnapshot(root, result.snapshot), + }); } catch (error) { this.onboardingRecoveryRequired = true; if (isCommitOutcomeUnknown(error)) throw error; @@ -973,21 +1020,26 @@ export class RuntimePolicyCoordinator { 'test_credentials', ); if (prepared.kind !== 'ready') return prepared; + const projectedConnection = applyModelFactOverridesToConnection( + structuredClone(prepared.connection), + (await this.modelFacts.read(root)).overrides, + ); + const projected = { ...prepared, connection: projectedConnection }; const modelId = rawModelId === null ? null : decodeConnectionInput(() => decodeConnectionModelId(rawModelId)); - if (modelId !== null && !isCanonicalConnectionTestModel(prepared.connection, modelId)) { + if (modelId !== null && !isCanonicalConnectionTestModel(projectedConnection, modelId)) { throw codecError( 'invalid_connection_input', 'Connection test model is not in the canonical model set', ); } - const ticket = this.issueTicket('connection_test', connectionTestSemanticBasis(prepared)); + const ticket = this.issueTicket('connection_test', connectionTestSemanticBasis(projected)); return deepFreeze({ kind: 'ready' as const, ticket: ticket as ConnectionTestTicket, - connection: structuredClone(prepared.connection), + connection: projectedConnection, modelId, secretMaterial: prepared.secretMaterial, networkProxy: structuredClone(prepared.networkProxy), @@ -1013,7 +1065,10 @@ export class RuntimePolicyCoordinator { connectionBasis(checked.connection), result, ); - return deepFreeze({ kind: 'committed' as const, snapshot }); + return deepFreeze({ + kind: 'committed' as const, + snapshot: await this.projectCatalogSnapshot(root, snapshot), + }); }), ); } @@ -1157,17 +1212,25 @@ export class RuntimePolicyCoordinator { }> { const connection = findConnection(catalog, { connectionId: basis.connectionId }); const changed: ConnectionEffectChangedDomain[] = []; + const effectiveConnection = + connection && basis.kind === 'connection_test' + ? applyModelFactOverridesToConnection( + connection, + (await this.modelFacts.read(root)).overrides, + ) + : connection; if ( - !connection || - connection.providerType !== basis.providerType || - !connection.enabled || - canonicalEffectiveEndpoint(connection) !== basis.effectiveEndpoint || + !effectiveConnection || + effectiveConnection.providerType !== basis.providerType || + !effectiveConnection.enabled || + canonicalEffectiveEndpoint(effectiveConnection) !== basis.effectiveEndpoint || (basis.kind === 'model_fetch' && - !sameStringArray(connection.enabledModelIds, basis.enabledModelIds)) || + !sameStringArray(effectiveConnection.enabledModelIds, basis.enabledModelIds)) || (basis.kind === 'connection_test' && - JSON.stringify(connection.requestBodyOverlay ?? {}) !== basis.requestBodyOverlayJson) || + JSON.stringify(effectiveConnection.requestBodyOverlay ?? {}) !== + basis.requestBodyOverlayJson) || (basis.kind === 'connection_test' && - !sameConnectionTestModelBasis(connectionTestModelBasis(connection), basis.model)) + !sameConnectionTestModelBasis(connectionTestModelBasis(effectiveConnection), basis.model)) ) { changed.push('connection'); } @@ -1343,6 +1406,28 @@ export class RuntimePolicyCoordinator { return operation(root); }); } + + private async projectCatalogSnapshot( + root: string, + snapshot: ConnectionCatalogSnapshot, + ): Promise { + const facts = await this.modelFacts.readWithDiagnostics(root); + return deepFreeze(applyModelFactOverridesToCatalogSnapshot(snapshot, facts.document.overrides)); + } + + private async projectCatalogMutation( + root: string, + result: T, + ): Promise { + if (result.kind !== 'committed' || !('snapshot' in result)) return result; + return deepFreeze({ + ...result, + snapshot: await this.projectCatalogSnapshot( + root, + (result as T & { readonly snapshot: ConnectionCatalogSnapshot }).snapshot, + ), + }) as T; + } } function isCommitOutcomeUnknown(error: unknown): error is RuntimePolicyStoreError { diff --git a/packages/storage/src/runtime-policy/document-io.ts b/packages/storage/src/runtime-policy/document-io.ts index 5632836253..723e554bb4 100644 --- a/packages/storage/src/runtime-policy/document-io.ts +++ b/packages/storage/src/runtime-policy/document-io.ts @@ -16,7 +16,7 @@ export const VAULT_DOCUMENT_MAX_BYTES = 2 * 1024 * 1024; const READ_CHUNK_BYTES = 64 * 1024; const RUNTIME_POLICY_TEMP_PATTERN = - /^(?:runtime-policy|connection-catalog|credential-vault|runtime-policy-onboarding)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; + /^(?:runtime-policy|connection-catalog|credential-vault|runtime-policy-onboarding|model-facts)\.json\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/; export async function cleanupRuntimePolicyDocumentTemps(root: string): Promise { let failure: unknown; From 13f4751acf449a36ebf55853e33f63373e7b38e5 Mon Sep 17 00:00:00 2001 From: Nyvo <75425811+Nyvo-io@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:16:22 +0800 Subject: [PATCH 2/6] fix(storage): close model facts replacement races Generated-by: Codex --- .../src/__tests__/model-facts-store.test.ts | 17 +++++++ .../runtime-policy-model-facts.test.ts | 45 ++++++++++++++++++- packages/storage/src/model-facts-store.ts | 16 ++++++- .../storage/src/runtime-policy/coordinator.ts | 39 +++++++++++++--- 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/packages/storage/src/__tests__/model-facts-store.test.ts b/packages/storage/src/__tests__/model-facts-store.test.ts index 553be1cb0c..9fe578f786 100644 --- a/packages/storage/src/__tests__/model-facts-store.test.ts +++ b/packages/storage/src/__tests__/model-facts-store.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { ModelFactsDocumentOwner } from '../model-facts-store.js'; +import { RuntimePolicyStoreError } from '../runtime-policy/errors.js'; import { cleanupRuntimePolicyDocumentTemps } from '../runtime-policy/document-io.js'; test('model facts persist and malformed documents fail closed with a bounded diagnostic', async () => { @@ -28,6 +29,22 @@ test('model facts persist and malformed documents fail closed with a bounded dia } }); +test('model facts reject own prototype keys from JSON input', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-facts-prototype-')); + try { + const owner = new ModelFactsDocumentOwner(); + const overrides = JSON.parse('{"__proto__":{"contextWindow":200000}}'); + await assert.rejects( + () => owner.replace(root, overrides), + (error: unknown) => + error instanceof RuntimePolicyStoreError && error.code === 'invalid_policy_input', + ); + assert.deepEqual((await owner.read(root)).overrides, {}); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('model facts temporary writes are removed by runtime policy recovery', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-model-facts-recovery-')); try { diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts index 473fb4107c..c4d3b1dfed 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { RuntimePolicyCoordinator } from '../runtime-policy/coordinator.js'; +import { RuntimePolicyStoreError } from '../runtime-policy/errors.js'; test('runtime policy catalog overlays enabled custom model facts without changing the raw catalog', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-')); @@ -52,9 +53,23 @@ test('runtime policy catalog overlays enabled custom model facts without changin (await coordinator.getCatalogSnapshot()).connections[0]?.lastTest?.status, 'verified', ); + const staleTest = await coordinator.beginConnectionTest( + snapshot.connections[0]!.connectionId, + null, + ); + assert.equal(staleTest.kind, 'ready'); await coordinator.replaceModelFacts({ - 'ollama:custom-model': { contextWindow: 65_000, apiProtocol: 'openai-responses' }, + 'ollama:custom-model': { contextWindow: 65_000 }, }); + if (staleTest.kind === 'ready') { + assert.deepEqual( + await coordinator.completeConnectionTest(staleTest.ticket, { + status: 'verified', + checkedAt: '2026-08-01T00:01:00.000Z', + }), + { kind: 'superseded', changed: ['connection'] }, + ); + } assert.equal((await coordinator.getCatalogSnapshot()).connections[0]?.lastTest, undefined); const restarted = new RuntimePolicyCoordinator((operation) => operation(root)); const persisted = await restarted.getCatalogSnapshot(); @@ -67,3 +82,31 @@ test('runtime policy catalog overlays enabled custom model facts without changin await rm(root, { recursive: true, force: true }); } }); + +test('model facts are not persisted when verification invalidation fails', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-invalidation-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const catalogOwner = ( + coordinator as unknown as { + catalog: { clearAllConnectionLastTests: () => Promise }; + } + ).catalog; + const original = catalogOwner.clearAllConnectionLastTests; + catalogOwner.clearAllConnectionLastTests = async () => { + throw new Error('injected invalidation failure'); + }; + try { + await assert.rejects( + () => coordinator.replaceModelFacts({ 'ollama:custom-model': { contextWindow: 64_000 } }), + (error: unknown) => + error instanceof RuntimePolicyStoreError && error.code === 'commit_outcome_unknown', + ); + } finally { + catalogOwner.clearAllConnectionLastTests = original; + } + assert.deepEqual((await coordinator.getModelFacts()).document.overrides, {}); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/src/model-facts-store.ts b/packages/storage/src/model-facts-store.ts index 771e40f022..609cca1949 100644 --- a/packages/storage/src/model-facts-store.ts +++ b/packages/storage/src/model-facts-store.ts @@ -42,13 +42,21 @@ export class ModelFactsDocumentOwner { } async replace(root: string, overrides: ModelFactOverrides): Promise { + const validated = this.prepareReplacement(overrides); + return this.writeReplacement(root, validated); + } + + prepareReplacement(overrides: ModelFactOverrides): ModelFactsDocument { const entries = Object.entries(overrides); if (entries.length > MODEL_FACTS_MAX_OVERRIDES) { throw new RuntimePolicyStoreError('invalid_policy_input', 'Too many model fact overrides'); } let validated: ModelFactsDocument; try { - const normalized: Record> = {}; + const normalized: Record< + string, + ReturnType + > = Object.create(null); for (const [key, value] of entries) normalized[key] = normalizeModelFactOverride(value); const document: ModelFactsDocument = { schemaVersion: MODEL_FACTS_SCHEMA_VERSION, @@ -63,9 +71,13 @@ export class ModelFactsDocumentOwner { { cause: error }, ); } - await writeJsonDocument(root, FILE, validated, MODEL_FACTS_DOCUMENT_MAX_BYTES); return validated; } + + async writeReplacement(root: string, document: ModelFactsDocument): Promise { + await writeJsonDocument(root, FILE, document, MODEL_FACTS_DOCUMENT_MAX_BYTES); + return document; + } } function emptyDocument(): ModelFactsDocument { diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index 6f572f38ad..bd93490229 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -158,6 +158,7 @@ type SemanticConnectionBasis = readonly kind: 'connection_test'; readonly requestBodyOverlayJson: string; readonly model: ConnectionTestModelBasis; + readonly modelFactsGeneration: number; }); interface ConnectionTicketRecord { @@ -182,6 +183,7 @@ export class RuntimePolicyCoordinator { private readonly catalog = new ConnectionCatalogDocumentOwner(); private readonly vault = new CredentialVaultDocumentOwner(); private readonly modelFacts = new ModelFactsDocumentOwner(); + private modelFactsGeneration = 0; private readonly tickets = new WeakMap(); private onboardingRecoveryRequired = false; @@ -219,16 +221,32 @@ export class RuntimePolicyCoordinator { replaceModelFacts(overrides: ModelFactOverrides) { return this.inLane(async (root) => { - const document = await this.modelFacts.replace(root, overrides); + const document = this.modelFacts.prepareReplacement(overrides); + let cleared = false; try { - await this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)); + cleared = await this.catalog.clearAllConnectionLastTests( + root, + await this.catalog.read(root), + ); } catch (error) { throw commitOutcomeUnknown( - 'Model facts were replaced before connection verification could be cleared', + 'Connection verification clearing failed before model facts replacement', error, ); } - return deepFreeze(document); + try { + const persisted = await this.modelFacts.writeReplacement(root, document); + this.modelFactsGeneration += 1; + return deepFreeze(persisted); + } catch (error) { + if (cleared) { + throw commitOutcomeUnknown( + 'Connection verification was cleared before model facts replacement completed', + error, + ); + } + throw error; + } }); } @@ -1035,7 +1053,10 @@ export class RuntimePolicyCoordinator { 'Connection test model is not in the canonical model set', ); } - const ticket = this.issueTicket('connection_test', connectionTestSemanticBasis(projected)); + const ticket = this.issueTicket( + 'connection_test', + connectionTestSemanticBasis(projected, this.modelFactsGeneration), + ); return deepFreeze({ kind: 'ready' as const, ticket: ticket as ConnectionTestTicket, @@ -1212,6 +1233,12 @@ export class RuntimePolicyCoordinator { }> { const connection = findConnection(catalog, { connectionId: basis.connectionId }); const changed: ConnectionEffectChangedDomain[] = []; + if ( + basis.kind === 'connection_test' && + basis.modelFactsGeneration !== this.modelFactsGeneration + ) { + changed.push('connection'); + } const effectiveConnection = connection && basis.kind === 'connection_test' ? applyModelFactOverridesToConnection( @@ -1461,12 +1488,14 @@ function modelFetchSemanticBasis( function connectionTestSemanticBasis( prepared: PreparedConnectionMaterial, + modelFactsGeneration: number, ): Extract { return { kind: 'connection_test', ...commonSemanticConnectionBasis(prepared), requestBodyOverlayJson: JSON.stringify(prepared.connection.requestBodyOverlay ?? {}), model: connectionTestModelBasis(prepared.connection), + modelFactsGeneration, }; } From 2f7144988332eac823b72341cf3d61dd3d4eebb9 Mon Sep 17 00:00:00 2001 From: Nyvo <75425811+Nyvo-io@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:36:37 +0800 Subject: [PATCH 3/6] fix(core,storage): harden model facts replacement Generated-by: Codex --- .../__tests__/runtime-policy-codec.test.ts | 17 ++ .../connection-catalog-codec.ts | 4 +- .../runtime-policy-model-facts.test.ts | 179 +++++++++++++++++- packages/storage/src/model-facts-store.ts | 43 ++++- .../storage/src/runtime-policy/coordinator.ts | 75 +++++--- .../storage/src/runtime-policy/document-io.ts | 37 ++-- 6 files changed, 307 insertions(+), 48 deletions(-) diff --git a/packages/core/src/__tests__/runtime-policy-codec.test.ts b/packages/core/src/__tests__/runtime-policy-codec.test.ts index a51e830e7a..ac80209a3c 100644 --- a/packages/core/src/__tests__/runtime-policy-codec.test.ts +++ b/packages/core/src/__tests__/runtime-policy-codec.test.ts @@ -400,6 +400,23 @@ test('normalizes extended model facts used by the runtime host catalog', () => { }); }); +test('rejects sparse model modality arrays', () => { + assert.throws( + () => + normalizeConnectionModelDiscoveryResult({ + models: [ + { + id: 'custom-model', + modalities: { input: Array(1), output: ['text'] }, + }, + ], + source: 'fetched', + fetchedAt: 42, + }), + RuntimePolicyDomainDecodeError, + ); +}); + test('credential domain validation requires material but leaves capacity to callers', () => { const input = normalizeSetCredentialInput({ locator: { diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 95726fba5f..cfe50e4d01 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -515,8 +515,8 @@ function decodeModelModalities(value: unknown): NonNullable decodeModelInputModality(entry)); - const output = item.output.map((entry) => decodeModelOutputModality(entry)); + const input = Array.from(item.input, (entry) => decodeModelInputModality(entry)); + const output = Array.from(item.output, (entry) => decodeModelOutputModality(entry)); return { input, output }; } diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts index c4d3b1dfed..439d8aef1a 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { RuntimePolicyCoordinator } from '../runtime-policy/coordinator.js'; import { RuntimePolicyStoreError } from '../runtime-policy/errors.js'; +import { MODEL_FACTS_DOCUMENT_MAX_BYTES, ModelFactsDocumentOwner } from '../model-facts-store.js'; +import { writeJsonDocument } from '../runtime-policy/document-io.js'; test('runtime policy catalog overlays enabled custom model facts without changing the raw catalog', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-')); @@ -110,3 +112,178 @@ test('model facts are not persisted when verification invalidation fails', async await rm(root, { recursive: true, force: true }); } }); + +test('oversized replacements preserve existing connection verification', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-oversized-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + const prepared = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind === 'ready') { + assert.equal( + ( + await coordinator.completeConnectionTest( + prepared.ticket, + verifiedAt('2026-08-01T00:00:00.000Z'), + ) + ).kind, + 'committed', + ); + } + + await assert.rejects( + () => coordinator.replaceModelFacts(oversizedOverrides()), + (error: unknown) => + error instanceof RuntimePolicyStoreError && error.code === 'invalid_policy_input', + ); + assert.equal( + (await coordinator.getCatalogSnapshot()).connections[0]?.lastTest?.status, + 'verified', + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('external model facts edits clear verification, supersede tickets, and warn on malformed input', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-external-edit-')); + const emitWarning = process.emitWarning; + const warnings: string[] = []; + process.emitWarning = ((warning: string | Error) => { + warnings.push(String(warning)); + }) as typeof process.emitWarning; + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + await coordinator.replaceModelFacts({ 'ollama:custom-model': { contextWindow: 64_000 } }); + const verified = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(verified.kind, 'ready'); + if (verified.kind === 'ready') { + assert.equal( + ( + await coordinator.completeConnectionTest( + verified.ticket, + verifiedAt('2026-08-01T00:00:00.000Z'), + ) + ).kind, + 'committed', + ); + } + const ticket = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(ticket.kind, 'ready'); + await writeFile( + join(root, 'model-facts.json'), + JSON.stringify({ + schemaVersion: 1, + overrides: { 'ollama:custom-model': { contextWindow: 65_000 } }, + }), + 'utf8', + ); + if (ticket.kind === 'ready') { + assert.deepEqual( + await coordinator.completeConnectionTest( + ticket.ticket, + verifiedAt('2026-08-01T00:01:00.000Z'), + ), + { kind: 'superseded', changed: ['connection'] }, + ); + } + assert.equal((await coordinator.getCatalogSnapshot()).connections[0]?.lastTest, undefined); + + await writeFile(join(root, 'model-facts.json'), '{not-json}', 'utf8'); + const snapshot = await coordinator.getCatalogSnapshot(); + assert.equal( + snapshot.connections[0]?.models.find((model) => model.id === 'custom-model')?.contextWindow, + undefined, + ); + assert.equal( + warnings.some((warning) => warning.includes('model-facts.json')), + true, + ); + } finally { + process.emitWarning = emitWarning; + await rm(root, { recursive: true, force: true }); + } +}); + +test('a post-publication model facts failure supersedes existing connection-test tickets', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-unknown-write-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + await coordinator.replaceModelFacts({ 'ollama:custom-model': { contextWindow: 64_000 } }); + const ticket = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(ticket.kind, 'ready'); + const owner = (coordinator as unknown as { modelFacts: ModelFactsDocumentOwner }).modelFacts; + const original = owner.writeReplacement; + owner.writeReplacement = async (writeRoot, document) => { + await writeJsonDocument( + writeRoot, + 'model-facts.json', + document, + MODEL_FACTS_DOCUMENT_MAX_BYTES, + async () => { + throw new Error('injected directory sync failure'); + }, + ); + return document; + }; + try { + await assert.rejects( + () => coordinator.replaceModelFacts({ 'ollama:custom-model': { contextWindow: 65_000 } }), + (error: unknown) => + error instanceof RuntimePolicyStoreError && error.code === 'commit_outcome_unknown', + ); + } finally { + owner.writeReplacement = original; + } + if (ticket.kind === 'ready') { + assert.deepEqual( + await coordinator.completeConnectionTest( + ticket.ticket, + verifiedAt('2026-08-01T00:01:00.000Z'), + ), + { kind: 'superseded', changed: ['connection'] }, + ); + } + const restarted = new RuntimePolicyCoordinator((operation) => operation(root)); + assert.equal( + (await restarted.getCatalogSnapshot()).connections[0]?.models.find( + (model) => model.id === 'custom-model', + )?.contextWindow, + 65_000, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function createTestConnection(coordinator: RuntimePolicyCoordinator): Promise { + const created = await coordinator.createConnection({ + expectedCatalogRevision: 0, + connection: { + slug: 'custom-openai', + name: 'Custom OpenAI', + providerType: 'ollama', + enabled: true, + enabledModelIds: ['custom-model'], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') throw new Error('Expected connection creation to commit'); + return created.snapshot.connections[0]!.connectionId; +} + +function verifiedAt(checkedAt: string) { + return { status: 'verified' as const, checkedAt }; +} + +function oversizedOverrides() { + return Object.fromEntries( + Array.from({ length: 512 }, (_, index) => [ + `ollama:custom-model-${index}`, + { description: 'x'.repeat(2_048) }, + ]), + ); +} diff --git a/packages/storage/src/model-facts-store.ts b/packages/storage/src/model-facts-store.ts index 609cca1949..2870b9e009 100644 --- a/packages/storage/src/model-facts-store.ts +++ b/packages/storage/src/model-facts-store.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { decodeModelFactsDocument, MODEL_FACTS_MAX_OVERRIDES, @@ -7,7 +8,11 @@ import { type ModelFactsDocument, } from '@maka/core/model-facts'; import { RuntimePolicyStoreError } from './runtime-policy/errors.js'; -import { readBoundedJsonDocument, writeJsonDocument } from './runtime-policy/document-io.js'; +import { + readBoundedDocumentBytes, + serializeJsonDocument, + writeJsonDocument, +} from './runtime-policy/document-io.js'; export const MODEL_FACTS_DOCUMENT_MAX_BYTES = 256 * 1024; const FILE = 'model-facts.json'; @@ -15,6 +20,7 @@ const FILE = 'model-facts.json'; export interface ModelFactsReadResult { readonly document: ModelFactsDocument; readonly diagnostic?: 'malformed' | 'oversized' | 'io_failed'; + readonly fingerprint: string; } export class ModelFactsDocumentOwner { @@ -23,21 +29,27 @@ export class ModelFactsDocumentOwner { } async readWithDiagnostics(root: string): Promise { - let value: unknown | undefined; + let bytes: Buffer | undefined; try { - value = await readBoundedJsonDocument(root, FILE, MODEL_FACTS_DOCUMENT_MAX_BYTES); + bytes = await readBoundedDocumentBytes(root, FILE, MODEL_FACTS_DOCUMENT_MAX_BYTES); } catch (error) { if (error instanceof RuntimePolicyStoreError && error.code === 'invalid_document') { - const diagnostic = error.message.includes('exceeds') ? 'oversized' : 'malformed'; - return { document: emptyDocument(), diagnostic }; + return { + document: emptyDocument(), + diagnostic: error.message.includes('exceeds') ? 'oversized' : 'malformed', + fingerprint: `invalid:${error.message}`, + }; } throw error; } - if (value === undefined) return { document: emptyDocument() }; + if (bytes === undefined) return { document: emptyDocument(), fingerprint: 'missing' }; + const fingerprint = fingerprintBytes(bytes); + let value: unknown; try { - return { document: decodeModelFactsDocument(value) }; + value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown; + return { document: decodeModelFactsDocument(value), fingerprint }; } catch { - return { document: emptyDocument(), diagnostic: 'malformed' }; + return { document: emptyDocument(), diagnostic: 'malformed', fingerprint }; } } @@ -64,7 +76,14 @@ export class ModelFactsDocumentOwner { }; // Reuse the canonical decoder for key grammar and exact bounded shape. validated = decodeModelFactsDocument(document); + if (serializeJsonDocument(validated).length > MODEL_FACTS_DOCUMENT_MAX_BYTES) { + throw new RuntimePolicyStoreError( + 'invalid_policy_input', + `model-facts.json exceeds its ${MODEL_FACTS_DOCUMENT_MAX_BYTES} byte limit`, + ); + } } catch (error) { + if (error instanceof RuntimePolicyStoreError) throw error; throw new RuntimePolicyStoreError( 'invalid_policy_input', error instanceof Error ? error.message : 'Invalid model fact overrides', @@ -78,8 +97,16 @@ export class ModelFactsDocumentOwner { await writeJsonDocument(root, FILE, document, MODEL_FACTS_DOCUMENT_MAX_BYTES); return document; } + + fingerprint(document: ModelFactsDocument): string { + return fingerprintBytes(serializeJsonDocument(document)); + } } function emptyDocument(): ModelFactsDocument { return { schemaVersion: MODEL_FACTS_SCHEMA_VERSION, overrides: {} }; } + +function fingerprintBytes(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex'); +} diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index bd93490229..7c8b5a2dcb 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -184,6 +184,8 @@ export class RuntimePolicyCoordinator { private readonly vault = new CredentialVaultDocumentOwner(); private readonly modelFacts = new ModelFactsDocumentOwner(); private modelFactsGeneration = 0; + private modelFactsFingerprint: string | undefined; + private warnedModelFactsFingerprint: string | undefined; private readonly tickets = new WeakMap(); private onboardingRecoveryRequired = false; @@ -210,18 +212,17 @@ export class RuntimePolicyCoordinator { } getCatalogSnapshot() { - return this.inLane(async (root) => - this.projectCatalogSnapshot(root, catalogSnapshot(await this.catalog.read(root))), - ); + return this.inLane(async (root) => this.projectCatalogSnapshot(root)); } getModelFacts() { - return this.inLane(async (root) => deepFreeze(await this.modelFacts.readWithDiagnostics(root))); + return this.inLane(async (root) => deepFreeze(await this.readModelFacts(root))); } replaceModelFacts(overrides: ModelFactOverrides) { return this.inLane(async (root) => { const document = this.modelFacts.prepareReplacement(overrides); + await this.readModelFacts(root); let cleared = false; try { cleared = await this.catalog.clearAllConnectionLastTests( @@ -234,11 +235,15 @@ export class RuntimePolicyCoordinator { error, ); } + // Clearing is durable, so no ticket may survive any replacement attempt that follows it. + this.modelFactsGeneration += 1; try { const persisted = await this.modelFacts.writeReplacement(root, document); - this.modelFactsGeneration += 1; + this.modelFactsFingerprint = this.modelFacts.fingerprint(persisted); return deepFreeze(persisted); } catch (error) { + // A post-rename directory sync failure may have published the replacement. + this.modelFactsFingerprint = undefined; if (cleared) { throw commitOutcomeUnknown( 'Connection verification was cleared before model facts replacement completed', @@ -326,7 +331,7 @@ export class RuntimePolicyCoordinator { await this.vault.deleteConnectionCredentials(root, vault, expected.connectionId); return deepFreeze({ kind: 'committed' as const, - snapshot: await this.projectCatalogSnapshot(root, catalogSnapshot(catalog)), + snapshot: await this.projectCatalogSnapshot(root), }); } const result = await this.catalog.remove(root, { expected }); @@ -638,7 +643,7 @@ export class RuntimePolicyCoordinator { kind: 'ready' as const, connection: applyModelFactOverridesToConnection( structuredClone(connection), - (await this.modelFacts.read(root)).overrides, + (await this.readModelFacts(root)).document.overrides, ), secretMaterial: prepared.secretMaterial, networkProxy: structuredClone(prepared.networkProxy), @@ -939,7 +944,7 @@ export class RuntimePolicyCoordinator { ); return deepFreeze({ kind: 'committed' as const, - snapshot: await this.projectCatalogSnapshot(root, snapshot), + snapshot: await this.projectCatalogSnapshot(root), }); }), ); @@ -1011,7 +1016,7 @@ export class RuntimePolicyCoordinator { return deepFreeze({ kind: 'committed' as const, ...result, - snapshot: await this.projectCatalogSnapshot(root, result.snapshot), + snapshot: await this.projectCatalogSnapshot(root), }); } catch (error) { this.onboardingRecoveryRequired = true; @@ -1040,7 +1045,7 @@ export class RuntimePolicyCoordinator { if (prepared.kind !== 'ready') return prepared; const projectedConnection = applyModelFactOverridesToConnection( structuredClone(prepared.connection), - (await this.modelFacts.read(root)).overrides, + (await this.readModelFacts(root)).document.overrides, ); const projected = { ...prepared, connection: projectedConnection }; const modelId = @@ -1088,7 +1093,7 @@ export class RuntimePolicyCoordinator { ); return deepFreeze({ kind: 'committed' as const, - snapshot: await this.projectCatalogSnapshot(root, snapshot), + snapshot: await this.projectCatalogSnapshot(root), }); }), ); @@ -1233,6 +1238,7 @@ export class RuntimePolicyCoordinator { }> { const connection = findConnection(catalog, { connectionId: basis.connectionId }); const changed: ConnectionEffectChangedDomain[] = []; + const facts = basis.kind === 'connection_test' ? await this.readModelFacts(root) : undefined; if ( basis.kind === 'connection_test' && basis.modelFactsGeneration !== this.modelFactsGeneration @@ -1241,10 +1247,7 @@ export class RuntimePolicyCoordinator { } const effectiveConnection = connection && basis.kind === 'connection_test' - ? applyModelFactOverridesToConnection( - connection, - (await this.modelFacts.read(root)).overrides, - ) + ? applyModelFactOverridesToConnection(connection, facts!.document.overrides) : connection; if ( !effectiveConnection || @@ -1434,12 +1437,39 @@ export class RuntimePolicyCoordinator { }); } - private async projectCatalogSnapshot( - root: string, - snapshot: ConnectionCatalogSnapshot, - ): Promise { + private async projectCatalogSnapshot(root: string): Promise { + const facts = await this.readModelFacts(root); + return deepFreeze( + applyModelFactOverridesToCatalogSnapshot( + catalogSnapshot(await this.catalog.read(root)), + facts.document.overrides, + ), + ); + } + + private async readModelFacts(root: string) { const facts = await this.modelFacts.readWithDiagnostics(root); - return deepFreeze(applyModelFactOverridesToCatalogSnapshot(snapshot, facts.document.overrides)); + const changed = + this.modelFactsFingerprint !== undefined && this.modelFactsFingerprint !== facts.fingerprint; + if (changed) { + this.modelFactsGeneration += 1; + try { + await this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)); + } catch (error) { + throw commitOutcomeUnknown( + 'Connection verification clearing failed after model facts changed externally', + error, + ); + } + } + this.modelFactsFingerprint = facts.fingerprint; + if (facts.diagnostic !== undefined && this.warnedModelFactsFingerprint !== facts.fingerprint) { + process.emitWarning(`model-facts.json is ${facts.diagnostic}; ignoring its overrides`, { + type: 'RuntimePolicyWarning', + }); + this.warnedModelFactsFingerprint = facts.fingerprint; + } + return facts; } private async projectCatalogMutation( @@ -1449,10 +1479,7 @@ export class RuntimePolicyCoordinator { if (result.kind !== 'committed' || !('snapshot' in result)) return result; return deepFreeze({ ...result, - snapshot: await this.projectCatalogSnapshot( - root, - (result as T & { readonly snapshot: ConnectionCatalogSnapshot }).snapshot, - ), + snapshot: await this.projectCatalogSnapshot(root), }) as T; } } diff --git a/packages/storage/src/runtime-policy/document-io.ts b/packages/storage/src/runtime-policy/document-io.ts index 723e554bb4..372af9cc23 100644 --- a/packages/storage/src/runtime-policy/document-io.ts +++ b/packages/storage/src/runtime-policy/document-io.ts @@ -65,6 +65,26 @@ export async function readBoundedJsonDocument( file: string, maxBytes: number, ): Promise { + const bytes = await readBoundedDocumentBytes(root, file, maxBytes); + if (bytes === undefined) return undefined; + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch (error) { + throw invalidDocument(`${file} is not valid UTF-8`, error); + } + try { + return JSON.parse(text) as unknown; + } catch (error) { + throw invalidDocument(`${file} is not valid JSON`, error); + } +} + +export async function readBoundedDocumentBytes( + root: string, + file: string, + maxBytes: number, +): Promise { const path = join(root, file); const flags = process.platform === 'win32' @@ -81,7 +101,7 @@ export async function readBoundedJsonDocument( throw ioFailed(`${file} could not be opened`, error); } - let result: unknown | undefined; + let result: Buffer | undefined; let failure: unknown; try { const metadata = await handle.stat(); @@ -102,17 +122,7 @@ export async function readBoundedJsonDocument( } if (total > maxBytes) throw invalidDocument(`${file} exceeds its ${maxBytes} byte limit`); - let text: string; - try { - text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, total)); - } catch (error) { - throw invalidDocument(`${file} is not valid UTF-8`, error); - } - try { - result = JSON.parse(text) as unknown; - } catch (error) { - throw invalidDocument(`${file} is not valid JSON`, error); - } + result = Buffer.concat(chunks, total); } catch (error) { failure = error; } finally { @@ -135,6 +145,7 @@ export async function writeJsonDocument( file: string, value: unknown, maxBytes: number, + synchronizeDirectory: (root: string) => Promise = syncDirectory, ): Promise { const bytes = serializeJsonDocument(value); if (bytes.length > maxBytes) throw invalidDocument(`${file} exceeds its ${maxBytes} byte limit`); @@ -154,7 +165,7 @@ export async function writeJsonDocument( handle = undefined; await rename(temporaryPath, path); published = true; - await syncDirectory(root); + await synchronizeDirectory(root); } catch (error) { failure = error; } finally { From f321119a7f2ebf3576e1a9bc9decfa2ea8326249 Mon Sep 17 00:00:00 2001 From: Nyvo <75425811+Nyvo-io@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:38:10 +0800 Subject: [PATCH 4/6] fix(core,storage): preserve selected model facts on refresh Generated-by: Codex --- .../src/__tests__/llm-connections.test.ts | 27 ++++++++ packages/core/src/llm-connections.ts | 17 ++++- packages/core/src/model-facts.ts | 12 ++++ .../runtime-policy-model-facts.test.ts | 65 +++++++++++++++++++ .../__tests__/runtime-policy-stores.test.ts | 8 ++- .../connection-catalog-document.ts | 8 ++- .../storage/src/runtime-policy/coordinator.ts | 14 ++++ 7 files changed, 145 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/llm-connections.test.ts b/packages/core/src/__tests__/llm-connections.test.ts index e6cf144aed..302f950952 100644 --- a/packages/core/src/__tests__/llm-connections.test.ts +++ b/packages/core/src/__tests__/llm-connections.test.ts @@ -131,6 +131,33 @@ test('model reconciliation never invents a default the user cleared', () => { ); }); +test('model reconciliation preserves enabled model-fact overrides outside live inventory', () => { + assert.deepEqual( + reconcileConnectionAfterModelFetch( + { + defaultModel: 'custom-model', + enabledModelIds: ['custom-model'], + hasModelInventory: true, + }, + [{ id: 'live-model' }], + { factBackedModelIds: new Set(['custom-model']) }, + ), + { defaultModel: 'custom-model', enabledModelIds: ['custom-model'] }, + ); + assert.deepEqual( + reconcileConnectionAfterModelFetch( + { + defaultModel: 'live-model', + enabledModelIds: ['live-model', 'custom-model'], + hasModelInventory: true, + }, + [{ id: 'live-model' }, { id: 'other-live-model' }], + { factBackedModelIds: new Set(['custom-model']) }, + ), + { defaultModel: 'live-model', enabledModelIds: ['live-model', 'custom-model'] }, + ); +}); + test('a renamed id follows its model, and only for a caller that supplies the table', () => { const curated = [{ id: 'claude-opus-5' }, { id: 'claude-haiku-4-5' }]; const stored = { diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 5f0cf88707..d7592d8cd5 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -258,6 +258,12 @@ export function reconcileConnectionAfterModelFetch( * caller that knows the provider's naming supplies the table. */ readonly aliases?: Readonly>; + /** + * Model-fact-backed ids that were already enabled on this connection. + * These remain selectable when a provider omits them from live discovery; + * the ids are never added to the provider inventory itself. + */ + readonly factBackedModelIds?: ReadonlySet; }, ): { defaultModel: string; @@ -290,6 +296,7 @@ export function reconcileConnectionAfterModelFetch( ), ), ]; + const factBackedModelIds = options?.factBackedModelIds ?? new Set(); if (liveIds.length === 0) { const defaultModel = previousDefault; @@ -308,20 +315,24 @@ export function reconcileConnectionAfterModelFetch( if (connection.hasModelInventory || previousEnabled.length > 0) { return { defaultModel: '', - enabledModelIds: previousEnabled.filter((id) => live.has(id)), + enabledModelIds: previousEnabled.filter((id) => live.has(id) || factBackedModelIds.has(id)), }; } return { defaultModel: liveIds[0]!, enabledModelIds: [liveIds[0]!] }; } const defaultModel = - (live.has(previousDefault) ? previousDefault : undefined) ?? + (live.has(previousDefault) || factBackedModelIds.has(previousDefault) + ? previousDefault + : undefined) ?? previousEnabled.find((id) => live.has(id)) ?? liveIds[0]!; // Keep previously enabled ids that still exist live, plus the (possibly // repaired) default. Do not auto-enable the entire discovered catalog. - const keptEnabled = previousEnabled.filter((id) => live.has(id) || id === defaultModel); + const keptEnabled = previousEnabled.filter( + (id) => live.has(id) || factBackedModelIds.has(id) || id === defaultModel, + ); return { defaultModel, enabledModelIds: connectionEnabledModelIds({ diff --git a/packages/core/src/model-facts.ts b/packages/core/src/model-facts.ts index e10a2c6491..dfb7159519 100644 --- a/packages/core/src/model-facts.ts +++ b/packages/core/src/model-facts.ts @@ -44,6 +44,18 @@ export function lookupModelFactOverride( } } +/** Return model ids with facts for one provider without exposing other providers. */ +export function modelFactOverrideIdsForProvider( + overrides: ModelFactOverrides | undefined, + providerType: ProviderType | string, +): string[] { + if (!overrides) return []; + const prefix = `${providerType.trim()}:`; + return Object.keys(overrides) + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)); +} + export function decodeModelFactsDocument(value: unknown): ModelFactsDocument { if (!isRecord(value) || value.schemaVersion !== MODEL_FACTS_SCHEMA_VERSION) { throw new Error('model-facts.json has an unsupported schema'); diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts index 439d8aef1a..5f079e769c 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -85,6 +85,71 @@ test('runtime policy catalog overlays enabled custom model facts without changin } }); +test('model fetch keeps an enabled facts-backed model outside provider inventory', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-refresh-')); + try { + const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + await coordinator.replaceModelFacts({ + 'ollama:custom-model': { contextWindow: 64_000 }, + 'ollama:unselected-model': { contextWindow: 128_000 }, + }); + const beforeRefresh = await coordinator.getCatalogSnapshot(); + const defaulted = await coordinator.setDefaultTarget({ + expectedCatalogRevision: beforeRefresh.revision, + target: { connectionId, modelId: 'custom-model' }, + }); + assert.equal(defaulted.kind, 'committed'); + + const fetch = await coordinator.beginModelFetch(connectionId); + assert.equal(fetch.kind, 'ready'); + if (fetch.kind !== 'ready') return; + const refreshed = await coordinator.completeModelFetch(fetch.ticket, { + models: [{ id: 'live-model' }], + source: 'fetched', + fetchedAt: 1, + }); + assert.equal(refreshed.kind, 'committed'); + if (refreshed.kind !== 'committed') return; + + const raw = await ( + coordinator as unknown as { + catalog: { + read(root: string): Promise<{ + connections: readonly { models: readonly unknown[] }[]; + }>; + }; + } + ).catalog.read(root); + assert.deepEqual(raw.connections[0]?.models, [{ id: 'live-model' }]); + const projected = refreshed.snapshot.connections[0]; + assert.deepEqual(projected?.enabledModelIds, ['custom-model']); + assert.deepEqual(refreshed.snapshot.defaultTarget, { + connectionId, + modelId: 'custom-model', + }); + assert.equal( + projected?.models.find((model) => model.id === 'custom-model')?.contextWindow, + 64_000, + ); + assert.equal( + projected?.models.some((model) => model.id === 'unselected-model'), + false, + ); + + const execution = await coordinator.resolveExecutionConnection('custom-openai'); + assert.equal(execution.kind, 'ready'); + if (execution.kind === 'ready') { + assert.equal( + execution.connection.models?.some((model) => model.id === 'custom-model'), + true, + ); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('model facts are not persisted when verification invalidation fails', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-invalidation-')); try { diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index a5ec5b02f3..1eb13dc8c6 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -997,8 +997,12 @@ describe('runtime policy stores', () => { if (discovered.kind !== 'committed') return; const afterDiscovery = discovered.snapshot.connections[0]; assert.ok(afterDiscovery); - assert.deepEqual(afterDiscovery.models, [{ id: 'gpt-5.1' }, { id: 'gpt-5.2' }]); - assert.deepEqual(afterDiscovery.enabledModelIds, ['gpt-5.1']); + assert.deepEqual(afterDiscovery.models, [ + { id: 'gpt-5.1' }, + { id: 'gpt-5.2' }, + { id: 'gpt-5', apiProtocol: 'openai-responses' }, + ]); + assert.deepEqual(afterDiscovery.enabledModelIds, ['gpt-5']); assert.equal(afterDiscovery.modelSource, 'fetched'); assert.equal(afterDiscovery.modelsFetchedAt, 42); diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index 07dd10b0b0..f8df2735a5 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -301,6 +301,9 @@ export class ConnectionCatalogDocumentOwner { current: ConnectionCatalogDocument, expected: ConnectionVersionBasis, rawResult: ConnectionModelDiscoveryResult, + options?: { + readonly factBackedModelIds?: ReadonlySet; + }, ): Promise { const result = decodeConnectionInput(() => normalizeConnectionModelDiscoveryResult(rawResult)); if (result.models.length === 0) { @@ -328,7 +331,10 @@ export class ConnectionCatalogDocumentOwner { hasModelInventory: previous.models.length > 0, }, result.models, - { aliases: modelIdAliasesForProvider(previous.providerType) }, + { + aliases: modelIdAliasesForProvider(previous.providerType), + factBackedModelIds: options?.factBackedModelIds, + }, ) : { defaultModel: currentDefaultTarget?.modelId ?? previous.enabledModelIds[0] ?? '', diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index 7c8b5a2dcb..dc542b7181 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -35,6 +35,7 @@ import { import { applyModelFactOverridesToConnection, applyModelFactOverridesToCatalogSnapshot, + modelFactOverrideIdsForProvider, type ModelFactOverrides, } from '@maka/core/model-facts'; import { deriveProviderAuthContract, type ProviderAuthAction } from '@maka/core/provider-auth'; @@ -931,16 +932,29 @@ export class RuntimePolicyCoordinator { const claimed = this.claimTicket(ticket, 'model_fetch'); return this.completeClaimedTicket(claimed, () => this.inLane(async (root) => { + const facts = await this.readModelFacts(root); const catalog = await this.catalog.read(root); const checked = await this.checkSemanticConnectionBasis(root, catalog, claimed.basis); if (checked.changed.length > 0 || !checked.connection) { return deepFreeze({ kind: 'superseded' as const, changed: checked.changed }); } + const selectedModelIds = new Set(checked.connection.enabledModelIds); + if (catalog.defaultTarget?.connectionId === checked.connection.connectionId) { + selectedModelIds.add(catalog.defaultTarget.modelId); + } const snapshot = await this.catalog.writeModelFetchResult( root, catalog, connectionBasis(checked.connection), result, + { + factBackedModelIds: new Set( + modelFactOverrideIdsForProvider( + facts.document.overrides, + checked.connection.providerType, + ).filter((modelId) => selectedModelIds.has(modelId)), + ), + }, ); return deepFreeze({ kind: 'committed' as const, From 2d6f745e7af6b96efeac8ecb48cde42cdfea49f8 Mon Sep 17 00:00:00 2001 From: Nyvo <75425811+Nyvo-io@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:37:05 +0800 Subject: [PATCH 5/6] fix(core,storage): make model facts projection authoritative Generated-by: Codex --- .../core/src/__tests__/model-catalog.test.ts | 51 +++---- .../core/src/__tests__/model-facts.test.ts | 22 ++- packages/core/src/llm-connections.ts | 19 +++ packages/core/src/model-catalog.ts | 32 +---- packages/core/src/model-facts.ts | 78 +++++++++-- packages/core/src/runtime-policy.ts | 2 + .../connection-catalog-codec.ts | 10 ++ .../context-budget-model-facts.test.ts | 19 +++ packages/runtime/src/context-budget-policy.ts | 12 +- .../src/__tests__/model-facts-store.test.ts | 46 +++++- .../runtime-policy-model-facts.test.ts | 24 +++- packages/storage/src/model-facts-store.ts | 57 +++++++- packages/storage/src/runtime-policy-stores.ts | 5 +- .../connection-catalog-document.ts | 14 +- .../storage/src/runtime-policy/coordinator.ts | 131 ++++++++++++------ packages/storage/src/runtime-policy/errors.ts | 1 + 16 files changed, 391 insertions(+), 132 deletions(-) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index f7e7c01db4..4ed5b685e0 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -132,6 +132,27 @@ test('connection catalogs preserve user-choice provenance without inventing avai assert.deepEqual(entries[2]?.provenance.sources?.userChoice, ['session_model']); }); +test('catalog provenance follows the projected model facts marker used in production', () => { + const [entry] = buildConnectionModelCatalogEntries({ + connection: { + slug: 'facts', + providerType: 'openai', + defaultModel: 'custom-model', + models: [ + { + id: 'custom-model', + contextWindow: 200_000, + capabilities: { chat: true }, + factOverriddenFields: ['contextWindow', 'capabilities'], + }, + ], + modelSource: 'fetched', + }, + }); + assert.equal(entry?.capabilitySource, 'user_override'); + assert.equal(entry?.contextWindow, 200_000); +}); + test('unknown persisted provider ids return an empty catalog', () => { assert.deepEqual( buildConnectionModelCatalogEntries({ @@ -175,7 +196,7 @@ test('Alibaba Token Plan catalogs the formal Qwen3.8 model instead of its retire } }); -test('saved model choices can materialize a user fact override without exposing unrelated entries', () => { +test('saved model choices do not expose unrelated entries', () => { const entries = buildConnectionModelCatalogEntries({ connection: { slug: 'zai-live', @@ -185,37 +206,11 @@ test('saved model choices can materialize a user fact override without exposing modelSource: 'fetched', }, savedModelIds: [{ id: 'saved-custom', source: 'session_model' }], - modelFactOverrides: { - 'zai-coding-plan:saved-custom': { - displayName: 'Saved Custom', - contextWindow: 88_000, - knowledgeCutoff: '2026-01-01', - structuredOutput: true, - lastUpdated: '2026-02-01', - modalities: { input: ['text'], output: ['text'] }, - }, - 'zai-coding-plan:hidden': { contextWindow: 1_000 }, - }, }); const saved = entries.find((entry) => entry.id === 'saved-custom'); - assert.equal(saved?.displayName, 'Saved Custom'); - assert.equal(saved?.contextWindow, 88_000); - assert.equal(saved?.knowledgeCutoff, '2026-01-01'); - assert.equal(saved?.structuredOutput, true); - assert.equal(saved?.lastUpdated, '2026-02-01'); - assert.deepEqual(saved?.modalities, { input: ['text'], output: ['text'] }); + assert.equal(saved?.displayName, undefined); assert.equal( entries.some((entry) => entry.id === 'hidden'), false, ); }); - -test('catalog capability merges retain provider web search facts alongside overrides', () => { - const entries = buildModelCatalogEntries({ - providerType: 'deepseek', - models: [{ id: 'deepseek-v4-flash', capabilities: { webSearch: true } }], - modelSource: 'fetched', - modelFactOverrides: { 'deepseek:deepseek-v4-flash': { capabilities: { chat: true } } }, - }); - assert.equal(entries[0]?.capabilities.webSearch, true); -}); diff --git a/packages/core/src/__tests__/model-facts.test.ts b/packages/core/src/__tests__/model-facts.test.ts index 0706cd14f5..6b29b02d4e 100644 --- a/packages/core/src/__tests__/model-facts.test.ts +++ b/packages/core/src/__tests__/model-facts.test.ts @@ -46,6 +46,12 @@ test('malformed and unknown model fact fields are rejected', () => { overrides: { 'openai:o4-mini': { capabilities: { toString: true } } }, }), ); + assert.throws(() => + decodeModelFactsDocument({ + schemaVersion: 1, + overrides: { 'toString:model': { contextWindow: 1 } }, + }), + ); }); test('model fact keys preserve colons in provider model ids', () => { @@ -72,10 +78,24 @@ test('override-only models are projected only when enabled', () => { }); assert.deepEqual(result.models, [ { id: 'provider-model' }, - { id: 'custom', contextWindow: 64_000 }, + { + id: 'custom', + contextWindow: 64_000, + inputLimit: 64_000, + factOverriddenFields: ['contextWindow', 'inputLimit'], + }, ]); }); +test('context window facts cannot be truncated by an older input limit', () => { + const result = applyModelFactOverride( + { id: 'model', contextWindow: 8_192, inputLimit: 8_192 }, + { contextWindow: 200_000 }, + ); + assert.equal(result.contextWindow, 200_000); + assert.equal(result.inputLimit, 200_000); +}); + test('overrides replace fields on discovered models while preserving untouched provider facts', () => { const result = applyModelFactOverridesToConnection( { diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index d7592d8cd5..a8abdcde3d 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -86,8 +86,27 @@ export interface ModelInfo { input: Array<'text' | 'image' | 'audio' | 'pdf'>; output: Array<'text' | 'image' | 'audio'>; }; + /** + * Read-time provenance for values overlaid from model-facts.json. This is + * never persisted in a provider inventory; it lets catalog consumers show + * where a projected value came from. + */ + factOverriddenFields?: readonly ModelFactField[]; } +export type ModelFactField = + | 'displayName' + | 'description' + | 'apiProtocol' + | 'contextWindow' + | 'inputLimit' + | 'maxOutputTokens' + | 'knowledgeCutoff' + | 'structuredOutput' + | 'lastUpdated' + | 'capabilities' + | 'modalities'; + export type ModelDiscoverySource = 'fetched' | 'fallback'; export interface ModelDiscoveryResult { diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 680544f0e3..9320f06073 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -8,11 +8,6 @@ import { PROVIDER_DEFAULTS, providerSupportsModelDiscovery } from './llm-connect import type { PricingConfig } from './usage-stats/types.js'; import { curatedCatalogFallbackModelsForProvider, lookupModelMetadata } from './model-metadata.js'; import { pricingModelKey } from './usage-stats/pricing.js'; -import { - applyModelFactOverride, - lookupModelFactOverride, - type ModelFactOverrides, -} from './model-facts.js'; export type ModelCapabilitySource = 'provider_api' | 'static_catalog' | 'user_override' | 'unknown'; @@ -39,7 +34,6 @@ export interface KnownModelCapabilities { reasoning?: true; functionCalling?: true; imageGeneration?: true; - webSearch?: true; } export interface ModelCatalogPricing { @@ -115,7 +109,6 @@ export interface BuildConnectionModelCatalogInput { authOk?: boolean; pricing?: Iterable; pricingSource?: 'builtin' | 'user_override'; - modelFactOverrides?: ModelFactOverrides; } export interface BuildModelCatalogInput { @@ -133,7 +126,6 @@ export interface BuildModelCatalogInput { pricing?: Iterable; pricingSource?: 'builtin' | 'user_override'; savedModelIds?: Iterable; - modelFactOverrides?: ModelFactOverrides; } const DEFAULT_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; @@ -166,10 +158,7 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa .map((model) => makeEntry( input, - applyModelFactOverride( - model, - lookupModelFactOverride(input.modelFactOverrides, input.providerType, model.id.trim()), - ), + model, source, modelSource, savedChoiceSources, @@ -239,7 +228,6 @@ export function buildConnectionModelCatalogEntries( pricing: input.pricing, pricingSource: input.pricingSource, savedModelIds: input.savedModelIds, - modelFactOverrides: input.modelFactOverrides, }); } @@ -302,11 +290,7 @@ function makeEntry( providerType: input.providerType, ...(input.connectionSlug ? { connectionSlug: input.connectionSlug } : {}), source, - capabilitySource: lookupModelFactOverride( - input.modelFactOverrides, - input.providerType, - normalizedModel.id, - )?.capabilities + capabilitySource: normalizedModel.factOverriddenFields?.includes('capabilities') ? 'user_override' : normalizedModel.capabilities ? source @@ -358,7 +342,6 @@ function mergeCapabilities( reasoning: providerCapabilities.reasoning ?? metadataCapabilities.reasoning, functionCalling: providerCapabilities.functionCalling ?? metadataCapabilities.functionCalling, imageGeneration: providerCapabilities.imageGeneration ?? metadataCapabilities.imageGeneration, - webSearch: providerCapabilities.webSearch ?? metadataCapabilities.webSearch, }; } @@ -372,10 +355,7 @@ function makeMissingDefaultEntry( ): ModelCatalogEntry { const unavailableReason = missingEntryUnavailableReason(input, modelSource); const metadata = lookupModelMetadata(input.providerType, id); - const model = applyModelFactOverride( - { id }, - lookupModelFactOverride(input.modelFactOverrides, input.providerType, id), - ); + const model: ModelInfo = { id }; const recommendedRank = recommendedRanks.get(id); return { id, @@ -440,10 +420,7 @@ function makeMissingUserChoiceEntry( ): ModelCatalogEntry { const unavailableReason = missingEntryUnavailableReason(input, modelSource); const metadata = lookupModelMetadata(input.providerType, id); - const model = applyModelFactOverride( - { id }, - lookupModelFactOverride(input.modelFactOverrides, input.providerType, id), - ); + const model: ModelInfo = { id }; const recommendedRank = recommendedRanks.get(id); return { id, @@ -624,7 +601,6 @@ function normalizeCapabilities(caps: ModelInfo['capabilities']): KnownModelCapab ...(caps.reasoning === true ? { reasoning: true as const } : {}), ...(caps.functionCalling === true ? { functionCalling: true as const } : {}), ...(caps.imageGeneration === true ? { imageGeneration: true as const } : {}), - ...(caps.webSearch === true ? { webSearch: true as const } : {}), }; } diff --git a/packages/core/src/model-facts.ts b/packages/core/src/model-facts.ts index dfb7159519..ffdc8facd2 100644 --- a/packages/core/src/model-facts.ts +++ b/packages/core/src/model-facts.ts @@ -1,11 +1,16 @@ -import type { ModelInfo, ProviderType } from './llm-connections.js'; +import { PROVIDER_REGISTRY, type ProviderType } from './provider-registry.js'; +import type { ModelFactField, ModelInfo } from './llm-connections.js'; import type { ConnectionCatalogEntry, ConnectionCatalogSnapshot } from './runtime-policy.js'; export const MODEL_FACTS_SCHEMA_VERSION = 1 as const; export const MODEL_FACT_KEY_MAX_LENGTH = 512; export const MODEL_FACTS_MAX_OVERRIDES = 512; -export type ModelFactOverride = Readonly>>; +export type ModelFactOverride = Readonly< + Omit>, 'modalities'> & { + readonly modalities?: Readonly>; + } +>; export type ModelFactOverrides = Readonly>; export interface ModelFactsDocument { @@ -13,6 +18,13 @@ export interface ModelFactsDocument { readonly overrides: ModelFactOverrides; } +export class UnsupportedModelFactsSchemaError extends Error { + constructor(readonly schemaVersion: number) { + super(`model-facts.json schema version ${schemaVersion} is not supported`); + this.name = 'UnsupportedModelFactsSchemaError'; + } +} + const PROVIDER_ID_PATTERN = /^[^:\s]{1,128}$/; // Model ids may contain colons (for example, Ollama's `gpt-oss:120b`). The // provider is the only component that is constrained to the first separator. @@ -26,6 +38,9 @@ export function modelFactKey(providerType: ProviderType | string, modelId: strin if (!provider || !model || !PROVIDER_ID_PATTERN.test(provider) || !MODEL_ID_PATTERN.test(model)) { throw new Error('Model fact keys must use a non-empty provider:model identifier'); } + if (!Object.hasOwn(PROVIDER_REGISTRY, provider)) { + throw new Error(`Unknown model-facts provider: ${provider}`); + } const key = `${provider}:${model}`; if (key.length > MODEL_FACT_KEY_MAX_LENGTH) throw new Error('Model fact key is too long'); return key; @@ -57,9 +72,12 @@ export function modelFactOverrideIdsForProvider( } export function decodeModelFactsDocument(value: unknown): ModelFactsDocument { - if (!isRecord(value) || value.schemaVersion !== MODEL_FACTS_SCHEMA_VERSION) { - throw new Error('model-facts.json has an unsupported schema'); + if (!isRecord(value)) throw new Error('model-facts.json must be an object'); + if (!Number.isSafeInteger(value.schemaVersion)) { + throw new Error('model-facts.json schemaVersion must be an integer'); } + if (value.schemaVersion !== MODEL_FACTS_SCHEMA_VERSION) + throw new UnsupportedModelFactsSchemaError(value.schemaVersion); if (!isRecord(value.overrides)) throw new Error('model-facts.json.overrides must be an object'); const keys = Object.keys(value.overrides); if (keys.length > MODEL_FACTS_MAX_OVERRIDES) @@ -68,6 +86,7 @@ export function decodeModelFactsDocument(value: unknown): ModelFactsDocument { for (const key of keys) { const match = PROVIDER_MODEL_KEY_PATTERN.exec(key); if (!match || key.length > MODEL_FACT_KEY_MAX_LENGTH) throw new Error('Invalid model fact key'); + modelFactKey(match[1]!, match[2]!); overrides[key] = normalizeModelFactOverride(value.overrides[key]); } return { schemaVersion: MODEL_FACTS_SCHEMA_VERSION, overrides }; @@ -149,14 +168,27 @@ function normalizeCapabilities(value: unknown): NonNullable { - if (!isRecord(value) || !Array.isArray(value.input) || !Array.isArray(value.output)) +function normalizeModalities(value: unknown): NonNullable { + if (!isRecord(value)) throw new Error('Invalid modalities'); + if (value.input === undefined && value.output === undefined) throw new Error('Invalid modalities'); - const inputs = value.input.filter(isModality); - const outputs = value.output.filter(isOutputModality); - if (inputs.length !== value.input.length || outputs.length !== value.output.length) - throw new Error('Invalid modality value'); - return { input: [...new Set(inputs)], output: [...new Set(outputs)] }; + const input = normalizeModalityDirection(value.input, isModality); + const output = normalizeModalityDirection(value.output, isOutputModality); + return { + ...(input === undefined ? {} : { input }), + ...(output === undefined ? {} : { output }), + }; +} + +function normalizeModalityDirection( + value: unknown, + allowed: (value: unknown) => value is T, +): T[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value)) throw new Error('Invalid modality value'); + const entries = Array.from(value); + if (!entries.every(allowed)) throw new Error('Invalid modality value'); + return [...new Set(entries)]; } function isModality(value: unknown): value is 'text' | 'image' | 'audio' | 'pdf' { @@ -182,15 +214,33 @@ export function applyModelFactOverride( override: ModelFactOverride | undefined, ): ModelInfo { if (!override) return { ...model }; + const overriddenFields = new Set(model.factOverriddenFields); + for (const field of Object.keys(override) as ModelFactField[]) overriddenFields.add(field); + // An authoritative context-window correction must not leave a stale, + // narrower provider input limit to silently win in the runtime resolver. + if (override.contextWindow !== undefined && override.inputLimit === undefined) { + overriddenFields.add('inputLimit'); + } + const modalities = override.modalities + ? { + input: override.modalities.input ?? model.modalities?.input ?? ['text'], + output: override.modalities.output ?? model.modalities?.output ?? ['text'], + } + : model.modalities; + const { modalities: _ignoredModalities, ...scalarOverride } = override; return { ...model, - ...override, + ...scalarOverride, id: model.id, + factOverriddenFields: [...overriddenFields], + ...(override.contextWindow === undefined || override.inputLimit !== undefined + ? {} + : { inputLimit: override.contextWindow }), ...(override.capabilities === undefined ? {} : { capabilities: { ...model.capabilities, ...override.capabilities } }), - ...(override.modalities === undefined ? {} : { modalities: override.modalities }), - }; + ...(modalities === undefined ? {} : { modalities }), + } satisfies ModelInfo; } type ModelFactConnectionLike = { diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts index 5ba11406d1..3148cc4228 100644 --- a/packages/core/src/runtime-policy.ts +++ b/packages/core/src/runtime-policy.ts @@ -221,6 +221,8 @@ export interface ConnectionCatalogEntry extends ConnectionConfiguration { readonly modelSource?: ConnectionModelDiscoveryResult['source']; readonly modelsFetchedAt?: ConnectionModelDiscoveryResult['fetchedAt']; readonly lastTest?: ConnectionTestSummary; + /** Digest of the model-facts subset used when `lastTest` was recorded. */ + readonly lastTestModelFactsFingerprint?: string; } export type ConnectionCatalogEntryDraft = ConnectionConfiguration; diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index cfe50e4d01..b6a3d35816 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -322,6 +322,7 @@ export function decodeCanonicalConnectionCatalogEntry(value: unknown): Connectio 'modelSource', 'modelsFetchedAt', 'lastTest', + 'lastTestModelFactsFingerprint', ], [ 'connectionId', @@ -390,6 +391,15 @@ export function decodeCanonicalConnectionCatalogEntry(value: unknown): Connectio ...(item.lastTest === undefined ? {} : { lastTest: decodeConnectionTestSummary(item.lastTest) }), + ...(item.lastTestModelFactsFingerprint === undefined + ? {} + : { + lastTestModelFactsFingerprint: stringValue( + item.lastTestModelFactsFingerprint, + 'connection test model facts fingerprint', + 128, + ), + }), }; assertCanonicalValue(value, decoded, 'connection catalog entry'); return decoded; diff --git a/packages/runtime/src/__tests__/context-budget-model-facts.test.ts b/packages/runtime/src/__tests__/context-budget-model-facts.test.ts index c003892ca5..9d2507d253 100644 --- a/packages/runtime/src/__tests__/context-budget-model-facts.test.ts +++ b/packages/runtime/src/__tests__/context-budget-model-facts.test.ts @@ -42,3 +42,22 @@ test('a relay user declaration remains ahead of runtime and static model facts', assert.equal(resolveSelectedModelContextWindow(connection, undefined), 32_000); }); + +test('a model-facts context window is the authoritative user declaration', () => { + const connection = { + slug: 'relay', + providerType: 'openai-compatible' as const, + defaultModel: 'relay-model', + models: [ + { + id: 'relay-model', + contextWindow: 200_000, + inputLimit: 200_000, + factOverriddenFields: ['contextWindow', 'inputLimit'] as const, + }, + ], + relayModelProfiles: { 'relay-model': { contextWindow: 8_192 } }, + }; + + assert.equal(resolveSelectedModelContextWindow(connection, undefined), 200_000); +}); diff --git a/packages/runtime/src/context-budget-policy.ts b/packages/runtime/src/context-budget-policy.ts index 886c1780ce..a4b429fe19 100644 --- a/packages/runtime/src/context-budget-policy.ts +++ b/packages/runtime/src/context-budget-policy.ts @@ -419,12 +419,16 @@ export function resolveSelectedModelContextWindow( ): number | undefined { const selectedModelId = modelId ?? connection.defaultModel; if (selectedModelId === undefined) return undefined; - // A user declaration outranks both the relay's /models report and generated - // metadata — mirrors the declared-vision precedence in model-metadata.ts, - // through the same provider-gated seam (relay declarations only). + const model = connection.models?.find((candidate) => candidate.id === selectedModelId); + // A model-facts pin is the cross-provider correction authority. It must win + // over the older relay-only declaration so catalog display and execution use + // the same window. Relay declarations retain their existing precedence when + // there is no facts pin for this field. + if (model?.factOverriddenFields?.includes('contextWindow')) { + return narrowestPositiveLimit(model.contextWindow, model.inputLimit); + } const declared = relayModelProfile(connection, selectedModelId)?.contextWindow; if (declared !== undefined) return declared; - const model = connection.models?.find((candidate) => candidate.id === selectedModelId); const metadata = lookupModelMetadata(connection.providerType, selectedModelId); // Provider/access-path facts outrank static metadata. Within one source, // use the narrowest positive bound: models.dev's input limit can be lower diff --git a/packages/storage/src/__tests__/model-facts-store.test.ts b/packages/storage/src/__tests__/model-facts-store.test.ts index 9fe578f786..b03b87ccc6 100644 --- a/packages/storage/src/__tests__/model-facts-store.test.ts +++ b/packages/storage/src/__tests__/model-facts-store.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, readdir, writeFile, rm } from 'node:fs/promises'; +import { mkdtemp, readdir, readFile, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -59,3 +59,47 @@ test('model facts temporary writes are removed by runtime policy recovery', asyn await rm(root, { recursive: true, force: true }); } }); + +test('future model facts schemas are preserved and cannot be overwritten', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-facts-future-')); + try { + const owner = new ModelFactsDocumentOwner(); + const future = JSON.stringify({ + schemaVersion: 2, + overrides: { 'openai:o4-mini': { contextWindow: 1 } }, + }); + await writeFile(join(root, 'model-facts.json'), future, 'utf8'); + const read = await owner.readWithDiagnostics(root); + assert.equal(read.diagnostic, 'unsupported_schema'); + await assert.rejects( + () => owner.replace(root, { 'openai:o4-mini': { contextWindow: 200_000 } }), + (error: unknown) => + error instanceof RuntimePolicyStoreError && error.code === 'invalid_policy_input', + ); + assert.equal(await readFile(join(root, 'model-facts.json'), 'utf8'), future); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('model facts replacement supports fingerprint compare-and-set', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-model-facts-cas-')); + try { + const owner = new ModelFactsDocumentOwner(); + const initial = await owner.readWithDiagnostics(root); + await owner.replace( + root, + { 'openai:o4-mini': { contextWindow: 100_000 } }, + initial.fingerprint, + ); + await assert.rejects( + () => + owner.replace(root, { 'openai:o4-mini': { contextWindow: 200_000 } }, initial.fingerprint), + (error: unknown) => + error instanceof RuntimePolicyStoreError && error.code === 'revision_conflict', + ); + assert.equal((await owner.read(root)).overrides['openai:o4-mini']?.contextWindow, 100_000); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts index 5f079e769c..2904d1ddd3 100644 --- a/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-model-facts.test.ts @@ -154,23 +154,37 @@ test('model facts are not persisted when verification invalidation fails', async const root = await mkdtemp(join(tmpdir(), 'maka-runtime-facts-invalidation-')); try { const coordinator = new RuntimePolicyCoordinator((operation) => operation(root)); + const connectionId = await createTestConnection(coordinator); + const testTicket = await coordinator.beginConnectionTest(connectionId, null); + assert.equal(testTicket.kind, 'ready'); + if (testTicket.kind === 'ready') { + assert.equal( + ( + await coordinator.completeConnectionTest( + testTicket.ticket, + verifiedAt('2026-08-01T00:00:00.000Z'), + ) + ).kind, + 'committed', + ); + } const catalogOwner = ( coordinator as unknown as { - catalog: { clearAllConnectionLastTests: () => Promise }; + catalog: { clearConnectionLastTest: () => Promise }; } ).catalog; - const original = catalogOwner.clearAllConnectionLastTests; - catalogOwner.clearAllConnectionLastTests = async () => { + const original = catalogOwner.clearConnectionLastTest; + catalogOwner.clearConnectionLastTest = async () => { throw new Error('injected invalidation failure'); }; try { await assert.rejects( () => coordinator.replaceModelFacts({ 'ollama:custom-model': { contextWindow: 64_000 } }), (error: unknown) => - error instanceof RuntimePolicyStoreError && error.code === 'commit_outcome_unknown', + error instanceof Error && error.message === 'injected invalidation failure', ); } finally { - catalogOwner.clearAllConnectionLastTests = original; + catalogOwner.clearConnectionLastTest = original; } assert.deepEqual((await coordinator.getModelFacts()).document.overrides, {}); } finally { diff --git a/packages/storage/src/model-facts-store.ts b/packages/storage/src/model-facts-store.ts index 2870b9e009..e2bce008cd 100644 --- a/packages/storage/src/model-facts-store.ts +++ b/packages/storage/src/model-facts-store.ts @@ -4,10 +4,12 @@ import { MODEL_FACTS_MAX_OVERRIDES, MODEL_FACTS_SCHEMA_VERSION, normalizeModelFactOverride, + UnsupportedModelFactsSchemaError, type ModelFactOverrides, type ModelFactsDocument, } from '@maka/core/model-facts'; import { RuntimePolicyStoreError } from './runtime-policy/errors.js'; +import type { ConnectionCatalogEntry } from '@maka/core/runtime-policy'; import { readBoundedDocumentBytes, serializeJsonDocument, @@ -19,7 +21,7 @@ const FILE = 'model-facts.json'; export interface ModelFactsReadResult { readonly document: ModelFactsDocument; - readonly diagnostic?: 'malformed' | 'oversized' | 'io_failed'; + readonly diagnostic?: 'malformed' | 'oversized' | 'unsupported_schema'; readonly fingerprint: string; } @@ -48,14 +50,21 @@ export class ModelFactsDocumentOwner { try { value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown; return { document: decodeModelFactsDocument(value), fingerprint }; - } catch { + } catch (error) { + if (error instanceof UnsupportedModelFactsSchemaError) { + return { document: emptyDocument(), diagnostic: 'unsupported_schema', fingerprint }; + } return { document: emptyDocument(), diagnostic: 'malformed', fingerprint }; } } - async replace(root: string, overrides: ModelFactOverrides): Promise { + async replace( + root: string, + overrides: ModelFactOverrides, + expectedFingerprint?: string, + ): Promise { const validated = this.prepareReplacement(overrides); - return this.writeReplacement(root, validated); + return this.writeReplacement(root, validated, expectedFingerprint); } prepareReplacement(overrides: ModelFactOverrides): ModelFactsDocument { @@ -93,7 +102,24 @@ export class ModelFactsDocumentOwner { return validated; } - async writeReplacement(root: string, document: ModelFactsDocument): Promise { + async writeReplacement( + root: string, + document: ModelFactsDocument, + expectedFingerprint?: string, + ): Promise { + const current = await this.readWithDiagnostics(root); + if (current.diagnostic === 'unsupported_schema') { + throw new RuntimePolicyStoreError( + 'invalid_policy_input', + 'model-facts.json uses a newer unsupported schema and cannot be overwritten', + ); + } + if (expectedFingerprint !== undefined && current.fingerprint !== expectedFingerprint) { + throw new RuntimePolicyStoreError( + 'revision_conflict', + 'model-facts.json changed before replacement; reload before retrying', + ); + } await writeJsonDocument(root, FILE, document, MODEL_FACTS_DOCUMENT_MAX_BYTES); return document; } @@ -101,6 +127,27 @@ export class ModelFactsDocumentOwner { fingerprint(document: ModelFactsDocument): string { return fingerprintBytes(serializeJsonDocument(document)); } + + fingerprintForConnection( + document: ModelFactsDocument, + connection: Pick, + ): string { + const modelIds = new Set([ + ...(connection.models ?? []).map((model) => model.id), + ...connection.enabledModelIds, + ]); + const entries = Object.entries(document.overrides) + .filter(([key]) => { + const separator = key.indexOf(':'); + return ( + separator > 0 && + key.slice(0, separator) === connection.providerType && + modelIds.has(key.slice(separator + 1)) + ); + }) + .sort(([left], [right]) => left.localeCompare(right)); + return fingerprintBytes(Buffer.from(JSON.stringify(entries), 'utf8')); + } } function emptyDocument(): ModelFactsDocument { diff --git a/packages/storage/src/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts index 160c3c9813..2447327fc4 100644 --- a/packages/storage/src/runtime-policy-stores.ts +++ b/packages/storage/src/runtime-policy-stores.ts @@ -96,7 +96,7 @@ export interface ModelFactsReader { } export interface ModelFactsWriter extends ModelFactsReader { - replace(overrides: ModelFactOverrides): Promise; + replace(overrides: ModelFactOverrides, expectedFingerprint?: string): Promise; } export interface CredentialVaultReader { @@ -216,7 +216,8 @@ function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolic }, modelFacts: { get: () => coordinator.getModelFacts(), - replace: (overrides) => coordinator.replaceModelFacts(overrides), + replace: (overrides, expectedFingerprint) => + coordinator.replaceModelFacts(overrides, expectedFingerprint), }, credentialVault: { getSnapshot: () => coordinator.getVaultSnapshot(), diff --git a/packages/storage/src/runtime-policy/connection-catalog-document.ts b/packages/storage/src/runtime-policy/connection-catalog-document.ts index f8df2735a5..bf42aee372 100644 --- a/packages/storage/src/runtime-policy/connection-catalog-document.ts +++ b/packages/storage/src/runtime-policy/connection-catalog-document.ts @@ -503,6 +503,7 @@ export class ConnectionCatalogDocumentOwner { current: ConnectionCatalogDocument, expected: ConnectionVersionBasis, rawResult: ConnectionTestSummary, + modelFactsFingerprint: string, ): Promise { const result = decodeConnectionInput(() => decodeConnectionTestSummary(rawResult)); const index = findConnectionIndex(current, expected); @@ -514,6 +515,7 @@ export class ConnectionCatalogDocumentOwner { ...previous, revision: nextRevision(previous.revision), lastTest: result, + lastTestModelFactsFingerprint: modelFactsFingerprint, }); } @@ -528,7 +530,11 @@ export class ConnectionCatalogDocumentOwner { throw codecError('invalid_document', 'Coordinator admitted an unknown connection'); } if (previous.lastTest === undefined) return false; - const { lastTest: _lastTest, ...withoutLastTest } = previous; + const { + lastTest: _lastTest, + lastTestModelFactsFingerprint: _lastTestModelFactsFingerprint, + ...withoutLastTest + } = previous; await this.writePatchedResult(root, current, index, { ...withoutLastTest, revision: nextRevision(previous.revision), @@ -543,7 +549,11 @@ export class ConnectionCatalogDocumentOwner { if (current.connections.every((connection) => connection.lastTest === undefined)) return false; const connections = current.connections.map((connection) => { if (connection.lastTest === undefined) return connection; - const { lastTest: _lastTest, ...withoutLastTest } = connection; + const { + lastTest: _lastTest, + lastTestModelFactsFingerprint: _lastTestModelFactsFingerprint, + ...withoutLastTest + } = connection; return { ...withoutLastTest, revision: nextRevision(connection.revision), diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index dc542b7181..10d76d03c2 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -37,6 +37,7 @@ import { applyModelFactOverridesToCatalogSnapshot, modelFactOverrideIdsForProvider, type ModelFactOverrides, + type ModelFactsDocument, } from '@maka/core/model-facts'; import { deriveProviderAuthContract, type ProviderAuthAction } from '@maka/core/provider-auth'; import { @@ -159,7 +160,7 @@ type SemanticConnectionBasis = readonly kind: 'connection_test'; readonly requestBodyOverlayJson: string; readonly model: ConnectionTestModelBasis; - readonly modelFactsGeneration: number; + readonly modelFactsFingerprint: string; }); interface ConnectionTicketRecord { @@ -184,8 +185,6 @@ export class RuntimePolicyCoordinator { private readonly catalog = new ConnectionCatalogDocumentOwner(); private readonly vault = new CredentialVaultDocumentOwner(); private readonly modelFacts = new ModelFactsDocumentOwner(); - private modelFactsGeneration = 0; - private modelFactsFingerprint: string | undefined; private warnedModelFactsFingerprint: string | undefined; private readonly tickets = new WeakMap(); private onboardingRecoveryRequired = false; @@ -220,31 +219,44 @@ export class RuntimePolicyCoordinator { return this.inLane(async (root) => deepFreeze(await this.readModelFacts(root))); } - replaceModelFacts(overrides: ModelFactOverrides) { + replaceModelFacts(overrides: ModelFactOverrides, expectedFingerprint?: string) { return this.inLane(async (root) => { const document = this.modelFacts.prepareReplacement(overrides); - await this.readModelFacts(root); - let cleared = false; - try { - cleared = await this.catalog.clearAllConnectionLastTests( - root, - await this.catalog.read(root), + const currentFacts = await this.modelFacts.readWithDiagnostics(root); + if (currentFacts.diagnostic === 'unsupported_schema') { + throw new RuntimePolicyStoreError( + 'invalid_policy_input', + 'model-facts.json uses a newer unsupported schema and cannot be overwritten', ); - } catch (error) { - throw commitOutcomeUnknown( - 'Connection verification clearing failed before model facts replacement', - error, + } + if (expectedFingerprint !== undefined && currentFacts.fingerprint !== expectedFingerprint) { + throw new RuntimePolicyStoreError( + 'revision_conflict', + 'model-facts.json changed before replacement; reload before retrying', ); } - // Clearing is durable, so no ticket may survive any replacement attempt that follows it. - this.modelFactsGeneration += 1; + const currentCatalog = await this.catalog.read(root); + const affected = currentCatalog.connections.filter( + (connection) => + connection.lastTest !== undefined && + this.modelFacts.fingerprintForConnection(currentFacts.document, connection) !== + this.modelFacts.fingerprintForConnection(document, connection), + ); + let cleared = false; try { - const persisted = await this.modelFacts.writeReplacement(root, document); - this.modelFactsFingerprint = this.modelFacts.fingerprint(persisted); + for (const connection of affected) { + const latest = await this.catalog.read(root); + cleared = + (await this.catalog.clearConnectionLastTest(root, latest, connection.connectionId)) || + cleared; + } + const persisted = await this.modelFacts.writeReplacement( + root, + document, + currentFacts.fingerprint, + ); return deepFreeze(persisted); } catch (error) { - // A post-rename directory sync failure may have published the replacement. - this.modelFactsFingerprint = undefined; if (cleared) { throw commitOutcomeUnknown( 'Connection verification was cleared before model facts replacement completed', @@ -1057,9 +1069,10 @@ export class RuntimePolicyCoordinator { 'test_credentials', ); if (prepared.kind !== 'ready') return prepared; + const facts = await this.readModelFacts(root); const projectedConnection = applyModelFactOverridesToConnection( structuredClone(prepared.connection), - (await this.readModelFacts(root)).document.overrides, + facts.document.overrides, ); const projected = { ...prepared, connection: projectedConnection }; const modelId = @@ -1074,7 +1087,10 @@ export class RuntimePolicyCoordinator { } const ticket = this.issueTicket( 'connection_test', - connectionTestSemanticBasis(projected, this.modelFactsGeneration), + connectionTestSemanticBasis( + projected, + this.modelFacts.fingerprintForConnection(facts.document, prepared.connection), + ), ); return deepFreeze({ kind: 'ready' as const, @@ -1094,6 +1110,9 @@ export class RuntimePolicyCoordinator { const claimed = this.claimTicket(ticket, 'connection_test'); return this.completeClaimedTicket(claimed, () => this.inLane(async (root) => { + if (claimed.basis.kind !== 'connection_test') { + throw new Error('Coordinator admitted a non-connection-test ticket'); + } const catalog = await this.catalog.read(root); const checked = await this.checkSemanticConnectionBasis(root, catalog, claimed.basis); if (checked.changed.length > 0 || !checked.connection) { @@ -1104,6 +1123,7 @@ export class RuntimePolicyCoordinator { catalog, connectionBasis(checked.connection), result, + claimed.basis.modelFactsFingerprint, ); return deepFreeze({ kind: 'committed' as const, @@ -1255,7 +1275,9 @@ export class RuntimePolicyCoordinator { const facts = basis.kind === 'connection_test' ? await this.readModelFacts(root) : undefined; if ( basis.kind === 'connection_test' && - basis.modelFactsGeneration !== this.modelFactsGeneration + (!connection || + this.modelFacts.fingerprintForConnection(facts!.document, connection) !== + basis.modelFactsFingerprint) ) { changed.push('connection'); } @@ -1453,30 +1475,19 @@ export class RuntimePolicyCoordinator { private async projectCatalogSnapshot(root: string): Promise { const facts = await this.readModelFacts(root); + const snapshot = catalogSnapshot(await this.catalog.read(root)); return deepFreeze( - applyModelFactOverridesToCatalogSnapshot( - catalogSnapshot(await this.catalog.read(root)), - facts.document.overrides, + hideStaleModelFactsVerification( + applyModelFactOverridesToCatalogSnapshot(snapshot, facts.document.overrides), + snapshot, + facts.document, + this.modelFacts, ), ); } private async readModelFacts(root: string) { const facts = await this.modelFacts.readWithDiagnostics(root); - const changed = - this.modelFactsFingerprint !== undefined && this.modelFactsFingerprint !== facts.fingerprint; - if (changed) { - this.modelFactsGeneration += 1; - try { - await this.catalog.clearAllConnectionLastTests(root, await this.catalog.read(root)); - } catch (error) { - throw commitOutcomeUnknown( - 'Connection verification clearing failed after model facts changed externally', - error, - ); - } - } - this.modelFactsFingerprint = facts.fingerprint; if (facts.diagnostic !== undefined && this.warnedModelFactsFingerprint !== facts.fingerprint) { process.emitWarning(`model-facts.json is ${facts.diagnostic}; ignoring its overrides`, { type: 'RuntimePolicyWarning', @@ -1498,6 +1509,42 @@ export class RuntimePolicyCoordinator { } } +function hideStaleModelFactsVerification( + projected: ConnectionCatalogSnapshot, + persisted: ConnectionCatalogSnapshot, + document: ModelFactsDocument, + owner: ModelFactsDocumentOwner, +): ConnectionCatalogSnapshot { + const persistedById = new Map( + persisted.connections.map((connection) => [connection.connectionId, connection] as const), + ); + return { + ...projected, + connections: projected.connections.map((connection) => { + if (connection.lastTest === undefined) return connection; + const raw = persistedById.get(connection.connectionId); + if (!raw) return connection; + const current = owner.fingerprintForConnection(document, raw); + // Catalogs written before model facts existed have no marker. They remain + // valid until facts actually exist; every test recorded by this feature + // carries a connection-scoped marker and is checked exactly. + if ( + raw.lastTestModelFactsFingerprint === current || + (raw.lastTestModelFactsFingerprint === undefined && + Object.keys(document.overrides).length === 0) + ) { + return connection; + } + const { + lastTest: _lastTest, + lastTestModelFactsFingerprint: _lastTestModelFactsFingerprint, + ...withoutLastTest + } = connection; + return withoutLastTest; + }), + }; +} + function isCommitOutcomeUnknown(error: unknown): error is RuntimePolicyStoreError { return error instanceof RuntimePolicyStoreError && error.code === 'commit_outcome_unknown'; } @@ -1529,14 +1576,14 @@ function modelFetchSemanticBasis( function connectionTestSemanticBasis( prepared: PreparedConnectionMaterial, - modelFactsGeneration: number, + modelFactsFingerprint: string, ): Extract { return { kind: 'connection_test', ...commonSemanticConnectionBasis(prepared), requestBodyOverlayJson: JSON.stringify(prepared.connection.requestBodyOverlay ?? {}), model: connectionTestModelBasis(prepared.connection), - modelFactsGeneration, + modelFactsFingerprint, }; } diff --git a/packages/storage/src/runtime-policy/errors.ts b/packages/storage/src/runtime-policy/errors.ts index dd8caf95cf..67c50fb3c9 100644 --- a/packages/storage/src/runtime-policy/errors.ts +++ b/packages/storage/src/runtime-policy/errors.ts @@ -5,6 +5,7 @@ export type RuntimePolicyStoreErrorCode = | 'invalid_policy_input' | 'invalid_connection_input' | 'invalid_credential_input' + | 'revision_conflict' | 'io_failed' | 'commit_outcome_unknown'; From 7c4bbffa464e68db02c14cf7b9d686bcbc94d745 Mon Sep 17 00:00:00 2001 From: Nyvo <75425811+Nyvo-io@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:52:48 +0800 Subject: [PATCH 6/6] test: document model facts selector projection Generated-by: Codex --- .../3129-facts-backed-model-selector-open.png | Bin 0 -> 20034 bytes .../3129-facts-backed-model-selector.png | Bin 0 -> 9225 bytes .../src/__tests__/runtime-policy-stores.test.ts | 6 +++++- 3 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/.maka-shots/3129-facts-backed-model-selector-open.png create mode 100644 apps/desktop/.maka-shots/3129-facts-backed-model-selector.png diff --git a/apps/desktop/.maka-shots/3129-facts-backed-model-selector-open.png b/apps/desktop/.maka-shots/3129-facts-backed-model-selector-open.png new file mode 100644 index 0000000000000000000000000000000000000000..ab29ffbe0c7185c5b36971a8149e71ba7853a9bc GIT binary patch literal 20034 zcmeIaS5#AL_%|3tu>hh+K{_f{s)!V6v4RbdqckZ&5Kvk`q(g$04k}WVrlNxMh!A?{ zNEeZ2fIyJY2@qN!ka_n1o9|-Qn!8yuH*+q|(M8>Rzwi6}>Ne!2o(}IW;aw;ciudZ3 zOaG!!+u*NTe(u-;f1H!J#(_fZL0!Fc{V{Im5G zeCXQ{I?nYI|lzvhX4DS_)J&`B~$qA+c%y~>1)@nokp!1z~Br!H@*;-IeGO& zILGSwErNj(%J_#T?#Aw^o0Zi%9&-w1xVI7|bA|ac+d(LHkIx!SUr&gqk_F#rC}reQ zaT8Z{K%otb*!yf z-m3ZjP+z|N`px?&+Sot$Q5Cn!hKfx{+{qhy$y%j7OSxVqguCTxO5`)xyodQ*5mP;F zL-wa|3yXJCkMW9~eX#Ibh#tlILOC?``>SaH2U&;Ho+0-vXD-JI_4;Ta$EwluwIoG* zy`?7^h4|s8xv98Z4cDaI*|_K`e8-S}y=WJGN!>S>c%FRB(D~M#mw5AHF0O;ek#A(8 zl5gG2G|J9Uj$A9B?bBadH23OG%(`>6+v;nUT|qG$ql=ki#NUc-O^j}hkJf$1-~Ovu zzi=NGHxry~+?O}Wm8@W+U9g3N=bRpj^HdLOgtA&_*ZMBTMZ;%px-AXOWMOdXetJe} zzJ)ikvr4jtNT0vUh^R{@Sib8@k9B6HHM}s-voP$r(4Kf!QMxQ-V2@y6qW)u880u2H zq4P@&Q*_C!)VKS4;o2xkDjD0{oxI+3U4t`M!)nH|hn?0Wn`tb+>1Kuc0lVO`EycJi z2#sdZ;xco8=3UIB+?H;{-I9KasXbjlv&2#!#MqM0WU_lJU&XlWYaia#KFJgKeWw*l zYBT1y{}%JEn2%3`^s{c+&wlP$a$r@e<48~#uzb9E%j9_~!`o&2+I^U1*kX(6YJv;C1>lfXG z<;LSF_gEjTcbn(V?Hu)R>=8ZVZ~d8E>+Zd|GmnSs%3BgAPvr`0D9h=`f#~I4*HM#n z!=FNb}Ovs4RR%qgAy zoa|C6`;O6T6IDKz>}S`O$b@TeaHUV@x3>|)BW1{9}ke_fZ4$z6$+HOncYhB6n5 z+(u?e?9GB6%k=>-O2kIL?^O1D!QGk{e42|lOFd_d%$y5otqJnP{)g>oHcK)}%8!B?h{mEj!PZjasAc5q)M>W%Sc zYF@@4yC1<<)6QV`a2I5FN4fIg$3^6ABzlI=5tCW zGYB>f^+bzOPiIQSM#%AGlm4M3ufaxifpo3%E6S)&LxE$D9G7c{2)0bYjanNoMcQ1O zBAgG$+wi%Om%7d5w|H+X_0W3>BON*t8${BE?7vJ5d(BuQ_5>3ig=e2_oQ1>J&!=Ni zOvSv`1>Qj+g|Beg`gXiL=Z*z({D`4H+qQi!9&2|d2czDc!J5w8F79pBWoSGA-`JGv z^gF#{ua0O(ntsapr(7PBzfZ0o^6GbM(2)pOtUG`)cdO%9$P-g?xP8M>h2qax?U);` zvH6sUufz(fx>|+1hN&dNDO#HB5>at6H&=W#{!>s=r=WgXrTzyY(=oT)bA5&=p=fsl z=2M)U#o@rIuFRhA4?26DKF1h^Ce%)={ZW0bDqx*I(V5n8A>Xd`bnFqP;;H0_`NS-T zgoS-WEK}L19RJcZD^aXBhxbdGmwHSov!gyLdJTB=G>H2--CfRa*IKXDyHnuc(W20B ziEzn4!h7iPtFQB^o-=>y9?oO;li(@Ndvrb#Bx}!#d-mQ`~Zf{fQ&uCRyne1}RF6b84^j)-oJDRK7-jj<#SCm?Sw{L7& zTelv16}u@ua>~9f@%})B%3x9TeBz^DSLc_yj4|1h*h_@13x`Zl`*&Ie{*KxBgy%nY zjL?y8xN+u7R!3~JEYZC19HF&_56xmR+I8kJtVpeSdSU>z)segPkS{g zruOJZ92&l+C^ftAj}$4D%5%29`csdeq?Tk%j~TW+3ccz5lvmjs77v-8^${W}&VRleW*W6?Fj`h&yoL+8 zk3^`@$WfwaACCY0(9z;n6VI>W)awefw4+7gcfio&?F$obP7>?=FmP;qW(s;{dkHq; z1VSrp5*&Qi?zz{=G4=3Rt&woW1qJMFw;Qk9&yFQ3wreOJgP*3lS>iEehpNntKyeMF z?e84WOHzZW7oa2Nar-&?z2KG|eV44)gT#H)brY1`u0#kc zHC!yS4*(3`I6r%Y3t9O6_0~wFIKYSCiR713|Jw^_)OOP8q!DGMrPY~#qg4PY z?+@6*#&JcI$&RDw8T%B}TxK-;;!FI$ zvx!gowi(uu>Fi~PC|={)KL9e0_67KH{VkW1B1Kekd>FL4m5qw!KaMM~^Kk1k2X>YO ziquJ~PEV1j z|8zBawqz=YIOsd|^5X6ggE`nlYJlX_s+M<3jthSq;b6+)pDDa*Lmw-Nr`A2+)y%r_ zCC1L?i+wJB)sSucVqnm({2DPN?vTU&+tMda1}|xCc;}dN)~Pp7@S1T^=WtC>Lu%x0xy@I@+kWIr zjl?NFFjBs2=h?%Mh>3|!E^%MKMud~|2FUKe#B>hU4>L)Mpz`MX8KYdw*GieuhMjv^ z|IpspN~X1L_->G1IQ{f|OYwBDoUjA+v0$FGt@2}Eu{&c$M^Bj57~f~xS0p6FI$V*g zu%cPX8H8JEG>*>9z@X9rDDr-+iG6F6SSzo%UEt&}tQ-G1=O~qQA{jfMg^=B>*gD4C zmy6wvD68QeRwx0}q<3BP@$$hC6;dp}R`C75-;3$>;RoNQK4bP>TB&z;qGlDh6CXV1 z+kxJs8Phjv)|4Dqql8cbvHEn>P($x~=jil-)^_jdCeH5J?@izK+1bru6kW%Sb(Z`z zC%wv*bx)&A?jy_L5w>>7WK=i7^v~29`@6Fd377P4#^`?0|3zu&YwN9zH-|*0wX*(Z zHmbm#RJY&)b_k$G5v-BbLyd`xtd71Pj^^S^S#1lxYEv;^Y{HUxCuiXpA;7Da)9czA z%Zo3iRpTaOKvrpR@VqRk3>%)}Jpz-qVuSdQUcNHu z)*yCZp%7t~fVkZl{DwsTLLlevcqK=p0*9_iI6oBu>jR$TY=9pj)SMz1cQ-#@+Q zz-fzs#n!o(hb{^n3#D;o1i{l*txl3^3B~(I2k*^1WVD2*-1Jhp!qlmH+r*m)x zK34+PxdQM4%mEEQ1_NI}q~O$zmZy5KO>a&r)I~Uy!>|CR5H{X*r8CE()Dp)Wy)IjD zo4vUKyw$%`R|OdC6!3PlIg3Y=zaRAG-6hPy46_JW>gwo?GkCzbX+mDU)jN;Z&TGJwb|bwSC5S#2D=(k;l3?jtwXO4uZZj&-2e{(BTuaRom5S~h3w7mWt$?+^ zjnS!>)%j&s#S@gBg(pO~0YqsaaC$ym3^Ex6ioLcxQ;P7xht09FrH>~5;Z9Qbl9QEn z?#?om0UqT0JKezTcD{Z4Ai$|jYpj`zQ1m3wn4*lwil`%3;Pl`8nEQ>*?{h{jyk@t$ zQ8w+1J!@gn=waA! zN7jmUqQtr7e#KAG)I$syfgGHcelrCok?R0TD(L{y6)){QE6ibUsD2XMg6ObO30m-8 zN!Xp8tkEqm4+64YxSB70SSIY1(Ao-JLb4lpMj0@h9!o#xt5>gXtS#9*y&8Q?>Hg1y z%E2i#q7*6m1+Pdl5KKk87H^P{2pI%^Q4IKMI4}a!S9qi!Hg=3*tf}4GWKBOiZfC%L zF*MG9p6oVKU-#~L@rV0c5AGf0r*b?$-#0zf01Z%oTcwc zT8rt2(DkLMm8U^HaNMrUtLB+(&eiaZT>10;K{N-75s%`mto{Q4%7`L4^cT5p&i4O= zLjtQV`tZjgMf)~TVqy$hxKyOuJjZrXpfW0tfCPm~=M*v8)KgPdD0nt2qH0vkt#+^J z@BnFsOhf}~v>r1Zb}k<%@lYRqb1TCzQ$XeHY|#jAZq8aDV4?9i#-qy+?oYS%of0Cv z{pEO3f3IcuiU|i^`pe$)hQH-xyu}{Hmhh(9PeeHO{6@g|CD#o96n!fFc-D$H(kjW?zu zn`lD1Yar?A=8}%%c>gc*=>NYqnpq{tqycSXT-YmMkM2yg;?W?49X(d7#6-0pSgRjsI?((pFyLd75};>ZTn`V1y!kEw^cAO)~(U{T0WH=+iR;&rX_xx$|{AAu@>|usW<~vi$uwyMfqKml^4@nZnzXr;TNi;4JQ#kiAmpNI z!iFbVeqoZ7N1eJOG=ivC05biO;XpUTp8QzU3S`dr$DtsY$cMz68zk`N69zFcF&2+H zF1ywAIkeK<;ztsNzv z#D8ZcSTgedpYIcZeq$!=Qp(2c#tJ10MTU_}O+x^1&=1dS#)$h(6WrU*jsa!?G#Klv zO?=wc-(!pk0FYH}%J2p*d5cinsj^GSV4i|&-muhvUYkTY@O@xdIPI^Pfe;O=JPo;K6tlEy2W>1E5RKBh} z`V+<4`hrL3pOXplac|z#ZiPW)9I!T#W^?T=zue2n5C7vmq!&5n6d$ugZp+z8Aip#C zFC4Q|+EofiBTYh!9q*a+-vgbY@FC)T#OROT+w=nwrxRv139LqfIXJ>F{MDNB0ZQRP z)}+I+=r;&g13zW7cNh-;zL9P+mb9TlV(L0kz&Q+Vq~=&__<@M4KX~SvymZUU?L2~e zJPv$)%Re|JqJx7w*N)cV)qYRY0bSf&uPY_m1=<%-SL9IAaof#1=M3|yS*I2wd$#Cl zF8$W!{&+U-j>FdzExYV#ut6gXoG%aVQ1b5_-V39vJc# z;NrL~N)Eq+9h%}&-+!wn2ig=0)wBSo@J9%Y9jKuBB`_N&VEa_)ewsig#x*a&tD-fH z*}K393%W9%^|H?=j%KY|25{0)eY#mDx(90%{C!9DiN<(}vSYUlSB!-J+&r6?Umk^N+1XQ*XX<|-#?28-h278h=jW9!sIY@ajwgFl5x2`?^uQ^drO|}T zS#9h+R9x!gF!H5P9l81oEfLZIKfq}0^CUaVm(5l89u&VEu5S!ZBU2^pVyFdw6uM-M zj%WMba0y#^Eq|%EVNo63&dmLy>Mn<^A~!hhQ`jC^`h)nOE29f&$jg*y)f$CYk!um7 zMZ=&7MRt}4D#mhD{(~4``+_4g=Jq!6Y0Om`=!o%K?4S7qPIf$3L(^Gz+NFK7MftvY*5%6m}j%Fb{74|d}T1fU$Jhs6&k>CPkeV7D7k#1e)Hm{0HAu)UulqV5OsUX}0wg$kokxd+I zB8H5IwjvfdOjR`ImVb)FZKJI4KAZ2|JC7&2!X!rcsDfb|g<@6(&L@er&^#K4(TZh| zF7H1f&+%ayj(4*9sYxuaw`vp&gC=p<+ZVH4?{W3H53)5DqxsWS$`+gK}o14LkJ*% zHk>;C2K@{n{rDaGj@?*=v|u-^xd}qCsVM=WHFBGJnQAtpxr*BZF`;}_TZZgAz(I(w z($?43ioxTjVS+BfH(3H$Ki$r!1r#Cdd@15ZbzDA7Gv(*(Jy8i%={^V&FoiCI_jurx zO62^=-5@bUs|>ieYa-^v?>jA_Bd9NWNifu->h<*0XTHPSrHRmiJ?yOqOa0L&8z(%t zym**-%&5b_=!!t}U&Ip! zWovBVVYoO515c=C(EH$CoZA(Jm7lGjK^%h*^5>IA-&&@JA1_HX8ZcZfv3`@bFYOAtY;e zW6dkA_tk#w1wKZtt-lz2ItpWcBf%!1>|7oQ;d*mT?QVsUh?*b7+{tiTRNmGT^B-l- zQ5Wz#nszN@y4oK&19QX732rZJh2S(>!XkBNBXtrdgfTMR_CwFmp_`X5Reg;Z@b}yxx4Xf=RPWj+^S~Id@%L#rDt&% zAwLmyPbUzdV-&5n8)crS*z2LqDQ{UhHu-ElvwDj$ude9;bWuT^A{Id}xuI!|k&V!c!kG zYEY~Ef?A$`W{u1G-RijTGkr%n10^m)f#7;cI%Pl(Pq@|m0x@R(?c&99tiJL;u??O_ z@jUbHMIC)P60Lb>XDMd>-!uWO7coNLZvqOQLdaA?E zfd3g4j3NXYPPT2>IM8BIz?KUxthK3JgoN`OMG7iVVc+6Gex~Ur#LC{4(j5A@Es+3a z1fcv(AeLcQ+63;LVL*DL1Q`3g53y2NLr?jjrGPmU$YEj*x^&`&h>M`92ich@3ydaU zeYq9)FQR0?8H$yC5&sH)=^P(^wFYEn{6=o@gJrOvd{<@%AVPRf>Ox{bNTmY6S>eu~ z2i{r^`IcP}*q4 z;_|`xfT^j*R4@m_0obn|5ESD(4?geGK|lcLfA%YX9Z|PS(}!N9Ahzt61Q%aWA#9*l zh>>Iyge{xb26ur7J{rQ0+@k$vi;o}s?1q7)@1dxFl(;5B3si^FtAIG?vfA*j&8$G_ zX;KEK<(!bTyxszMY0><`9u1Hbz-$;vL?k;HUcm6PC6`8Gf>$SCiV5+#ug(o;=9D^->m~>S(1B6Aoq`=?tpVrv*rVy*c`ygCz@?aeXW?F`pCe=odk>?BR{3kW;Iix! z%USp7V4kVI?lLl)fmL+@Gsa2a;shj7nRxalp$bAeVPY%FD&+1Ko=WSG95XOLkdy|& zp4#BOd#}8@NWlI5@d`n!zqc;g$zVVVk7AX;P~(;>I_TmDn&omv-NVg}PWB3(eSlv~ zWdg|Q@1DhpPCoVNJcu@2z#4>W@asRdunL>*ELaSMusY3hr7}@7z0AD3H6Pd1JSO)C z84T}G_nIvOH?el>YSBofW8b&Ya3MwOksWkM;2$zas);?mt(xD}P*~|J$z(W8X?RWc+8G|D3T@3V!}{x7rDp(@4gl zm7(5d51zykFQeeUy~9u_*sGoh<&wl4i0M|Q`xJ5e%S&KOT+T)OKez7(EWPh0#^PBc z68y7AU$ijme(f!Rke39)@#EY`U2xIeX$^6wX${K{!8r){G4%IFt>S^-ShEd&yIlc$ zrse;9(p0sX@13(iq9G&A1O!C9vU|=wITkG%HtD6Xwd%MGbPcRK{OS^FZ!CaKiQJ;i zk79sKWIYMTc1eAc5>~BNUEUKtoc=BTCwTmtT-D6rRYaD}H%O2ko_{k2UMT-wk(zcO zS$4p;B6$(CL&tH+Maf}0_F{1C~|-@@*#?O`KH(-FfLf@vvk^+Kjy z);m(zAz7`vzEzu-!+~YC*%XR0CI!uVpz=}?(dhphTPCxBG&{I?_cEbQB?QAfOnR3h zEv2IzImOQ~;qEqDgaX##-X&ZsISA*E7#Xd`k5hH6jYKi2??N39AGm(~x=o;@^CzNeBArq`S+7kM#sr~Q#f6+vA|8 z_}9eT@Bsn3n%*>POwsZ{@Z&k4*oaa3XyVt=SO$06*ntls7dKaBbqtUwPT{3IiE(01 zHDzAi0>o~9HRDi4_eqm2Xb+yiH(srl>-ig?p=#O)C=i+BUrj&Rkt? zQEZnXxy`Q)!-Uj64I9aMZ%JJW4k+X+pS?joN%j7x|IGl))AVkh5Jp7VN${=@MEheO zV#1sv7eoP+-rU1!^j!!Os}Hz*hDMA?x@{PU*8993wcT(6xyiapWxHv^0fQUW629Xf z;?o=YmnPCuDuB5!h}EZ`rC33b?50EbQqAiHptR!;0uwMoC&@&tq5F{PG>J7TzxOYq zIK$t8At*+wInJ8a>}yr z!2e!jSrgpDkHiuFXFG~V5_o};3hN@WJ4<0cE z&;XBPfhb|@O?SD3z2w32RPJ}Jao9#+AZ98kr^qCow>l{Y?Ej$hYEN@;SrINxCA(Jd zguDR5VBUj{3u45X+=uA^j2HMs6PN&(#Wsfd@WE?RgHn?!A6F*9!dRazh1mgAxZ#pQ zH-du9u?Jn*W<;?13>*m$0dK-vKT_Ht`c9{sjCW-k-=-c*aQX#jw|5BSL>YKlyJ5BN z=gAWLPt)FV2dwr%gGfwWIN00?EI&8_ei)i z=t<`7Vo(&@CH!5>H1;yZ@+qrIw$CX&um=w?2IH^8b!b{z3v+kN01RD#j7U|QC&H#pv_U`}${R_!(Ah$KrtkuYp1;M&urixbEd3_- zp%c=^8CO%%_o`vQ{Lk8}K8E&P9Tv#DE9 z?CV=kotwnHM$z#4H(rdKbOWE2CUJII8cK{O^iiCj^ia3HLj6OpCf2ORTu+tCZGKR&xTZ$3tGU4h=Y_b`mFqi+oy#Ukmuz{L}dSsGo>c;ZF%A?*wWLai?r1r_qQzTCx zls)QH6nx~LSG?ffy*=x0$3@bEgycurz=1$IT$X?4>WV>FTsSlU>vZnUJMM2MdoR=U zM_9~6{$#SR;6XR|t;M0wZ?crX9g?B~DmM=G9K#RB%3DrwBZac`_xF+=wgL8qU%kUc z5}yx`7<1m-DyNmAi2Csjnz4;x8Yl2y-Q8Fsm`vkbEg+#kLZE5!bw{l0{3j%SfhHJa zEg7x1hJB_U@Vxx?4|aCTLFqRaHg@<;}csz|%Qsj~r7*z>JzY$JNR^oz>6k3TqzSlL6QgY;h3^Z1v1k4L)R z5=QC~Qa{;~n~UQ}4+U?_uL(mIeJ~Gw3%HWQlPX^vm^$YUaG&igXMGGkidb^qq1v2$ zP}c$#!#qxm$)((YV+5)#J$+Tb2Dqs3nfovCL+%8e^ru|7ugkYjkRNn@8SNYby7>ii z^S&}vPM!iPgn^vUH+U2sCYeK5Mhu2w4zz&UaEaS0b}K)%5abEO42^7U<1?;huBkYV z_!Q)rb?_j26Mf@^a1o1gQR?K%_Gl^A=#f=y#>=AlOA$L=HH__B;)erBsN2_&^Sxte zZE33E0vrITXP200%DXE0jMXFID9CFe#qa)mGFnkz4<#iDH(%;OFGTHc+Q04k zCu-+u%dd^3ws@@?cmBX@@LZ)-{5&7vM%|fHDPitkp$h|~cd^A}k|Htess^W2goYqVwVTMIRH&cLSI&8YXMj~=y+$Pe0?+E{gL@A@-J=XZHc`F}`Y42tH6hXtF+Q z@6i}zr16Jz4qv$#{+n&hVqI}RPDVdOyJJl^(o|nC{UKTb(D(yU4e-fUr;z>fuiwPT z%su^?VVF?bh-dn8NZi%H#6BxDg_43q5qr~w0X+gNd<$UhAQ*g>>~?%c$q0-74|;vtJ;CMM zgs@nT>g=jb+Ksob+2djI6}^xig7?(5)NMh3!Yy~gvP^R|$!oX9P@oB^wVTY^*d(=+ z4Tjmf)&}~qQJTIt5>*vqp*iv4{=#lCHPMdJ6|h|yyw*qj)+X$;puYww*DO@h1l8pd zZZ+0KNqCU6{YnkVGYGEM@R&If>V~Q)a(GZs;NA}YZFENyCXlk|0DakoTsWgG;_MIN zzV033C4jz)cn%a`VNr4O4Z1f>zT`gA#&XP7iaJXhhbU8x(k@pzy#S8oY@ba#;6JEk zx+>>!;4B$4Mj+62VLlWy`JBd*ypl^dlHG~)fYtsPebxEeRS&0zSJKu12z_#bMK9|O@H(S zGXqv4al^!Rmtde2gc?%BEBGN|R+hCadWFS6`YQ7cDXzd2znc*1D!`!2MQX7Diyb;3 z7337?7S2muUQ>l+poBQg&xfC5T#7c=T9qjp-CzM|ZC_S9b=n)A$H3SMDMi`&&gT`8 z)H-kzEgw!C#cPeV8(VsenqhZ`FE`^KK`7FdVTe3`tr`8a0qE$i47%%Qi9*VvMWWaj z+qKS?tmjbZjP;Qy_03`pCN1I>a8>*$zcB=Jmw82$A)Y$`-fEHN%AcujGi;Jl>Ee6K z?XA%LD?CeQxB0Q=@!GdB(5M50bp2-(`W?IAiFbU1sCx@!yWT4nUPO#@flFy?Ks;d+ z`Z!5i0d^I-6VrY06#5d+x?3~fKi%!nqebqGEw{v)kO_Z^IoPzj5GuC5^Q zhbA)SP}5oI_oG7u{BWiqfF!P{?D85TCN|#>2eM{2`a6b{kg7q1axtx0o?XewcPV9M zHlUVu+!>65fHm{|V`;#HbIaBqI+6Stv<(SwtE7S+O=>Wf7$pV^7?76i>p+>x2~#vL z%LM~`1p_%LI|+|VWRU4a0nmMAx_UwBP;-Ue_r*B@qDjR+tMF@rQ{VYnj)V|`M<|FF z6-lNCyKjP@W{-~4uiWx}mmIu7@i6RB-{i|f^h;F&n;O6Rr9bADtWC-#sUwB~QVULz z17fKlAMw~;W)VgWI3cruI9XBWsiz}E@Cw$o!bxgM*zPiy88xvU6G>_F-4-Cp#k_jm zCO*e$0cG`D9DfBJ_RrRVK8=?TWZzj8{W2PBCP(``mh5HOPlu-|LXj)@{jWvIBP3C` z4A@vIUmjdXVyQ3z>0|&*w{#0NuLnQhR{>yr^QoClOYu?rxB(~B5jK#&A5<1aJ$}D> zMIj)QtL`GwmWQdcKJZ&URL!oKpv9Foe}ab2sOVYN93Q$udum;LJx)$H*7VpncR
    eCv$tlZpF>}*Ct#y`GiHd;V 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 g0}m=25vvUPcH#$AL}B80pa*1U { assert.deepEqual(afterDiscovery.models, [ { id: 'gpt-5.1' }, { id: 'gpt-5.2' }, - { id: 'gpt-5', apiProtocol: 'openai-responses' }, + { + id: 'gpt-5', + apiProtocol: 'openai-responses', + factOverriddenFields: ['apiProtocol'], + }, ]); assert.deepEqual(afterDiscovery.enabledModelIds, ['gpt-5']); assert.equal(afterDiscovery.modelSource, 'fetched');