diff --git a/packages/coding-agent/src/config/config-file.ts b/packages/coding-agent/src/config/config-file.ts index e7f4d4d73..d098250e6 100644 --- a/packages/coding-agent/src/config/config-file.ts +++ b/packages/coding-agent/src/config/config-file.ts @@ -53,6 +53,10 @@ function migrateJsonToYml(jsonPath: string, ymlPath: string) { } } +export type ConfigSchemaSource = + | { readonly kind: "eager"; readonly schema: Type } + | { readonly kind: "deferred"; readonly resolve: () => Type }; + export interface IConfigFile { readonly id: string; readonly schema: Type; @@ -125,14 +129,17 @@ export class ConfigFile implements IConfigFile { readonly #basePath: string; readonly #yamlFallbackPath: string | null; readonly #jsonMigrationPath: string | null; + readonly #schemaSource: ConfigSchemaSource; + #resolvedSchema?: Type; #cache?: LoadResult; #auxValidate?: (value: T) => void; constructor( readonly id: string, - readonly schema: Type, + schema: Type | ConfigSchemaSource, configPath: string = path.join(getAgentDir(), `${id}.yml`), ) { + this.#schemaSource = typeof schema === "function" ? { kind: "eager", schema } : schema; this.#basePath = configPath; if (configPath.endsWith(".yml")) { this.#yamlFallbackPath = `${configPath.slice(0, -4)}.yaml`; @@ -150,6 +157,12 @@ export class ConfigFile implements IConfigFile { } } + get schema(): Type { + if (this.#schemaSource.kind === "eager") return this.#schemaSource.schema; + if (!this.#resolvedSchema) this.#resolvedSchema = this.#schemaSource.resolve(); + return this.#resolvedSchema; + } + /** * Run the JSON → YAML migration synchronously, if applicable. Idempotent. * Sync callers (tests, settings init) hit this implicitly via {@link tryLoad}. @@ -164,8 +177,9 @@ export class ConfigFile implements IConfigFile { relocate(configPath?: string): ConfigFile { if (!configPath || configPath === this.#basePath) return this; - const result = new ConfigFile(this.id, this.schema, configPath); + const result = new ConfigFile(this.id, this.#schemaSource, configPath); result.#auxValidate = this.#auxValidate; + result.#resolvedSchema = this.#resolvedSchema; result.#ensureMigrated(); return result; } diff --git a/packages/coding-agent/src/config/models-config-schema-bundle.ts b/packages/coding-agent/src/config/models-config-schema-bundle.ts new file mode 100644 index 000000000..731f82458 --- /dev/null +++ b/packages/coding-agent/src/config/models-config-schema-bundle.ts @@ -0,0 +1,313 @@ +import { once } from "@oh-my-pi/pi-utils"; +import { scope } from "arktype"; + +export const getModelsConfigSchemaBundle = once(() => { + // Config schemas validate at most a handful of times per process (on config + // load), so the eager JIT codegen ArkType runs at definition time is pure + // startup tax. A local jitless scope skips that codegen and falls back to + // interpreted traversal — ~65% cheaper to construct, validation correctness + // unchanged. (No `name`: duplicate module instances would collide.) + const { type } = scope({}, { jitless: true }); + + const OpenRouterRoutingSchema = type({ + "only?": "string[]", + "order?": "string[]", + }); + + const VercelGatewayRoutingSchema = type({ + "only?": "string[]", + "order?": "string[]", + }); + + const ReasoningEffortMapSchema = type({ + "minimal?": "string", + "low?": "string", + "medium?": "string", + "high?": "string", + "xhigh?": "string", + "max?": "string", + }); + + const OpenAICompatFields = { + "supportsStore?": "boolean", + "supportsDeveloperRole?": "boolean", + "supportsMultipleSystemMessages?": "boolean", + "supportsReasoningEffort?": "boolean", + "reasoningEffortMap?": ReasoningEffortMapSchema, + "maxTokensField?": '"max_completion_tokens" | "max_tokens"', + "supportsUsageInStreaming?": "boolean", + "requiresToolResultName?": "boolean", + "requiresMistralToolIds?": "boolean", + "requiresAssistantAfterToolResult?": "boolean", + "requiresThinkingAsText?": "boolean", + "reasoningContentField?": '"reasoning_content" | "reasoning" | "reasoning_text"', + "requiresReasoningContentForToolCalls?": "boolean", + "allowsSyntheticReasoningContentForToolCalls?": "boolean", + "requiresAssistantContentForToolCalls?": "boolean", + "supportsToolChoice?": "boolean", + "supportsForcedToolChoice?": "boolean", + "disableReasoningOnForcedToolChoice?": "boolean", + "disableReasoningOnToolChoice?": "boolean", + "thinkingFormat?": '"openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template"', + "openRouterRouting?": OpenRouterRoutingSchema, + "vercelGatewayRouting?": VercelGatewayRoutingSchema, + "extraBody?": { "[string]": "unknown" }, + "cacheControlFormat?": '"anthropic"', + "supportsStrictMode?": "boolean", + "toolStrictMode?": '"all_strict" | "none"', + "streamIdleTimeoutMs?": "number >= 0", + "supportsLongPromptCacheRetention?": "boolean", + "supportsReasoningParams?": "boolean", + "alwaysSendMaxTokens?": "boolean", + "strictResponsesPairing?": "boolean", + "supportsImageDetailOriginal?": "boolean", + // anthropic-messages compat flags (same `compat` slot, per-api interpretation) + "supportsEagerToolInputStreaming?": "boolean", + "allowAnthropicHeaderOverrides?": "boolean", + "requiresToolResultId?": "boolean", + "replayUnsignedThinking?": "boolean", + } as const; + + const OpenAICompatFieldsSchema = type(OpenAICompatFields); + + const OpenAICompatSchema = type({ + ...OpenAICompatFields, + "whenThinking?": OpenAICompatFieldsSchema, + }); + + const BedrockCompatSchema = type({ + "promptCacheMode?": '"none" | "automatic" | "explicit"', + "supportsLongPromptCacheRetention?": "boolean", + "promptCacheMinimumTokens?": "number >= 0", + "promptCacheMaximumCheckpoints?": "number >= 0", + }); + + // Provider-level overrides can target bundled models whose API is not repeated + // in models.yml, so preserve the sparse compat shape for each supported API. + const ApiCompatSchema = OpenAICompatSchema.and(BedrockCompatSchema); + + const ApiSchema = type( + '"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"', + ); + + const EffortSchema = type('"minimal" | "low" | "medium" | "high" | "xhigh" | "max"'); + + const ThinkingControlModeSchema = type( + '"effort" | "budget" | "google-level" | "anthropic-adaptive" | "anthropic-budget-effort"', + ); + + const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh", "max"] as const; + + /** + * Accepts the canonical `efforts` vocabulary plus the legacy + * `minLevel`/`maxLevel`/`levels` range shape, normalizing both to + * `ThinkingConfig` (ordered `efforts`, never empty). Precedence mirrors the + * old runtime: explicit `levels` beat the min..max range; `efforts` beats both. + */ + const ModelThinkingSchema = type({ + mode: ThinkingControlModeSchema, + "efforts?": EffortSchema.array(), + "defaultLevel?": EffortSchema, + "effortMap?": ReasoningEffortMapSchema, + "supportsDisplay?": "boolean", + // Legacy range vocabulary (pre-efforts configs). + "minLevel?": EffortSchema, + "maxLevel?": EffortSchema, + "levels?": EffortSchema.array(), + }) + .narrow( + (value, ctx) => + value.efforts !== undefined || + value.levels !== undefined || + (value.minLevel !== undefined && value.maxLevel !== undefined) || + ctx.mustBe("thinking with `efforts` (or legacy `levels`/`minLevel`+`maxLevel`)"), + ) + .pipe((value: any) => { + let resolved = value.efforts ?? value.levels; + if (!resolved) { + const minIndex = EFFORT_ORDER.indexOf(value.minLevel!); + const maxIndex = EFFORT_ORDER.indexOf(value.maxLevel!); + resolved = EFFORT_ORDER.slice(minIndex, Math.max(minIndex, maxIndex) + 1); + } + return { + mode: value.mode, + efforts: resolved, + ...(value.defaultLevel !== undefined && { defaultLevel: value.defaultLevel }), + ...(value.effortMap !== undefined && { effortMap: value.effortMap }), + ...(value.supportsDisplay !== undefined && { supportsDisplay: value.supportsDisplay }), + }; + }); + + const RemoteCompactionSchema = type({ + "enabled?": "boolean", + "api?": ApiSchema, + "endpoint?": "string", + "model?": "string", + "v2StreamingEnabled?": "boolean", + "v2Endpoint?": "string", + "streamingEndpoint?": "string", + }).narrow((value, ctx) => { + if (value.endpoint !== undefined && typeof value.endpoint === "string" && value.endpoint.length === 0) { + return ctx.mustBe("remoteCompaction.endpoint a non-empty string"); + } + if (value.model !== undefined && typeof value.model === "string" && value.model.length === 0) { + return ctx.mustBe("remoteCompaction.model a non-empty string"); + } + if (value.v2Endpoint !== undefined && typeof value.v2Endpoint === "string" && value.v2Endpoint.length === 0) { + return ctx.mustBe("remoteCompaction.v2Endpoint a non-empty string"); + } + if ( + value.streamingEndpoint !== undefined && + typeof value.streamingEndpoint === "string" && + value.streamingEndpoint.length === 0 + ) { + return ctx.mustBe("remoteCompaction.streamingEndpoint a non-empty string"); + } + return true; + }); + + const ModelDefinitionSchema = type({ + id: "string", + "name?": "string", + "api?": ApiSchema, + "baseUrl?": "string", + "reasoning?": "boolean", + "thinking?": ModelThinkingSchema, + "input?": '("text" | "image")[]', + "supportsTools?": "boolean", + "cost?": { + input: "number", + output: "number", + cacheRead: "number", + cacheWrite: "number", + }, + "premiumMultiplier?": "number", + "contextWindow?": "number", + "maxTokens?": "number", + "omitMaxOutputTokens?": "boolean", + "headers?": { "[string]": "string" }, + "compat?": ApiCompatSchema, + "contextPromotionTarget?": "string", + "compactionModel?": "string", + "remoteCompaction?": RemoteCompactionSchema, + }).narrow((value, ctx) => { + // Enforce id non-empty + if (typeof value.id === "string" && value.id.length === 0) { + return ctx.mustBe("id a non-empty string"); + } + if (value.name !== undefined && typeof value.name === "string" && value.name.length === 0) { + return ctx.mustBe("name a non-empty string"); + } + if (value.baseUrl !== undefined && typeof value.baseUrl === "string" && value.baseUrl.length === 0) { + return ctx.mustBe("baseUrl a non-empty string"); + } + if ( + value.contextPromotionTarget !== undefined && + typeof value.contextPromotionTarget === "string" && + value.contextPromotionTarget.length === 0 + ) { + return ctx.mustBe("contextPromotionTarget a non-empty string"); + } + if ( + value.compactionModel !== undefined && + typeof value.compactionModel === "string" && + value.compactionModel.length === 0 + ) { + return ctx.mustBe("compactionModel a non-empty string"); + } + return true; + }); + + const ModelOverrideSchema = type({ + "name?": "string", + "reasoning?": "boolean", + "thinking?": ModelThinkingSchema, + "input?": '("text" | "image")[]', + "supportsTools?": "boolean", + "cost?": { + "input?": "number", + "output?": "number", + "cacheRead?": "number", + "cacheWrite?": "number", + }, + "premiumMultiplier?": "number", + "contextWindow?": "number", + "maxTokens?": "number", + "omitMaxOutputTokens?": "boolean", + "headers?": { "[string]": "string" }, + "compat?": ApiCompatSchema, + "contextPromotionTarget?": "string", + "compactionModel?": "string", + "remoteCompaction?": RemoteCompactionSchema, + }).narrow((value, ctx) => { + if (value.name !== undefined && typeof value.name === "string" && value.name.length === 0) { + return ctx.mustBe("name a non-empty string"); + } + if ( + value.contextPromotionTarget !== undefined && + typeof value.contextPromotionTarget === "string" && + value.contextPromotionTarget.length === 0 + ) { + return ctx.mustBe("contextPromotionTarget a non-empty string"); + } + if ( + value.compactionModel !== undefined && + typeof value.compactionModel === "string" && + value.compactionModel.length === 0 + ) { + return ctx.mustBe("compactionModel a non-empty string"); + } + return true; + }); + + const ProviderDiscoverySchema = type({ + type: '"ollama" | "llama.cpp" | "lm-studio" | "openai-models-list" | "proxy" | "litellm"', + }); + + const ProviderAuthSchema = type('"apiKey" | "none" | "oauth"'); + + const ProviderConfigSchema = type({ + "baseUrl?": "string", + "apiKey?": "string", + "api?": ApiSchema, + "headers?": { "[string]": "string" }, + "compat?": ApiCompatSchema, + "remoteCompaction?": RemoteCompactionSchema, + "authHeader?": "boolean", + "auth?": ProviderAuthSchema, + "discovery?": ProviderDiscoverySchema, + "models?": ModelDefinitionSchema.array(), + "modelOverrides?": { "[string]": ModelOverrideSchema }, + "disableStrictTools?": "boolean", + /** + * Streaming transport override. When set to `"pi-native"`, omp dispatches + * every model under this provider via the auth-gateway's + * `POST /v1/pi/stream` endpoint instead of the per-provider SDK. The + * provider's `baseUrl` must point at a compatible `omp auth-gateway` + * and `apiKey` must carry the gateway bearer. + */ + "transport?": '"pi-native"', + }).narrow((value, ctx) => { + if (value.baseUrl !== undefined && typeof value.baseUrl === "string" && value.baseUrl.length === 0) { + return ctx.mustBe("baseUrl a non-empty string"); + } + if (value.apiKey !== undefined && typeof value.apiKey === "string" && value.apiKey.length === 0) { + return ctx.mustBe("apiKey a non-empty string"); + } + return true; + }); + + const ModelsConfigSchema = type({ + "providers?": { "[string]": ProviderConfigSchema }, + }); + + return { + OpenAICompatSchema, + ModelOverrideSchema, + ProviderDiscoverySchema, + ProviderAuthSchema, + ModelsConfigSchema, + }; +}); + +export const getModelsConfigSchema = () => getModelsConfigSchemaBundle().ModelsConfigSchema; diff --git a/packages/coding-agent/src/config/models-config-schema.ts b/packages/coding-agent/src/config/models-config-schema.ts index a612d6cb3..053ffa107 100644 --- a/packages/coding-agent/src/config/models-config-schema.ts +++ b/packages/coding-agent/src/config/models-config-schema.ts @@ -1,307 +1,14 @@ -import { scope } from "arktype"; +import { getModelsConfigSchemaBundle } from "./models-config-schema-bundle"; -// Config schemas validate at most a handful of times per process (on config -// load), so the eager JIT codegen ArkType runs at definition time is pure -// startup tax. A local jitless scope skips that codegen and falls back to -// interpreted traversal — ~65% cheaper to construct, validation correctness -// unchanged. (No `name`: duplicate module instances would collide.) -const { type } = scope({}, { jitless: true }); - -const OpenRouterRoutingSchema = type({ - "only?": "string[]", - "order?": "string[]", -}); - -const VercelGatewayRoutingSchema = type({ - "only?": "string[]", - "order?": "string[]", -}); - -const ReasoningEffortMapSchema = type({ - "minimal?": "string", - "low?": "string", - "medium?": "string", - "high?": "string", - "xhigh?": "string", - "max?": "string", -}); - -const OpenAICompatFields = { - "supportsStore?": "boolean", - "supportsDeveloperRole?": "boolean", - "supportsMultipleSystemMessages?": "boolean", - "supportsReasoningEffort?": "boolean", - "reasoningEffortMap?": ReasoningEffortMapSchema, - "maxTokensField?": '"max_completion_tokens" | "max_tokens"', - "supportsUsageInStreaming?": "boolean", - "requiresToolResultName?": "boolean", - "requiresMistralToolIds?": "boolean", - "requiresAssistantAfterToolResult?": "boolean", - "requiresThinkingAsText?": "boolean", - "reasoningContentField?": '"reasoning_content" | "reasoning" | "reasoning_text"', - "requiresReasoningContentForToolCalls?": "boolean", - "allowsSyntheticReasoningContentForToolCalls?": "boolean", - "requiresAssistantContentForToolCalls?": "boolean", - "supportsToolChoice?": "boolean", - "supportsForcedToolChoice?": "boolean", - "disableReasoningOnForcedToolChoice?": "boolean", - "disableReasoningOnToolChoice?": "boolean", - "thinkingFormat?": '"openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template"', - "openRouterRouting?": OpenRouterRoutingSchema, - "vercelGatewayRouting?": VercelGatewayRoutingSchema, - "extraBody?": { "[string]": "unknown" }, - "cacheControlFormat?": '"anthropic"', - "supportsStrictMode?": "boolean", - "toolStrictMode?": '"all_strict" | "none"', - "streamIdleTimeoutMs?": "number >= 0", - "supportsLongPromptCacheRetention?": "boolean", - "supportsReasoningParams?": "boolean", - "alwaysSendMaxTokens?": "boolean", - "strictResponsesPairing?": "boolean", - "supportsImageDetailOriginal?": "boolean", - // anthropic-messages compat flags (same `compat` slot, per-api interpretation) - "supportsEagerToolInputStreaming?": "boolean", - "allowAnthropicHeaderOverrides?": "boolean", - "requiresToolResultId?": "boolean", - "replayUnsignedThinking?": "boolean", -} as const; - -const OpenAICompatFieldsSchema = type(OpenAICompatFields); - -export const OpenAICompatSchema = type({ - ...OpenAICompatFields, - "whenThinking?": OpenAICompatFieldsSchema, -}); - -const BedrockCompatSchema = type({ - "promptCacheMode?": '"none" | "automatic" | "explicit"', - "supportsLongPromptCacheRetention?": "boolean", - "promptCacheMinimumTokens?": "number >= 0", - "promptCacheMaximumCheckpoints?": "number >= 0", -}); - -// Provider-level overrides can target bundled models whose API is not repeated -// in models.yml, so preserve the sparse compat shape for each supported API. -const ApiCompatSchema = OpenAICompatSchema.and(BedrockCompatSchema); - -const ApiSchema = type( - '"openai-completions" | "openai-responses" | "openai-codex-responses" | "azure-openai-responses" | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" | "google-gemini-cli" | "google-vertex"', -); - -const EffortSchema = type('"minimal" | "low" | "medium" | "high" | "xhigh" | "max"'); - -const ThinkingControlModeSchema = type( - '"effort" | "budget" | "google-level" | "anthropic-adaptive" | "anthropic-budget-effort"', -); - -const EFFORT_ORDER = ["minimal", "low", "medium", "high", "xhigh", "max"] as const; - -/** - * Accepts the canonical `efforts` vocabulary plus the legacy - * `minLevel`/`maxLevel`/`levels` range shape, normalizing both to - * `ThinkingConfig` (ordered `efforts`, never empty). Precedence mirrors the - * old runtime: explicit `levels` beat the min..max range; `efforts` beats both. - */ -const ModelThinkingSchema = type({ - mode: ThinkingControlModeSchema, - "efforts?": EffortSchema.array(), - "defaultLevel?": EffortSchema, - "effortMap?": ReasoningEffortMapSchema, - "supportsDisplay?": "boolean", - // Legacy range vocabulary (pre-efforts configs). - "minLevel?": EffortSchema, - "maxLevel?": EffortSchema, - "levels?": EffortSchema.array(), -}) - .narrow( - (value, ctx) => - value.efforts !== undefined || - value.levels !== undefined || - (value.minLevel !== undefined && value.maxLevel !== undefined) || - ctx.mustBe("thinking with `efforts` (or legacy `levels`/`minLevel`+`maxLevel`)"), - ) - .pipe((value: any) => { - let resolved = value.efforts ?? value.levels; - if (!resolved) { - const minIndex = EFFORT_ORDER.indexOf(value.minLevel!); - const maxIndex = EFFORT_ORDER.indexOf(value.maxLevel!); - resolved = EFFORT_ORDER.slice(minIndex, Math.max(minIndex, maxIndex) + 1); - } - return { - mode: value.mode, - efforts: resolved, - ...(value.defaultLevel !== undefined && { defaultLevel: value.defaultLevel }), - ...(value.effortMap !== undefined && { effortMap: value.effortMap }), - ...(value.supportsDisplay !== undefined && { supportsDisplay: value.supportsDisplay }), - }; - }); - -const RemoteCompactionSchema = type({ - "enabled?": "boolean", - "api?": ApiSchema, - "endpoint?": "string", - "model?": "string", - "v2StreamingEnabled?": "boolean", - "v2Endpoint?": "string", - "streamingEndpoint?": "string", -}).narrow((value, ctx) => { - if (value.endpoint !== undefined && typeof value.endpoint === "string" && value.endpoint.length === 0) { - return ctx.mustBe("remoteCompaction.endpoint a non-empty string"); - } - if (value.model !== undefined && typeof value.model === "string" && value.model.length === 0) { - return ctx.mustBe("remoteCompaction.model a non-empty string"); - } - if (value.v2Endpoint !== undefined && typeof value.v2Endpoint === "string" && value.v2Endpoint.length === 0) { - return ctx.mustBe("remoteCompaction.v2Endpoint a non-empty string"); - } - if ( - value.streamingEndpoint !== undefined && - typeof value.streamingEndpoint === "string" && - value.streamingEndpoint.length === 0 - ) { - return ctx.mustBe("remoteCompaction.streamingEndpoint a non-empty string"); - } - return true; -}); - -const ModelDefinitionSchema = type({ - id: "string", - "name?": "string", - "api?": ApiSchema, - "baseUrl?": "string", - "reasoning?": "boolean", - "thinking?": ModelThinkingSchema, - "input?": '("text" | "image")[]', - "supportsTools?": "boolean", - "cost?": { - input: "number", - output: "number", - cacheRead: "number", - cacheWrite: "number", - }, - "premiumMultiplier?": "number", - "contextWindow?": "number", - "maxTokens?": "number", - "omitMaxOutputTokens?": "boolean", - "headers?": { "[string]": "string" }, - "compat?": ApiCompatSchema, - "contextPromotionTarget?": "string", - "compactionModel?": "string", - "remoteCompaction?": RemoteCompactionSchema, -}).narrow((value, ctx) => { - // Enforce id non-empty - if (typeof value.id === "string" && value.id.length === 0) { - return ctx.mustBe("id a non-empty string"); - } - if (value.name !== undefined && typeof value.name === "string" && value.name.length === 0) { - return ctx.mustBe("name a non-empty string"); - } - if (value.baseUrl !== undefined && typeof value.baseUrl === "string" && value.baseUrl.length === 0) { - return ctx.mustBe("baseUrl a non-empty string"); - } - if ( - value.contextPromotionTarget !== undefined && - typeof value.contextPromotionTarget === "string" && - value.contextPromotionTarget.length === 0 - ) { - return ctx.mustBe("contextPromotionTarget a non-empty string"); - } - if ( - value.compactionModel !== undefined && - typeof value.compactionModel === "string" && - value.compactionModel.length === 0 - ) { - return ctx.mustBe("compactionModel a non-empty string"); - } - return true; -}); - -export const ModelOverrideSchema = type({ - "name?": "string", - "reasoning?": "boolean", - "thinking?": ModelThinkingSchema, - "input?": '("text" | "image")[]', - "supportsTools?": "boolean", - "cost?": { - "input?": "number", - "output?": "number", - "cacheRead?": "number", - "cacheWrite?": "number", - }, - "premiumMultiplier?": "number", - "contextWindow?": "number", - "maxTokens?": "number", - "omitMaxOutputTokens?": "boolean", - "headers?": { "[string]": "string" }, - "compat?": ApiCompatSchema, - "contextPromotionTarget?": "string", - "compactionModel?": "string", - "remoteCompaction?": RemoteCompactionSchema, -}).narrow((value, ctx) => { - if (value.name !== undefined && typeof value.name === "string" && value.name.length === 0) { - return ctx.mustBe("name a non-empty string"); - } - if ( - value.contextPromotionTarget !== undefined && - typeof value.contextPromotionTarget === "string" && - value.contextPromotionTarget.length === 0 - ) { - return ctx.mustBe("contextPromotionTarget a non-empty string"); - } - if ( - value.compactionModel !== undefined && - typeof value.compactionModel === "string" && - value.compactionModel.length === 0 - ) { - return ctx.mustBe("compactionModel a non-empty string"); - } - return true; -}); +export const { + OpenAICompatSchema, + ModelOverrideSchema, + ProviderDiscoverySchema, + ProviderAuthSchema, + ModelsConfigSchema, +} = getModelsConfigSchemaBundle(); export type ModelOverride = typeof ModelOverrideSchema.infer; - -export const ProviderDiscoverySchema = type({ - type: '"ollama" | "llama.cpp" | "lm-studio" | "openai-models-list" | "proxy" | "litellm"', -}); - -export const ProviderAuthSchema = type('"apiKey" | "none" | "oauth"'); - export type ProviderAuthMode = typeof ProviderAuthSchema.infer; export type ProviderDiscovery = typeof ProviderDiscoverySchema.infer; - -const ProviderConfigSchema = type({ - "baseUrl?": "string", - "apiKey?": "string", - "api?": ApiSchema, - "headers?": { "[string]": "string" }, - "compat?": ApiCompatSchema, - "remoteCompaction?": RemoteCompactionSchema, - "authHeader?": "boolean", - "auth?": ProviderAuthSchema, - "discovery?": ProviderDiscoverySchema, - "models?": ModelDefinitionSchema.array(), - "modelOverrides?": { "[string]": ModelOverrideSchema }, - "disableStrictTools?": "boolean", - /** - * Streaming transport override. When set to `"pi-native"`, omp dispatches - * every model under this provider via the auth-gateway's - * `POST /v1/pi/stream` endpoint instead of the per-provider SDK. The - * provider's `baseUrl` must point at a compatible `omp auth-gateway` - * and `apiKey` must carry the gateway bearer. - */ - "transport?": '"pi-native"', -}).narrow((value, ctx) => { - if (value.baseUrl !== undefined && typeof value.baseUrl === "string" && value.baseUrl.length === 0) { - return ctx.mustBe("baseUrl a non-empty string"); - } - if (value.apiKey !== undefined && typeof value.apiKey === "string" && value.apiKey.length === 0) { - return ctx.mustBe("apiKey a non-empty string"); - } - return true; -}); - -export const ModelsConfigSchema = type({ - "providers?": { "[string]": ProviderConfigSchema }, -}); - export type ModelsConfig = typeof ModelsConfigSchema.infer; diff --git a/packages/coding-agent/src/config/models-config.ts b/packages/coding-agent/src/config/models-config.ts index 04ff71c71..d9e4fd47b 100644 --- a/packages/coding-agent/src/config/models-config.ts +++ b/packages/coding-agent/src/config/models-config.ts @@ -4,12 +4,8 @@ import type { Api, ModelSpec } from "@oh-my-pi/pi-ai/types"; import { ConfigFile } from "./config-file"; -import { - type ModelsConfig, - ModelsConfigSchema, - type ProviderAuthMode, - type ProviderDiscovery, -} from "./models-config-schema"; +import type { ModelsConfig, ProviderAuthMode, ProviderDiscovery } from "./models-config-schema"; +import { getModelsConfigSchema } from "./models-config-schema-bundle"; export type ProviderValidationMode = "models-config" | "runtime-register"; @@ -106,29 +102,29 @@ export function validateProviderConfiguration( } } -export const ModelsConfigFile = new ConfigFile("models", ModelsConfigSchema).withValidation( - "models", - config => { - const providers = config.providers ?? {}; - for (const providerName in providers) { - const providerConfig = providers[providerName]; - validateProviderConfiguration( - providerName, - { - baseUrl: providerConfig.baseUrl, - headers: providerConfig.headers, - apiKey: providerConfig.apiKey, - api: providerConfig.api as Api | undefined, - auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode, - discovery: providerConfig.discovery as ProviderDiscovery | undefined, - compat: providerConfig.compat, - remoteCompaction: providerConfig.remoteCompaction, - disableStrictTools: providerConfig.disableStrictTools, - modelOverrides: providerConfig.modelOverrides, - models: (providerConfig.models ?? []) as ProviderValidationModel[], - }, - "models-config", - ); - } - }, -); +export const ModelsConfigFile = new ConfigFile("models", { + kind: "deferred", + resolve: getModelsConfigSchema, +}).withValidation("models", config => { + const providers = config.providers ?? {}; + for (const providerName in providers) { + const providerConfig = providers[providerName]; + validateProviderConfiguration( + providerName, + { + baseUrl: providerConfig.baseUrl, + headers: providerConfig.headers, + apiKey: providerConfig.apiKey, + api: providerConfig.api as Api | undefined, + auth: (providerConfig.auth ?? "apiKey") as ProviderAuthMode, + discovery: providerConfig.discovery as ProviderDiscovery | undefined, + compat: providerConfig.compat, + remoteCompaction: providerConfig.remoteCompaction, + disableStrictTools: providerConfig.disableStrictTools, + modelOverrides: providerConfig.modelOverrides, + models: (providerConfig.models ?? []) as ProviderValidationModel[], + }, + "models-config", + ); + } +}); diff --git a/packages/coding-agent/test/fixtures/models-config-validator-construction-probe.ts b/packages/coding-agent/test/fixtures/models-config-validator-construction-probe.ts new file mode 100644 index 000000000..6753d4933 --- /dev/null +++ b/packages/coding-agent/test/fixtures/models-config-validator-construction-probe.ts @@ -0,0 +1,78 @@ +import * as path from "node:path"; +import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; +import { ModelsConfigFile } from "@oh-my-pi/pi-coding-agent/config/models-config"; +import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; +import { YAML } from "bun"; + +interface HeapSnapshot { + nodes: number[]; + snapshot: { meta: { node_fields: string[] } }; +} + +const root = process.argv[2]; +const mode = process.argv[3]; +if (!root || (mode !== "missing" && mode !== "custom")) { + throw new Error("Expected an isolated config root and missing|custom mode"); +} + +const configPath = path.join(root, mode, "models.yml"); +if (mode === "custom") { + await Bun.write( + configPath, + YAML.stringify( + { + providers: { + "lazy-models": { + baseUrl: "https://lazy.example/v1", + api: "openai-responses", + auth: "none", + models: [ + { + id: "lazy-model", + reasoning: true, + thinking: { + mode: "effort", + minLevel: "low", + maxLevel: "high", + defaultLevel: "medium", + }, + }, + ], + }, + }, + }, + null, + 2, + ), + ); +} + +const authStorage = await AuthStorage.create(":memory:"); +try { + const registry = new ModelRegistry(authStorage, configPath); + const model = + mode === "custom" ? registry.find("lazy-models", "lazy-model") : registry.find("anthropic", "claude-sonnet-4-5"); + const firstSchema = mode === "custom" ? ModelsConfigFile.relocate(configPath).schema : undefined; + const secondSchema = + mode === "custom" ? ModelsConfigFile.relocate(path.join(root, "second", "models.yml")).schema : undefined; + + Bun.gc(true); + const snapshot = JSON.parse(Bun.generateHeapSnapshot("v8")) as HeapSnapshot; + const nodeWidth = snapshot.snapshot.meta.node_fields.length; + + process.stdout.write( + JSON.stringify({ + retainedHeapNodes: snapshot.nodes.length / nodeWidth, + schemaIdentityStable: mode === "custom" ? firstSchema === secondSchema : undefined, + model: model && { + provider: model.provider, + id: model.id, + baseUrl: model.baseUrl, + api: model.api, + thinking: model.thinking, + }, + }), + ); +} finally { + authStorage.close(); +} diff --git a/packages/coding-agent/test/models-config-lazy-validator.test.ts b/packages/coding-agent/test/models-config-lazy-validator.test.ts new file mode 100644 index 000000000..bf0b0ae18 --- /dev/null +++ b/packages/coding-agent/test/models-config-lazy-validator.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; +import * as path from "node:path"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +interface ProbeResult { + retainedHeapNodes: number; + schemaIdentityStable?: boolean; + model?: { + provider: string; + id: string; + baseUrl: string; + api: string; + thinking?: unknown; + }; +} + +const probePath = path.join(import.meta.dir, "fixtures", "models-config-validator-construction-probe.ts"); + +async function runProbe(root: string, mode: "missing" | "custom"): Promise { + const proc = Bun.spawn([process.execPath, probePath, root, mode], { + cwd: path.join(import.meta.dir, "../../.."), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(exitCode, stderr).toBe(0); + return JSON.parse(stdout) as ProbeResult; +} + +test("models config validation resources are retained only for a custom config", async () => { + const tempDir = TempDir.createSync("@models-config-validator-"); + try { + const missing = await runProbe(tempDir.path(), "missing"); + const custom = await runProbe(tempDir.path(), "custom"); + + expect(missing.model).toMatchObject({ + provider: "anthropic", + id: "claude-sonnet-4-5", + baseUrl: "https://api.anthropic.com", + api: "anthropic-messages", + }); + expect(custom.model).toEqual({ + provider: "lazy-models", + id: "lazy-model", + baseUrl: "https://lazy.example/v1", + api: "openai-responses", + thinking: { + mode: "effort", + efforts: ["low", "medium", "high"], + defaultLevel: "medium", + }, + }); + expect(custom.schemaIdentityStable).toBe(true); + expect( + custom.retainedHeapNodes - missing.retainedHeapNodes, + "custom config validation should retain its schema bundle", + ).toBeGreaterThan(15_000); + } finally { + await tempDir.remove().catch(() => {}); + } +});