Merge remote-tracking branch 'origin/master' into feat/web-message-feedback-ui

This commit is contained in:
Chinesezjc
2026-08-12 16:48:46 +08:00
20 changed files with 535 additions and 37 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
README.md: abf1924a1d1297a8fb7300710223886ccf8bc931
README.zh.md: f4c5ddd6dbe05ae709145cfac341f17a716bac82
README.md: c68db532e94c1b0e956d933fbdcf51e5bb63dd29
README.zh.md: f0f3f7f9f4abd3578cc6e6d1c496df23f5976a3d

View File

@@ -91,6 +91,10 @@ How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking:
A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap.
Request modalities resolve entry `input` → installed catalog entry → route `defaultInput` (default `[text]`), the same order and the same fallback role the capacities above use. So a catalog model keeps the modalities the catalog records for it, and a narrower route default never strips them; a gateway whose *undescribed* models all take images declares `[text, image]` once at the route instead of on every entry. An entry's empty list means the same as an absent one — it describes a model accepting nothing, so it states no answer and resolution continues past it, which is what keeps a catalog model's own modalities when a `models` entry names it without declaring any. The route's may not be empty, since nothing sits below it to answer instead.
`[text]` is the absence of a declaration rather than a guess at the endpoint, which is why the fallback here is conservative where the capacity fallbacks are merely plausible. Nothing interrogates a gateway for what it accepts, and the two wrong answers do not cost the same: the harness refuses an image before it is attached when a model's modalities omit one, so under-claiming costs a refusal naming the model, while over-claiming admits an image the provider then rejects mid-turn — after the message is durable, which leaves the session repeating a request that cannot succeed.
Resolution still fails loud, naming the offending route and model, when a route cannot be served at all: a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list of uniquely-identified models. That resolution runs inside the section schema, so an unserviceable profile is refused **where it is written**`settings.mutate` answers `settings-rejected` naming the route and model — rather than being stored and then quietly disabling every route in the namespace. The settings seam keeps a namespace's last good value for an already-stored section that fails, so this cannot strand a deployment. `api` accepts the protocols in `supportedProtocols()` and is only needed when the catalog cannot supply one: a model absent from the catalog inherits the protocol its shipped siblings agree on, so adding a model to a single-protocol catalog route restates nothing.
`baseURL` sets the endpoint of every model on the route, so private proxies such as `https://proxy.example.com:8443` remain supported; a catalog route that omits it keeps each catalog model's own endpoint. Naming `api` on a catalog route repoints the whole route at that protocol, which is how a deployment moves a provider between, say, Responses and Chat Completions.
@@ -109,7 +113,7 @@ A model that carries reasoning metadata — from the installed catalog or from i
A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`.
Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
@@ -190,6 +194,7 @@ Recorded response content appends to the next request and does not invalidate it
- **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work).
- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one.
- **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround.
- **A modality declaration is not verified, and over-claiming outlives the turn** — nothing interrogates an endpoint for what it accepts, so a model declaring `image` its gateway does not serve is refused by the provider mid-turn rather than here. Prompt admission commits the user message durably before the request is built, so the rejected image stays in the session log: that model keeps re-sending it, and model selection refuses a switch to any text-only model. Recovery is another image-capable model, a fork before the image, or a new session; rolling an unconsumed image message back out of the log on a failed send is deferred.
- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`.
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.

View File

