Merge PR #6745: perf: lazily construct models config schema (@usr-bin-roygbiv)
This commit is contained in:
@@ -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<T> {
|
||||
readonly id: string;
|
||||
readonly schema: Type;
|
||||
@@ -125,14 +129,17 @@ export class ConfigFile<T> implements IConfigFile<T> {
|
||||
readonly #basePath: string;
|
||||
readonly #yamlFallbackPath: string | null;
|
||||
readonly #jsonMigrationPath: string | null;
|
||||
readonly #schemaSource: ConfigSchemaSource;
|
||||
#resolvedSchema?: Type;
|
||||
#cache?: LoadResult<T>;
|
||||
#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<T> implements IConfigFile<T> {
|
||||
}
|
||||
}
|
||||
|
||||
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<T> implements IConfigFile<T> {
|
||||
|
||||
relocate(configPath?: string): ConfigFile<T> {
|
||||
if (!configPath || configPath === this.#basePath) return this;
|
||||
const result = new ConfigFile<T>(this.id, this.schema, configPath);
|
||||
const result = new ConfigFile<T>(this.id, this.#schemaSource, configPath);
|
||||
result.#auxValidate = this.#auxValidate;
|
||||
result.#resolvedSchema = this.#resolvedSchema;
|
||||
result.#ensureMigrated();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ModelsConfig>("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<ModelsConfig>("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",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+78
@@ -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();
|
||||
}
|
||||
@@ -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<ProbeResult> {
|
||||
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(() => {});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user