|
|
@@ -14,7 +14,15 @@
|
|
|
|
|
|
import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
|
|
|
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
|
|
|
-import type { Api, Model, ModelCost, Provider } from '@earendil-works/pi-ai'
|
|
|
+import type {
|
|
|
+ Api,
|
|
|
+ Model,
|
|
|
+ ModelCost,
|
|
|
+ ModelThinkingLevel,
|
|
|
+ OpenAICompletionsCompat,
|
|
|
+ Provider,
|
|
|
+ ThinkingLevelMap,
|
|
|
+} from '@earendil-works/pi-ai'
|
|
|
|
|
|
/**
|
|
|
* Pricing for a model the installed catalog does not describe. The harness
|
|
|
@@ -30,6 +38,58 @@ const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
|
|
*/
|
|
|
const TEXT_ONLY: Model<Api>['input'] = ['text']
|
|
|
|
|
|
+/**
|
|
|
+ * Every pi-ai thinking level, in pi-ai's canonical escalation order. The
|
|
|
+ * `Record` key type is a drift gate: a pi-ai upgrade that adds or removes a
|
|
|
+ * level fails compilation here naming the drifted key, instead of silently
|
|
|
+ * narrowing what a profile may declare.
|
|
|
+ */
|
|
|
+const THINKING_LEVEL_GATE: Record<ModelThinkingLevel, true> = {
|
|
|
+ off: true,
|
|
|
+ minimal: true,
|
|
|
+ low: true,
|
|
|
+ medium: true,
|
|
|
+ high: true,
|
|
|
+ xhigh: true,
|
|
|
+ max: true,
|
|
|
+}
|
|
|
+
|
|
|
+/** Every pi-ai thinking level a profile may declare, in escalation order. */
|
|
|
+export const THINKING_LEVELS = Object.keys(THINKING_LEVEL_GATE) as readonly ModelThinkingLevel[]
|
|
|
+
|
|
|
+/** The `compat.thinkingFormat` spellings pi-ai accepts on an `openai-completions` model. */
|
|
|
+type PiThinkingFormat = NonNullable<OpenAICompletionsCompat['thinkingFormat']>
|
|
|
+
|
|
|
+/**
|
|
|
+ * pi-ai thinking formats a profile cannot name: both drive the request through
|
|
|
+ * `chatTemplateKwargs`, which this configuration does not expose, so offering
|
|
|
+ * them would hand back a format with nothing to say.
|
|
|
+ */
|
|
|
+type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template'
|
|
|
+
|
|
|
+/** One reasoning-dispatch wire format a profile may name. */
|
|
|
+export type PiAiThinkingFormat = Exclude<PiThinkingFormat, WithheldThinkingFormat>
|
|
|
+
|
|
|
+/**
|
|
|
+ * The nameable reasoning-dispatch formats, most-reached first. The `Record`
|
|
|
+ * key type is a drift gate: a pi-ai upgrade that adds a format (0.84 added
|
|
|
+ * `baseten`) fails compilation here until the format is classified as offered
|
|
|
+ * here or withheld above, so the offer never silently lags the upstream set.
|
|
|
+ */
|
|
|
+const THINKING_FORMAT_GATE: Record<PiAiThinkingFormat, true> = {
|
|
|
+ 'openai': true,
|
|
|
+ 'deepseek': true,
|
|
|
+ 'openrouter': true,
|
|
|
+ 'together': true,
|
|
|
+ 'zai': true,
|
|
|
+ 'qwen': true,
|
|
|
+ 'string-thinking': true,
|
|
|
+ 'ant-ling': true,
|
|
|
+}
|
|
|
+
|
|
|
+/** Reasoning-dispatch wire formats a profile may name, most-reached first. */
|
|
|
+export const SUPPORTED_THINKING_FORMATS = Object.keys(THINKING_FORMAT_GATE) as readonly PiAiThinkingFormat[]
|
|
|
+
|
|
|
let providerIndex: Map<string, Provider> | undefined
|
|
|
|
|
|
/**
|
|
|
@@ -71,6 +131,32 @@ export function catalogModels(provider: string): Map<string, Model<Api>> {
|
|
|
return new Map(models.map(model => [model.id, model]))
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * Selectable reasoning efforts for one model: each key is a level the model
|
|
|
+ * offers (and selectors show), and its value is the wire spelling dispatch
|
|
|
+ * sends for it. `off` alone may leave its value empty — "supported, send
|
|
|
+ * nothing" — because for most providers not thinking is the parameter's
|
|
|
+ * absence; every other declared level must name a wire value. A level absent
|
|
|
+ * from the dict is not offered.
|
|
|
+ */
|
|
|
+export type PiAiReasoningEfforts = Partial<Record<ModelThinkingLevel, string | null>>
|
|
|
+
|
|
|
+/**
|
|
|
+ * Reasoning-dispatch compatibility switches, set on the route (its models'
|
|
|
+ * default) or per model (winning over the route). Only the switches pi-ai's
|
|
|
+ * reasoning dispatch reads are offered; the rest of pi-ai's compat surface
|
|
|
+ * keeps its baseURL-derived auto-detection. pi-ai types both fields only on
|
|
|
+ * `OpenAICompletionsCompat` — the other wire protocols carry their reasoning
|
|
|
+ * shape in the protocol itself — so resolution rejects a model-level switch
|
|
|
+ * anywhere else, while a route-level default skips past models it cannot fit.
|
|
|
+ */
|
|
|
+export interface PiAiCompatProfile {
|
|
|
+ /** Reasoning parameter shape the endpoint expects; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */
|
|
|
+ thinkingFormat?: PiAiThinkingFormat
|
|
|
+ /** Whether the endpoint accepts `reasoning_effort`; absent keeps the catalog entry's, then pi-ai's baseURL-derived guess. */
|
|
|
+ supportsReasoningEffort?: boolean
|
|
|
+}
|
|
|
+
|
|
|
/** One configured model entry: an id plus the catalog fields it overrides. */
|
|
|
export interface PiAiModelProfile {
|
|
|
/** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */
|
|
|
@@ -86,6 +172,16 @@ export interface PiAiModelProfile {
|
|
|
* default on its own.
|
|
|
*/
|
|
|
maxTokens?: number
|
|
|
+ /**
|
|
|
+ * Selectable reasoning efforts. Absent inherits the installed catalog
|
|
|
+ * entry's capability (a hand-declared model has none and does not reason);
|
|
|
+ * `false` declares a non-reasoning model, which is how a profile strips
|
|
|
+ * reasoning from a catalog model its gateway cannot serve; a non-empty dict
|
|
|
+ * declares the offered levels and their wire spellings.
|
|
|
+ */
|
|
|
+ reasoningEfforts?: false | PiAiReasoningEfforts
|
|
|
+ /** Reasoning-dispatch switches for this model, winning over the route's. */
|
|
|
+ compat?: PiAiCompatProfile
|
|
|
}
|
|
|
|
|
|
/** The route-level facts model materialization reads. */
|
|
|
@@ -98,6 +194,8 @@ export interface RouteCatalogRequest {
|
|
|
baseURL?: string
|
|
|
/** Configured catalog; absent means the whole installed catalog for this route. */
|
|
|
models?: readonly PiAiModelProfile[]
|
|
|
+ /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */
|
|
|
+ compat?: PiAiCompatProfile
|
|
|
/** Context capacity for a model neither the entry nor the catalog sizes. */
|
|
|
defaultContextWindow: number
|
|
|
/** Output capability for a model neither the entry nor the catalog sizes. */
|
|
|
@@ -123,6 +221,133 @@ function sharedCatalogApi(defaults: ReadonlyMap<string, Model<Api>>): string | u
|
|
|
return apis.size === 1 ? [...apis][0] : undefined
|
|
|
}
|
|
|
|
|
|
+/** The reasoning fields one materialized model carries. */
|
|
|
+interface ModelReasoning {
|
|
|
+ /** Whether the model reasons at all; `false` makes pi-ai ignore the map. */
|
|
|
+ reasoning: boolean
|
|
|
+ /** The map dispatch reads; absent only when the installed entry's (or none) applies. */
|
|
|
+ thinkingLevelMap?: ThinkingLevelMap
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Resolve one model's reasoning capability from its declared efforts.
|
|
|
+ *
|
|
|
+ * A declared dict translates to pi-ai's `thinkingLevelMap` with every level
|
|
|
+ * decided explicitly: declared levels carry their wire spelling, undeclared
|
|
|
+ * levels are pinned to `null` (unsupported). Pinning matters because pi-ai's
|
|
|
+ * own defaulting is asymmetric — an absent key means "supported" for the five
|
|
|
+ * base levels but "unsupported" for `xhigh`/`max` — and a profile author
|
|
|
+ * should not need to know that. A declared `off` with no value is the one
|
|
|
+ * exception: it stays absent from the map, which pi-ai reads as "supported,
|
|
|
+ * send nothing" — the correct dispatch where not thinking is the parameter's
|
|
|
+ * absence — while `off` with a value sends that value.
|
|
|
+ * @param provider - provider route key, for diagnostics.
|
|
|
+ * @param entry - the configured model entry.
|
|
|
+ * @param base - the installed catalog entry of the same id, when one exists.
|
|
|
+ * @returns the reasoning fields the materialized model carries.
|
|
|
+ */
|
|
|
+function resolveModelReasoning(
|
|
|
+ provider: string,
|
|
|
+ entry: PiAiModelProfile,
|
|
|
+ base: Model<Api> | undefined,
|
|
|
+): ModelReasoning {
|
|
|
+ const efforts = entry.reasoningEfforts
|
|
|
+ if (efforts === undefined) {
|
|
|
+ // Reasoning rides the installed entry or is absent: a bare capability flag
|
|
|
+ // would make pi-ai advertise effort levels with no `thinkingLevelMap` to
|
|
|
+ // spell them, and no listing endpoint reports a model's reasoning
|
|
|
+ // protocol. The entry's map (when any) arrives through the `...base`
|
|
|
+ // spread in the model literal.
|
|
|
+ return { reasoning: base?.reasoning ?? false }
|
|
|
+ }
|
|
|
+ // The installed entry's map may ride along through `...base`; pi-ai never
|
|
|
+ // reads it on a non-reasoning model, so stripping it is not worth a field
|
|
|
+ // enumeration here.
|
|
|
+ if (efforts === false) return { reasoning: false }
|
|
|
+ // A YAML `reasoningEfforts:` left valueless arrives as null through the
|
|
|
+ // schema union — outside the field's declared type, hence the widening —
|
|
|
+ // while an explicit `{}` arrives as an empty dict. Both declare nothing,
|
|
|
+ // and neither is a spelling of "inherit" or "disable".
|
|
|
+ if ((efforts as unknown) === null || Object.keys(efforts).length === 0) {
|
|
|
+ invalid(provider, `model "${entry.id}" has an empty reasoningEfforts; declare the offered levels, set`
|
|
|
+ + ' false for a non-reasoning model, or omit the field to keep the installed catalog\'s capability')
|
|
|
+ }
|
|
|
+ const declared = THINKING_LEVELS.flatMap((level) => {
|
|
|
+ const wire = efforts[level]
|
|
|
+ return wire === undefined ? [] : [[level, wire] as const]
|
|
|
+ })
|
|
|
+ for (const [level, wire] of declared) {
|
|
|
+ if (wire === null) {
|
|
|
+ if (level !== 'off') {
|
|
|
+ invalid(provider, `model "${entry.id}" reasoningEfforts.${level} needs the wire value dispatch`
|
|
|
+ + ' should send; only "off" may leave it empty')
|
|
|
+ }
|
|
|
+ } else if (wire.length === 0) {
|
|
|
+ invalid(provider, `model "${entry.id}" reasoningEfforts.${level} must not be an empty string`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!declared.some(([level]) => level !== 'off')) {
|
|
|
+ invalid(provider, `model "${entry.id}" reasoningEfforts offers no level beyond "off"; declare a thinking`
|
|
|
+ + ' level, or set reasoningEfforts to false for a non-reasoning model')
|
|
|
+ }
|
|
|
+ const map: ThinkingLevelMap = {}
|
|
|
+ for (const level of THINKING_LEVELS) {
|
|
|
+ const wire = efforts[level]
|
|
|
+ if (wire === undefined) {
|
|
|
+ map[level] = null
|
|
|
+ } else if (wire !== null) {
|
|
|
+ map[level] = wire
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return { reasoning: true, thinkingLevelMap: map }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Resolve one model's compat block from the profile's reasoning switches.
|
|
|
+ *
|
|
|
+ * A model switch wins over the route switch; whatever neither sets keeps the
|
|
|
+ * installed entry's value, and a field no layer decides falls through to
|
|
|
+ * pi-ai's baseURL-derived detection. Only an `openai-completions` model takes
|
|
|
+ * the switches at all: a model-level switch on any other protocol fails
|
|
|
+ * resolution, while a route-level default skips past such models — the same
|
|
|
+ * posture as the route-level `reasoning` default, which also must not fail
|
|
|
+ * models it does not fit.
|
|
|
+ * @param provider - provider route key, for diagnostics.
|
|
|
+ * @param entry - the configured model entry.
|
|
|
+ * @param route - the route-level switches, when any.
|
|
|
+ * @param base - the installed catalog entry of the same id, when one exists.
|
|
|
+ * @param api - the model's resolved wire protocol.
|
|
|
+ * @returns a `compat` field to spread into the model, or nothing.
|
|
|
+ */
|
|
|
+function resolveModelCompat(
|
|
|
+ provider: string,
|
|
|
+ entry: PiAiModelProfile,
|
|
|
+ route: PiAiCompatProfile | undefined,
|
|
|
+ base: Model<Api> | undefined,
|
|
|
+ api: string,
|
|
|
+): { compat: OpenAICompletionsCompat } | Record<string, never> {
|
|
|
+ const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat
|
|
|
+ const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort
|
|
|
+ if (thinkingFormat === undefined && supportsReasoningEffort === undefined) return {}
|
|
|
+ if (api !== 'openai-completions') {
|
|
|
+ if (entry.compat?.thinkingFormat !== undefined || entry.compat?.supportsReasoningEffort !== undefined) {
|
|
|
+ invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}";`
|
|
|
+ + ' thinkingFormat and supportsReasoningEffort exist only on openai-completions')
|
|
|
+ }
|
|
|
+ return {}
|
|
|
+ }
|
|
|
+ // The installed entry's compat matches its own api, so on an
|
|
|
+ // openai-completions model it is the completions shape.
|
|
|
+ const inherited: OpenAICompletionsCompat | undefined = base?.compat
|
|
|
+ return {
|
|
|
+ compat: {
|
|
|
+ ...inherited,
|
|
|
+ ...thinkingFormat === undefined ? {} : { thinkingFormat },
|
|
|
+ ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort },
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/** One route's materialized catalog, plus the request caps its profile chose. */
|
|
|
export interface RouteCatalog {
|
|
|
/** The materialized models in configuration order. */
|
|
|
@@ -164,6 +389,8 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
|
|
|
+ ' must be listed in configuration')
|
|
|
}
|
|
|
const routeApi = sharedCatalogApi(defaults)
|
|
|
+ const routeCompatDefined = request.compat?.thinkingFormat !== undefined
|
|
|
+ || request.compat?.supportsReasoningEffort !== undefined
|
|
|
const seen = new Set<string>()
|
|
|
const configuredMaxTokens = new Map<string, number>()
|
|
|
const models = entries.map((entry) => {
|
|
|
@@ -209,15 +436,17 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
|
|
|
api,
|
|
|
provider,
|
|
|
baseUrl,
|
|
|
- // Reasoning rides the installed entry or is absent: a bare boolean would
|
|
|
- // make pi-ai advertise effort levels with no `thinkingLevelMap` to spell
|
|
|
- // them, and no listing endpoint reports a model's reasoning protocol.
|
|
|
- reasoning: base?.reasoning ?? false,
|
|
|
input: base?.input ?? TEXT_ONLY,
|
|
|
cost: base?.cost ?? NO_COST,
|
|
|
contextWindow,
|
|
|
maxTokens,
|
|
|
+ ...resolveModelReasoning(provider, entry, base),
|
|
|
+ ...resolveModelCompat(provider, entry, request.compat, base, api),
|
|
|
}
|
|
|
})
|
|
|
+ if (routeCompatDefined && !models.some(model => model.api === 'openai-completions')) {
|
|
|
+ invalid(provider, 'sets compat reasoning switches, but no model on the route speaks openai-completions;'
|
|
|
+ + ' thinkingFormat and supportsReasoningEffort exist only on that protocol')
|
|
|
+ }
|
|
|
return { models, configuredMaxTokens }
|
|
|
}
|