@@ -91,6 +91,10 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩
条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`262,144`defaultMaxTokens`32,768因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成每请求上限。
请求模态的解析顺序是:条目的 `input` → 已安装 catalog 条目 → 路由的 `defaultInput`(默认 `[text]`),与上面两个容量字段的顺序和「回退值」定位完全一致。因此 catalog 模型保留 catalog 为它记录的模态,更窄的路由默认值也绝不会把它剥掉;而**未被 catalog 描述的**模型全都接受图片的网关,只需在路由上写一次 `[text, image]`,不必逐条目写。条目的空列表与缺省同义——它描述的是一个什么都不接受的模型,因此不作答,解析继续往下走——这正是当 `models` 条目点到某个 catalog 模型却不声明模态时,该模型仍保留 catalog 自有模态的原因。路由的那个则不得为空,因为它下面已经没有可以代为作答的层级。
`[text]` 是「尚未声明」而不是对端点的猜测——这也是为什么这里的回退值取保守值而两个容量回退值只是取一个说得过去的值。这里没有任何环节会去询问网关实际接受什么而两种猜错的代价并不对等模态中不含图片时Harness 会在图片被附加之前就拒绝,因此少声明的代价是一次点名该模型的拒绝;而多声明会接纳一张图片、再由提供方在轮次中途拒绝——此时消息已经持久化,会话便会不断重复一个不可能成功的请求。
路由完全无法服务时解析仍会失败得响亮并点名出问题的路由与模型catalog 未提供的路由需要 `api``baseURL`,以及一个由唯一标识的模型组成的非空 `models` 列表。该解析在分节 schema 内部运行,因此无法服务的 profile 会在**写入之处**被拒绝——`settings.mutate``settings-rejected` 点名路由与模型——而不是先存下来、再悄悄让该 namespace 下每条路由失效。对于已经存下的、在此失败的分节settings seam 会保留该 namespace 上一份可用值,因此这不会把部署卡死。`api` 接受 `supportedProtocols()` 中的协议,且仅在 catalog 无法提供协议时才需要catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 catalog 路由添加模型无需重述任何内容。
`baseURL` 设定该路由下每个模型的端点,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy省略它的 catalog 路由会保留每个 catalog 模型自己的端点。在 catalog 路由上点名 `api` 会把整条路由改指到该协议,这正是部署把某个提供方在 Responses 与 Chat Completions 之间迁移的方式。
@@ -109,7 +113,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩
**没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`
受支持的 profile 字段是 `apiKeyEnv``displayName``api``baseURL``models``modelOverrides``compat``defaultContextWindow``defaultMaxTokens``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
受支持的 profile 字段是 `apiKeyEnv``displayName``api``baseURL``models``modelOverrides``compat``defaultContextWindow``defaultMaxTokens``defaultInput``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries``maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent智能体级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`
@@ -190,6 +194,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
- **`headers` 可能承载一条脱敏器看不见的凭据**profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization``api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。
- **路由的 catalog 不会自我刷新**catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。
- **每条路由只有一种协议格式**`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog无法承载另一种协议的模型向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。
- **模态声明不经验证,且多声明的后果超出本轮**:没有任何环节会去询问端点接受什么,因此声明了网关并不提供的 `image` 的模型不会在这里被拦下而是由提供方在轮次中途拒绝。prompt 准入在构造请求之前就把用户消息持久化提交于是被拒绝的图片留在会话日志里该模型会不断重发它而模型选择拒绝切换到任何纯文本模型。恢复途径是换一个确实支持图片的模型、fork 到图片之前,或开启新会话;发送失败时把尚未消费的图片消息从日志中回滚出去这件事已暂缓。
- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个由 `apiKeyEnv` 引用的占位凭据,或在 `headers` 中给出 `Authorization` 条目。
- **不支持 `GenerateOptions.stop`**pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence因此适配器会拒绝该字段。
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。

View File

