diff --git a/src/permission/store.ts b/src/permission/store.ts index e052d4c3d..b6233246f 100644 --- a/src/permission/store.ts +++ b/src/permission/store.ts @@ -80,26 +80,15 @@ function sameApproval(a: Approval, b: Approval): boolean { } // Equality on every confirmed dimension except cwd: a planted file entry and -// the gate's minted confirmation of it differ only in cwd. -function sameGrantModuloCwd(a: Approval, b: Approval): boolean { - return ( - a.tool === b.tool && - a.pattern === b.pattern && - a.providerModel === b.providerModel - ); -} +// the gate's minted confirmation of it differ only in cwd. One implementation; +// sameGrantModuloCwd keeps its call-site name. +const sameGrantModuloCwd = sameApproval; async function readApprovalsField( path: string, field: string, ): Promise { - try { - const raw = await readFile(path, "utf-8"); - const parsed = JSON.parse(raw) as Record; - return parseApprovalList(parsed?.[field]); - } catch { - return []; - } + return parseApprovalList((await readObjectFile(path))[field]); } async function readObjectFile(path: string): Promise> { diff --git a/src/provider/anthropic-session-adapter.ts b/src/provider/anthropic-session-adapter.ts index 2549be01b..04451684b 100644 --- a/src/provider/anthropic-session-adapter.ts +++ b/src/provider/anthropic-session-adapter.ts @@ -32,16 +32,9 @@ export function createSessionHeaderAnthropicAdapter( return { ...base, buildRequest }; } -export function createZenAnthropicAdapter( - source: AdapterSource, - quirks?: unknown, -): ProviderAdapter { - return createSessionHeaderAnthropicAdapter(source, quirks); -} +// Both providers share the session-header wrapper above; keep both export +// names for the adapter registration table. +export const createZenAnthropicAdapter = createSessionHeaderAnthropicAdapter; -export function createOpenCodeGoAnthropicAdapter( - source: AdapterSource, - quirks?: unknown, -): ProviderAdapter { - return createSessionHeaderAnthropicAdapter(source, quirks); -} +export const createOpenCodeGoAnthropicAdapter = + createSessionHeaderAnthropicAdapter; diff --git a/src/provider/bounded-model-catalog.ts b/src/provider/bounded-model-catalog.ts index 01e5b56af..51ec7e74e 100644 --- a/src/provider/bounded-model-catalog.ts +++ b/src/provider/bounded-model-catalog.ts @@ -1,20 +1,15 @@ import { type } from "arktype"; -import { requestModelsEndpoint } from "./models-endpoint.js"; +import { + endpointErrorMessage, + type ModelsEndpointDiscoveryState, + ModelsEndpointResponse, + requestModelsEndpoint, +} from "./models-endpoint.js"; -const CatalogModelsResponse = type({ - data: type({ id: "string" }).array(), -}); +const CatalogModelsResponse = ModelsEndpointResponse; -export type CatalogDiscoveryState = - | { readonly status: "models"; readonly models: readonly string[] } - | { readonly status: "empty" } - | { readonly status: "unavailable"; readonly message: string } - | { readonly status: "malformed"; readonly message: string }; - -function catalogErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} +export type CatalogDiscoveryState = ModelsEndpointDiscoveryState; function declaredCatalogBytes(response: Response): number | undefined { const raw = response.headers.get("content-length"); @@ -70,7 +65,7 @@ async function readBoundedCatalogText( } return { ok: true, text: new TextDecoder().decode(buffer) }; } catch (error) { - return { ok: false, message: catalogErrorMessage(error) }; + return { ok: false, message: endpointErrorMessage(error) }; } } @@ -116,7 +111,7 @@ export function createBoundedModelCatalog(args: { const value: unknown = JSON.parse(text.text); return { ok: true, value }; } catch (error) { - return { ok: false, message: catalogErrorMessage(error) }; + return { ok: false, message: endpointErrorMessage(error) }; } } @@ -134,7 +129,7 @@ export function createBoundedModelCatalog(args: { } catch (error) { return { status: "unavailable", - message: catalogErrorMessage(error), + message: endpointErrorMessage(error), }; } diff --git a/src/provider/context-window.ts b/src/provider/context-window.ts index 5bb72dbe7..9820ffb65 100644 --- a/src/provider/context-window.ts +++ b/src/provider/context-window.ts @@ -28,17 +28,23 @@ let contextWindowRegistry: Record = {}; // later models.dev refresh because it lives beside the registry, not in it. let contextWindowOverrides: Record = {}; -export function setModelContextWindows( - windows: Record | undefined, -): void { - contextWindowRegistry = windows ?? {}; +// Both tables are wholesale-replaced on refresh; one factory keeps the two +// trivial setters from drifting. `undefined` (no cache yet) means empty. +function createRegistrySetter( + replace: (windows: Record) => void, +): (windows: Record | undefined) => void { + return (windows) => replace(windows ?? {}); } -export function setProviderContextWindowOverrides( - windows: Record | undefined, -): void { - contextWindowOverrides = windows ?? {}; -} +export const setModelContextWindows = createRegistrySetter((windows) => { + contextWindowRegistry = windows; +}); + +export const setProviderContextWindowOverrides = createRegistrySetter( + (windows) => { + contextWindowOverrides = windows; + }, +); export type ProviderContextWindowSource = { models: readonly string[]; diff --git a/src/provider/model-catalogs.ts b/src/provider/model-catalogs.ts index 60fa724e6..a709f0f2c 100644 --- a/src/provider/model-catalogs.ts +++ b/src/provider/model-catalogs.ts @@ -9,23 +9,37 @@ import { import { createBoundedModelCatalog } from "./bounded-model-catalog.js"; // Bound live /models so a huge or hostile catalog cannot blow process memory. -export const MAX_GO_CATALOG_BYTES = 256 * 1024; -export const MAX_GO_CATALOG_MODELS = 1024; -export const MAX_ZEN_CATALOG_BYTES = 256 * 1024; -export const MAX_ZEN_CATALOG_MODELS = 1024; +// One shared bound for both catalogs; the per-catalog names stay as aliases. +const MAX_CATALOG_BYTES = 256 * 1024; +const MAX_CATALOG_MODELS = 1024; + +export const MAX_GO_CATALOG_BYTES = MAX_CATALOG_BYTES; +export const MAX_GO_CATALOG_MODELS = MAX_CATALOG_MODELS; +export const MAX_ZEN_CATALOG_BYTES = MAX_CATALOG_BYTES; +export const MAX_ZEN_CATALOG_MODELS = MAX_CATALOG_MODELS; + +// The two live catalogs differ only in source and label — one row each. +const MODEL_CATALOG_CONFIGS = { + go: { + baseURL: OPENCODE_GO_BASE_URL, + seedIds: OPENCODE_GO_MODEL_IDS, + catalogLabel: "OpenCode Go", + }, + zen: { + baseURL: ZEN_DEFAULT_BASE_URL, + seedIds: ZEN_MODEL_IDS, + catalogLabel: "OpenCode Zen", + }, +} as const; const goCatalog = createBoundedModelCatalog({ - baseURL: OPENCODE_GO_BASE_URL, - seedIds: OPENCODE_GO_MODEL_IDS, - catalogLabel: "OpenCode Go", + ...MODEL_CATALOG_CONFIGS.go, maxBytes: MAX_GO_CATALOG_BYTES, maxModels: MAX_GO_CATALOG_MODELS, }); const zenCatalog = createBoundedModelCatalog({ - baseURL: ZEN_DEFAULT_BASE_URL, - seedIds: ZEN_MODEL_IDS, - catalogLabel: "OpenCode Zen", + ...MODEL_CATALOG_CONFIGS.zen, maxBytes: MAX_ZEN_CATALOG_BYTES, maxModels: MAX_ZEN_CATALOG_MODELS, }); diff --git a/src/provider/models-endpoint.ts b/src/provider/models-endpoint.ts index c20511814..01d9d137a 100644 --- a/src/provider/models-endpoint.ts +++ b/src/provider/models-endpoint.ts @@ -1,3 +1,5 @@ +import { type } from "arktype"; + import { normalizeOpenAICompatibleBaseURL } from "../config/settings.js"; export const DEFAULT_MODELS_REQUEST_TIMEOUT_MS = 10_000; @@ -8,6 +10,23 @@ export function modelsEndpointURL(baseURL: string): string { ); } +// One /models `{ data: [{ id }] }` shape shared by every discovery importer +// (bounded-model-catalog, ollama) so the parsers cannot drift. +export const ModelsEndpointResponse = type({ + data: type({ id: "string" }).array(), +}); + +export type ModelsEndpointDiscoveryState = + | { readonly status: "models"; readonly models: readonly string[] } + | { readonly status: "empty" } + | { readonly status: "unavailable"; readonly message: string } + | { readonly status: "malformed"; readonly message: string }; + +// Single unknown→message coercion for discovery failure paths. +export function endpointErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + // Single GET against an OpenAI-compatible /models endpoint. Every caller that // probes a provider's model list goes through here so URL normalization and // the request timeout stay consistent. diff --git a/src/provider/ollama.ts b/src/provider/ollama.ts index a00a6670d..b508dd64a 100644 --- a/src/provider/ollama.ts +++ b/src/provider/ollama.ts @@ -1,6 +1,11 @@ import { type } from "arktype"; -import { requestModelsEndpoint } from "./models-endpoint.js"; +import { + endpointErrorMessage, + type ModelsEndpointDiscoveryState, + ModelsEndpointResponse, + requestModelsEndpoint, +} from "./models-endpoint.js"; export const OLLAMA_PROVIDER_ID = "ollama"; @@ -36,15 +41,9 @@ export function ollamaOpenAIBaseURL(rootURL: string): string { return `${normalizeOllamaRootURL(rootURL)}/v1`; } -const OllamaModelsResponse = type({ - data: type({ id: "string" }).array(), -}); +const OllamaModelsResponse = ModelsEndpointResponse; -export type OllamaDiscoveryState = - | { readonly status: "models"; readonly models: readonly string[] } - | { readonly status: "empty" } - | { readonly status: "unavailable"; readonly message: string } - | { readonly status: "malformed"; readonly message: string }; +export type OllamaDiscoveryState = ModelsEndpointDiscoveryState; /** Discover installed Ollama models without leaking transport or parsing failures. */ export async function discoverOllamaModels(args: { @@ -58,7 +57,7 @@ export async function discoverOllamaModels(args: { } catch (error) { return { status: "malformed", - message: error instanceof Error ? error.message : String(error), + message: endpointErrorMessage(error), }; } @@ -72,7 +71,7 @@ export async function discoverOllamaModels(args: { } catch (error) { return { status: "unavailable", - message: error instanceof Error ? error.message : String(error), + message: endpointErrorMessage(error), }; } diff --git a/src/provider/reasoning-effort.ts b/src/provider/reasoning-effort.ts index 78a3a6359..b436df70d 100644 --- a/src/provider/reasoning-effort.ts +++ b/src/provider/reasoning-effort.ts @@ -61,17 +61,13 @@ const UNKNOWN_MODEL_EFFORTS: readonly ReasoningEffort[] = [ "high", ]; -// Muse Spark (Responses protocol) accepts minimal through high. Not `none` — +// Muse Spark (Responses protocol) accepts minimal through high — the same +// ladder as DEFAULT_EFFORTS above. Not `none` — // the gateway rejects it with HTTP 400 on `reasoning.effort`. Measured on // muse-spark-1.3-contributor and muse-spark-1.2-contributor via the Go // endpoint and muse-spark-1.3-contributor-free via Zen: `minimal` returns 200 // and `none` returns 400 on all three. See CL-7867. -const MUSE_SPARK_EFFORTS: readonly ReasoningEffort[] = [ - "minimal", - "low", - "medium", - "high", -]; +const MUSE_SPARK_EFFORTS: readonly ReasoningEffort[] = DEFAULT_EFFORTS; // Matched by prefix, not by an id list. The family ships under five ids across // two catalogs — `muse-spark-1.3-contributor` / `-1.2-contributor` in @@ -85,13 +81,9 @@ function isMuseSparkModel(model: string): boolean { return /^muse-spark/i.test(model.trim()); } -// grok-4.6 accepts xhigh; grok-4.5 and composer stay on the unknown-model subset. -const GROK_46_EFFORTS: readonly ReasoningEffort[] = [ - "low", - "medium", - "high", - "xhigh", -]; +// grok-4.6 accepts xhigh — the same ladder as CODEX_EFFORTS above; +// grok-4.5 and composer stay on the unknown-model subset. +const GROK_46_EFFORTS: readonly ReasoningEffort[] = CODEX_EFFORTS; const GROK_46_MODELS: readonly string[] = ["grok-4.6"]; // GPT-6 Astra accepts low through max on both the OpenAI API and Codex surfaces.