|
|
@@ -15,11 +15,16 @@
|
|
|
import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
|
|
|
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
|
|
|
import type {
|
|
|
+ AnthropicMessagesCompat,
|
|
|
Api,
|
|
|
+ BedrockCompat,
|
|
|
+ ChatTemplateKwargValue,
|
|
|
+ KnownApi,
|
|
|
Model,
|
|
|
ModelCost,
|
|
|
ModelThinkingLevel,
|
|
|
OpenAICompletionsCompat,
|
|
|
+ OpenAIResponsesCompat,
|
|
|
Provider,
|
|
|
ThinkingLevelMap,
|
|
|
} from '@earendil-works/pi-ai'
|
|
|
@@ -79,23 +84,16 @@ const THINKING_LEVEL_GATE: Record<ModelThinkingLevel, 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.
|
|
|
- */
|
|
|
-type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template'
|
|
|
-
|
|
|
/** One reasoning-dispatch wire format a profile may name. */
|
|
|
-export type PiAiThinkingFormat = Exclude<PiThinkingFormat, WithheldThinkingFormat>
|
|
|
+export type PiAiThinkingFormat = NonNullable<OpenAICompletionsCompat['thinkingFormat']>
|
|
|
|
|
|
/**
|
|
|
* 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.
|
|
|
+ * `baseten`) fails compilation here until the new format is named, so the
|
|
|
+ * offer never silently lags the upstream set. The two `chat-template` variants
|
|
|
+ * are nameable because {@link PiAiCompatProfile.chatTemplateKwargs} carries
|
|
|
+ * the kwargs they dispatch through.
|
|
|
*/
|
|
|
const THINKING_FORMAT_GATE: Record<PiAiThinkingFormat, true> = {
|
|
|
'openai': true,
|
|
|
@@ -104,6 +102,8 @@ const THINKING_FORMAT_GATE: Record<PiAiThinkingFormat, true> = {
|
|
|
'together': true,
|
|
|
'zai': true,
|
|
|
'qwen': true,
|
|
|
+ 'chat-template': true,
|
|
|
+ 'qwen-chat-template': true,
|
|
|
'string-thinking': true,
|
|
|
'ant-ling': true,
|
|
|
}
|
|
|
@@ -111,6 +111,41 @@ const THINKING_FORMAT_GATE: Record<PiAiThinkingFormat, 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[]
|
|
|
|
|
|
+/** The output-cap field spellings pi-ai accepts. */
|
|
|
+export type PiAiMaxTokensField = NonNullable<OpenAICompletionsCompat['maxTokensField']>
|
|
|
+
|
|
|
+/** Drift gate over {@link PiAiMaxTokensField}; an upstream spelling added here fails compilation until named. */
|
|
|
+const MAX_TOKENS_FIELD_GATE: Record<PiAiMaxTokensField, true> = {
|
|
|
+ max_completion_tokens: true,
|
|
|
+ max_tokens: true,
|
|
|
+}
|
|
|
+
|
|
|
+/** The output-cap field spellings a profile may name. */
|
|
|
+export const MAX_TOKENS_FIELDS = Object.keys(MAX_TOKENS_FIELD_GATE) as readonly PiAiMaxTokensField[]
|
|
|
+
|
|
|
+/** The prompt-cache marker conventions pi-ai accepts. */
|
|
|
+export type PiAiCacheControlFormat = NonNullable<OpenAICompletionsCompat['cacheControlFormat']>
|
|
|
+
|
|
|
+/** Drift gate over {@link PiAiCacheControlFormat}; a new upstream convention fails compilation until named. */
|
|
|
+const CACHE_CONTROL_FORMAT_GATE: Record<PiAiCacheControlFormat, true> = {
|
|
|
+ anthropic: true,
|
|
|
+}
|
|
|
+
|
|
|
+/** The prompt-cache marker conventions a profile may name. */
|
|
|
+export const CACHE_CONTROL_FORMATS = Object.keys(CACHE_CONTROL_FORMAT_GATE) as readonly PiAiCacheControlFormat[]
|
|
|
+
|
|
|
+/** The request-state placeholders a `chat_template_kwargs` value may name. */
|
|
|
+export type PiAiChatTemplateVar = Extract<ChatTemplateKwargValue, { $var: string }>['$var']
|
|
|
+
|
|
|
+/** Drift gate over {@link PiAiChatTemplateVar}; a new upstream placeholder fails compilation until named. */
|
|
|
+const CHAT_TEMPLATE_VAR_GATE: Record<PiAiChatTemplateVar, true> = {
|
|
|
+ 'thinking.enabled': true,
|
|
|
+ 'thinking.effort': true,
|
|
|
+}
|
|
|
+
|
|
|
+/** The request-state placeholders a profile may name. */
|
|
|
+export const CHAT_TEMPLATE_VARS = Object.keys(CHAT_TEMPLATE_VAR_GATE) as readonly PiAiChatTemplateVar[]
|
|
|
+
|
|
|
let providerIndex: Map<string, Provider> | undefined
|
|
|
|
|
|
/**
|
|
|
@@ -183,19 +218,336 @@ export function catalogModels(provider: string): Map<string, Model<Api>> {
|
|
|
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 define their reasoning
|
|
|
- * fields in the protocol itself — so resolution rejects a model-level switch
|
|
|
- * anywhere else, while a route-level default skips past models it cannot fit.
|
|
|
+ * Whether one pi-ai compat field is configurable on a profile.
|
|
|
+ *
|
|
|
+ * `withhold` is the disposition for a field pi-ai's installed catalog already
|
|
|
+ * sets for a named vendor. Reaching for one of those on a hand-declared route
|
|
|
+ * means configuring a provider that should have been named as a catalog route
|
|
|
+ * instead, where the installed entry carries the right value already.
|
|
|
+ */
|
|
|
+type CompatDisposition = 'offer' | 'withhold'
|
|
|
+
|
|
|
+/**
|
|
|
+ * Disposition of every `OpenAICompletionsCompat` field. The `Record` key type
|
|
|
+ * is a drift gate: a pi-ai upgrade that adds a field fails compilation here
|
|
|
+ * until it is classified, so the offer never silently lags the upstream set.
|
|
|
+ */
|
|
|
+const COMPLETIONS_COMPAT_GATE = {
|
|
|
+ supportsStore: 'offer',
|
|
|
+ supportsDeveloperRole: 'offer',
|
|
|
+ supportsReasoningEffort: 'offer',
|
|
|
+ supportsUsageInStreaming: 'offer',
|
|
|
+ maxTokensField: 'offer',
|
|
|
+ requiresToolResultName: 'offer',
|
|
|
+ requiresAssistantAfterToolResult: 'offer',
|
|
|
+ requiresThinkingAsText: 'offer',
|
|
|
+ requiresReasoningContentOnAssistantMessages: 'offer',
|
|
|
+ thinkingFormat: 'offer',
|
|
|
+ chatTemplateKwargs: 'offer',
|
|
|
+ supportsStrictMode: 'offer',
|
|
|
+ cacheControlFormat: 'offer',
|
|
|
+ supportsLongCacheRetention: 'offer',
|
|
|
+ openRouterRouting: 'withhold',
|
|
|
+ vercelGatewayRouting: 'withhold',
|
|
|
+ zaiToolStream: 'withhold',
|
|
|
+ supportsOpenAIGrammarTools: 'withhold',
|
|
|
+ sendSessionAffinityHeaders: 'withhold',
|
|
|
+ deferredToolsMode: 'withhold',
|
|
|
+ sessionAffinityFormat: 'withhold',
|
|
|
+} as const satisfies Record<keyof OpenAICompletionsCompat, CompatDisposition>
|
|
|
+
|
|
|
+/** Disposition of every `OpenAIResponsesCompat` field; a drift gate like the one above. */
|
|
|
+const RESPONSES_COMPAT_GATE = {
|
|
|
+ supportsDeveloperRole: 'offer',
|
|
|
+ supportsStrictMode: 'offer',
|
|
|
+ supportsLongCacheRetention: 'offer',
|
|
|
+ sessionAffinityFormat: 'withhold',
|
|
|
+ supportsOpenAIGrammarTools: 'withhold',
|
|
|
+ supportsToolSearch: 'withhold',
|
|
|
+ supportsExplicitPromptCacheMode: 'withhold',
|
|
|
+} as const satisfies Record<keyof OpenAIResponsesCompat, CompatDisposition>
|
|
|
+
|
|
|
+/** Disposition of every `AnthropicMessagesCompat` field; a drift gate like the one above. */
|
|
|
+const ANTHROPIC_COMPAT_GATE = {
|
|
|
+ supportsEagerToolInputStreaming: 'offer',
|
|
|
+ supportsLongCacheRetention: 'offer',
|
|
|
+ supportsCacheControlOnTools: 'offer',
|
|
|
+ supportsTemperature: 'offer',
|
|
|
+ forceAdaptiveThinking: 'offer',
|
|
|
+ allowEmptySignature: 'offer',
|
|
|
+ supportsStrictTools: 'offer',
|
|
|
+ sendSessionAffinityHeaders: 'withhold',
|
|
|
+ supportsToolReferences: 'withhold',
|
|
|
+} as const satisfies Record<keyof AnthropicMessagesCompat, CompatDisposition>
|
|
|
+
|
|
|
+/** Disposition of every `BedrockCompat` field; a drift gate like the one above. */
|
|
|
+const BEDROCK_COMPAT_GATE = {
|
|
|
+ supportsStrictMode: 'offer',
|
|
|
+} as const satisfies Record<keyof BedrockCompat, CompatDisposition>
|
|
|
+
|
|
|
+/**
|
|
|
+ * Every wire protocol pi-ai gives a compat type. Derived from `Model.compat`'s
|
|
|
+ * own conditional rather than listed by hand, so a pi-ai release that gives a
|
|
|
+ * further protocol a compat type fails the {@link COMPAT_GATES} entry list
|
|
|
+ * until someone classifies its fields. A protocol pi-ai gives no compat type
|
|
|
+ * resolves away here and takes no configured compat at all.
|
|
|
+ */
|
|
|
+type ApiWithCompat = { [K in KnownApi]: NonNullable<Model<K>['compat']> extends never ? never : K }[KnownApi]
|
|
|
+
|
|
|
+/**
|
|
|
+ * The compat gate of every wire protocol a profile may configure.
|
|
|
+ *
|
|
|
+ * Keyed by protocol, but grouped by pi-ai's compat *type*: the three Responses
|
|
|
+ * protocols share `OpenAIResponsesCompat`, so a switch settable on one is
|
|
|
+ * settable on all three. Keying by protocol alone would refuse
|
|
|
+ * `azure-openai-responses` and `openai-codex-responses` the fields their own
|
|
|
+ * models declare.
|
|
|
+ */
|
|
|
+const COMPAT_GATES: Readonly<Record<ApiWithCompat, Readonly<Record<string, CompatDisposition>>>> = {
|
|
|
+ 'openai-completions': COMPLETIONS_COMPAT_GATE,
|
|
|
+ 'openai-responses': RESPONSES_COMPAT_GATE,
|
|
|
+ 'azure-openai-responses': RESPONSES_COMPAT_GATE,
|
|
|
+ 'openai-codex-responses': RESPONSES_COMPAT_GATE,
|
|
|
+ 'anthropic-messages': ANTHROPIC_COMPAT_GATE,
|
|
|
+ 'bedrock-converse-stream': BEDROCK_COMPAT_GATE,
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * The compat gate of one resolved protocol. A `string` lookup rather than a
|
|
|
+ * keyed read: a route's `api` is configuration, so it may name a protocol
|
|
|
+ * pi-ai gives no compat type — or none at all.
|
|
|
+ * @param api - resolved wire protocol.
|
|
|
+ * @returns that protocol's field gate, or `undefined` when it takes no compat.
|
|
|
+ */
|
|
|
+function compatGate(api: string): Readonly<Record<string, CompatDisposition>> | undefined {
|
|
|
+ return (COMPAT_GATES as Readonly<Record<string, Readonly<Record<string, CompatDisposition>>>>)[api]
|
|
|
+}
|
|
|
+
|
|
|
+/** The field names one gate offers. */
|
|
|
+type OfferedIn<G> = { [K in keyof G]: G[K] extends 'offer' ? K : never }[keyof G]
|
|
|
+
|
|
|
+/** Every compat field name a profile may set, on whichever protocol takes it. */
|
|
|
+type OfferedCompatField =
|
|
|
+ | OfferedIn<typeof COMPLETIONS_COMPAT_GATE>
|
|
|
+ | OfferedIn<typeof RESPONSES_COMPAT_GATE>
|
|
|
+ | OfferedIn<typeof ANTHROPIC_COMPAT_GATE>
|
|
|
+ | OfferedIn<typeof BEDROCK_COMPAT_GATE>
|
|
|
+
|
|
|
+/**
|
|
|
+ * pi-ai wire-compatibility switches, set on the route (its models' default) or
|
|
|
+ * per model (winning over the route, field by field).
|
|
|
+ *
|
|
|
+ * pi-ai decides each of these from the provider id and baseURL when no layer
|
|
|
+ * sets it, and a private gateway's URL says nothing: for an endpoint it does
|
|
|
+ * not recognize the detection answers as though it were OpenAI itself, which
|
|
|
+ * is wrong for most OpenAI-compatible gateways. So every field here is one a
|
|
|
+ * deployment must be able to state because nothing can infer it, while the
|
|
|
+ * fields pi-ai's catalog sets for a named vendor stay withheld.
|
|
|
+ *
|
|
|
+ * A field belongs to the protocols whose upstream compat type declares it: a
|
|
|
+ * model-level switch its protocol does not take fails resolution, and a
|
|
|
+ * route-level one skips past models it cannot fit. "The three Responses
|
|
|
+ * protocols" below means `openai-responses`, `azure-openai-responses`, and
|
|
|
+ * `openai-codex-responses`, which pi-ai gives one shared compat type, so a
|
|
|
+ * switch settable on one is settable on all three.
|
|
|
*/
|
|
|
export interface PiAiCompatProfile {
|
|
|
- /** Reasoning parameter format 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. */
|
|
|
+ /** Whether the endpoint accepts `store`; `openai-completions`. */
|
|
|
+ supportsStore?: boolean
|
|
|
+ /**
|
|
|
+ * Whether the endpoint accepts the `developer` role for the system prompt,
|
|
|
+ * which pi-ai sends only to a reasoning model; `false` keeps `system`.
|
|
|
+ * `openai-completions` and the three Responses protocols.
|
|
|
+ */
|
|
|
+ supportsDeveloperRole?: boolean
|
|
|
+ /** Whether the endpoint accepts `reasoning_effort`; `openai-completions`. */
|
|
|
supportsReasoningEffort?: boolean
|
|
|
+ /** Whether the endpoint accepts `stream_options: {include_usage: true}`; `openai-completions`. */
|
|
|
+ supportsUsageInStreaming?: boolean
|
|
|
+ /** Which output-cap field the endpoint reads; `openai-completions`. */
|
|
|
+ maxTokensField?: NonNullable<OpenAICompletionsCompat['maxTokensField']>
|
|
|
+ /** Whether tool results must carry `name`; `openai-completions`. */
|
|
|
+ requiresToolResultName?: boolean
|
|
|
+ /** Whether a user message after tool results needs an assistant message between; `openai-completions`. */
|
|
|
+ requiresAssistantAfterToolResult?: boolean
|
|
|
+ /** Whether thinking blocks must travel as text in `<thinking>` delimiters; `openai-completions`. */
|
|
|
+ requiresThinkingAsText?: boolean
|
|
|
+ /** Whether replayed assistant messages need an empty `reasoning_content` while reasoning is on; `openai-completions`. */
|
|
|
+ requiresReasoningContentOnAssistantMessages?: boolean
|
|
|
+ /** Reasoning parameter format the endpoint expects; `openai-completions`. */
|
|
|
+ thinkingFormat?: PiAiThinkingFormat
|
|
|
+ /**
|
|
|
+ * Kwargs sent as `chat_template_kwargs`, which pi-ai reads only under the
|
|
|
+ * two `chat-template` thinking formats; `openai-completions`. Nothing checks
|
|
|
+ * that pairing: the format in force may come from the installed catalog
|
|
|
+ * entry or from pi-ai's own baseURL detection, neither of which resolution
|
|
|
+ * can read, so kwargs set beside another format are sent nowhere.
|
|
|
+ */
|
|
|
+ chatTemplateKwargs?: NonNullable<OpenAICompletionsCompat['chatTemplateKwargs']>
|
|
|
+ /**
|
|
|
+ * Whether the endpoint accepts `strict` in tool definitions;
|
|
|
+ * `openai-completions`, the three Responses protocols, `bedrock-converse-stream`.
|
|
|
+ */
|
|
|
+ supportsStrictMode?: boolean
|
|
|
+ /** Prompt-cache marker convention; `openai-completions`. */
|
|
|
+ cacheControlFormat?: NonNullable<OpenAICompletionsCompat['cacheControlFormat']>
|
|
|
+ /**
|
|
|
+ * Whether the endpoint accepts long prompt-cache retention;
|
|
|
+ * `openai-completions`, the three Responses protocols, `anthropic-messages`.
|
|
|
+ */
|
|
|
+ supportsLongCacheRetention?: boolean
|
|
|
+ /** Whether the endpoint accepts per-tool `eager_input_streaming`; `anthropic-messages`. */
|
|
|
+ supportsEagerToolInputStreaming?: boolean
|
|
|
+ /** Whether the endpoint accepts `cache_control` on tool definitions; `anthropic-messages`. */
|
|
|
+ supportsCacheControlOnTools?: boolean
|
|
|
+ /** Whether the endpoint accepts the `temperature` request field; `anthropic-messages`. */
|
|
|
+ supportsTemperature?: boolean
|
|
|
+ /** Whether to force adaptive thinking regardless of model id; `anthropic-messages`. */
|
|
|
+ forceAdaptiveThinking?: boolean
|
|
|
+ /** Whether to replay an empty thinking signature instead of converting thinking to text; `anthropic-messages`. */
|
|
|
+ allowEmptySignature?: boolean
|
|
|
+ /** Whether the endpoint accepts Anthropic strict tool schemas; `anthropic-messages`. */
|
|
|
+ supportsStrictTools?: boolean
|
|
|
+}
|
|
|
+
|
|
|
+/** Compile-time constraint that `T` is `never`. */
|
|
|
+type AssertNever<T extends never> = T
|
|
|
+
|
|
|
+/**
|
|
|
+ * Proof that every documented field is one a gate offers. A field the profile
|
|
|
+ * declares past the gates fails compilation with its own name in the error.
|
|
|
+ */
|
|
|
+export type EveryProfileFieldIsOffered = AssertNever<Exclude<keyof PiAiCompatProfile, OfferedCompatField>>
|
|
|
+
|
|
|
+/**
|
|
|
+ * Proof that every offered field is documented. A gate entry flipped to
|
|
|
+ * `offer` without a profile field fails compilation with its own name in the
|
|
|
+ * error, which is the half a schema alone cannot catch.
|
|
|
+ */
|
|
|
+export type EveryOfferedFieldIsDocumented = AssertNever<Exclude<OfferedCompatField, keyof PiAiCompatProfile>>
|
|
|
+
|
|
|
+/** Compile-time constraint that `T` is `true`. */
|
|
|
+type AssertTrue<T extends true> = T
|
|
|
+
|
|
|
+/** Every compat type a gate classifies, merged so one `Pick` reaches all offered fields. */
|
|
|
+type UpstreamCompat = OpenAICompletionsCompat & OpenAIResponsesCompat & AnthropicMessagesCompat & BedrockCompat
|
|
|
+
|
|
|
+/**
|
|
|
+ * Proof that each documented field carries its upstream type, not a hand-copied
|
|
|
+ * restatement of it. The name gates above pin *which* fields exist; this pins
|
|
|
+ * their types, in both directions because each catches a different drift. A
|
|
|
+ * profile field wider than upstream accepts a value the provider rejects, and
|
|
|
+ * `resolveModelCompat`'s cast to `ModelCompat` would hide it; a narrower one
|
|
|
+ * refuses a value the provider accepts, which is how an upgrade that widens a
|
|
|
+ * union would otherwise leave configuration silently behind.
|
|
|
+ */
|
|
|
+export type EveryProfileFieldMatchesUpstream = AssertTrue<
|
|
|
+ PiAiCompatProfile extends Partial<Pick<UpstreamCompat, OfferedCompatField>>
|
|
|
+ ? Partial<Pick<UpstreamCompat, OfferedCompatField>> extends PiAiCompatProfile ? true : false
|
|
|
+ : false
|
|
|
+>
|
|
|
+
|
|
|
+/**
|
|
|
+ * The compat entries a profile actually set.
|
|
|
+ *
|
|
|
+ * schemastery materializes an absent dict as `{}` — the behavior
|
|
|
+ * `reasoningEfforts` works around with a union — so every parsed profile
|
|
|
+ * carries a `chatTemplateKwargs` key whether or not anyone wrote one. An empty
|
|
|
+ * one states nothing here: it would send no kwargs, which is exactly what
|
|
|
+ * leaving the field out does, so absent and empty are the same request and
|
|
|
+ * neither may make a route look like it configured a switch. A valueless
|
|
|
+ * scalar is the other thing schemastery lets through, and it is refused by
|
|
|
+ * {@link assertOfferedCompatFields} before this runs rather than filtered.
|
|
|
+ * @param compat - the configured switches, when any.
|
|
|
+ * @returns the entries carrying a value, in declaration order.
|
|
|
+ */
|
|
|
+function configuredCompatEntries(compat: PiAiCompatProfile | undefined): readonly (readonly [string, unknown])[] {
|
|
|
+ return Object.entries(compat ?? {}).flatMap(([field, value]) => {
|
|
|
+ const empty = typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
|
+ && Object.keys(value as object).length === 0
|
|
|
+ return empty ? [] : [[field, value] as const]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * The protocols offering one compat field, in {@link COMPAT_GATES} order.
|
|
|
+ * @param field - configured compat field name.
|
|
|
+ * @returns the protocols whose compat takes it; empty when none does, which
|
|
|
+ * is either a withheld field or a name no upstream compat type declares.
|
|
|
+ */
|
|
|
+function compatProtocols(field: string): readonly string[] {
|
|
|
+ return Object.entries(COMPAT_GATES).flatMap(([api, gate]) => gate[field] === 'offer' ? [api] : [])
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * The compat fields one protocol offers, for a diagnostic that has to show
|
|
|
+ * what was available instead of the name that missed.
|
|
|
+ * @param api - wire protocol.
|
|
|
+ * @returns the offered field names, or an empty list for a protocol taking no compat.
|
|
|
+ */
|
|
|
+function offeredCompatFields(api: string): readonly string[] {
|
|
|
+ return Object.entries(compatGate(api) ?? {}).flatMap(([field, disposition]) => disposition === 'offer' ? [field] : [])
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Every offered field name, deduplicated, for the one diagnostic that cannot
|
|
|
+ * narrow by protocol: the vocabulary check runs before any protocol resolves,
|
|
|
+ * which is what lets it refuse a misspelling on a route whose models would
|
|
|
+ * never have reached the protocol that declares the intended field.
|
|
|
+ * @returns the offered field names across every protocol, in gate order.
|
|
|
+ */
|
|
|
+function allOfferedCompatFields(): readonly string[] {
|
|
|
+ const fields = new Set<string>()
|
|
|
+ for (const api of Object.keys(COMPAT_GATES)) {
|
|
|
+ for (const field of offeredCompatFields(api)) fields.add(field)
|
|
|
+ }
|
|
|
+ return [...fields]
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Reject a compat key no protocol offers. Runs before any protocol is
|
|
|
+ * resolved, so a withheld field or a misspelling fails even on a route whose
|
|
|
+ * models never reach the protocol that would have taken it — the alternative
|
|
|
+ * being the silent drop that let an unreadable switch look applied.
|
|
|
+ * @param provider - provider route key, for diagnostics.
|
|
|
+ * @param site - the configuration site, for diagnostics.
|
|
|
+ * @param compat - the configured switches, when any.
|
|
|
+ * @throws Error naming the offending key.
|
|
|
+ */
|
|
|
+function assertOfferedCompatFields(
|
|
|
+ provider: string,
|
|
|
+ site: string,
|
|
|
+ compat: PiAiCompatProfile | undefined,
|
|
|
+): void {
|
|
|
+ // Every key, not only the ones carrying a value: a withheld or undeclared
|
|
|
+ // name is never in the schema, so schemastery cannot have materialized it —
|
|
|
+ // whatever its value, a person wrote it and expects it to do something.
|
|
|
+ for (const [field, value] of Object.entries(compat ?? {})) {
|
|
|
+ // The name is judged before the value, so a withheld or misspelled key
|
|
|
+ // written bare is refused for being that name rather than for being empty:
|
|
|
+ // the other order sends someone to supply a value the key would be refused
|
|
|
+ // with anyway.
|
|
|
+ if (compatProtocols(field).length === 0) {
|
|
|
+ const declared = Object.values(COMPAT_GATES).some(gate => gate[field] !== undefined)
|
|
|
+ if (declared) {
|
|
|
+ invalid(provider, `${site} sets compat "${field}", which is not configurable here: pi-ai's installed`
|
|
|
+ + ' catalog sets it for the vendors that need it, so name that provider as the route instead')
|
|
|
+ }
|
|
|
+ invalid(provider, `${site} sets compat "${field}", which no wire protocol declares; the configurable`
|
|
|
+ + ` switches are ${allOfferedCompatFields().join(', ')}`)
|
|
|
+ }
|
|
|
+ // A valueless key (`supportsDeveloperRole:`) survives schemastery, which
|
|
|
+ // passes nullable data through before any member schema runs — the same
|
|
|
+ // behavior `reasoningEfforts` documents — and a `cordis.yml` entry may
|
|
|
+ // reach the same state through `!!js undefined`. Either way the key is
|
|
|
+ // kept, so carrying it forward writes nothing over whatever the next layer
|
|
|
+ // resolved, leaving pi-ai's `??` at its baseURL detection: the "written but
|
|
|
+ // not applied" outcome this surface exists to refuse.
|
|
|
+ if (value == null) {
|
|
|
+ invalid(provider, `${site} sets compat "${field}" with no value; give it one, or remove the key to`
|
|
|
+ + ' leave the field to the next layer — the installed catalog entry, then pi-ai\'s own detection')
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/** One configured model entry: an id plus the catalog fields it overrides. */
|
|
|
@@ -233,7 +585,7 @@ export interface PiAiModelProfile {
|
|
|
* declares the offered levels and their wire spellings.
|
|
|
*/
|
|
|
reasoningEfforts?: false | PiAiReasoningEfforts
|
|
|
- /** Reasoning-dispatch switches for this model, winning over the route's. */
|
|
|
+ /** pi-ai wire-compatibility switches for this model, winning over the route's per field; one its protocol does not declare is refused. */
|
|
|
compat?: PiAiCompatProfile
|
|
|
}
|
|
|
|
|
|
@@ -258,7 +610,7 @@ export interface RouteCatalogRequest {
|
|
|
models?: readonly PiAiModelProfile[]
|
|
|
/** Installed-catalog customizations by model id; only meaningful while `models` is absent. */
|
|
|
modelOverrides?: Readonly<Record<string, PiAiModelOverride>>
|
|
|
- /** Reasoning-dispatch switches for every `openai-completions` model on the route; entries override per field. */
|
|
|
+ /** Route-level wire-compatibility switches, landing on each model whose protocol declares them; entries override per field. */
|
|
|
compat?: PiAiCompatProfile
|
|
|
/** Context capacity for a model neither the entry nor the catalog sizes. */
|
|
|
defaultContextWindow: number
|
|
|
@@ -368,16 +720,20 @@ function resolveModelReasoning(
|
|
|
return { reasoning: true, thinkingLevelMap: map }
|
|
|
}
|
|
|
|
|
|
+/** The compat block a materialized model carries, whichever protocol it speaks. */
|
|
|
+type ModelCompat = OpenAICompletionsCompat | OpenAIResponsesCompat | AnthropicMessagesCompat | BedrockCompat
|
|
|
+
|
|
|
/**
|
|
|
- * Resolve one model's compat block from the profile's reasoning switches.
|
|
|
+ * Resolve one model's compat block from the profile's 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.
|
|
|
+ * A model switch wins over the route switch field by field; whatever neither
|
|
|
+ * sets keeps the installed entry's value, and a field no layer decides falls
|
|
|
+ * through to pi-ai's own detection. A model-level switch its protocol does not
|
|
|
+ * take fails resolution — about one named model it can only be a mistake —
|
|
|
+ * while a route-level one skips past such models, since a route default must
|
|
|
+ * stay settable on a route whose models do not all speak one protocol. Every
|
|
|
+ * field reaching here is offered by some protocol; {@link
|
|
|
+ * assertOfferedCompatFields} has already refused the rest.
|
|
|
* @param provider - provider route key, for diagnostics.
|
|
|
* @param entry - the configured model entry.
|
|
|
* @param route - the route-level switches, when any.
|
|
|
@@ -391,31 +747,31 @@ function resolveModelCompat(
|
|
|
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')
|
|
|
+): { compat: ModelCompat } | Record<string, never> {
|
|
|
+ const gate = compatGate(api)
|
|
|
+ const configured: Record<string, unknown> = {}
|
|
|
+ for (const [field, value] of configuredCompatEntries(route)) {
|
|
|
+ if (gate?.[field] !== 'offer') continue
|
|
|
+ configured[field] = value
|
|
|
+ }
|
|
|
+ for (const [field, value] of configuredCompatEntries(entry.compat)) {
|
|
|
+ if (gate?.[field] !== 'offer') {
|
|
|
+ const offered = offeredCompatFields(api)
|
|
|
+ invalid(provider, `model "${entry.id}" sets compat "${field}", but its api is "${api}", which does not`
|
|
|
+ + ` take it; that switch exists on ${compatProtocols(field).join(', ')}, and "${api}" offers`
|
|
|
+ + ` ${offered.length === 0 ? 'no configurable compat' : offered.join(', ')}`)
|
|
|
}
|
|
|
- return {}
|
|
|
+ configured[field] = value
|
|
|
}
|
|
|
+ if (Object.keys(configured).length === 0) return {}
|
|
|
// The installed entry's compat matches the entry's OWN api — a route-level
|
|
|
// `api` repoint (an anthropic catalog served through an OpenAI-compatible
|
|
|
// gateway) leaves `base.compat` in the other protocol's shape, so it is
|
|
|
// inherited only while the resolved api still is the entry's. A repointed
|
|
|
// model starts from pi-ai's baseURL-derived detection instead, which is
|
|
|
// what a protocol change means for every other compat field too.
|
|
|
- const inherited: OpenAICompletionsCompat | undefined = base?.api === api ? base.compat : undefined
|
|
|
- return {
|
|
|
- compat: {
|
|
|
- ...inherited,
|
|
|
- ...thinkingFormat === undefined ? {} : { thinkingFormat },
|
|
|
- ...supportsReasoningEffort === undefined ? {} : { supportsReasoningEffort },
|
|
|
- },
|
|
|
- }
|
|
|
+ const inherited = base?.api === api ? base.compat : undefined
|
|
|
+ return { compat: { ...inherited, ...configured } as ModelCompat }
|
|
|
}
|
|
|
|
|
|
/** One route's materialized catalog, plus the request caps its profile chose. */
|
|
|
@@ -485,8 +841,13 @@ 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
|
|
|
+ // Vocabulary before protocols: a withheld or undeclared switch is refused
|
|
|
+ // wherever it is written, so it cannot look applied on a route whose models
|
|
|
+ // never reach the protocol that would have taken it.
|
|
|
+ assertOfferedCompatFields(provider, 'route', request.compat)
|
|
|
+ for (const entry of entries) {
|
|
|
+ assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat)
|
|
|
+ }
|
|
|
const seen = new Set<string>()
|
|
|
const configuredMaxTokens = new Map<string, number>()
|
|
|
const models = entries.map((entry) => {
|
|
|
@@ -538,9 +899,15 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
|
|
|
...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')
|
|
|
+ // Per field, not per block: a route may default a switch its completions
|
|
|
+ // models take beside one only its anthropic models do, and neither should
|
|
|
+ // fail for the other's sake. What is refused is a route default no model on
|
|
|
+ // the route could ever read, which is a route that will not behave as written.
|
|
|
+ for (const [field] of configuredCompatEntries(request.compat)) {
|
|
|
+ const takers = compatProtocols(field)
|
|
|
+ if (models.some(model => takers.includes(model.api))) continue
|
|
|
+ invalid(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it;`
|
|
|
+ + ` it exists on ${takers.join(', ')}`)
|
|
|
}
|
|
|
return { models, configuredMaxTokens }
|
|
|
}
|