@@ -31,12 +31,34 @@ import type {
*/
const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
/** One request modality a pi-ai model may accept. */
export type PiAiModality = Model<Api>['input'][number]
/**
* Input modalities for a model the installed catalog does not describe. The
* request converter keeps only text blocks, so text is the adapter's actual
* capability rather than a deployment choice.
* Every pi-ai request modality. The `Record` key type is a drift gate: a pi-ai
* upgrade that adds or removes a modality fails compilation here naming the
* drifted key, instead of silently narrowing what a profile may declare.
*/
const TEXT_ONLY: Model<Api>['input'] = ['text']
const MODALITY_GATE: Record<PiAiModality, true> = {
text: true,
image: true,
}
/** Every request modality a profile may declare. */
export const MODALITIES = Object.keys(MODALITY_GATE) as readonly PiAiModality[]
/**
* One entry's modality list, or `undefined` when it states no answer. Absent
* and empty mean the same thing — `[]` describes a model that accepts nothing
* and could serve no request — which is what makes an entry naming a catalog
* model without declaring modalities keep the catalog's, since the config
* schema materializes `[]` for an absent array.
* @param configured - the list a `models` or `modelOverrides` entry supplied.
* @returns the declared modalities, or `undefined` to ask the next level.
*/
function declaredInput(configured: readonly PiAiModality[] | undefined): Model<Api>['input'] | undefined {
return configured === undefined || configured.length === 0 ? undefined : [...configured]
}
/**
* Every pi-ai thinking level, in pi-ai's canonical escalation order. The
@@ -171,6 +193,18 @@ export interface PiAiModelProfile {
* default on its own.
*/
maxTokens?: number
/**
* Request modalities this model accepts. Absent — or empty, which describes
* a model that accepts nothing and so states no answer either — keeps the
* installed catalog entry's modalities, then the route's `defaultInput`.
* Declaring images is what makes a hand-declared vision model usable, and
* declaring text alone corrects a catalog model whose gateway does not serve
* what the catalog records. This is a claim about the endpoint, not a check
* of it: nothing interrogates a gateway for what it accepts, so a model
* claiming images its endpoint refuses is refused by the provider instead,
* mid-turn.
*/
input?: PiAiModality[]
/**
* Selectable reasoning efforts. Absent inherits the installed catalog
* entry's capability (a hand-declared model has none and does not reason);
@@ -210,6 +244,8 @@ export interface RouteCatalogRequest {
defaultContextWindow: number
/** Output capability for a model neither the entry nor the catalog sizes. */
defaultMaxTokens: number
/** Modalities for a model neither the entry nor the catalog declares. */
defaultInput: Model<Api>['input']
}
/** Report a route the deployment cannot serve, naming the settings key at fault. */
@@ -474,7 +510,7 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
api,
provider,
baseUrl,
input: base?.input ?? TEXT_ONLY,
input: declaredInput(entry.input) ?? base?.input ?? [...request.defaultInput],
cost: base?.cost ?? NO_COST,
contextWindow,
maxTokens,

View File

