diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 6575ccc5aa..a0cc04e5a7 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1298,6 +1298,11 @@ function registerHostClientIpc( emitTargetConnectionListChanged(); sendToRenderer("settings:externalChanged", { ts: Date.now() }); }); + // No `settings:externalChanged` here: the user's settings did not move, the + // Host just resolved the same connections against a newer model catalog. + const unsubscribeConnectionCatalogChanges = client.subscribeConnectionCatalogChanges(() => { + emitTargetConnectionListChanged(); + }); const unsubscribeSessionCatalogChanges = client.subscribeSessionCatalogChanges( ({ sessionId }) => emitTargetSessionsChanged("updated", sessionId), ); @@ -1556,6 +1561,7 @@ function registerHostClientIpc( registerTaskSubmissionReadinessIpc(taskSubmissionReadinessService, scopedIpc); return async () => { unsubscribeConfigurationChanges(); + unsubscribeConnectionCatalogChanges(); unsubscribeSessionCatalogChanges(); unsubscribeProjectCatalogChanges(); unsubscribeScheduledTaskChanges(); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 6a202ce727..9bc0318393 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -356,6 +356,11 @@ export class DesktopRuntimeHostClient { return this.connection.subscribeConfigurationChanges(listener); } + subscribeConnectionCatalogChanges(listener: (revision: number) => void): () => void { + this.#assertOpen(); + return this.connection.subscribeConnectionCatalogChanges(listener); + } + subscribeProjectCatalogChanges(listener: (revision: number) => void): () => void { this.#assertOpen(); return this.connection.subscribeProjectCatalogChanges(listener); diff --git a/docs/code-origin-audit.md b/docs/code-origin-audit.md index 7eb930b026..60a3ad1147 100644 --- a/docs/code-origin-audit.md +++ b/docs/code-origin-audit.md @@ -161,7 +161,7 @@ Upstream is MIT, Copyright (c) 2025 opencode. The repository now resolves to `an ### models.dev data snapshot -`packages/core/src/model-metadata.generated.ts` and `packages/runtime/src/telemetry/model-pricing.generated.ts` are build-time, untracked derivations of the committed `scripts/model-metadata/models-dev-api.snapshot.json` projection selected from `https://models.dev/api.json`. This is a two-level authority boundary: models.dev remains the upstream refresh source, while the committed snapshot is the sole build input for a particular repository revision and release. An explicit refresh imports upstream changes for review; normal installation and build paths never fetch a moving latest response. Upstream `anomalyco/models.dev` is MIT, Copyright (c) 2025 models.dev. The individual entries are facts and are not themselves copyrightable, but the selection and arrangement — which providers and fields are carried, and upstream's normalized structures such as `lifecycle` and `thinkingOptions.efforts` — come from that database. The same generator boundary applies: models.dev is not an npm dependency, so the root `LICENSE` records its source, repository, copyright, MIT permission notice, generated outputs, and snapshot provenance explicitly. The committed snapshot and generated headers bind the redistributed projection to recorded digests, making the fixed input identifiable without relying on the npm notice generator or a runtime network request. +`packages/core/src/model-metadata.generated.ts` and `packages/runtime/src/telemetry/model-pricing.generated.ts` are build-time, untracked derivations of the committed `scripts/model-metadata/models-dev-api.snapshot.json` projection selected from `https://models.dev/api.json`. This is a two-level authority boundary: models.dev remains the upstream refresh source, while the committed snapshot is the sole build input for a particular repository revision and release. An explicit refresh imports upstream changes for review; normal installation and build paths never fetch a moving latest response. At run time the Runtime Host fetches `https://models.dev/api.json` once at startup and holds the projection in memory for that process; it is never written to disk and never enters a build, so the redistributed artifact stays bound to the committed snapshot. That fetch goes through the same outbound admission as the WebFetch tool, so privacy mode suppresses it and a configured proxy carries it. Upstream `anomalyco/models.dev` is MIT, Copyright (c) 2025 models.dev. The individual entries are facts and are not themselves copyrightable, but the selection and arrangement — which providers and fields are carried, and upstream's normalized structures such as `lifecycle` and `thinkingOptions.efforts` — come from that database. The same generator boundary applies: models.dev is not an npm dependency, so the root `LICENSE` records its source, repository, copyright, MIT permission notice, generated outputs, and snapshot provenance explicitly. The committed snapshot and generated headers bind the redistributed projection to recorded digests, making the fixed input identifiable without relying on the npm notice generator or a runtime network request. ### PawWork browser port diff --git a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts index e64df0fc6f..83fe1dad99 100644 --- a/packages/cli/src/__tests__/runtime-host-cli-context.test.ts +++ b/packages/cli/src/__tests__/runtime-host-cli-context.test.ts @@ -61,6 +61,7 @@ test('CLI Runtime Host bootstrap launches the execution composition', async () = closed: new Promise(() => {}), status: async () => ({ state: 'ready' }), subscribeConfigurationChanges: () => () => {}, + subscribeConnectionCatalogChanges: () => () => {}, subscribeProjectCatalogChanges: () => () => {}, subscribeSessionCatalogChanges: () => () => {}, subscribeScheduledTaskChanges: () => () => {}, @@ -247,6 +248,7 @@ test('remote CLI profiles pin root identity and resolve credential outside the p closed: new Promise(() => {}), status: async () => ({ state: 'ready' }), subscribeConfigurationChanges: () => () => {}, + subscribeConnectionCatalogChanges: () => () => {}, subscribeProjectCatalogChanges: () => () => {}, subscribeSessionCatalogChanges: () => () => {}, subscribeScheduledTaskChanges: () => () => {}, @@ -347,6 +349,7 @@ test('remote CLI profile state and Client identity use the explicit Client Data closed: new Promise(() => {}), status: async () => ({ state: 'ready' }), subscribeConfigurationChanges: () => () => {}, + subscribeConnectionCatalogChanges: () => () => {}, subscribeProjectCatalogChanges: () => () => {}, subscribeSessionCatalogChanges: () => () => {}, subscribeScheduledTaskChanges: () => () => {}, @@ -425,6 +428,7 @@ test('remote CLI enables SSH prompts only for an explicitly interactive TTY', as closed: new Promise(() => {}), status: async () => ({ state: 'ready' }), subscribeConfigurationChanges: () => () => {}, + subscribeConnectionCatalogChanges: () => () => {}, subscribeProjectCatalogChanges: () => () => {}, subscribeSessionCatalogChanges: () => () => {}, subscribeScheduledTaskChanges: () => () => {}, diff --git a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts index 1e366974ad..b8c6864226 100644 --- a/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts +++ b/packages/cli/src/__tests__/tui-mcp-remote-publication.test.ts @@ -860,6 +860,7 @@ function connectionHarness( return { registrationId: 'registration-a', revision: harness.unregisters }; }, subscribeConfigurationChanges: () => () => undefined, + subscribeConnectionCatalogChanges: () => () => undefined, subscribeProjectCatalogChanges: () => () => undefined, subscribeSessionCatalogChanges: () => () => undefined, subscribeScheduledTaskChanges: () => () => undefined, diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 9bd7ec8ba3..ca20558c21 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -227,6 +227,17 @@ export interface MakaPiTuiInput { }; subscribeSessionTitleChanges?: (listener: (sessionId: string) => void) => () => void; subscribeShellRunUpdates?: (listener: (update: ShellRunUpdate) => void) => () => void; + /** + * The Host re-resolved its model catalog and handed back the new projection. + * Adopt it wholesale — the picker shows what the Host says, never a local + * merge of it. + */ + subscribeModelCatalogChanges?: ( + listener: (refresh: { + readonly modelChoices: readonly ModelChoice[]; + readonly connectionIdentities: readonly ConnectionIdentity[]; + }) => void, + ) => () => void; listShellRunUpdates?: (sessionId: string) => Promise; /** Host-owned invocable Skill catalog used for picker, completion, and token highlighting. */ listSkills?: (cwd: string) => Promise; @@ -439,17 +450,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { let model = input.model; let connectionId = input.connectionId; let connectionSlug = input.connectionSlug; - let modelContextWindow = input.modelContextWindow; let permissionMode = input.permissionMode; let orchestrationMode = input.driver.getOrchestrationMode?.() ?? 'default'; let thinkingLevel: ThinkingLevel | undefined = undefined; - // The Host resolved these when it projected the choice — including a relay's - // declared `relayModelProfiles[model].thinkingLevels`. A model no choice - // describes offers none rather than a locally guessed list. - let thinkingLevels: readonly ThinkingLevel[] = - input.modelChoices?.find( - (choice) => choice.connectionSlug === connectionSlug && choice.model === model, - )?.thinkingLevels ?? []; let sessionListScope: 'current' | 'all' = input.sessionListScope ?? 'current'; let connectionIdentityNotice: string | undefined; let busy = false; @@ -608,11 +611,11 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { permissionMode, orchestrationMode, thinkingLevel, - thinkingLevels, + thinkingLevels: currentThinkingLevels(), sessionId: input.driver.getSessionId(), busy, usage: state.usage, - modelContextWindow, + modelContextWindow: currentModelContextWindow(), turnElapsedMs: turnStartedAt !== undefined ? Date.now() - turnStartedAt : undefined, providerRetry: state.providerRetry, uiLocale: locale, @@ -919,8 +922,10 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { process.off('unhandledRejection', handleUnhandledRejection); }; + let unsubscribeModelCatalogChanges: (() => void) | undefined; const restoreTerminal = () => { removeProcessHandlers(); + unsubscribeModelCatalogChanges?.(); unsubscribeSessionTitleChanges(); unsubscribeGoalChanges?.(); void sideConversation?.stopParentObserver?.(); @@ -1239,6 +1244,39 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // available — the single source the picker and connection/model lookups read. let modelChoices = input.modelChoices; let connectionIdentities = input.connectionIdentities; + // Derived, never mirrored: both the choices and the selected target move — + // the Host republishes its catalog, the user switches model — and a stored + // copy of what the two imply has to be resynchronized at every one of those + // points or go stale at the one that was missed. + // The slug and model identify the target; the id narrows it only once the + // caller or a session summary has supplied one, because two connections can + // share a slug across a rebind but a caller need not know either id. + const currentModelChoice = (): ModelChoice | undefined => + modelChoices?.find( + (choice) => + choice.connectionSlug === connectionSlug && + choice.model === model && + (connectionId === undefined || choice.connectionId === connectionId), + ); + const onInitialTarget = (): boolean => + connectionId === input.connectionId && + connectionSlug === input.connectionSlug && + model === input.model; + /** The caller's value stands only while no choice describes the target it came with. */ + const currentModelContextWindow = (): number | undefined => + currentModelChoice()?.contextWindow ?? + (onInitialTarget() ? input.modelContextWindow : undefined); + // The Host resolved these when it projected the choice — including a relay's + // declared `relayModelProfiles[model].thinkingLevels`. A model no choice + // describes offers none rather than a locally guessed list. + const currentThinkingLevels = (): readonly ThinkingLevel[] => + currentModelChoice()?.thinkingLevels ?? []; + unsubscribeModelCatalogChanges = input.subscribeModelCatalogChanges?.((refresh) => { + if (closed) return; + modelChoices = refresh.modelChoices; + connectionIdentities = refresh.connectionIdentities; + requestRender(); + }); // Monotonic attempt id: each setup submit captures one, and any transition // that abandons the in-flight attempt (back, re-pick, close) increments it so // a late verify/save settlement cannot clobber a newer attempt. @@ -1515,9 +1553,6 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const adoptSessionMetadata = (summary: SessionSummary, announceIdentity = true) => { cwd = summary.cwd ?? cwd; setSessionTitle(summary.name); - const previousModel = model; - const previousConnectionId = connectionId; - const previousConnectionSlug = connectionSlug; model = summary.model; connectionId = summary.llmConnectionId; connectionSlug = summary.llmConnectionSlug; @@ -1530,25 +1565,9 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { state.entries.push({ kind: 'notice', level: 'error', text: identityNotice }); } connectionIdentityNotice = identityNotice; - const contextWindowMatch = modelChoices?.find( - (choice) => - choice.connectionId === summary.llmConnectionId && - choice.connectionSlug === summary.llmConnectionSlug && - choice.model === summary.model, - ); - if (contextWindowMatch) { - modelContextWindow = contextWindowMatch.contextWindow; - } else if ( - previousConnectionId !== summary.llmConnectionId || - previousConnectionSlug !== summary.llmConnectionSlug || - previousModel !== summary.model - ) { - modelContextWindow = undefined; - } permissionMode = input.driver.getPermissionMode?.() ?? summary.permissionMode; orchestrationMode = summary.orchestrationMode ?? 'default'; thinkingLevel = summary.thinkingLevel; - thinkingLevels = contextWindowMatch?.thinkingLevels ?? []; refreshEditorCwd?.(cwd); }; @@ -1565,15 +1584,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const previousModel = transcriptLastUsedModel ?? model; await input.driver.setModel(nextModel); model = nextModel; - // Same-connection switch: scope the choice lookup to the live connection - // (another connection may expose the same model id with different - // declared thinking levels). - const match = modelChoices?.find( - (choice) => choice.connectionSlug === connectionSlug && choice.model === nextModel, - ); - if (match) modelContextWindow = match.contextWindow; thinkingLevel = undefined; - thinkingLevels = match?.thinkingLevels ?? []; state.entries.push({ kind: 'notice', level: 'info', @@ -1599,9 +1610,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { model = choice.model; connectionId = choice.connectionId; connectionSlug = choice.connectionSlug; - modelContextWindow = choice.contextWindow; thinkingLevel = undefined; - thinkingLevels = choice.thinkingLevels; state.entries.push({ kind: 'notice', level: 'info', @@ -2854,7 +2863,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { }; const showThinkingLevelList = () => { - const items = thinkingLevelPickerItems(thinkingLevels, thinkingLevel, locale); + const items = thinkingLevelPickerItems(currentThinkingLevels(), thinkingLevel, locale); showSelectPicker( pickerCopy.thinkingPickerTitle, thinkingLevel ?? 'default', @@ -3507,6 +3516,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { description: primaryGuidance.commands.thinking, midTurn: 'refuse', run: (parts: string[]) => { + const thinkingLevels = currentThinkingLevels(); if (parts.length === 1) { if (thinkingLevels.length === 0) { state.entries.push({ diff --git a/packages/cli/src/runtime-host-tui-command.ts b/packages/cli/src/runtime-host-tui-command.ts index b5c08db693..e8edf22d42 100644 --- a/packages/cli/src/runtime-host-tui-command.ts +++ b/packages/cli/src/runtime-host-tui-command.ts @@ -94,6 +94,7 @@ export async function runRuntimeHostTui(input: RunRuntimeHostTuiInput): Promise< ) .map((choice) => choice.model), modelChoices: context.modelChoices, + subscribeModelCatalogChanges: context.subscribeModelCatalogChanges, connectionSlug: context.connectionSlug, connectionId: context.connectionId, connectionIdentities: context.connectionIdentities, diff --git a/packages/cli/src/runtime-host-tui-context.ts b/packages/cli/src/runtime-host-tui-context.ts index b618f92faf..f4456ecf1a 100644 --- a/packages/cli/src/runtime-host-tui-context.ts +++ b/packages/cli/src/runtime-host-tui-context.ts @@ -37,6 +37,7 @@ import { } from '@maka/storage/process-lifetime-owner'; import { readRuntimeHostAgentGraphEpochs, + readRuntimeHostConnectionCatalog, readRuntimeHostInvocableSkills, readRuntimeHostProjects, isRuntimeHostReconnectingConnection, @@ -84,6 +85,18 @@ export interface RuntimeHostTuiContext { readonly model: string; readonly modelContextWindow?: number; readonly modelChoices: readonly ModelChoice[]; + /** + * The Host now resolves connection catalogs differently — it refreshed its + * models.dev catalog. Re-read and re-project rather than patching what is + * held: which models are offerable and what is true about them are both the + * Host's answers. + */ + readonly subscribeModelCatalogChanges: ( + listener: (refresh: { + readonly modelChoices: readonly ModelChoice[]; + readonly connectionIdentities: readonly ConnectionIdentity[]; + }) => void, + ) => () => void; /** * Mode a Session created right now would start in, for display only. The * driver never receives it: an omitted create field is what lets the Host @@ -207,6 +220,19 @@ export async function createRuntimeHostTuiContext( model: selectedTarget.model, ...(modelContextWindow === undefined ? {} : { modelContextWindow }), modelChoices, + subscribeModelCatalogChanges: (listener) => + connection.subscribeConnectionCatalogChanges(() => { + void readRuntimeHostConnectionCatalog(connection) + .then((refreshed) => + listener({ + modelChoices: projectRuntimeHostModelChoices(refreshed), + connectionIdentities: projectRuntimeHostConnectionIdentities(refreshed), + }), + ) + // A catalog that will not read leaves the choices the TUI already + // has. The Host announces again the next time it changes. + .catch(() => undefined); + }), prospectivePermissionMode, turnActivity: createHostOwnedTurnActivity(), listSkills: (cwd) => diff --git a/packages/core/package.json b/packages/core/package.json index 5ce1b558e5..bace3c8bc0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,6 +82,7 @@ "./long-term-memory": "./dist/long-term-memory.js", "./local-memory": "./dist/local-memory.js", "./web-search": "./dist/web-search.js", + "./bounded-response": "./dist/bounded-response.js", "./incognito": "./dist/incognito.js", "./backend-types": "./dist/backend-types.js", "./codex-model-compatibility": "./dist/codex-model-compatibility.js", @@ -90,6 +91,8 @@ "./model-catalog": "./dist/model-catalog.js", "./model-facts": "./dist/model-facts.js", "./model-metadata": "./dist/model-metadata.js", + "./models-dev-projection": "./dist/models-dev-projection.js", + "./models-dev-refresh": "./dist/models-dev-refresh.js", "./model-web-search": "./dist/model-web-search.js", "./model-thinking": "./dist/model-thinking.js", "./persisted-value": "./dist/persisted-value.js", diff --git a/packages/core/src/__tests__/models-dev-refresh.test.ts b/packages/core/src/__tests__/models-dev-refresh.test.ts new file mode 100644 index 0000000000..ee945ce2d6 --- /dev/null +++ b/packages/core/src/__tests__/models-dev-refresh.test.ts @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { MODELS_DEV_PROVIDERS } from '../models-dev-projection.js'; +import { MODELS_DEV_RESPONSE_MAX_BYTES, fetchModelsDevProjection } from '../models-dev-refresh.js'; + +test('a refresh reports every committed path upstream stopped carrying', async () => { + const removals: string[][] = []; + const metadata = await fetchModelsDevProjection({ + fetch: respondWith(JSON.stringify(catalog())), + previous: { + anthropic: { + 'claude-gone': { displayName: 'Gone', contextWindow: 1_000 }, + 'claude-kept': { displayName: 'Kept', contextWindow: 2_000 }, + }, + }, + onRemovals: (paths) => removals.push([...paths]), + }); + + assert.equal(metadata.anthropic?.['claude-kept']?.displayName, 'Kept Model'); + assert.deepEqual(removals, [['/metadata/anthropic/claude-gone']]); +}); + +test('a refresh that removes nothing does not call back', async () => { + let called = false; + await fetchModelsDevProjection({ + fetch: respondWith(JSON.stringify(catalog())), + previous: { anthropic: { 'claude-kept': { displayName: 'Kept', contextWindow: 2_000 } } }, + onRemovals: () => { + called = true; + }, + }); + + assert.equal(called, false); +}); + +test('a declared oversized body is refused instead of drained', async () => { + let cancelled = false; + const response = new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { + headers: { + 'content-type': 'application/json', + 'content-length': String(MODELS_DEV_RESPONSE_MAX_BYTES + 1), + }, + }, + ); + + await assert.rejects( + fetchModelsDevProjection({ fetch: async () => response }), + /exceeded the accepted size/u, + ); + assert.equal(cancelled, true, 'the declared length alone ends it — the body is never read'); +}); + +test('a non-ok response never reaches the projection', async () => { + await assert.rejects( + fetchModelsDevProjection({ + fetch: async () => new Response('nope', { status: 503 }), + }), + /models\.dev responded 503/u, + ); +}); + +/** Every provider the projection demands; only anthropic's model is asserted on. */ +function catalog(): Record { + const providers: Record = {}; + for (const sourceId of new Set(Object.values(MODELS_DEV_PROVIDERS))) { + providers[sourceId] = { + id: sourceId, + name: sourceId, + doc: `https://models.dev/${sourceId}`, + models: { + 'claude-kept': { + name: 'Kept Model', + reasoning: false, + tool_call: true, + limit: { context: 2_000, output: 100 }, + modalities: { input: ['text'], output: ['text'] }, + }, + }, + }; + } + return providers; +} + +function respondWith(body: string): typeof globalThis.fetch { + return (async () => + new Response(body, { + headers: { 'content-type': 'application/json' }, + })) as typeof globalThis.fetch; +} diff --git a/packages/core/src/bounded-response.ts b/packages/core/src/bounded-response.ts new file mode 100644 index 0000000000..7421373ebd --- /dev/null +++ b/packages/core/src/bounded-response.ts @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Reading a response body without letting the far end decide how much memory + * we spend. Every outbound fetch whose peer is not us belongs here. + * + * The bound is enforced twice because either check alone is a hole: the + * declared `content-length` refuses an oversized body before a byte arrives, + * and the running byte count refuses one that never declared a length or lied + * about it. Buffering first and measuring afterwards is not a bound at all, + * and `String.length` is not a byte count — a multi-byte body passes a limit + * it exceeds by up to three times. + */ +export async function readBoundedResponseText( + response: Response, + maxBytes: number, + overflow: () => Error, +): Promise { + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + await response.body?.cancel(); + throw overflow(); + } + if (!response.body) return ''; + + const reader = response.body.getReader(); + const decoder = responseTextDecoder(response); + let bytes = 0; + let text = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytes += value.byteLength; + if (bytes > maxBytes) { + await reader.cancel(); + throw overflow(); + } + text += decoder.decode(value, { stream: true }); + } + return text + decoder.decode(); + } finally { + reader.releaseLock(); + } +} + +function responseTextDecoder(response: Response): TextDecoder { + const contentType = response.headers.get('content-type') ?? ''; + const charset = /(?:^|;)\s*charset\s*=\s*"?([^;"\s]+)/i.exec(contentType)?.[1]; + if (!charset) return new TextDecoder(); + try { + return new TextDecoder(charset); + } catch { + return new TextDecoder(); + } +} diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index 45e5db3c0d..a456e33bb9 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -167,6 +167,12 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa * unencodable. Providers that do discover models substitute their fallback * list instead of prepending it, so they add nothing here. */ +// What a model row may carry on the wire. The producer reads them here and the +// decoder enforces them: an over-long string caught only on arrival is one the +// catalog already built. +export const CONNECTION_MODEL_DISPLAY_NAME_MAX_LENGTH = 512; +export const CONNECTION_MODEL_DESCRIPTION_MAX_LENGTH = 2_048; + export const MAX_PREPENDED_FALLBACK_MODELS: number = Object.keys(PROVIDER_REGISTRY).reduce( (largest, providerType) => { if (providerSupportsModelDiscovery(providerType as ProviderType)) return largest; @@ -461,7 +467,9 @@ function makeEntry( return { id: normalizedModel.id, ...displayNameForModel(input.providerType, normalizedModel), - ...(description !== undefined ? { description } : {}), + ...(description !== undefined + ? { description: withinWireLimit(description, CONNECTION_MODEL_DESCRIPTION_MAX_LENGTH) } + : {}), canUseAsChatDefault, isDefault: overrides.isDefault ?? normalizedModel.id === normalizedDefaultModel, supportsVision: capabilities.vision === true, @@ -494,7 +502,9 @@ function displayNameForModel( model: ModelInfo, ): { displayName?: string } { const displayName = model.displayName?.trim(); - if (displayName && displayName !== model.id) return { displayName }; + if (displayName && displayName !== model.id) { + return { displayName: withinWireLimit(displayName, CONNECTION_MODEL_DISPLAY_NAME_MAX_LENGTH) }; + } return displayNameForKnownModel(providerType, model.id); } @@ -503,7 +513,20 @@ function displayNameForKnownModel( id: string, ): { displayName?: string } { const displayName = lookupModelMetadata(providerType, id).displayName; - return displayName ? { displayName } : {}; + return displayName + ? { displayName: withinWireLimit(displayName, CONNECTION_MODEL_DISPLAY_NAME_MAX_LENGTH) } + : {}; +} + +/** + * Entries are what the connection catalog puts on the wire, and its decoder + * refuses an over-long string by failing the whole catalog read. Every text a + * model row or metadata table can carry passes through here, so this is where + * a source that grew past the bound gets cut rather than where it takes the + * catalog down. + */ +function withinWireLimit(value: string, maxLength: number): string { + return value.length <= maxLength ? value : value.slice(0, maxLength); } /** diff --git a/packages/core/src/model-metadata.ts b/packages/core/src/model-metadata.ts index 81366ad80f..9148b8065d 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -49,8 +49,32 @@ export interface ModelMetadata { thinkingOptions?: ThinkingOptions; } -const generatedMetadata: Partial>> = - GENERATED_MODELS_DEV_METADATA; +type ModelsDevMetadata = Partial>>; + +/** + * What this build shipped, before any refresh. Read it to compare a refresh + * against the snapshot; `lookupModelMetadata` already answers "what is true + * now" and is what every renderer should use. + */ +export const bundledModelMetadata: ModelsDevMetadata = GENERATED_MODELS_DEV_METADATA; +let refreshedMetadata: ModelsDevMetadata | undefined; + +/** + * Replace the models.dev layer for this process, or pass `undefined` to return + * to the snapshot this build shipped. + * + * Whole table, never per model: once a refresh lands, the catalog says what + * upstream says, so a model upstream delisted stops being described here. The + * Runtime Host installs once at startup. Other processes keep the snapshot, + * and read Host-resolved catalog entries rather than their own merge. + */ +export function installRefreshedModelMetadata(metadata: ModelsDevMetadata | undefined): void { + refreshedMetadata = metadata; +} + +function activeMetadata(): ModelsDevMetadata { + return refreshedMetadata ?? bundledModelMetadata; +} const generatedModelProviderOverrides: Partial< Record> > = GENERATED_MODELS_DEV_MODEL_PROVIDER_OVERRIDES; @@ -67,7 +91,7 @@ function generatedMetadataProviderType(providerType: ProviderType): ProviderType } /** - * Whether the bundled metadata describes this model at all. `lookupModelMetadata` + * Whether the active metadata describes this model at all. `lookupModelMetadata` * answers "no" with an empty object, and callers were reading that sentinel by * hand; the question they mean to ask is this one. */ @@ -78,13 +102,14 @@ export function hasModelMetadata(providerType: ProviderType, modelId: string): b export function lookupModelMetadata(providerType: ProviderType, modelId: string): ModelMetadata { const id = modelId.trim(); const metadataProviderType = generatedMetadataProviderType(providerType); - const generated = generatedMetadata[metadataProviderType]?.[id]; + const generated = activeMetadata()[metadataProviderType]?.[id]; + const statics = staticModelMetadata(); const override = - STATIC_MODEL_METADATA[providerType]?.[id] ?? + statics[providerType]?.[id] ?? (providerType === 'xai-oauth' - ? STATIC_MODEL_METADATA.xai?.[id] + ? statics.xai?.[id] : providerType === 'opencode-free' - ? STATIC_MODEL_METADATA.opencode?.[id] + ? statics.opencode?.[id] : undefined); if (!generated) return override ?? {}; if (!override) return generated; @@ -103,12 +128,13 @@ export function lookupModelMetadata(providerType: ProviderType, modelId: string) */ export function modelMetadataIdsForProvider(providerType: ProviderType): string[] { const metadataProviderType = generatedMetadataProviderType(providerType); + const statics = staticModelMetadata(); return Array.from( new Set([ - ...Object.keys(generatedMetadata[metadataProviderType] ?? {}), - ...Object.keys(STATIC_MODEL_METADATA[providerType] ?? {}), + ...Object.keys(activeMetadata()[metadataProviderType] ?? {}), + ...Object.keys(statics[providerType] ?? {}), ...(metadataProviderType !== providerType - ? Object.keys(STATIC_MODEL_METADATA[metadataProviderType] ?? {}) + ? Object.keys(statics[metadataProviderType] ?? {}) : []), ]), ); @@ -204,10 +230,9 @@ const ANTHROPIC_MODEL_OVERRIDES: Record = { }, }; -const CLAUDE_SUBSCRIPTION_MODEL_METADATA = displayMetadataOnly( - GENERATED_MODELS_DEV_METADATA.anthropic, - ANTHROPIC_MODEL_OVERRIDES, -); +function claudeSubscriptionModelMetadata(active: ModelsDevMetadata): Record { + return displayMetadataOnly(active.anthropic ?? {}, ANTHROPIC_MODEL_OVERRIDES); +} const GOOGLE_MODEL_OVERRIDES: Record = { // Gemini 2.5 Flash disables thinking via the budget-zero wire; newer Gemini @@ -217,29 +242,34 @@ const GOOGLE_MODEL_OVERRIDES: Record = { }, }; -const OPENAI_OAUTH_MODEL_METADATA: Record = { - 'gpt-5.6-sol': { - ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.6-sol']!, - contextWindow: 372_000, - thinkingOptions: { efforts: ['none', 'low', 'medium', 'high', 'xhigh'] }, - }, - 'gpt-5.5': { - ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.5']!, - contextWindow: 272_000, - }, - 'gpt-5.4': { ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.4']!, contextWindow: 272_000 }, - 'gpt-5.4-mini': { - ...GENERATED_MODELS_DEV_METADATA.openai['gpt-5.4-mini']!, - contextWindow: 272_000, - }, - 'gpt-5.3-codex-spark': GENERATED_MODELS_DEV_METADATA.openai['gpt-5.3-codex-spark']!, -}; +// The OAuth path pins its own context windows over whatever the public +// catalog says. Base facts come from the active table, falling back to the +// shipped snapshot so a model upstream stops listing keeps a display name. +function openAiOAuthBase(active: ModelsDevMetadata, modelId: string): ModelMetadata { + return active.openai?.[modelId] ?? GENERATED_MODELS_DEV_METADATA.openai[modelId] ?? {}; +} -const SILICONFLOW_MODEL_OVERRIDES: Record = Object.fromEntries( - Object.entries(GENERATED_MODELS_DEV_METADATA.siliconflow) - .filter(([, metadata]) => metadata.capabilities?.functionCalling) - .map(([id]) => [id, { capabilities: { chat: true } }]), -); +function openAiOAuthModelMetadata(active: ModelsDevMetadata): Record { + return { + 'gpt-5.6-sol': { + ...openAiOAuthBase(active, 'gpt-5.6-sol'), + contextWindow: 372_000, + thinkingOptions: { efforts: ['none', 'low', 'medium', 'high', 'xhigh'] }, + }, + 'gpt-5.5': { ...openAiOAuthBase(active, 'gpt-5.5'), contextWindow: 272_000 }, + 'gpt-5.4': { ...openAiOAuthBase(active, 'gpt-5.4'), contextWindow: 272_000 }, + 'gpt-5.4-mini': { ...openAiOAuthBase(active, 'gpt-5.4-mini'), contextWindow: 272_000 }, + 'gpt-5.3-codex-spark': openAiOAuthBase(active, 'gpt-5.3-codex-spark'), + }; +} + +function siliconflowModelOverrides(active: ModelsDevMetadata): Record { + return Object.fromEntries( + Object.entries(active.siliconflow ?? {}) + .filter(([, metadata]) => metadata.capabilities?.functionCalling) + .map(([id]) => [id, { capabilities: { chat: true } }]), + ); +} const VOLCENGINE_CODING_PLAN_MODEL_METADATA: Record = { 'ark-code-latest': planModel('Ark Code Latest', false), @@ -290,144 +320,157 @@ const VOLCENGINE_AGENT_PLAN_MODEL_METADATA: Record = { }; // Ollama Cloud accepts reasoning_effort for every active reasoning model in its -// generated catalog. GPT-OSS is the narrower exception and cannot be disabled. +// generated catalog, whatever knob the model declares on its own. const OLLAMA_CLOUD_STANDARD_THINKING_OPTIONS: ThinkingOptions = { efforts: ['none', 'low', 'medium', 'high', 'max'], toggle: true, }; -const OLLAMA_CLOUD_GPT_OSS_THINKING_OPTIONS: ThinkingOptions = { - efforts: ['low', 'medium', 'high'], -}; - -const ollamaCloudThinkingModels: Record = Object.fromEntries( - Object.entries(GENERATED_MODELS_DEV_METADATA['ollama-cloud']) - .filter( - ([, metadata]) => metadata.capabilities?.reasoning && metadata.lifecycle !== 'deprecated', - ) - .map(([id]) => [ - id, - { - thinkingOptions: id.startsWith('gpt-oss') - ? OLLAMA_CLOUD_GPT_OSS_THINKING_OPTIONS - : OLLAMA_CLOUD_STANDARD_THINKING_OPTIONS, - }, - ]), -); +function ollamaCloudThinkingModels(active: ModelsDevMetadata): Record { + return Object.fromEntries( + Object.entries(active['ollama-cloud'] ?? {}) + .filter( + ([id, metadata]) => + metadata.capabilities?.reasoning && + metadata.lifecycle !== 'deprecated' && + // GPT-OSS is the narrower exception and cannot be disabled. models.dev + // declares that set itself, so pinning it here would only restate it. + !id.startsWith('gpt-oss'), + ) + .map(([id]) => [id, { thinkingOptions: OLLAMA_CLOUD_STANDARD_THINKING_OPTIONS }]), + ); +} // Facts that models.dev cannot express: provider wire controls and // access-path-specific aliases/limits. Standard model facts stay generated. -const STATIC_MODEL_METADATA: Partial>> = { - anthropic: ANTHROPIC_MODEL_OVERRIDES, - 'claude-subscription': CLAUDE_SUBSCRIPTION_MODEL_METADATA, - 'alibaba-token-plan-cn': { - 'qwen3.8-max': { - thinkingOptions: { efforts: ['none', 'low', 'medium', 'xhigh'], toggle: true }, +// +// Built over the active table rather than the shipped one, so the entries +// derived from a provider's catalog cover models a refresh introduced. +function buildStaticModelMetadata(active: ModelsDevMetadata): ModelsDevMetadata { + return { + anthropic: ANTHROPIC_MODEL_OVERRIDES, + 'claude-subscription': claudeSubscriptionModelMetadata(active), + 'alibaba-token-plan-cn': { + 'qwen3.8-max': { + thinkingOptions: { efforts: ['none', 'low', 'medium', 'xhigh'], toggle: true }, + }, }, - }, - 'alibaba-token-plan': { - 'qwen3.8-max': { - thinkingOptions: { efforts: ['none', 'low', 'medium', 'xhigh'], toggle: true }, + 'alibaba-token-plan': { + 'qwen3.8-max': { + thinkingOptions: { efforts: ['none', 'low', 'medium', 'xhigh'], toggle: true }, + }, }, - }, - google: GOOGLE_MODEL_OVERRIDES, - cohere: { - 'command-a-plus-05-2026': { - thinkingOptions: { toggle: true, offBehavior: 'cohere-thinking-disabled' }, + google: GOOGLE_MODEL_OVERRIDES, + cohere: { + 'command-a-plus-05-2026': { + thinkingOptions: { toggle: true, offBehavior: 'cohere-thinking-disabled' }, + }, + 'command-a-reasoning-08-2025': { + thinkingOptions: { toggle: true, offBehavior: 'cohere-thinking-disabled' }, + }, }, - 'command-a-reasoning-08-2025': { - thinkingOptions: { toggle: true, offBehavior: 'cohere-thinking-disabled' }, + 'openai-codex': openAiOAuthModelMetadata(active), + siliconflow: siliconflowModelOverrides(active), + 'tencent-coding-plan': { + 'kimi-k2.5': { capabilities: { vision: false } }, }, - }, - 'openai-codex': OPENAI_OAUTH_MODEL_METADATA, - siliconflow: SILICONFLOW_MODEL_OVERRIDES, - 'tencent-coding-plan': { - 'kimi-k2.5': { capabilities: { vision: false } }, - }, - 'volcengine-ark': { - 'doubao-seed-2-0-pro-260215': { - displayName: 'Doubao Seed 2.0 Pro', - lifecycle: 'active', - capabilities: { reasoning: true, functionCalling: true }, - thinkingOptions: { - efforts: ['minimal', 'low', 'medium', 'high'], - toggle: true, - offBehavior: 'volcengine-thinking-disabled', + 'volcengine-ark': { + 'doubao-seed-2-0-pro-260215': { + displayName: 'Doubao Seed 2.0 Pro', + lifecycle: 'active', + capabilities: { reasoning: true, functionCalling: true }, + thinkingOptions: { + efforts: ['minimal', 'low', 'medium', 'high'], + toggle: true, + offBehavior: 'volcengine-thinking-disabled', + }, }, }, - }, - 'volcengine-coding-plan': VOLCENGINE_CODING_PLAN_MODEL_METADATA, - 'volcengine-agent-plan': VOLCENGINE_AGENT_PLAN_MODEL_METADATA, - 'tencent-token-plan': { - // hy3-preview is absent from the current snapshot; hy3's effort set now - // comes from the models.dev snapshot. - 'hy3-preview': { thinkingOptions: { efforts: ['low', 'medium', 'high'] } }, - }, - deepinfra: { - 'moonshotai/Kimi-K2.7-Code': { - thinkingOptions: { efforts: ['none', 'low', 'medium', 'high'], toggle: true }, + 'volcengine-coding-plan': VOLCENGINE_CODING_PLAN_MODEL_METADATA, + 'volcengine-agent-plan': VOLCENGINE_AGENT_PLAN_MODEL_METADATA, + 'tencent-token-plan': { + // hy3-preview is absent from the current snapshot; hy3's effort set now + // comes from the models.dev snapshot. + 'hy3-preview': { thinkingOptions: { efforts: ['low', 'medium', 'high'] } }, }, - }, - groq: { - // Groq documents reasoning_effort only for the gpt-oss family - // (low/medium/high) and qwen3.6-27b (none/default); see - // console.groq.com/docs/reasoning. models.dev currently declares - // ['none','default'] for qwen/qwen3-32b, which is qwen3.6's value set - // misapplied — qwen3-32b reasons with no knob, so it is pinned to no - // options until a live check proves otherwise. The gpt-oss family's - // effort sets now come from the models.dev snapshot. - 'qwen/qwen3-32b': { thinkingOptions: { efforts: [] } }, - }, - openrouter: { - // gpt-5.6-sol and deepseek-v4-pro pin Maka-verified effort sets; the rest - // of openrouter's effort declarations come from the models.dev snapshot. - 'openai/gpt-5.6-sol': { - thinkingOptions: { efforts: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], toggle: true }, + deepinfra: { + 'moonshotai/Kimi-K2.7-Code': { + thinkingOptions: { efforts: ['none', 'low', 'medium', 'high'], toggle: true }, + }, }, - 'deepseek/deepseek-v4-pro': { thinkingOptions: { efforts: ['high', 'xhigh'], toggle: true } }, - }, - 'cloudflare-workers-ai': { - '@cf/moonshotai/kimi-k2.6': { - thinkingOptions: { - efforts: ['low', 'medium', 'high'], - toggle: true, - offBehavior: 'cloudflare-chat-template-thinking-false', + groq: { + // Groq documents reasoning_effort only for the gpt-oss family + // (low/medium/high) and qwen3.6-27b (none/default); see + // console.groq.com/docs/reasoning. qwen3-32b reasons with no knob, and + // models.dev no longer lists it at all, so this is the only thing that + // keeps a connection carrying the id from offering an effort menu. + 'qwen/qwen3-32b': { thinkingOptions: { efforts: [] } }, + }, + openrouter: { + // gpt-5.6-sol pins the toggle models.dev omits; every other openrouter + // effort declaration comes from models.dev. + 'openai/gpt-5.6-sol': { + thinkingOptions: { + efforts: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + toggle: true, + }, }, }, - }, - 'ollama-cloud': ollamaCloudThinkingModels, - deepseek: { - 'deepseek-v4-flash': { - capabilities: { ...REASONING_FUNCTION_CALLING, webSearch: true }, - lastUpdated: '2026-08-24', - thinkingOptions: { efforts: ['low', 'high', 'max'], toggle: true }, + 'cloudflare-workers-ai': { + '@cf/moonshotai/kimi-k2.6': { + thinkingOptions: { + efforts: ['low', 'medium', 'high'], + toggle: true, + offBehavior: 'cloudflare-chat-template-thinking-false', + }, + }, }, - 'deepseek-v4-flash-vision-exp': { - capabilities: { vision: true, ...REASONING_FUNCTION_CALLING, webSearch: true }, - thinkingOptions: { efforts: ['low', 'high', 'max'], toggle: true }, - modalities: { input: ['text', 'image'], output: ['text'] }, - displayName: 'DeepSeek-V4-Flash-Vision-Exp', - description: - 'Experimental DeepSeek V4 Flash model for image understanding and multimodal agent tasks', - contextWindow: 1_000_000, - maxOutputTokens: 384_000, - structuredOutput: true, - lastUpdated: '2026-08-21', + 'ollama-cloud': ollamaCloudThinkingModels(active), + deepseek: { + 'deepseek-v4-flash': { + capabilities: { ...REASONING_FUNCTION_CALLING, webSearch: true }, + lastUpdated: '2026-08-24', + thinkingOptions: { efforts: ['low', 'high', 'max'], toggle: true }, + }, + 'deepseek-v4-flash-vision-exp': { + capabilities: { vision: true, ...REASONING_FUNCTION_CALLING, webSearch: true }, + thinkingOptions: { efforts: ['low', 'high', 'max'], toggle: true }, + modalities: { input: ['text', 'image'], output: ['text'] }, + displayName: 'DeepSeek-V4-Flash-Vision-Exp', + description: + 'Experimental DeepSeek V4 Flash model for image understanding and multimodal agent tasks', + contextWindow: 1_000_000, + maxOutputTokens: 384_000, + structuredOutput: true, + lastUpdated: '2026-08-21', + }, + 'deepseek-v4-pro': { + capabilities: { ...REASONING_FUNCTION_CALLING, webSearch: true }, + lastUpdated: '2026-08-13', + thinkingOptions: { efforts: ['low', 'high', 'max'], toggle: true }, + }, }, - 'deepseek-v4-pro': { - capabilities: { ...REASONING_FUNCTION_CALLING, webSearch: true }, - lastUpdated: '2026-08-13', - thinkingOptions: { efforts: ['low', 'high', 'max'], toggle: true }, + 'zai-coding-plan': { + // glm-5.1 / glm-5v-turbo / glm-4.5-air are absent from the current + // snapshot; their toggle facts are preserved here until they return. + 'glm-5.1': { thinkingOptions: { toggle: true } }, + 'glm-5v-turbo': { thinkingOptions: { toggle: true } }, + 'glm-4.5-air': { thinkingOptions: { toggle: true } }, }, - }, - 'zai-coding-plan': { - // glm-5.1 / glm-5v-turbo / glm-4.5-air are absent from the current - // snapshot; their toggle facts are preserved here until they return. - 'glm-5.1': { thinkingOptions: { toggle: true } }, - 'glm-5v-turbo': { thinkingOptions: { toggle: true } }, - 'glm-4.5-air': { thinkingOptions: { toggle: true } }, - }, -}; + }; +} + +// Rebuilt when the active table is replaced, which happens at most once per +// process; identity is the only signal that a refresh landed. +let staticMetadataCache: { active: ModelsDevMetadata; value: ModelsDevMetadata } | undefined; + +function staticModelMetadata(): ModelsDevMetadata { + const active = activeMetadata(); + if (staticMetadataCache?.active !== active) { + staticMetadataCache = { active, value: buildStaticModelMetadata(active) }; + } + return staticMetadataCache.value; +} function planModel( displayName: string, diff --git a/packages/core/src/models-dev-projection.ts b/packages/core/src/models-dev-projection.ts new file mode 100644 index 0000000000..bf31492690 --- /dev/null +++ b/packages/core/src/models-dev-projection.ts @@ -0,0 +1,390 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Turning a models.dev response into `ModelMetadata`, for the build-time + * generator and the Runtime Host's startup refresh alike. One projection means + * a model cannot mean one thing in the committed snapshot and another when the + * Host reads it live. + * + * `scripts/sync-model-metadata.mjs` loads this file through Node's type + * stripping, which erases type-only imports but cannot resolve a relative + * value import. Every relative import here must stay `import type`. + * + * Validation fails loud and whole. A caller that cannot accept a rejected + * catalog keeps whatever it had. + */ + +import type { ProviderType } from './llm-connections.js'; +import type { ModelMetadata } from './model-metadata.js'; + +export const MODELS_DEV_SOURCE_URL = 'https://models.dev/api.json'; + +/** Every provider access path Maka serves, and the models.dev provider it reads. */ +export const MODELS_DEV_PROVIDERS = { + anthropic: 'anthropic', + alibaba: 'alibaba', + 'alibaba-cn': 'alibaba-cn', + 'alibaba-coding-plan-cn': 'alibaba-coding-plan-cn', + 'alibaba-coding-plan': 'alibaba-coding-plan', + 'alibaba-token-plan-cn': 'alibaba-token-plan-cn', + 'alibaba-token-plan': 'alibaba-token-plan', + cerebras: 'cerebras', + cohere: 'cohere', + 'cloudflare-workers-ai': 'cloudflare-workers-ai', + deepinfra: 'deepinfra', + deepseek: 'deepseek', + 'fireworks-ai': 'fireworks-ai', + 'github-copilot': 'github-copilot', + google: 'google', + groq: 'groq', + huggingface: 'huggingface', + 'kimi-coding-plan': 'kimi-for-coding', + MiniMax: 'minimax', + 'MiniMax-cn': 'minimax-cn', + 'minimax-coding-plan': 'minimax-coding-plan', + mistral: 'mistral', + moonshot: 'moonshotai-cn', + nvidia: 'nvidia', + 'ollama-cloud': 'ollama-cloud', + openai: 'openai', + opencode: 'opencode', + 'opencode-go': 'opencode-go', + openrouter: 'openrouter', + siliconflow: 'siliconflow', + stepfun: 'stepfun', + 'stepfun-ai': 'stepfun-ai', + 'stepfun-ai-step-plan': 'stepfun-ai-step-plan', + 'stepfun-step-plan': 'stepfun-step-plan', + togetherai: 'togetherai', + 'tencent-coding-plan': 'tencent-coding-plan', + 'tencent-token-plan': 'tencent-token-plan', + 'tencent-tokenhub': 'tencent-tokenhub', + vercel: 'vercel', + xai: 'xai', + xiaomi: 'xiaomi', + 'xiaomi-token-plan-cn': 'xiaomi-token-plan-cn', + 'xiaomi-token-plan-sgp': 'xiaomi-token-plan-sgp', + 'xiaomi-token-plan-ams': 'xiaomi-token-plan-ams', + zai: 'zai', + 'zai-coding-plan': 'zai-coding-plan', + zenmux: 'zenmux', +} as const satisfies Partial>; + +export interface ModelsDevProvider { + readonly id: string; + readonly name: string; + readonly api?: string; + readonly doc: string; + readonly models: Readonly>; +} + +export interface ModelsDevModel { + readonly name: string; + readonly description?: string; + readonly knowledge?: string; + readonly last_updated?: string; + readonly status?: string; + readonly reasoning: boolean; + readonly tool_call: boolean; + readonly structured_output?: boolean; + readonly limit: { readonly context: number; readonly output: number; readonly input?: number }; + readonly modalities?: { readonly input: string[]; readonly output: string[] }; + readonly reasoning_options?: ReadonlyArray<{ readonly type?: string; readonly values?: unknown }>; + readonly cost?: Readonly>; + readonly provider?: { readonly npm?: string; readonly api?: string }; +} + +export type ModelsDevCatalog = Readonly>; + +type ModelModality = NonNullable['input'][number]; + +// Every member must be listed, so a modality added to `ModelInfo` cannot reach +// the wire without a decision here. +const KNOWN_MODALITIES: Record = { + text: true, + image: true, + audio: true, + pdf: true, + video: true, +}; + +function requireModalities( + providerId: string, + modelId: string, + values: readonly string[], +): ModelModality[] { + for (const value of values) { + if (!Object.hasOwn(KNOWN_MODALITIES, value)) { + throw new Error(`models.dev model ${providerId}/${modelId} has unsupported modalities`); + } + } + return values as ModelModality[]; +} + +/** + * The providers Maka reads, taken from a models.dev response and checked to + * the depth every downstream projection depends on. A missing or shapeless + * provider rejects the whole response: a partial catalog would read as an + * upstream removal. + */ +export function selectModelsDevCatalog(catalog: unknown): ModelsDevCatalog { + if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) { + throw new Error('models.dev response is not an object'); + } + const source = catalog as Record; + const selected: Record = {}; + for (const sourceId of [...new Set(Object.values(MODELS_DEV_PROVIDERS))].sort()) { + const provider = source[sourceId]; + assertModelsDevProvider(sourceId, provider); + selected[sourceId] = provider; + } + return selected; +} + +/** + * The provider shape every downstream projection depends on. A caller that + * reports rather than rejects — the drift report names what upstream broke — + * checks one provider at a time instead of taking the whole catalog. + */ +export function assertModelsDevProvider( + sourceId: string, + provider: unknown, +): asserts provider is ModelsDevProvider { + if (!provider || typeof provider !== 'object' || Array.isArray(provider)) { + throw new Error(`models.dev provider ${sourceId} is missing`); + } + const candidate = provider as Record; + if ( + !candidate.models || + typeof candidate.models !== 'object' || + Array.isArray(candidate.models) || + Object.keys(candidate.models).length === 0 + ) { + throw new Error(`models.dev provider ${sourceId} has no non-empty models object`); + } + if ( + typeof candidate.id !== 'string' || + typeof candidate.name !== 'string' || + typeof candidate.doc !== 'string' || + (candidate.api !== undefined && typeof candidate.api !== 'string') + ) { + throw new Error(`models.dev provider ${sourceId} has an unsupported shape`); + } +} + +/** Every access path's models, keyed as Maka names the provider. */ +export function projectModelsDevMetadata( + catalog: ModelsDevCatalog, +): Partial>> { + const metadata: Partial>> = {}; + for (const [providerType, sourceId] of Object.entries(MODELS_DEV_PROVIDERS)) { + const provider = catalog[sourceId]; + if (!provider) throw new Error(`models.dev provider ${sourceId} is missing`); + metadata[providerType as ProviderType] = Object.fromEntries( + Object.entries(provider.models) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([id, model]) => [id, projectModelsDevModel(sourceId, id, provider, model)]), + ); + } + return metadata; +} + +export function projectModelsDevModel( + providerId: string, + modelId: string, + provider: ModelsDevProvider, + model: ModelsDevModel, +): ModelMetadata { + if ( + typeof provider.doc !== 'string' || + typeof model?.name !== 'string' || + (model.modalities !== undefined && !Array.isArray(model.modalities?.input)) || + (model.modalities !== undefined && !Array.isArray(model.modalities?.output)) || + typeof model.limit?.context !== 'number' || + typeof model.limit?.output !== 'number' || + typeof model.reasoning !== 'boolean' || + typeof model.tool_call !== 'boolean' + ) { + throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); + } + const modalities = + model.modalities === undefined + ? undefined + : { + input: requireModalities(providerId, modelId, model.modalities.input), + output: requireModalities(providerId, modelId, model.modalities.output), + }; + if ( + (model.description !== undefined && typeof model.description !== 'string') || + (model.knowledge !== undefined && typeof model.knowledge !== 'string') || + (model.limit?.input !== undefined && + (typeof model.limit.input !== 'number' || !Number.isFinite(model.limit.input))) || + (model.structured_output !== undefined && typeof model.structured_output !== 'boolean') || + (model.last_updated !== undefined && typeof model.last_updated !== 'string') + ) { + throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); + } + const lifecycle = lifecycleForStatus(providerId, modelId, model.status); + const reasoningOptions = model.reasoning_options ?? []; + if (!Array.isArray(reasoningOptions)) { + throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); + } + let efforts: string[] | undefined; + let toggle = false; + for (const entry of reasoningOptions) { + if (entry?.type === 'effort') { + const values = entry.values; + if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) { + throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); + } + efforts = values; + } else if (entry?.type === 'toggle') { + toggle = true; + } else if (entry?.type !== 'budget_tokens') { + // budget_tokens is a known models.dev option type with no wire consumer + // yet; any other unknown type fails loudly so a models.dev schema change + // is a conscious decision, not silent drift. + throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); + } + } + return { + displayName: model.name, + ...(model.description !== undefined ? { description: model.description } : {}), + lifecycle, + contextWindow: model.limit?.context, + ...(model.limit?.input !== undefined ? { inputLimit: model.limit.input } : {}), + maxOutputTokens: model.limit?.output, + ...(model.knowledge !== undefined ? { knowledgeCutoff: model.knowledge } : {}), + ...(model.structured_output !== undefined ? { structuredOutput: model.structured_output } : {}), + ...(model.last_updated !== undefined ? { lastUpdated: model.last_updated } : {}), + ...(model.cost?.input === 0 ? { isFree: true } : {}), + capabilities: { + ...(modalities ? { vision: modalities.input.includes('image') } : {}), + reasoning: model.reasoning === true, + functionCalling: model.tool_call === true, + }, + ...(efforts?.length || toggle + ? { + thinkingOptions: { + ...(efforts?.length ? { efforts } : {}), + ...(toggle ? { toggle: true } : {}), + }, + } + : {}), + ...(modalities + ? { + modalities: { + input: [...modalities.input], + output: [...modalities.output], + }, + } + : {}), + }; +} + +function lifecycleForStatus( + providerId: string, + modelId: string, + status: string | undefined, +): NonNullable { + if (status === undefined) return 'active'; + if (status === 'active' || status === 'beta' || status === 'alpha' || status === 'deprecated') { + return status; + } + throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported status`); +} + +/** + * Paths present in `previous` that `next` no longer carries, in JSON Pointer + * form. Both arguments are whole projections, so a caller comparing only one + * section passes it under the same key each side and reads the same paths the + * generator reports. + * + * Upstream removing a fact is not upstream correcting one. A model that + * stopped declaring its effort set does not stop having a knob, and a build + * that silently adopts the shorter list drops a level the user had already + * chosen. What the caller does about it is policy: the generator refuses the + * refresh until a human acknowledges it, the Host records it and carries on. + */ +export function collectProjectionRemovals(previous: unknown, next: unknown): string[] { + const removals: string[] = []; + collectRemovals(previous, next, [], removals); + return removals.sort(); +} + +function collectRemovals( + previous: unknown, + next: unknown, + path: (string | number)[], + removals: string[], +): void { + if (Array.isArray(previous)) { + if (!Array.isArray(next)) { + removals.push(projectionPath(path)); + return; + } + if (path.length === 1 && path[0] === 'pricing') { + const nextByModelKey = new Map( + next.map((entry) => [(entry as { modelKey?: unknown })?.modelKey, entry]), + ); + for (const entry of previous) { + const modelKey = (entry as { modelKey?: unknown })?.modelKey; + const modelPath = [...path, String(modelKey)]; + const nextEntry = nextByModelKey.get(modelKey); + if (!nextEntry) removals.push(projectionPath(modelPath)); + else collectRemovals(entry, nextEntry, modelPath, removals); + } + return; + } + for (const value of previous) { + if (!next.some((candidate) => Object.is(candidate, value))) { + removals.push(`${projectionPath(path)} value ${JSON.stringify(value)}`); + } + } + return; + } + + if (!previous || typeof previous !== 'object') { + // A capability withdrawn is a removal even though the key survives. + if ( + previous === true && + next === false && + path.length === 5 && + path[0] === 'metadata' && + path[3] === 'capabilities' + ) { + removals.push(projectionPath(path)); + } + return; + } + if (!next || typeof next !== 'object' || Array.isArray(next)) { + removals.push(projectionPath(path)); + return; + } + for (const [key, value] of Object.entries(previous)) { + const childPath = [...path, key]; + if (!Object.hasOwn(next, key)) removals.push(projectionPath(childPath)); + else collectRemovals(value, (next as Record)[key], childPath, removals); + } +} + +function projectionPath(path: readonly (string | number)[]): string { + return `/${path.map((segment) => String(segment).replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`; +} diff --git a/packages/core/src/models-dev-refresh.ts b/packages/core/src/models-dev-refresh.ts new file mode 100644 index 0000000000..3b0d42901e --- /dev/null +++ b/packages/core/src/models-dev-refresh.ts @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ProviderType } from './llm-connections.js'; +import type { ModelMetadata } from './model-metadata.js'; +import { readBoundedResponseText } from './bounded-response.js'; +import { + MODELS_DEV_SOURCE_URL, + collectProjectionRemovals, + projectModelsDevMetadata, + selectModelsDevCatalog, +} from './models-dev-projection.js'; + +/** models.dev is a few megabytes of JSON; well past that it is not the catalog. */ +export const MODELS_DEV_RESPONSE_MAX_BYTES = 32 * 1024 * 1024; + +export type ModelsDevMetadataProjection = Partial< + Record> +>; + +export interface FetchModelsDevProjectionInput { + readonly fetch: typeof globalThis.fetch; + readonly signal?: AbortSignal; + /** + * The projection this caller is replacing. Given one, every path upstream + * dropped is reported through `onRemovals` before the new projection is + * returned. + */ + readonly previous?: ModelsDevMetadataProjection; + readonly onRemovals?: (paths: readonly string[]) => void; +} + +/** + * One models.dev refresh: fetch it under a byte bound, project it, and account + * for what upstream stopped carrying. + * + * The build-time generator and the Runtime Host both run this. They differ + * only in what they do with the result — the generator commits it to a + * snapshot and refuses removals until a human acknowledges them, the Host + * installs it for the process and records them — never in what the refresh + * itself owes. A second implementation of any of these three steps is how the + * two ended up disagreeing about which responses are too large and which + * removals matter. + * + * Validation fails loud and whole: a caller that cannot accept a rejected + * catalog keeps whatever it had. + */ +export async function fetchModelsDevProjection( + input: FetchModelsDevProjectionInput, +): Promise { + const response = await input.fetch(MODELS_DEV_SOURCE_URL, { + ...(input.signal ? { signal: input.signal } : {}), + headers: { accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`models.dev responded ${response.status}`); + const body = await readBoundedResponseText( + response, + MODELS_DEV_RESPONSE_MAX_BYTES, + () => new Error('models.dev response exceeded the accepted size'), + ); + const metadata = projectModelsDevMetadata(selectModelsDevCatalog(JSON.parse(body))); + if (input.previous && input.onRemovals) { + // Compared under the `metadata` key the generator uses, so both callers + // report a removal by the same path. + const removals = collectProjectionRemovals({ metadata: input.previous }, { metadata }); + if (removals.length > 0) input.onRemovals(removals); + } + return metadata; +} diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index cf2664fed8..39524ae772 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -26,7 +26,11 @@ import { type ModelModality, type ProviderType, } from '../llm-connections.js'; -import { MAX_PREPENDED_FALLBACK_MODELS } from '../model-catalog.js'; +import { + CONNECTION_MODEL_DESCRIPTION_MAX_LENGTH, + CONNECTION_MODEL_DISPLAY_NAME_MAX_LENGTH, + MAX_PREPENDED_FALLBACK_MODELS, +} from '../model-catalog.js'; import { DECLARABLE_RELAY_THINKING_LEVELS, isThinkingLevel, @@ -548,10 +552,22 @@ export function decodeConnectionModel(value: unknown): ConnectionModel { id: decodeConnectionModelId(item.id), ...(item.displayName === undefined ? {} - : { displayName: stringValue(item.displayName, 'model display name', 512) }), + : { + displayName: stringValue( + item.displayName, + 'model display name', + CONNECTION_MODEL_DISPLAY_NAME_MAX_LENGTH, + ), + }), ...(item.description === undefined ? {} - : { description: stringValue(item.description, 'model description', 2048) }), + : { + description: stringValue( + item.description, + 'model description', + CONNECTION_MODEL_DESCRIPTION_MAX_LENGTH, + ), + }), ...(item.apiProtocol === undefined ? {} : { apiProtocol: item.apiProtocol }), ...(item.contextWindow === undefined ? {} diff --git a/packages/runtime-host/src/__tests__/host-change-feed.test.ts b/packages/runtime-host/src/__tests__/host-change-feed.test.ts index f6c5161c95..837b0aad3c 100644 --- a/packages/runtime-host/src/__tests__/host-change-feed.test.ts +++ b/packages/runtime-host/src/__tests__/host-change-feed.test.ts @@ -40,7 +40,13 @@ test('routes each change kind only to subscribed connections', () => { ); feed.attachConnection( 'all', - { configuration: true, projectCatalog: true, sessionCatalog: true, scheduledTask: true }, + { + configuration: true, + connectionCatalog: true, + projectCatalog: true, + sessionCatalog: true, + scheduledTask: true, + }, { send: async (frame) => void all.push(frame) }, ); feed.attachConnection( @@ -55,6 +61,7 @@ test('routes each change kind only to subscribed connections', () => { ); feed.publishConfiguration(); + feed.publishConnectionCatalog(); feed.publishProjectCatalog(); feed.publishSessionCatalog('session-1'); feed.publishSessionCatalog('session-2'); @@ -65,12 +72,13 @@ test('routes each change kind only to subscribed connections', () => { assert.deepEqual( configuration.map((frame) => (frame as { kind: string }).kind), ['configuration.changed'], + 'a connection catalog refresh is not a settings mutation', ); assert.deepEqual( project.map((frame) => (frame as { kind: string }).kind), ['project.catalog.changed'], ); - assert.equal(all.length, 7); + assert.equal(all.length, 8); assert.deepEqual(scopedSession, [ { kind: 'session.catalog.changed', revision: 1, sessionId: 'session-1' }, { kind: 'session.catalog.changed', revision: 3, sessionId: 'session-1' }, diff --git a/packages/runtime-host/src/__tests__/model-metadata-refresh.test.ts b/packages/runtime-host/src/__tests__/model-metadata-refresh.test.ts new file mode 100644 index 0000000000..06d5e89587 --- /dev/null +++ b/packages/runtime-host/src/__tests__/model-metadata-refresh.test.ts @@ -0,0 +1,245 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createDefaultRuntimePolicy } from '@maka/core/runtime-policy'; +import { installRefreshedModelMetadata, lookupModelMetadata } from '@maka/core/model-metadata'; +import { MODELS_DEV_PROVIDERS } from '@maka/core/models-dev-projection'; +import type { ProxiedFetchProxy } from '@maka/runtime/network/scoped-fetch-transport'; +import type { + ResolveHostOutboundExecutionResult, + RuntimePolicyOperationCoordinator, +} from '@maka/storage/runtime-policy-stores'; +import { startHostModelMetadataRefresh } from '../server/model-metadata-refresh.js'; + +/** Read before any install, so a snapshot refresh cannot make it a stale literal. */ +const BUNDLED_OPUS_NAME = lookupModelMetadata('anthropic', 'claude-opus-4-5').displayName; + +test('a refreshed catalog replaces the bundled metadata before clients are told', async (t) => { + t.after(() => installRefreshedModelMetadata(undefined)); + const announced: string[] = []; + const refresh = startHostModelMetadataRefresh({ + policy: resolver(ready()), + publish: () => { + announced.push(lookupModelMetadata('anthropic', 'refreshed-model').displayName ?? ''); + }, + createFetchTransport: () => respondWith(JSON.stringify(catalogFixture())), + }); + + await refresh.settled; + + assert.deepEqual(announced, ['Refreshed Model']); + assert.equal( + lookupModelMetadata('anthropic', 'refreshed-model').displayName, + 'Refreshed Model', + 'the refreshed table answers lookups', + ); +}); + +test('a refresh drops every model the bundled snapshot described on its own', async (t) => { + t.after(() => installRefreshedModelMetadata(undefined)); + const refresh = startHostModelMetadataRefresh({ + policy: resolver(ready()), + publish: () => {}, + createFetchTransport: () => respondWith(JSON.stringify(catalogFixture())), + }); + + await refresh.settled; + + assert.equal(typeof BUNDLED_OPUS_NAME, 'string'); + assert.equal( + lookupModelMetadata('anthropic', 'claude-opus-4-5').displayName, + undefined, + 'a model upstream no longer lists stops being described by models.dev', + ); +}); + +test('an upstream shape the projection refuses keeps the bundled snapshot', async (t) => { + t.after(() => installRefreshedModelMetadata(undefined)); + const catalog = catalogFixture(); + catalog.anthropic.models['refreshed-model'].modalities = { + input: ['hologram'], + output: ['text'], + }; + let announced = 0; + const refresh = startHostModelMetadataRefresh({ + policy: resolver(ready()), + publish: () => { + announced += 1; + }, + createFetchTransport: () => respondWith(JSON.stringify(catalog)), + }); + + await refresh.settled; + + assert.equal(announced, 0); + assert.equal(lookupModelMetadata('anthropic', 'refreshed-model').displayName, undefined); + assert.equal(lookupModelMetadata('anthropic', 'claude-opus-4-5').displayName, BUNDLED_OPUS_NAME); +}); + +test('a failed fetch keeps the bundled snapshot and announces nothing', async (t) => { + t.after(() => installRefreshedModelMetadata(undefined)); + let announced = 0; + let closed = 0; + const refresh = startHostModelMetadataRefresh({ + policy: resolver(ready()), + publish: () => { + announced += 1; + }, + createFetchTransport: () => ({ + fetch: async () => { + throw new Error('offline'); + }, + close: async () => { + closed += 1; + }, + }), + }); + + await refresh.settled; + + assert.equal(announced, 0); + assert.equal(closed, 1, 'the transport is closed even when the fetch throws'); + assert.equal(lookupModelMetadata('anthropic', 'claude-opus-4-5').displayName, BUNDLED_OPUS_NAME); +}); + +test('privacy mode refuses the refresh before any transport exists', async () => { + let transportCreated = false; + const refresh = startHostModelMetadataRefresh({ + policy: resolver({ kind: 'privacy_mode' }), + publish: () => assert.fail('privacy mode must not announce a refresh'), + createFetchTransport: () => { + transportCreated = true; + throw new Error('transport must not be created'); + }, + }); + + await refresh.settled; + + assert.equal(transportCreated, false); +}); + +test('the refresh goes out over the resolved proxy snapshot', async (t) => { + t.after(() => installRefreshedModelMetadata(undefined)); + const networkProxy = { + ...createDefaultRuntimePolicy().networkProxy, + enabled: true, + protocol: 'http' as const, + host: 'proxy.example', + port: 8080, + authEnabled: true, + username: 'proxy-user', + bypassList: ['direct.example'], + }; + let proxy: ProxiedFetchProxy | null | undefined; + const refresh = startHostModelMetadataRefresh({ + policy: resolver({ + kind: 'ready', + networkProxy, + secretMaterial: { + networkProxy: { + locator: { scope: 'network_proxy', kind: 'password' }, + credentialId: 'proxy-credential', + revision: 1, + secret: 'proxy-secret', + }, + }, + }), + publish: () => {}, + createFetchTransport: (candidate) => { + proxy = candidate; + return respondWith(JSON.stringify(catalogFixture())); + }, + }); + + await refresh.settled; + + assert.deepEqual(proxy, { + enabled: true, + type: 'http', + host: 'proxy.example', + port: 8080, + username: 'proxy-user', + password: 'proxy-secret', + bypassList: [...networkProxy.bypassList, ...networkProxy.autoBypassDomains], + }); +}); + +function ready(): ResolveHostOutboundExecutionResult { + return { + kind: 'ready', + networkProxy: createDefaultRuntimePolicy().networkProxy, + secretMaterial: {}, + }; +} + +function resolver( + result: ResolveHostOutboundExecutionResult, +): Pick { + return { resolveHostOutboundExecution: async () => result }; +} + +function respondWith(body: string) { + return { + fetch: async () => new Response(body, { headers: { 'content-type': 'application/json' } }), + close: async () => {}, + }; +} + +interface FixtureModel { + name: string; + reasoning: boolean; + tool_call: boolean; + limit: { context: number; output: number }; + modalities: { input: string[]; output: string[] }; +} + +/** + * A whole models.dev response: the projection refuses a partial one, so every + * provider Maka reads has to be present with at least one model. + */ +function catalogFixture(): Record< + string, + { id: string; name: string; doc: string; models: Record } +> { + const catalog: Record< + string, + { id: string; name: string; doc: string; models: Record } + > = {}; + for (const sourceId of new Set(Object.values(MODELS_DEV_PROVIDERS))) { + catalog[sourceId] = { + id: sourceId, + name: sourceId, + doc: `https://models.dev/${sourceId}`, + models: { 'refreshed-model': model() }, + }; + } + return catalog; +} + +function model(): FixtureModel { + return { + name: 'Refreshed Model', + reasoning: false, + tool_call: true, + limit: { context: 200_000, output: 64_000 }, + modalities: { input: ['text', 'image'], output: ['text'] }, + }; +} diff --git a/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts b/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts index c1d3209a29..cb1699455e 100644 --- a/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts +++ b/packages/runtime-host/src/__tests__/reconnecting-connection.test.ts @@ -643,6 +643,7 @@ function connectionHarness( return openSubscription(); }, subscribeConfigurationChanges: () => () => {}, + subscribeConnectionCatalogChanges: () => () => {}, subscribeProjectCatalogChanges: () => () => {}, subscribeSessionCatalogChanges: () => () => {}, subscribeScheduledTaskChanges: () => () => {}, diff --git a/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts b/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts index f31086638f..c0fe8521da 100644 --- a/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts +++ b/packages/runtime-host/src/__tests__/web-fetch-tool.test.ts @@ -23,7 +23,7 @@ import { createDefaultRuntimePolicy } from '@maka/core/runtime-policy'; import type { MakaToolContext } from '@maka/runtime/tool-runtime'; import type { ProxiedFetchProxy } from '@maka/runtime/network/scoped-fetch-transport'; import type { - ResolveWebFetchExecutionResult, + ResolveHostOutboundExecutionResult, RuntimePolicyOperationCoordinator, } from '@maka/storage/runtime-policy-stores'; import { createHostWebFetchTool } from '../server/web-fetch-tool.js'; @@ -160,9 +160,9 @@ test('Host WebFetch closes its transport when the owning turn is cancelled', asy }); function resolver( - result: ResolveWebFetchExecutionResult, -): Pick { - return { resolveWebFetchExecution: async () => result }; + result: ResolveHostOutboundExecutionResult, +): Pick { + return { resolveHostOutboundExecution: async () => result }; } function context(abortSignal = new AbortController().signal): MakaToolContext { diff --git a/packages/runtime-host/src/client/connection.ts b/packages/runtime-host/src/client/connection.ts index 109cb47b99..55a1749f3f 100644 --- a/packages/runtime-host/src/client/connection.ts +++ b/packages/runtime-host/src/client/connection.ts @@ -40,6 +40,7 @@ import { type ClientCapabilityUnregisterResult, type ClientHello, type ConfigurationChangedFrame, + type ConnectionCatalogChangedFrame, type HostOperationErrorCode, type HostIncompatible, type HostRegistration, @@ -252,6 +253,7 @@ export interface RuntimeHostConnection { ): Promise; unregisterClientCapabilities(timeoutMs?: number): Promise; subscribeConfigurationChanges(listener: (revision: number) => void): () => void; + subscribeConnectionCatalogChanges(listener: (revision: number) => void): () => void; subscribeProjectCatalogChanges(listener: (revision: number) => void): () => void; subscribeSessionCatalogChanges(listener: (frame: SessionCatalogChangedFrame) => void): () => void; subscribeScheduledTaskChanges(listener: (frame: ScheduledTaskChangedFrame) => void): () => void; @@ -342,6 +344,7 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { readonly #retiredSubscriptionIds = new Set(); readonly #clientCapabilities: ClientCapabilityChannel; readonly #configurationChangeListeners = new Set<(revision: number) => void>(); + readonly #connectionCatalogChangeListeners = new Set<(revision: number) => void>(); readonly #projectCatalogChangeListeners = new Set<(revision: number) => void>(); readonly #sessionCatalogChangeListeners = new Set<(frame: SessionCatalogChangedFrame) => void>(); readonly #scheduledTaskChangeListeners = new Set<(frame: ScheduledTaskChangedFrame) => void>(); @@ -625,6 +628,11 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { return () => this.#configurationChangeListeners.delete(listener); } + subscribeConnectionCatalogChanges(listener: (revision: number) => void): () => void { + this.#connectionCatalogChangeListeners.add(listener); + return () => this.#connectionCatalogChangeListeners.delete(listener); + } + subscribeProjectCatalogChanges(listener: (revision: number) => void): () => void { this.#projectCatalogChangeListeners.add(listener); return () => this.#projectCatalogChangeListeners.delete(listener); @@ -656,6 +664,9 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { case 'configuration.changed': this.#acceptConfigurationChanged(frame); continue; + case 'connection.catalog.changed': + this.#acceptConnectionCatalogChanged(frame); + continue; case 'project.catalog.changed': this.#acceptProjectCatalogChanged(frame); continue; @@ -734,6 +745,16 @@ class RuntimeHostConnectionImpl implements RuntimeHostConnection { } } + #acceptConnectionCatalogChanged(frame: ConnectionCatalogChangedFrame): void { + for (const listener of this.#connectionCatalogChangeListeners) { + try { + listener(frame.revision); + } catch { + // A presentation listener cannot invalidate the Host connection. + } + } + } + #acceptProjectCatalogChanged(frame: ProjectCatalogChangedFrame): void { for (const listener of this.#projectCatalogChangeListeners) { try { diff --git a/packages/runtime-host/src/client/reconnecting-connection.ts b/packages/runtime-host/src/client/reconnecting-connection.ts index 4287cd4c8e..46f1aa14cb 100644 --- a/packages/runtime-host/src/client/reconnecting-connection.ts +++ b/packages/runtime-host/src/client/reconnecting-connection.ts @@ -118,6 +118,7 @@ class RuntimeHostReconnectingConnectionImpl implements RuntimeHostReconnectingCo readonly #connectionAvailabilityListeners = new Set< (availability: RuntimeHostConnectionAvailability) => void >(); + readonly #connectionCatalogListeners = new Set<(revision: number) => void>(); readonly #projectListeners = new Set<(revision: number) => void>(); readonly #sessionListeners = new Set<(frame: SessionCatalogChangedFrame) => void>(); readonly #scheduledTaskListeners = new Set<(frame: ScheduledTaskChangedFrame) => void>(); @@ -243,6 +244,11 @@ class RuntimeHostReconnectingConnectionImpl implements RuntimeHostReconnectingCo return () => this.#connectionAvailabilityListeners.delete(listener); } + subscribeConnectionCatalogChanges(listener: (revision: number) => void): () => void { + this.#connectionCatalogListeners.add(listener); + return () => this.#connectionCatalogListeners.delete(listener); + } + subscribeProjectCatalogChanges(listener: (revision: number) => void): () => void { this.#projectListeners.add(listener); return () => this.#projectListeners.delete(listener); @@ -339,6 +345,9 @@ class RuntimeHostReconnectingConnectionImpl implements RuntimeHostReconnectingCo connection.subscribeConfigurationChanges((revision: number) => { notify(this.#configurationListeners, revision); }), + connection.subscribeConnectionCatalogChanges((revision: number) => { + notify(this.#connectionCatalogListeners, revision); + }), connection.subscribeProjectCatalogChanges((revision: number) => { notify(this.#projectListeners, revision); }), diff --git a/packages/runtime-host/src/protocol/connection-catalog-change.ts b/packages/runtime-host/src/protocol/connection-catalog-change.ts new file mode 100644 index 0000000000..b500ab601e --- /dev/null +++ b/packages/runtime-host/src/protocol/connection-catalog-change.ts @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { requireCount, requireExactRecord } from './codec.js'; + +/** + * The Host resolves a connection's models differently than it did a moment + * ago, without the stored catalog having changed. Re-read + * `connection.catalog.query`; nothing else about the connection moved. + * + * Separate from `configuration.changed`, which says the user's runtime policy + * was mutated. A client that shows a settings-changed-elsewhere notice must + * not show it because the Host refreshed its model metadata. + */ +export interface ConnectionCatalogChangedFrame { + readonly kind: 'connection.catalog.changed'; + readonly revision: number; +} + +export function decodeConnectionCatalogChangedFrame(value: unknown): ConnectionCatalogChangedFrame { + const frame = requireExactRecord(value, 'connection catalog changed frame', ['kind', 'revision']); + return { + kind: 'connection.catalog.changed', + revision: requireCount(frame.revision, 'connection catalog change revision'), + }; +} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 0a7784ff03..2a1706def0 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -53,6 +53,10 @@ import { decodeProjectCatalogChangedFrame, type ProjectCatalogChangedFrame, } from './project-catalog-change.js'; +import { + decodeConnectionCatalogChangedFrame, + type ConnectionCatalogChangedFrame, +} from './connection-catalog-change.js'; import { decodeRequestFrame, decodeResponseFrame, @@ -68,6 +72,7 @@ export * from './interaction.js'; export * from './daily-review.js'; export * from './client-capability.js'; export * from './configuration-change.js'; +export * from './connection-catalog-change.js'; export * from './goal.js'; export * from './hosted-execution.js'; export * from './plan.js'; @@ -95,7 +100,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 88 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 89 as const; +// 89: The Host refreshes its models.dev catalog at startup and announces the +// swap with a `connection.catalog.changed` frame, which an older client's +// strict frame decoder rejects as an unknown kind. // 88: Catalog model modalities admit video on either side and pdf as output. // models.dev declares both, and the modality decoder rejects any value it does // not name, so a newer Host describing such a model fails an older client's @@ -322,6 +330,7 @@ export type HostFrame = | SubscriptionFrame | ClientCapabilityHostFrame | ConfigurationChangedFrame + | ConnectionCatalogChangedFrame | ProjectCatalogChangedFrame | SessionCatalogChangedFrame | ScheduledTaskChangedFrame; @@ -450,6 +459,9 @@ export function decodeHostFrame(value: unknown): HostFrame { return decodeClientCapabilityHostFrame(frame); } if (frame.kind === 'configuration.changed') return decodeConfigurationChangedFrame(frame); + if (frame.kind === 'connection.catalog.changed') { + return decodeConnectionCatalogChangedFrame(frame); + } if (frame.kind === 'project.catalog.changed') return decodeProjectCatalogChangedFrame(frame); if (frame.kind === 'session.catalog.changed') return decodeSessionCatalogChangedFrame(frame); if (frame.kind === 'scheduled-task.changed') return decodeScheduledTaskChangedFrame(frame); diff --git a/packages/runtime-host/src/server/connection-session.ts b/packages/runtime-host/src/server/connection-session.ts index 0dae4dfd96..9d5141a7ed 100644 --- a/packages/runtime-host/src/server/connection-session.ts +++ b/packages/runtime-host/src/server/connection-session.ts @@ -358,6 +358,10 @@ export class RuntimeHostConnectionSession { this.#options.connection.authority, 'runtime.policy.query', ), + connectionCatalog: hasRuntimeHostOperationGrant( + this.#options.connection.authority, + 'connection.catalog.query', + ), projectCatalog: hasRuntimeHostOperationGrant( this.#options.connection.authority, 'project.catalog.query', diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index e5fc5a826f..7200050d7a 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -165,6 +165,7 @@ import { RootTurnCoordinator } from './root-turn-coordinator.js'; import { RuntimePolicyActivationGate } from './runtime-policy-activation-gate.js'; import { notifySandboxBoundaryGraphWake } from './sandbox-boundary-graph-wake.js'; import { HostRuntimePolicyCoordinator } from './runtime-policy-coordinator.js'; +import { startHostModelMetadataRefresh } from './model-metadata-refresh.js'; import { HostRuntimeResourceCoordinator } from './runtime-resource-coordinator.js'; import { SessionAdmissionGate } from './session-admission-gate.js'; import { HostSessionCatalogCoordinator } from './session-catalog-coordinator.js'; @@ -273,6 +274,7 @@ export async function createExecutionRuntimeHostComposition( let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; let goalExecutions: HostGoalExecutionCoordinator | undefined; let pluginPlatform: HostPluginPlatform | undefined; + let modelMetadataRefresh: ReturnType | undefined; try { pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory); const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(pluginPlatform); @@ -450,6 +452,13 @@ export async function createExecutionRuntimeHostComposition( ) => Promise) | undefined; const hostChanges = new HostChangeFeed(); + // Startup, once, in the background: the Host owns the model catalog, so it + // is the one process that gets to ask models.dev what is true today. On any + // failure the build's committed snapshot stands. + modelMetadataRefresh = startHostModelMetadataRefresh({ + policy: runtimePolicyStores.operations, + publish: () => hostChanges.publishConnectionCatalog(), + }); const projectMembership = new HostProjectMembershipGate(); const workspaceResolver = new HostWorkspaceResolver( openedProjectCatalog, @@ -1706,6 +1715,7 @@ export async function createExecutionRuntimeHostComposition( () => oauth?.beginDrain(), ], close: [ + () => modelMetadataRefresh?.close(), () => connectionEffects.close(), () => (backendInvalidationPoisoned ? undefined : manager.refreshIdleBackends()), () => skills.close(), @@ -1905,6 +1915,11 @@ export async function createExecutionRuntimeHostComposition( }; } catch (error) { const errors: unknown[] = [error]; + try { + await modelMetadataRefresh?.close(); + } catch (closeError) { + errors.push(closeError); + } try { await pluginPlatform?.close(); } catch (closeError) { diff --git a/packages/runtime-host/src/server/host-change-feed.ts b/packages/runtime-host/src/server/host-change-feed.ts index 22a24112b9..dc5080d5e8 100644 --- a/packages/runtime-host/src/server/host-change-feed.ts +++ b/packages/runtime-host/src/server/host-change-feed.ts @@ -19,6 +19,7 @@ import type { ConfigurationChangedFrame, + ConnectionCatalogChangedFrame, ProjectCatalogChangedFrame, ScheduledTaskChangedFrame, ScheduledTaskChangedReason, @@ -27,6 +28,7 @@ import type { export type HostChangeFrame = | ConfigurationChangedFrame + | ConnectionCatalogChangedFrame | ProjectCatalogChangedFrame | SessionCatalogChangedFrame | ScheduledTaskChangedFrame; @@ -37,6 +39,7 @@ export interface HostChangeSubscription { export interface HostChangeSubscriptionMask { readonly configuration?: boolean; + readonly connectionCatalog?: boolean; readonly projectCatalog?: boolean; readonly sessionCatalog?: true | { readonly sessionId: string; readonly principalId: string }; readonly scheduledTask?: boolean; @@ -54,6 +57,7 @@ interface Subscription { export class HostChangeFeed { readonly #subscriptions = new Map(); #configurationRevision = 0; + #connectionCatalogRevision = 0; #projectCatalogRevision = 0; #sessionCatalogRevision = 0; @@ -81,6 +85,15 @@ export class HostChangeFeed { }); } + /** The Host now resolves connection catalogs differently; clients re-read. */ + publishConnectionCatalog(): void { + this.#connectionCatalogRevision += 1; + this.#publish({ + kind: 'connection.catalog.changed', + revision: this.#connectionCatalogRevision, + }); + } + publishProjectCatalog(): void { this.#projectCatalogRevision += 1; this.#publish({ @@ -149,6 +162,8 @@ function isSubscribed(mask: HostChangeSubscriptionMask, frame: HostChangeFrame): switch (frame.kind) { case 'configuration.changed': return mask.configuration === true; + case 'connection.catalog.changed': + return mask.connectionCatalog === true; case 'project.catalog.changed': return mask.projectCatalog === true; case 'session.catalog.changed': diff --git a/packages/runtime-host/src/server/model-metadata-refresh.ts b/packages/runtime-host/src/server/model-metadata-refresh.ts new file mode 100644 index 0000000000..4330962122 --- /dev/null +++ b/packages/runtime-host/src/server/model-metadata-refresh.ts @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { bundledModelMetadata, installRefreshedModelMetadata } from '@maka/core/model-metadata'; +import { fetchModelsDevProjection } from '@maka/core/models-dev-refresh'; +import { redactSecrets } from '@maka/core/redaction'; +import { + createProxiedFetchTransport, + type ProxiedFetchProxy, + type ProxiedFetchTransport, +} from '@maka/runtime/network/scoped-fetch-transport'; +import type { RuntimePolicyOperationCoordinator } from '@maka/storage/runtime-policy-stores'; +import { toRuntimePolicyProxy } from './runtime-policy-proxy.js'; + +const MODELS_DEV_FETCH_TIMEOUT_MS = 10_000; +/** A normal refresh retires hundreds of paths; the count is the signal, the names are a sample. */ +const LOGGED_REMOVAL_SAMPLE = 20; + +export interface HostModelMetadataRefreshInput { + readonly policy: Pick; + /** Announce the swap so attached clients re-read the connection catalog. */ + readonly publish: () => void; + readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport; + readonly timeoutMs?: number; +} + +export interface HostModelMetadataRefresh { + /** Resolves when the one refresh attempt has finished, however it ended. */ + readonly settled: Promise; + close(): Promise; +} + +/** + * Fetch the models.dev catalog once and make it this Host's model metadata. + * + * The Host is the only process that does this. Clients read Host-resolved + * catalog entries, so a Host on a stale build still describes every model the + * way the live catalog does. + * + * Every failure — offline, timeout, an oversized body, an upstream shape the + * projection refuses — keeps the snapshot compiled into this build. There is + * no partial install: a catalog that does not project whole is not a catalog. + * + * A refresh that lands is taken whole, including what upstream stopped + * carrying: the snapshot is not a second opinion about a model upstream still + * publishes. Where the generator refuses a shrinking refresh until a human + * acknowledges it, the Host records the removals and adopts them — nothing + * here is committed or redistributed, and the next process start asks again. + * + * The attempt is made once, at startup. A Host started in privacy mode does + * not refresh at all, and leaving privacy mode later does not start one. + */ +export function startHostModelMetadataRefresh( + input: HostModelMetadataRefreshInput, +): HostModelMetadataRefresh { + const abort = new AbortController(); + const settled = run(input, abort.signal).catch((error: unknown) => { + if (abort.signal.aborted) return; + // The message itself, not a generalized category: the projection names the + // provider and model it refused, and that is the whole diagnostic here. + console.error( + `[runtime-host] models.dev catalog refresh failed, keeping the bundled snapshot: ${redactSecrets(error instanceof Error ? error.message : String(error))}`, + ); + }); + return { + settled, + close: async () => { + abort.abort(new Error('Runtime Host model metadata refresh closed')); + await settled; + }, + }; +} + +function removalSummary(paths: readonly string[]): string { + const sample = paths.slice(0, LOGGED_REMOVAL_SAMPLE).join(', '); + const rest = paths.length - LOGGED_REMOVAL_SAMPLE; + return `models.dev no longer carries ${paths.length} path(s) the bundled snapshot described; adopting upstream: ${sample}${rest > 0 ? ` and ${rest} more` : ''}`; +} + +async function run(input: HostModelMetadataRefreshInput, signal: AbortSignal): Promise { + const admission = await input.policy.resolveHostOutboundExecution(); + if (admission.kind !== 'ready') { + console.error( + admission.kind === 'privacy_mode' + ? '[runtime-host] models.dev catalog refresh skipped: privacy mode is active' + : '[runtime-host] models.dev catalog refresh skipped: the network proxy credential is not configured', + ); + return; + } + signal.throwIfAborted(); + const transport = (input.createFetchTransport ?? createProxiedFetchTransport)( + toRuntimePolicyProxy(admission.networkProxy, admission.secretMaterial.networkProxy?.secret), + ); + const timeout = AbortSignal.timeout(input.timeoutMs ?? MODELS_DEV_FETCH_TIMEOUT_MS); + try { + const metadata = await fetchModelsDevProjection({ + fetch: transport.fetch, + signal: AbortSignal.any([signal, timeout]), + previous: bundledModelMetadata, + onRemovals: (paths) => console.error(`[runtime-host] ${removalSummary(paths)}`), + }); + signal.throwIfAborted(); + // Install before publishing: a client that re-reads on the frame must find + // the refreshed catalog, not the one it already had. + installRefreshedModelMetadata(metadata); + input.publish(); + } finally { + await transport.close(); + } +} diff --git a/packages/runtime-host/src/server/web-fetch-tool.ts b/packages/runtime-host/src/server/web-fetch-tool.ts index 1f18cef172..956af4b517 100644 --- a/packages/runtime-host/src/server/web-fetch-tool.ts +++ b/packages/runtime-host/src/server/web-fetch-tool.ts @@ -29,7 +29,7 @@ import type { RuntimePolicyOperationCoordinator } from '@maka/storage/runtime-po import { toRuntimePolicyProxy } from './runtime-policy-proxy.js'; interface HostWebFetchServiceInput { - readonly policy: Pick; + readonly policy: Pick; readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport; } @@ -45,7 +45,7 @@ export function createHostWebFetchService(input: HostWebFetchServiceInput): Host const createFetchTransport = input.createFetchTransport ?? createProxiedFetchTransport; return { fetch: async ({ url, sessionId, abortSignal }) => { - const resolved = await input.policy.resolveWebFetchExecution(); + const resolved = await input.policy.resolveHostOutboundExecution(); if (resolved.kind === 'privacy_mode') { throw new Error('WebFetch is disabled while privacy mode is active.'); } diff --git a/packages/runtime/src/local-web-fetch.ts b/packages/runtime/src/local-web-fetch.ts index 02ee3bf0cb..069644d1c0 100644 --- a/packages/runtime/src/local-web-fetch.ts +++ b/packages/runtime/src/local-web-fetch.ts @@ -17,6 +17,7 @@ * under the License. */ +import { readBoundedResponseText } from '@maka/core/bounded-response'; import { Readability } from '@mozilla/readability'; import { parseHTML } from 'linkedom'; import TurndownService from 'turndown'; @@ -105,7 +106,11 @@ export function createLocalWebFetchExecutor(input: LocalWebFetchInput): WebFetch : String(response.status); throw new Error(`WebFetch HTTP error: ${status}`); } - const body = await readBoundedText(response); + const body = await readBoundedResponseText( + response, + WEB_FETCH_RESPONSE_MAX_BYTES, + responseLimitError, + ); const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; const content = contentType.includes('text/html') || contentType.includes('application/xhtml+xml') @@ -123,46 +128,6 @@ export function createLocalWebFetchExecutor(input: LocalWebFetchInput): WebFetch }; } -async function readBoundedText(response: Response): Promise { - const contentLength = Number(response.headers.get('content-length')); - if (Number.isFinite(contentLength) && contentLength > WEB_FETCH_RESPONSE_MAX_BYTES) { - await response.body?.cancel(); - throw responseLimitError(); - } - if (!response.body) return ''; - - const reader = response.body.getReader(); - const decoder = responseTextDecoder(response); - let bytes = 0; - let text = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - bytes += value.byteLength; - if (bytes > WEB_FETCH_RESPONSE_MAX_BYTES) { - await reader.cancel(); - throw responseLimitError(); - } - text += decoder.decode(value, { stream: true }); - } - return text + decoder.decode(); - } finally { - reader.releaseLock(); - } -} - -function responseTextDecoder(response: Response): TextDecoder { - const contentType = response.headers.get('content-type') ?? ''; - const charset = /(?:^|;)\s*charset\s*=\s*"?([^;"\s]+)/i.exec(contentType)?.[1]; - if (!charset) return new TextDecoder(); - try { - return new TextDecoder(charset); - } catch { - return new TextDecoder(); - } -} - function responseLimitError(): Error { return new Error('WebFetch response exceeds the 5 MB response limit.'); } diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 9813bb349d..d6a5ae1d33 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -3058,7 +3058,7 @@ describe('runtime policy stores', () => { }); assert.equal(policy.kind, 'committed'); - assert.deepEqual(await stores.operations.resolveWebFetchExecution(), { + assert.deepEqual(await stores.operations.resolveHostOutboundExecution(), { kind: 'privacy_mode', }); }); diff --git a/packages/storage/src/runtime-policy-stores.ts b/packages/storage/src/runtime-policy-stores.ts index b06bd4394c..cf4b882194 100644 --- a/packages/storage/src/runtime-policy-stores.ts +++ b/packages/storage/src/runtime-policy-stores.ts @@ -84,7 +84,7 @@ export type { ResolveNetworkProxyExecutionResult, ResolveWebSearchExecutionInput, ResolveWebSearchExecutionResult, - ResolveWebFetchExecutionResult, + ResolveHostOutboundExecutionResult, } from './runtime-policy/operations.js'; const readerBrand: unique symbol = Symbol('RuntimePolicyStoresReader'); @@ -243,7 +243,7 @@ function createWriterFacade(coordinator: RuntimePolicyCoordinator): RuntimePolic coordinator.replaceConnectionRequestHeaders(connectionId, updates), resolveExecutionConnection: (ref) => coordinator.resolveExecutionConnection(ref), resolveWebSearchExecution: (input) => coordinator.resolveWebSearchExecution(input), - resolveWebFetchExecution: () => coordinator.resolveWebFetchExecution(), + resolveHostOutboundExecution: () => coordinator.resolveHostOutboundExecution(), resolveNetworkProxyExecution: (input) => coordinator.resolveNetworkProxyExecution(input), compareAndSetOAuthCredential: (input) => coordinator.compareAndSetOAuthCredential(input), importConnectionCredential: (input) => coordinator.importConnectionCredential(input), diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index ab6e6e0f79..7669f9f7d1 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -128,7 +128,7 @@ import { type ResolveExecutionConnectionResult, type ResolveNetworkProxyExecutionInput, type ResolveNetworkProxyExecutionResult, - type ResolveWebFetchExecutionResult, + type ResolveHostOutboundExecutionResult, type ResolveWebSearchExecutionInput, type ResolveWebSearchExecutionResult, type ReplaceConnectionRequestHeadersResult, @@ -999,7 +999,7 @@ export class RuntimePolicyCoordinator { }); } - resolveWebFetchExecution(): Promise { + resolveHostOutboundExecution(): Promise { return this.inLane(async (root) => { const policy = (await this.policy.read(root)).policy; if (policy.privacy.incognitoActive) { diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index 9d77658d5e..b27bf35e09 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -89,7 +89,13 @@ export type ResolveNetworkProxyExecutionResult = readonly secretMaterial: Pick; }; -export type ResolveWebFetchExecutionResult = +/** + * Admission for a Host request that goes out over plain HTTP rather than to a + * configured model provider: the WebFetch tool, the models.dev catalog + * refresh. Privacy mode refuses it outright, and a configured proxy is + * mandatory rather than best effort. + */ +export type ResolveHostOutboundExecutionResult = | { readonly kind: 'privacy_mode' } | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } | { @@ -359,7 +365,7 @@ export interface RuntimePolicyOperationCoordinator { resolveWebSearchExecution( input?: ResolveWebSearchExecutionInput, ): Promise; - resolveWebFetchExecution(): Promise; + resolveHostOutboundExecution(): Promise; resolveNetworkProxyExecution( input?: ResolveNetworkProxyExecutionInput, ): Promise; diff --git a/scripts/sync-model-metadata.mjs b/scripts/sync-model-metadata.mjs index 5203456772..befb9a849b 100644 --- a/scripts/sync-model-metadata.mjs +++ b/scripts/sync-model-metadata.mjs @@ -22,14 +22,21 @@ import { createHash, randomUUID } from 'node:crypto'; import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { stripTypeScriptTypes } from 'node:module'; import { dirname } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -const SOURCE_URL = 'https://models.dev/api.json'; -// This script runs before packages/core is built, so it cannot read -// ModelModality itself. The build keeps the two in step: a value that reaches -// the projection but not that type fails to compile. A value missing here -// drops the whole refresh, not just the model that declares it. -const MODALITIES = new Set(['text', 'image', 'audio', 'pdf', 'video']); +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// The projection lives in @maka/core because the Runtime Host runs it too. +// `prepare` fires before any workspace builds, so read the TypeScript source +// rather than a dist build that does not exist yet. +const projection = await loadTypeScriptModule( + await readFile( + fileURLToPath(new URL('../packages/core/src/models-dev-projection.ts', import.meta.url)), + 'utf8', + ), +); +const { selectModelsDevCatalog, assertModelsDevProvider, collectProjectionRemovals } = projection; +const SOURCE_URL = projection.MODELS_DEV_SOURCE_URL; +export const PROVIDERS = projection.MODELS_DEV_PROVIDERS; +export const toMetadata = projection.projectModelsDevModel; const DEFAULT_SNAPSHOT = 'scripts/model-metadata/models-dev-api.snapshot.json'; const DEFAULT_OUTPUT = 'packages/core/src/model-metadata.generated.ts'; const DEFAULT_PRICING_OUTPUT = 'packages/runtime/src/telemetry/model-pricing.generated.ts'; @@ -59,55 +66,6 @@ export const PRICING_EXCLUDED_PROVIDER_TYPES = new Set([ 'xiaomi-token-plan-sgp', 'zai-coding-plan', ]); -export const PROVIDERS = { - anthropic: 'anthropic', - alibaba: 'alibaba', - 'alibaba-cn': 'alibaba-cn', - 'alibaba-coding-plan-cn': 'alibaba-coding-plan-cn', - 'alibaba-coding-plan': 'alibaba-coding-plan', - 'alibaba-token-plan-cn': 'alibaba-token-plan-cn', - 'alibaba-token-plan': 'alibaba-token-plan', - cerebras: 'cerebras', - cohere: 'cohere', - 'cloudflare-workers-ai': 'cloudflare-workers-ai', - deepinfra: 'deepinfra', - deepseek: 'deepseek', - 'fireworks-ai': 'fireworks-ai', - 'github-copilot': 'github-copilot', - google: 'google', - groq: 'groq', - huggingface: 'huggingface', - 'kimi-coding-plan': 'kimi-for-coding', - MiniMax: 'minimax', - 'MiniMax-cn': 'minimax-cn', - 'minimax-coding-plan': 'minimax-coding-plan', - mistral: 'mistral', - moonshot: 'moonshotai-cn', - nvidia: 'nvidia', - 'ollama-cloud': 'ollama-cloud', - openai: 'openai', - opencode: 'opencode', - 'opencode-go': 'opencode-go', - openrouter: 'openrouter', - siliconflow: 'siliconflow', - stepfun: 'stepfun', - 'stepfun-ai': 'stepfun-ai', - 'stepfun-ai-step-plan': 'stepfun-ai-step-plan', - 'stepfun-step-plan': 'stepfun-step-plan', - togetherai: 'togetherai', - 'tencent-coding-plan': 'tencent-coding-plan', - 'tencent-token-plan': 'tencent-token-plan', - 'tencent-tokenhub': 'tencent-tokenhub', - vercel: 'vercel', - xai: 'xai', - xiaomi: 'xiaomi', - 'xiaomi-token-plan-cn': 'xiaomi-token-plan-cn', - 'xiaomi-token-plan-sgp': 'xiaomi-token-plan-sgp', - 'xiaomi-token-plan-ams': 'xiaomi-token-plan-ams', - zai: 'zai', - 'zai-coding-plan': 'zai-coding-plan', - zenmux: 'zenmux', -}; export async function main(argv = process.argv) { const refreshInputPath = option('--refresh-input', argv); @@ -206,7 +164,7 @@ function buildProjection(catalog, options = {}) { for (const [providerType, sourceId] of Object.entries(PROVIDERS)) { const provider = catalog[sourceId]; try { - assertProviderShape(sourceId, provider); + assertModelsDevProvider(sourceId, provider); } catch (error) { if (!onReject) throw error; onReject('provider', providerType, error); @@ -250,27 +208,6 @@ function buildProjection(catalog, options = {}) { return { metadata, pricing, providerFacts, providerOverrides }; } -function assertProviderShape(sourceId, provider) { - if (!provider) { - throw new Error(`models.dev provider ${sourceId} is missing`); - } - if ( - !provider.models || - typeof provider.models !== 'object' || - Array.isArray(provider.models) || - Object.keys(provider.models).length === 0 - ) { - throw new Error(`models.dev provider ${sourceId} has no non-empty models object`); - } - if ( - typeof provider.id !== 'string' || - typeof provider.name !== 'string' || - typeof provider.doc !== 'string' - ) { - throw new Error(`models.dev provider ${sourceId} has an unsupported shape`); - } -} - async function readUpstream(refreshInputPath) { if (refreshInputPath) { return { @@ -290,7 +227,7 @@ async function readUpstream(refreshInputPath) { async function refreshSnapshot(snapshotPath, refreshInputPath, options = {}) { const { text: sourceText, etag: sourceEtag, retrievedAt } = await readUpstream(refreshInputPath); - const projection = buildProjection(selectCatalog(JSON.parse(sourceText))); + const projection = buildProjection(selectModelsDevCatalog(JSON.parse(sourceText))); if (!options.acceptUpstreamRemovals) { const previous = await loadSnapshotIfPresent(snapshotPath); if (previous) assertProjectionDoesNotShrink(previous.projection, projection); @@ -318,69 +255,14 @@ async function refreshSnapshot(snapshotPath, refreshInputPath, options = {}) { } function assertProjectionDoesNotShrink(previous, next) { - const removals = []; - collectProjectionRemovals(previous, next, [], removals); + const removals = collectProjectionRemovals(previous, next); if (removals.length === 0) return; throw new Error( - `models.dev refresh would remove committed projection paths: ${removals.sort().join(', ')}; inspect the upstream change and rerun with --accept-upstream-removals to acknowledge it`, + `models.dev refresh would remove committed projection paths: ${removals.join(', ')}; inspect the upstream change and rerun with --accept-upstream-removals to acknowledge it`, ); } -function collectProjectionRemovals(previous, next, path, removals) { - if (Array.isArray(previous)) { - if (!Array.isArray(next)) { - removals.push(projectionPath(path)); - return; - } - if (path.length === 1 && path[0] === 'pricing') { - const nextByModelKey = new Map(next.map((entry) => [entry?.modelKey, entry])); - for (const entry of previous) { - const modelPath = [...path, entry.modelKey]; - const nextEntry = nextByModelKey.get(entry.modelKey); - if (!nextEntry) removals.push(projectionPath(modelPath)); - else collectProjectionRemovals(entry, nextEntry, modelPath, removals); - } - return; - } - for (const value of previous) { - if (!next.some((candidate) => Object.is(candidate, value))) { - removals.push(`${projectionPath(path)} value ${JSON.stringify(value)}`); - } - } - return; - } - - if (!previous || typeof previous !== 'object') { - if ( - previous === true && - next === false && - path.length === 5 && - path[0] === 'metadata' && - path[3] === 'capabilities' - ) { - removals.push(projectionPath(path)); - } - return; - } - if (!next || typeof next !== 'object' || Array.isArray(next)) { - removals.push(projectionPath(path)); - return; - } - for (const [key, value] of Object.entries(previous)) { - const childPath = [...path, key]; - if (!Object.prototype.hasOwnProperty.call(next, key)) { - removals.push(projectionPath(childPath)); - } else { - collectProjectionRemovals(value, next[key], childPath, removals); - } - } -} - -function projectionPath(path) { - return `/${path.map((segment) => String(segment).replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`; -} - // `--check` only proves the generated modules match the committed snapshot. // Nothing compared that snapshot against models.dev, which is how it stayed // weeks behind upstream without anything reporting it. This walks the two one @@ -405,8 +287,8 @@ async function collectDrift(snapshot, refreshInputPath) { const rejectedProviders = []; const rejectedModels = []; const rejected = new Set(); - // The raw catalog, not selectCatalog's: a provider that vanished upstream is - // the report's most important finding, and selectCatalog throws on it. + // The raw catalog, not the selected one: a provider that vanished upstream + // is the report's most important finding, and selection throws on it. const upstream = buildProjection(JSON.parse((await readUpstream(refreshInputPath)).text), { onReject: (kind, label, error) => { rejected.add(label); @@ -621,15 +503,6 @@ async function loadSnapshotIfPresent(snapshotPath) { } } -function selectCatalog(catalog) { - const selected = {}; - for (const sourceId of [...new Set(Object.values(PROVIDERS))].sort()) { - if (!catalog[sourceId]) throw new Error(`models.dev provider ${sourceId} is missing`); - selected[sourceId] = catalog[sourceId]; - } - return selected; -} - function sha256(value) { return createHash('sha256').update(value).digest('hex'); } @@ -700,92 +573,6 @@ function toModelProviderOverride(providerId, modelId, override) { }; } -export function toMetadata(providerId, modelId, provider, model) { - if ( - typeof provider.doc !== 'string' || - typeof model?.name !== 'string' || - (model.modalities !== undefined && !Array.isArray(model.modalities?.input)) || - (model.modalities !== undefined && !Array.isArray(model.modalities?.output)) || - typeof model.limit?.context !== 'number' || - typeof model.limit?.output !== 'number' || - typeof model.reasoning !== 'boolean' || - typeof model.tool_call !== 'boolean' - ) { - throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); - } - if ( - model.modalities?.input.some((value) => !MODALITIES.has(value)) || - model.modalities?.output.some((value) => !MODALITIES.has(value)) - ) { - throw new Error(`models.dev model ${providerId}/${modelId} has unsupported modalities`); - } - if ( - (model.description !== undefined && typeof model.description !== 'string') || - (model.knowledge !== undefined && typeof model.knowledge !== 'string') || - (model.limit?.input !== undefined && - (typeof model.limit.input !== 'number' || !Number.isFinite(model.limit.input))) || - (model.structured_output !== undefined && typeof model.structured_output !== 'boolean') || - (model.last_updated !== undefined && typeof model.last_updated !== 'string') - ) { - throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); - } - const lifecycle = lifecycleForStatus(providerId, modelId, model.status); - const reasoningOptions = model.reasoning_options ?? []; - if (!Array.isArray(reasoningOptions)) { - throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); - } - let efforts; - let toggle = false; - for (const entry of reasoningOptions) { - if (entry?.type === 'effort') { - if (!Array.isArray(entry.values) || entry.values.some((value) => typeof value !== 'string')) { - throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); - } - efforts = entry.values; - } else if (entry?.type === 'toggle') { - toggle = true; - } else if (entry?.type !== 'budget_tokens') { - // budget_tokens is a known models.dev option type with no wire consumer - // yet; any other unknown type fails loudly so a models.dev schema change - // is a conscious decision, not silent drift. - throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`); - } - } - return { - displayName: model.name, - ...(model.description !== undefined ? { description: model.description } : {}), - lifecycle, - contextWindow: model.limit?.context, - ...(model.limit?.input !== undefined ? { inputLimit: model.limit.input } : {}), - maxOutputTokens: model.limit?.output, - ...(model.knowledge !== undefined ? { knowledgeCutoff: model.knowledge } : {}), - ...(model.structured_output !== undefined ? { structuredOutput: model.structured_output } : {}), - ...(model.last_updated !== undefined ? { lastUpdated: model.last_updated } : {}), - ...(model.cost?.input === 0 ? { isFree: true } : {}), - capabilities: { - ...(model.modalities ? { vision: model.modalities.input.includes('image') } : {}), - reasoning: model.reasoning === true, - functionCalling: model.tool_call === true, - }, - ...(efforts?.length || toggle - ? { - thinkingOptions: { - ...(efforts?.length ? { efforts } : {}), - ...(toggle ? { toggle: true } : {}), - }, - } - : {}), - ...(model.modalities - ? { - modalities: { - input: model.modalities.input, - output: model.modalities.output, - }, - } - : {}), - }; -} - export function toPricing(providerType, modelId, model) { const cost = model?.cost; if (cost === undefined) return undefined; @@ -825,14 +612,6 @@ export function toPricing(providerType, modelId, model) { }; } -function lifecycleForStatus(providerId, modelId, status) { - if (status === undefined) return 'active'; - if (status === 'active' || status === 'beta' || status === 'alpha' || status === 'deprecated') { - return status; - } - throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported status`); -} - function priceNumber(providerType, modelId, value, field) { if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { throw new Error(`models.dev model ${providerType}/${modelId} has an unsupported cost.${field}`);