@@ -21,8 +21,14 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts'
import type { PiAiCompatProfile, PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts'
import { MODALITIES, resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts'
import type {
PiAiCompatProfile,
PiAiModality,
PiAiModelOverride,
PiAiModelProfile,
PiAiReasoningEfforts,
} from './catalog.ts'
import { buildProvider, supportedProtocols } from './provider.ts'
/** Default maximum idle interval while an adapter stream read is outstanding. */
@@ -34,8 +40,21 @@ export const DEFAULT_CONTEXT_WINDOW = 262_144
/** Output capability assumed for a model neither configuration nor the catalog sizes. */
export const DEFAULT_MAX_TOKENS = 32_768
/**
* Modalities assumed for a model neither configuration nor the catalog
* declares. Text is the floor every supported protocol certainly carries, so
* this is the absence of a declaration rather than a guess at the endpoint:
* nothing can interrogate a gateway for its modalities, and the two wrong
* answers do not cost the same. Under-claiming refuses the image before it is
* attached, naming the model. Over-claiming admits one the provider then
* rejects mid-turn, after the message is durable, leaving the session
* repeating a request that cannot succeed.
*/
export const DEFAULT_INPUT: readonly PiAiModality[] = ['text']
export type {
PiAiCompatProfile,
PiAiModality,
PiAiModelOverride,
PiAiModelProfile,
PiAiReasoningEfforts,
@@ -90,6 +109,17 @@ export interface PiAiProviderProfile {
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
/**
* Request modalities for a model this route lists that neither its entry's
* {@link PiAiModelProfile.input} nor the installed catalog declares (default
* `[text]`). A fallback like the capacities above, not an override: a
* catalog model keeps the modalities the catalog records for it, and this
* value never narrows one. A gateway serving vision models the catalog does
* not describe declares `[text, image]` once here instead of on every entry.
* Unlike an entry's list, this one may not be empty — nothing sits below it
* to answer instead.
*/
defaultInput?: PiAiModality[]
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record<string, string>
/** Provider-neutral pi-ai reasoning level. */
@@ -180,6 +210,10 @@ const modelFields = {
name: z.string(),
contextWindow: z.number().step(1).min(1),
maxTokens: z.number().step(1).min(1),
// No explicit default, unlike the route's `defaultInput`: schemastery
// materializes `[]` for an absent array, and resolution reads that as "no
// answer here" so the catalog entry below still applies.
input: z.array(z.union(MODALITIES)),
// The union, not a bare dict: schemastery materializes an absent dict as
// `{}`, and absent must stay distinguishable — it means "inherit the
// installed catalog's capability", while `false` disables reasoning.
@@ -205,6 +239,7 @@ const profile = z.object({
compat: compatProfile,
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
defaultInput: z.array(z.union(MODALITIES)).default([...DEFAULT_INPUT]),
headers: z.dict(z.string()),
reasoning: z.union(THINKING_LEVELS),
thinkingBudgets,
@@ -288,6 +323,15 @@ export function resolveProfiles(
`llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
// Detached from the configuration object because pi-ai types `Model.input`
// mutable. The schema's explicit default covers an absent key, so an empty
// list here is always one someone typed — and unlike an entry's, nothing
// below it can answer instead — so it is refused rather than read as "no
// answer".
const defaultInput = [...source.defaultInput ?? DEFAULT_INPUT]
if (defaultInput.length === 0) {
throw new Error(`llm-pi-ai: provider "${provider}" defaultInput must name at least one modality`)
}
// The route key, not the installed provider's own name: the directory has
// always shown route keys, and a catalog route must not silently rename
// itself on every configuration surface just because it gained a profile.
@@ -299,6 +343,7 @@ export function resolveProfiles(
...source.models === undefined ? {} : { models: source.models },
...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides },
...source.compat === undefined ? {} : { compat: source.compat },
defaultInput,
defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
})

View File

@@ -71,6 +71,7 @@ export type { PiAiAdapterOptions } from './adapter.ts'
export { Config } from './config.ts'
export type {
PiAiCompatProfile,
PiAiModality,
PiAiModelOverride,
PiAiModelProfile,
PiAiProviderProfile,

View File

@@ -186,6 +186,108 @@ describe('hand-declared providers', () => {
expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('sized')).toBe(512)
})
it('takes a models declared modalities, then the catalogs, then the routes', () => {
const vision = getBuiltinModels('anthropic').find(model => model.input.includes('image'))
if (vision === undefined) throw new Error('the installed catalog ships no anthropic vision model')
const resolved = resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
// One route, two modality sets: the entry field is what says so.
models: [{ id: 'bare' }, { id: 'seeing', input: ['text', 'image'] }, { id: 'deaf', input: ['text'] }],
},
'seeing-gateway': {
api: 'openai-completions',
baseURL: 'https://seeing.test',
// A gateway whose undescribed models all take images says so once
// rather than on every entry; an entry still outranks it.
defaultInput: ['text', 'image'],
models: [{ id: 'bare' }, { id: 'deaf', input: ['text'] }],
},
// The route value is a fallback, never an override: a catalog model
// keeps what the catalog records even under a narrower route default,
// exactly as it keeps its own contextWindow.
'anthropic': { defaultInput: ['text'] },
})
const inputOf = (route: string, id: string): readonly string[] | undefined =>
resolved.get(route)?.piProvider.getModels().find(model => model.id === id)?.input
expect(inputOf('acme-gateway', 'bare')).toEqual(['text'])
expect(inputOf('acme-gateway', 'seeing')).toEqual(['text', 'image'])
expect(inputOf('acme-gateway', 'deaf')).toEqual(['text'])
expect(inputOf('seeing-gateway', 'bare')).toEqual(['text', 'image'])
expect(inputOf('seeing-gateway', 'deaf')).toEqual(['text'])
expect(inputOf('anthropic', vision.id)).toEqual(vision.input)
})
it('carries a written modality declaration all the way to the seams model metadata', async () => {
// The resolver-level cases above cannot see a break between the settings
// document and `LlmModelInfo`, so each rung is asserted once more through
// a written section, the plugin's own registration, and `ctx.llm`.
const dir = await home()
const ctx = await bootWithSettings(dir, {})
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test/v1',
models: [{ id: 'bare' }, { id: 'seeing', input: ['text', 'image'] }],
},
'vision-gateway': {
api: 'openai-completions',
baseURL: 'https://vision.test/v1',
defaultInput: ['text', 'image'],
models: [{ id: 'bare' }, { id: 'deaf', input: ['text'] }],
},
'anthropic': { defaultInput: ['text'] },
},
})
const listed = async (provider: string): Promise<Record<string, readonly string[] | undefined>> =>
Object.fromEntries((await ctx.llm.listModels(provider)).map(model => [model.id, model.inputModalities]))
expect(await listed('acme-gateway')).toEqual({ bare: ['text'], seeing: ['text', 'image'] })
expect(await listed('vision-gateway')).toEqual({ bare: ['text', 'image'], deaf: ['text'] })
expect((await ctx.llm.resolveModelInfo('acme-gateway', 'seeing')).inputModalities).toEqual(['text', 'image'])
// A catalog vision model keeps what the catalog records even under a
// narrower route default: the route value is a fallback, not an override.
const vision = getBuiltinModels('anthropic').find(model => model.input.includes('image'))
if (vision === undefined) throw new Error('the installed catalog ships no anthropic vision model')
expect((await ctx.llm.resolveModelInfo('anthropic', vision.id)).inputModalities).toEqual(vision.input)
})
it('reads an entrys empty modality list as no answer, and the routes as unserviceable', () => {
// Absent and empty are the same request on an entry, exactly as they are
// for the route's `models` list — which matters because the config schema
// materializes `[]` for an absent array, so an entry naming a catalog
// model without declaring modalities must keep the catalog's rather than
// describe a model that accepts nothing.
const [catalogModel] = getBuiltinModels('deepseek')
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
const resolved = resolveProfiles({
'deepseek': { baseURL: 'https://catalog.test', models: [{ id: catalogModel.id, input: [] }] },
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'bare', input: [] }],
},
})
expect(resolved.get('acme-gateway')?.piProvider.getModels()[0]?.input).toEqual(['text'])
expect(resolved.get('deepseek')?.piProvider.getModels()[0]?.input).toEqual(catalogModel.input)
// Nothing sits below the route value, so its empty list states no answer
// anything could take, and is refused where it is written.
expect(() => resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
defaultInput: [],
models: [{ id: 'bare' }],
},
})).toThrow(/defaultInput must name at least one modality/)
})
it('rejects a model the route cannot identify', () => {
const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })

View File

@@ -1,18 +1,24 @@
import { describe, expect, it } from 'vitest'
import { Config } from '../src/config.ts'
import { assertServiceable, Config } from '../src/config.ts'
/** Validate one hand-declared route, with the caller's fields layered onto it. */
const routeWith = (profile: Record<string, unknown>): (() => unknown) =>
() => Config({
providers: {
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'm' }],
...profile,
},
},
})
/** Validate that route with the caller's fields on its single model entry. */
const configWith = (model: Record<string, unknown>): (() => unknown) =>
routeWith({ models: [{ id: 'm', ...model }] })
describe('reasoning schema boundary', () => {
const configWith = (model: Record<string, unknown>): (() => unknown) =>
() => Config({
providers: {
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'm', ...model }],
},
},
})
it('rejects a level pi-ai does not know at the write that produced it', () => {
expect(configWith({ reasoningEfforts: { ultra: 'x' } })).toThrow(/"off"/)
expect(configWith({ reasoningEfforts: { high: 42 } })).toThrow()
@@ -30,3 +36,31 @@ describe('reasoning schema boundary', () => {
expect(configWith({ compat: { thinkingFormat: 'quantum' } })).toThrow(/expected/)
})
})
describe('modality schema boundary', () => {
it('rejects a modality pi-ai does not know, at either level', () => {
expect(configWith({ input: ['audio'] })).toThrow(/expected/)
expect(routeWith({ defaultInput: ['text', 'audio'] })).toThrow(/expected/)
})
it('refuses a route whose models could accept nothing', () => {
// The pair the settings seam runs: the schema accepts the empty list as
// well-typed, and the namespace validator is what refuses it. Asserting
// only the schema would report this route as writable.
expect(routeWith({ defaultInput: [] })).not.toThrow()
expect(() => { assertServiceable(routeWith({ defaultInput: [] })() as Config) })
.toThrow(/defaultInput must name at least one modality/)
})
type Materialized = {
providers: Record<string, { defaultInput?: unknown; models?: { input?: unknown }[] }>
}
it('materializes an absent entry list as empty and an absent route list as text', () => {
// The empty-list inheritance rule exists because of exactly this: an entry
// that declares nothing reaches resolution as `[]`, not as `undefined`.
const absent = configWith({})() as Materialized
expect(absent.providers['acme-gateway']?.models?.[0]?.input).toEqual([])
expect(absent.providers['acme-gateway']?.defaultInput).toEqual(['text'])
})
})