mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(llm-pi-ai): per-model reasoningEfforts and reasoning-dispatch compat switches
A model entry's reasoningEfforts dict declares its selectable thinking levels — key = offered level, value = the wire spelling dispatch sends; only off may leave the value empty (supported, send nothing). false strips reasoning from a catalog model; every level is materialized explicitly into pi-ai's thinkingLevelMap so nobody has to know pi-ai's asymmetric absent-key defaulting. compat.thinkingFormat and compat.supportsReasoningEffort become configurable on the route and per model (model > route > catalog entry > pi-ai's URL-derived guess), openai-completions only, so a private gateway speaking the DeepSeek reasoning dialect no longer depends on its URL being recognizable. Record-typed drift gates pin both enums to pi-ai's, and an unserviceable declaration is refused at the write that produced it, naming route, model, and level.
This commit is contained in:
95
apps/web/tests/declared-reasoning.e2e.ts
Normal file
95
apps/web/tests/declared-reasoning.e2e.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Web e2e scenario: a hand-declared model's `reasoningEfforts` reaches the
|
||||
// composer's effort pane — the levels a settings profile declares are exactly
|
||||
// what the picker offers, and picking one records it with the default route.
|
||||
// Zero model calls: declaring, describing, and switching are settings/llm
|
||||
// traffic only, so there is no fixture and a stray stream would fail loud.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Starts the shipped default on this scenario's declared reasoning model. */
|
||||
const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/declared-reasoning', import.meta.url))
|
||||
const UI_EXPECTED = fileURLToPath(new URL('./snapshots/declared-reasoning/ui.expected.md', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
||||
// The whole reasoning offer is the profile: key = selectable level, value
|
||||
// = the wire spelling dispatch would send (`max: ultra` renames; the
|
||||
// valueless `off` means "supported, send nothing"). The route sets no
|
||||
// deployment default, so the pane leads with the provider-default entry.
|
||||
await scaffold.ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{
|
||||
id: 'acme-think',
|
||||
name: 'Acme Think',
|
||||
reasoningEfforts: { off: null, high: 'high', max: 'ultra' },
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('offers exactly the declared levels and records the picked one', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning'))
|
||||
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
await page.getByRole('menuitem', { name: /推理等级/ }).click()
|
||||
|
||||
// Declared levels, nothing else: the provider-default entry (the route
|
||||
// configures no `reasoning`), then Off/High/Max — minimal, low, medium,
|
||||
// and xhigh were not declared and must not be offered.
|
||||
const levels = page.getByRole('menuitemradio')
|
||||
await expect.poll(async () => levels.allTextContents(), { timeout: 10_000 })
|
||||
.toEqual(['Default', 'Off', 'High', 'Max'])
|
||||
const snapshot = await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
|
||||
// Picking a level is the same gesture that saves the default target, so
|
||||
// the effort lands in the gateway's settings section beside the route.
|
||||
await page.getByRole('menuitemradio', { name: 'High' }).click()
|
||||
await expect.poll(
|
||||
async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
|
||||
{ timeout: 10_000 },
|
||||
).toContain('reasoningEffort: high')
|
||||
await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 })
|
||||
.toBe('选择模型,当前 Acme Think,推理等级 High')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
})
|
||||
})
|
||||
8
apps/web/tests/declared-reasoning.overlay.yml
Normal file
8
apps/web/tests/declared-reasoning.overlay.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
# The fixture-less web scaffold registers no adapter, so the shipped
|
||||
# deepseek-official default would be a route nothing serves. This scenario
|
||||
# starts the default on its own declared reasoning model so the effort pane
|
||||
# describes that model from the first open.
|
||||
- id: api-gateway
|
||||
config:
|
||||
provider: acme-gateway
|
||||
model: acme-think
|
||||
@@ -0,0 +1,7 @@
|
||||
- menu "模型与推理等级":
|
||||
- menuitemradio "Default" [checked]:
|
||||
- text: Default
|
||||
- img
|
||||
- menuitemradio "Off"
|
||||
- menuitemradio "High"
|
||||
- menuitemradio "Max"
|
||||
@@ -38,6 +38,7 @@
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
"tests/models-settings.e2e.ts",
|
||||
"tests/default-model.e2e.ts",
|
||||
"tests/declared-reasoning.e2e.ts",
|
||||
"tests/onboarding-deepseek-config.e2e.ts",
|
||||
"tests/remote-welcome.e2e.ts",
|
||||
"tests/workspace-management.e2e.ts",
|
||||
|
||||
@@ -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: 97bd629adedda9d63fee730bc31129b0c22cc704
|
||||
README.zh.md: 71d45b590f48f4b8162ae329b58b5ff4a9eb13b1
|
||||
README.md: 894aecc720f0a7616c0127d439b41129d94ef667
|
||||
README.zh.md: 63464f80ee3036ddec3fb6828ecccc68c5524478
|
||||
|
||||
@@ -42,18 +42,41 @@ Configure credentials, the model catalog, and deployment-specific transport sett
|
||||
apiKeyEnv: ACME_GATEWAY_API_KEY
|
||||
api: openai-completions
|
||||
baseURL: https://gateway.acme.example/v1
|
||||
# Reasoning dialect for an endpoint whose URL pi-ai cannot recognize.
|
||||
compat:
|
||||
thinkingFormat: deepseek
|
||||
models:
|
||||
- id: acme-large
|
||||
name: Acme Large
|
||||
contextWindow: 65536
|
||||
maxTokens: 4096
|
||||
- id: acme-think
|
||||
name: Acme Think
|
||||
contextWindow: 262144
|
||||
maxTokens: 32768
|
||||
# key = selectable level, value = its wire spelling; only off may
|
||||
# leave the value empty (supported, send nothing).
|
||||
reasoningEfforts:
|
||||
off:
|
||||
high: high
|
||||
max: ultra
|
||||
```
|
||||
|
||||
The dict shape makes duplicate routes unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Dormant or not, the plugin declares every installed catalog provider in the configurable-provider directory (`ctx.llm.listConfigurableProviders()`, settings path `providers.<provider>`), joined with every route the current profiles declare, so configuration surfaces can offer the full catalog before any route exists and can still address a hand-declared one. Each entry carries `declared`: whether pi-ai ships nothing under that key. It follows the installed catalog, never the settings document, because narrowing a shipped provider's models stores a profile too and that route is still one pi-ai knows — only the adapter can tell the two apart, which is why the directory answers rather than leaving a surface to infer it. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; a model the route does not configure fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
|
||||
|
||||
## Catalog resolution
|
||||
|
||||
A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits. Only the fields the harness consumes are configurable — `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent. Reasoning is not per-model configurable at all: 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, so reasoning rides the installed catalog entry or is absent.
|
||||
A profile's `models` list *replaces* the route's installed catalog rather than extending it; omitting it (or leaving it empty) serves that catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a catalog route to two models, correcting one capacity, or adding a model newer than the installed catalog are all one-line edits — but declaring any `models` list means every model the route should keep serving must appear in it, an entry of nothing but `id` being enough. The configurable entry fields are `id`, `name`, `contextWindow`, `maxTokens`, `reasoningEfforts`, and `compat`. Pricing and input modalities have no harness consumer and ride the installed entry or are absent.
|
||||
|
||||
### Per-model reasoning efforts
|
||||
|
||||
`reasoningEfforts` declares a model's selectable thinking levels: each key is a level selectors offer, its value the spelling dispatch sends on the wire, so `high: high` passes the canonical name through while `max: ultra` renames it for a gateway with its own vocabulary. Keys come from pi-ai's level set (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`); a level not declared is not offered. Omitting the field keeps 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; an empty declaration is refused rather than guessing between those two meanings.
|
||||
|
||||
The declaration translates to pi-ai's `Model.reasoning` + `thinkingLevelMap` with every level decided explicitly — undeclared levels are pinned unsupported rather than left to pi-ai's own defaulting, which is asymmetric (an absent key means "supported" for the five base levels but "unsupported" for `xhigh`/`max`) and which a profile author should not need to know. `off` is the one three-state key: left out, the model cannot stop thinking and selectors offer no Off; declared with no value (`off:`), Off is offered and selecting it sends nothing — for the `deepseek` dialect an explicit `thinking: {type: "disabled"}` — which also covers a request naming no effort at all; declared with a value (`off: none`), that value goes on the wire as the effort parameter. There is no spelling for restoring a catalog map key to "unset": the declaration is the whole offer, so restate the catalog levels you keep.
|
||||
|
||||
### Reasoning-dispatch compat switches
|
||||
|
||||
How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking: {type}` plus effort, z.ai's `thinking` object, and so on — is pi-ai's `compat.thinkingFormat`, which pi-ai guesses from the endpoint URL; a private gateway's URL says nothing, so a DeepSeek-dialect gateway would be spoken to in the OpenAI dialect with no way to correct it. `compat.thinkingFormat` and `compat.supportsReasoningEffort` are therefore configurable on the route (its models' default) and per model (winning per field), resolving model → route → installed catalog entry → pi-ai's URL-derived guess; setting a route-level switch shadows the catalog entry's value for every model on the route, and there is no spelling for handing a field back to the catalog short of restating its value. `thinkingFormat` accepts pi-ai's dispatchable formats except the two `chat-template` variants, which need `chatTemplateKwargs` this configuration does not expose. Both switches exist only on `openai-completions` — the other protocols carry their reasoning shape in the protocol itself — so a model-level switch elsewhere fails resolution, a route-level one skips models of other protocols, and a route with no `openai-completions` model at all is refused. The rest of pi-ai's compat surface (`supportsStore`, `maxTokensField`, …) stays auto-detected and is deliberately not configurable here.
|
||||
|
||||
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.
|
||||
|
||||
@@ -71,11 +94,11 @@ Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `ap
|
||||
|
||||
The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own.
|
||||
|
||||
A model that carries reasoning metadata exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`.
|
||||
A model that carries reasoning metadata — from the installed catalog or from its entry's `reasoningEfforts` — exposes pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`.
|
||||
|
||||
A model **without** that metadata — every hand-declared one, 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`.
|
||||
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 `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `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 `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `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.
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
@@ -42,18 +42,41 @@
|
||||
apiKeyEnv: ACME_GATEWAY_API_KEY
|
||||
api: openai-completions
|
||||
baseURL: https://gateway.acme.example/v1
|
||||
# Reasoning dialect for an endpoint whose URL pi-ai cannot recognize.
|
||||
compat:
|
||||
thinkingFormat: deepseek
|
||||
models:
|
||||
- id: acme-large
|
||||
name: Acme Large
|
||||
contextWindow: 65536
|
||||
maxTokens: 4096
|
||||
- id: acme-think
|
||||
name: Acme Think
|
||||
contextWindow: 262144
|
||||
maxTokens: 32768
|
||||
# key = selectable level, value = its wire spelling; only off may
|
||||
# leave the value empty (supported, send nothing).
|
||||
reasoningEfforts:
|
||||
off:
|
||||
high: high
|
||||
max: ultra
|
||||
```
|
||||
|
||||
字典形状使重复路由无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。无论是否休眠,插件都会在可配置提供方目录(`ctx.llm.listConfigurableProviders()`,settings 路径 `providers.<provider>`)中声明每个已安装 catalog 提供方,并与当前 profile 声明的每条路由取并集,因此配置界面既能在任何路由存在之前就提供完整 catalog,也能寻址一条手工声明的路由。每个条目都带上 `declared`:pi-ai 在这个键下是否什么都没有。它跟随已安装 catalog 而非设置文档,因为收窄一个内置提供方的模型同样会存下 profile,而那条路由仍然是 pi-ai 认识的——只有适配器分得清两者,所以由目录直接给出答案,而不是留给界面去猜。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;路由未配置的模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
|
||||
|
||||
## Catalog 解析
|
||||
|
||||
profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑。只有 harness 会消费的字段可配置——`id`、`name`、`contextWindow` 与 `maxTokens`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。推理则完全不按模型配置:一个孤立的能力布尔量会让 pi-ai 公布出没有 `thinkingLevelMap` 可供拼写的档位,而且没有任何列表端点会报告模型的推理协议,因此推理沿用已安装 catalog 条目或直接缺席。
|
||||
profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩充它;省略它(或留空)则原样服务该 catalog。每个条目都会从同 `id` 的已安装模型继承自身未设置的字段,因此把 catalog 路由收窄到两个模型、更正某个容量,或加入一个比已安装 catalog 更新的模型,都是一行编辑——但一旦声明了 `models` 列表,该路由要继续服务的每个模型就都必须出现在其中,条目哪怕只写一个 `id` 也足够。可配置的条目字段是 `id`、`name`、`contextWindow`、`maxTokens`、`reasoningEfforts` 与 `compat`。定价与输入模态没有 harness 消费方,因此沿用已安装条目或直接缺席。
|
||||
|
||||
### 按模型的推理档位
|
||||
|
||||
`reasoningEfforts` 声明模型可选的思考级别:每个键是选择器提供的一个档位,其值是分派在协议中发送的拼写,因此 `high: high` 原样透传规范名称,而 `max: ultra` 则为使用自有词汇的网关改名。键取自 pi-ai 的档位集合(`off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`);未声明的档位不会被提供。省略该字段会保留已安装 catalog 条目的能力(手工声明的模型没有这份能力,也不推理);`false` 声明一个不具备推理能力的模型,profile 正是以此从其网关无法服务的 catalog 模型上剥除推理;空声明会被拒绝,而不是在这两种含义之间去猜。
|
||||
|
||||
该声明会转换为 pi-ai 的 `Model.reasoning` + `thinkingLevelMap`,其中每个档位都被显式决定——未声明的档位一律固定为不支持,而不是留给 pi-ai 自己的默认规则:那套规则并不对称(键缺席对五个基础档位意味着「支持」,对 `xhigh`/`max` 却意味着「不支持」),也本不该要求 profile 作者了解。`off` 是唯一的三态键:不写它,模型就无法停止思考,选择器也不提供 Off;声明而不给值(`off:`),则会提供 Off,选中它时什么也不发送——对 `deepseek` 方言则是一个显式的 `thinking: {type: "disabled"}`——这同时覆盖完全不点名任何档位的请求;声明并给值(`off: none`),该值就会作为档位参数在协议中发送。没有任何写法能把 catalog 映射中的键恢复为「未设置」:这份声明就是对外提供的全部,因此把你要保留的 catalog 档位重述出来。
|
||||
|
||||
### 推理分派的 compat 开关
|
||||
|
||||
思考级别如何在协议中传输——单独一个 `reasoning_effort`、DeepSeek 的 `thinking: {type}` 加上档位、z.ai 的 `thinking` 对象,诸如此类——就是 pi-ai 的 `compat.thinkingFormat`,pi-ai 会从端点 URL 猜测它;私有网关的 URL 什么也说明不了,于是说 DeepSeek 方言的网关只会收到 OpenAI 方言的请求,且无从更正。因此 `compat.thinkingFormat` 与 `compat.supportsReasoningEffort` 既可配置在路由上(作为其模型的默认值),也可按模型配置(逐字段胜出),解析顺序为模型 → 路由 → 已安装 catalog 条目 → pi-ai 按 URL 得出的猜测;设置路由级开关会为路由上的每个模型遮蔽 catalog 条目的值,而且除了重述其值,没有任何写法能把某个字段交还给 catalog。`thinkingFormat` 接受 pi-ai 可分派的各种格式,但不含两个 `chat-template` 变体:它们需要的 `chatTemplateKwargs` 本配置并不暴露。两个开关都只存在于 `openai-completions` 上——其余协议的推理形状由协议本身承载——因此在其他协议的模型上设置模型级开关会使解析失败,路由级开关会跳过其他协议的模型,而完全没有 `openai-completions` 模型的路由则会被拒绝。pi-ai compat 面的其余部分(`supportsStore`、`maxTokensField`……)保持自动检测,特意不在此处开放配置。
|
||||
|
||||
条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`(262,144)与 `defaultMaxTokens`(32,768),因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成每请求上限。
|
||||
|
||||
@@ -71,11 +94,11 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。
|
||||
|
||||
携带推理元数据的模型会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。
|
||||
携带推理元数据的模型——来自已安装 catalog,或来自其条目的 `reasoningEfforts`——会公开 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。
|
||||
|
||||
**没有**这份元数据的模型——每一个手工声明的模型,以及 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`。
|
||||
**没有**这份元数据的模型——条目未声明 `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 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
|
||||
受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`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`。
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { resolveRouteModels } from './catalog.ts'
|
||||
import type { PiAiModelProfile } from './catalog.ts'
|
||||
import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts'
|
||||
import type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts'
|
||||
import { buildProvider, supportedProtocols } from './provider.ts'
|
||||
|
||||
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
||||
@@ -34,7 +34,7 @@ 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
|
||||
|
||||
export type { PiAiModelProfile } from './catalog.ts'
|
||||
export type { PiAiCompatProfile, PiAiModelProfile, PiAiReasoningEfforts, PiAiThinkingFormat } from './catalog.ts'
|
||||
|
||||
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
|
||||
export interface PiAiProviderProfile {
|
||||
@@ -62,6 +62,13 @@ export interface PiAiProviderProfile {
|
||||
* unset fields from the installed model of the same id.
|
||||
*/
|
||||
models?: PiAiModelProfile[]
|
||||
/**
|
||||
* Reasoning-dispatch switches for every `openai-completions` model on this
|
||||
* route; each model's own `compat` overrides per field. What neither sets
|
||||
* keeps the installed catalog entry's value, then pi-ai's baseURL-derived
|
||||
* detection.
|
||||
*/
|
||||
compat?: PiAiCompatProfile
|
||||
/**
|
||||
* Context capacity for a model this route lists that neither the entry nor
|
||||
* the installed catalog sizes (default 262,144). A guess by construction, so
|
||||
@@ -139,11 +146,34 @@ const thinkingBudgets = z.object({
|
||||
high: z.number(),
|
||||
})
|
||||
|
||||
const compatProfile: z<PiAiCompatProfile> = z.object({
|
||||
thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS),
|
||||
supportsReasoningEffort: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Keys are the offered levels, values their wire spellings. `z.const(null)`
|
||||
* keeps a valueless key (`off:`) alive through validation — only resolution
|
||||
* decides which levels may leave the value empty, so the diagnostic can name
|
||||
* the route and model. The assertion narrows schemastery's `Dict`, which
|
||||
* types every literal key as required; dict validation is per-present-key, so
|
||||
* the runtime shape is the partial record.
|
||||
*/
|
||||
const reasoningEfforts = z.dict(
|
||||
z.union([z.string(), z.const(null)]),
|
||||
z.union(THINKING_LEVELS),
|
||||
) as unknown as z<PiAiReasoningEfforts>
|
||||
|
||||
const modelProfile: z<PiAiModelProfile> = z.object({
|
||||
id: z.string().required(),
|
||||
name: z.string(),
|
||||
contextWindow: z.number().step(1).min(1),
|
||||
maxTokens: z.number().step(1).min(1),
|
||||
// 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.
|
||||
reasoningEfforts: z.union([z.const(false), reasoningEfforts]),
|
||||
compat: compatProfile,
|
||||
})
|
||||
|
||||
const profile = z.object({
|
||||
@@ -153,10 +183,11 @@ const profile = z.object({
|
||||
api: z.union(supportedProtocols()),
|
||||
baseURL: z.string(),
|
||||
models: z.array(modelProfile),
|
||||
compat: compatProfile,
|
||||
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
|
||||
defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
|
||||
headers: z.dict(z.string()),
|
||||
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
||||
reasoning: z.union(THINKING_LEVELS),
|
||||
thinkingBudgets,
|
||||
cacheRetention: z.union(['none', 'short', 'long']),
|
||||
transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']),
|
||||
@@ -260,6 +291,7 @@ export function resolveProfiles(
|
||||
...source.api === undefined ? {} : { api: source.api },
|
||||
...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
|
||||
...source.models === undefined ? {} : { models: source.models },
|
||||
...source.compat === undefined ? {} : { compat: source.compat },
|
||||
defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
})
|
||||
|
||||
@@ -32,11 +32,24 @@
|
||||
* apiKeyEnv: ACME_GATEWAY_API_KEY
|
||||
* api: openai-completions
|
||||
* baseURL: https://gateway.acme.example/v1
|
||||
* # Reasoning dialect for a URL pi-ai cannot recognize.
|
||||
* compat:
|
||||
* thinkingFormat: deepseek
|
||||
* models:
|
||||
* - id: acme-large
|
||||
* name: Acme Large
|
||||
* contextWindow: 65536
|
||||
* maxTokens: 4096
|
||||
* - id: acme-think
|
||||
* name: Acme Think
|
||||
* contextWindow: 262144
|
||||
* maxTokens: 32768
|
||||
* # key = selectable level, value = wire spelling; only off may
|
||||
* # leave the value empty (supported, send nothing).
|
||||
* reasoningEfforts:
|
||||
* off:
|
||||
* high: high
|
||||
* max: ultra
|
||||
* ```
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm-pi-ai
|
||||
@@ -55,7 +68,14 @@ import { discoverModels } from './discovery.ts'
|
||||
export { PiAiAdapter } from './adapter.ts'
|
||||
export type { PiAiAdapterOptions } from './adapter.ts'
|
||||
export { Config } from './config.ts'
|
||||
export type { PiAiModelProfile, PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
|
||||
export type {
|
||||
PiAiCompatProfile,
|
||||
PiAiModelProfile,
|
||||
PiAiProviderProfile,
|
||||
PiAiReasoningEfforts,
|
||||
PiAiThinkingFormat,
|
||||
ResolvedPiAiProviderProfile,
|
||||
} from './config.ts'
|
||||
export { supportedProtocols } from './provider.ts'
|
||||
|
||||
export const name = 'llm-pi-ai'
|
||||
|
||||
@@ -400,6 +400,149 @@ describe('provider profile lifecycle', () => {
|
||||
.resolves.toMatchObject({ reasoning: { defaultEffort: ReasoningEffortId('off') } })
|
||||
})
|
||||
|
||||
it('serves declared reasoning efforts to selectors and honours the profile default', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
reasoning: 'high',
|
||||
models: [{
|
||||
id: 'acme-think',
|
||||
contextWindow: 65_536,
|
||||
maxTokens: 4096,
|
||||
reasoningEfforts: { off: null, low: 'low', high: 'high' },
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Declared levels reach the same seam catalog metadata does, so the
|
||||
// effort picker works for a model pi-ai has never heard of.
|
||||
await expect(ctx.llm.resolveModelInfo('acme-gateway', 'acme-think')).resolves.toMatchObject({
|
||||
reasoning: {
|
||||
efforts: [
|
||||
{ id: ReasoningEffortId('off'), name: 'Off' },
|
||||
{ id: ReasoningEffortId('low'), name: 'Low' },
|
||||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||||
],
|
||||
defaultEffort: ReasoningEffortId('high'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('sends the declared wire spelling and refuses undeclared levels before network I/O', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
models: [{
|
||||
id: 'acme-think',
|
||||
contextWindow: 65_536,
|
||||
maxTokens: 4096,
|
||||
reasoningEfforts: { off: null, high: 'ultra' },
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await assemble(ctx, {
|
||||
provider: 'acme-gateway',
|
||||
model: 'acme-think',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
messages: [],
|
||||
})
|
||||
// The declared value, not the canonical level name, goes on the wire.
|
||||
expect(server.requests[0]).toMatchObject({ reasoning_effort: 'ultra' })
|
||||
|
||||
const undeclared = await assemble(ctx, {
|
||||
provider: 'acme-gateway',
|
||||
model: 'acme-think',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
messages: [],
|
||||
})
|
||||
expect(undeclared.finish).toMatchObject({
|
||||
kind: 'error',
|
||||
failure: { code: 'UNSUPPORTED_REASONING_EFFORT' },
|
||||
})
|
||||
expect(server.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('dispatches the compat-switched dialect on a declared route', async () => {
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
// Without the switch pi-ai guesses the dialect from the endpoint
|
||||
// URL, and a private gateway's URL says nothing.
|
||||
compat: { thinkingFormat: 'deepseek' },
|
||||
models: [{
|
||||
id: 'acme-think',
|
||||
contextWindow: 65_536,
|
||||
maxTokens: 4096,
|
||||
reasoningEfforts: { off: null, high: 'high' },
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
const prompt = (effort: string): Promise<unknown> => assemble(ctx, {
|
||||
provider: 'acme-gateway',
|
||||
model: 'acme-think',
|
||||
reasoningEffort: ReasoningEffortId(effort),
|
||||
messages: [],
|
||||
})
|
||||
|
||||
await prompt('high')
|
||||
expect(server.requests[0]).toMatchObject({ thinking: { type: 'enabled' }, reasoning_effort: 'high' })
|
||||
|
||||
await prompt('off')
|
||||
expect(server.requests[1]).toMatchObject({ thinking: { type: 'disabled' } })
|
||||
expect(server.requests[1]).not.toHaveProperty('reasoning_effort')
|
||||
})
|
||||
|
||||
it('holds back reasoning_effort when the endpoint cannot take it', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
compat: { supportsReasoningEffort: false },
|
||||
models: [{
|
||||
id: 'acme-think',
|
||||
contextWindow: 65_536,
|
||||
maxTokens: 4096,
|
||||
reasoningEfforts: { off: null, high: 'high' },
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await assemble(ctx, {
|
||||
provider: 'acme-gateway',
|
||||
model: 'acme-think',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
messages: [],
|
||||
})
|
||||
expect(server.requests[0]).not.toHaveProperty('reasoning_effort')
|
||||
})
|
||||
|
||||
it('accepts absent credentials for pi-ai ambient authentication', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
|
||||
@@ -10,8 +10,8 @@ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
|
||||
import { createModels } from '@earendil-works/pi-ai'
|
||||
import type { Api, Model, Provider } from '@earendil-works/pi-ai'
|
||||
import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
|
||||
import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai'
|
||||
import { resolveProfiles } from '../src/config.ts'
|
||||
import { buildProvider, supportedProtocols } from '../src/provider.ts'
|
||||
import { assemble } from './assemble.ts'
|
||||
@@ -475,6 +475,177 @@ describe('catalog routes with per-model configuration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-model reasoning efforts', () => {
|
||||
/** One hand-declared route holding exactly the given models. */
|
||||
function declared(models: LlmPiAi.PiAiModelProfile[]): Record<string, LlmPiAi.PiAiProviderProfile> {
|
||||
return { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models } }
|
||||
}
|
||||
|
||||
/** The first materialized model of one route, or throw. */
|
||||
function modelOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route = 'acme-gateway'): Model<Api> {
|
||||
const [model] = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
|
||||
if (model === undefined) throw new Error(`route "${route}" resolved no models`)
|
||||
return model
|
||||
}
|
||||
|
||||
it('declares selectable levels with their wire spellings on a hand-declared model', () => {
|
||||
const model = modelOf(declared([{
|
||||
id: 'acme-think',
|
||||
reasoningEfforts: { off: null, low: 'low', high: 'high', max: 'ultra' },
|
||||
}]))
|
||||
|
||||
expect(model.reasoning).toBe(true)
|
||||
// Undeclared levels are pinned null rather than left to pi-ai's own
|
||||
// defaulting, which is asymmetric: an absent key means "supported" for the
|
||||
// five base levels but "unsupported" for xhigh/max. A profile author
|
||||
// should not need to know that. Declared `off` with no value stays absent
|
||||
// from the map — supported, send nothing.
|
||||
expect(model.thinkingLevelMap).toEqual({
|
||||
minimal: null,
|
||||
medium: null,
|
||||
xhigh: null,
|
||||
low: 'low',
|
||||
high: 'high',
|
||||
max: 'ultra',
|
||||
})
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['off', 'low', 'high', 'max'])
|
||||
})
|
||||
|
||||
it('sends a declared off value on the wire instead of omitting the parameter', () => {
|
||||
const model = modelOf(declared([{ id: 'm', reasoningEfforts: { off: 'none', high: 'high' } }]))
|
||||
expect(model.thinkingLevelMap?.off).toBe('none')
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high'])
|
||||
})
|
||||
|
||||
it('offers exactly the declared keys: leaving off out makes thinking mandatory', () => {
|
||||
const model = modelOf(declared([{ id: 'm', reasoningEfforts: { high: 'high' } }]))
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['high'])
|
||||
})
|
||||
|
||||
it('narrows a catalog model’s levels in place', () => {
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
expect(getSupportedThinkingLevels(catalogModel as Model<Api>)).toEqual(['off', 'high', 'max'])
|
||||
|
||||
const model = modelOf({
|
||||
deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: { off: null, high: 'high' } }] },
|
||||
}, 'deepseek')
|
||||
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high'])
|
||||
// Only the reasoning fields change; identity and capacities stay catalog.
|
||||
expect(model.name).toBe(catalogModel.name)
|
||||
expect(model.contextWindow).toBe(catalogModel.contextWindow)
|
||||
})
|
||||
|
||||
it('strips reasoning from a catalog model with false', () => {
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
expect(catalogModel.reasoning).toBe(true)
|
||||
|
||||
const model = modelOf({ deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: false }] } }, 'deepseek')
|
||||
|
||||
expect(model.reasoning).toBe(false)
|
||||
expect(getSupportedThinkingLevels(model)).toEqual(['off'])
|
||||
})
|
||||
|
||||
it('inherits the catalog capability when the field is absent', () => {
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
|
||||
const model = modelOf({ deepseek: { models: [{ id: catalogModel.id }] } }, 'deepseek')
|
||||
|
||||
expect(model.reasoning).toBe(catalogModel.reasoning)
|
||||
expect(model.thinkingLevelMap).toEqual(catalogModel.thinkingLevelMap)
|
||||
})
|
||||
|
||||
it('rejects a declaration that offers nothing or spells a level it cannot send', () => {
|
||||
const declare = (efforts: NonNullable<LlmPiAi.PiAiModelProfile['reasoningEfforts']>): (() => unknown) =>
|
||||
() => resolveProfiles(declared([{ id: 'm', reasoningEfforts: efforts }]))
|
||||
|
||||
expect(declare({})).toThrow(/empty reasoningEfforts/)
|
||||
// A YAML `reasoningEfforts:` left valueless arrives as null through the
|
||||
// schema union; it declares nothing and is not a spelling of "inherit".
|
||||
expect(declare(null as never)).toThrow(/empty reasoningEfforts/)
|
||||
expect(declare({ off: null })).toThrow(/offers no level beyond "off"/)
|
||||
expect(declare({ off: 'none' })).toThrow(/offers no level beyond "off"/)
|
||||
expect(declare({ high: null })).toThrow(/only "off" may leave it empty/)
|
||||
expect(declare({ high: '' })).toThrow(/must not be an empty string/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reasoning-dispatch compat switches', () => {
|
||||
/** The materialized models of one route, keyed by id. */
|
||||
function modelsOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route: string): Map<string, Model<Api>> {
|
||||
const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
|
||||
return new Map(models.map(model => [model.id, model]))
|
||||
}
|
||||
|
||||
it('applies route switches to every openai-completions model, entries winning per field', () => {
|
||||
const models = modelsOf({
|
||||
'acme-gateway': {
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test',
|
||||
compat: { thinkingFormat: 'deepseek' },
|
||||
models: [
|
||||
{ id: 'dialect-default', reasoningEfforts: { off: null, high: 'high' } },
|
||||
{ id: 'dialect-odd', compat: { thinkingFormat: 'openai', supportsReasoningEffort: false } },
|
||||
],
|
||||
},
|
||||
}, 'acme-gateway')
|
||||
|
||||
expect(models.get('dialect-default')?.compat).toEqual({ thinkingFormat: 'deepseek' })
|
||||
expect(models.get('dialect-odd')?.compat).toEqual({ thinkingFormat: 'openai', supportsReasoningEffort: false })
|
||||
})
|
||||
|
||||
it('merges the switches over the catalog entry’s own compat instead of replacing it', () => {
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
const inherited = catalogModel.compat as OpenAICompletionsCompat
|
||||
expect(inherited.requiresReasoningContentOnAssistantMessages).toBe(true)
|
||||
|
||||
const models = modelsOf({
|
||||
deepseek: { models: [{ id: catalogModel.id, compat: { thinkingFormat: 'openai' } }] },
|
||||
}, 'deepseek')
|
||||
|
||||
// The one switched field changes; the catalog's other quirks survive,
|
||||
// because configuration has no way to restate them.
|
||||
expect(models.get(catalogModel.id)?.compat).toEqual({ ...inherited, thinkingFormat: 'openai' })
|
||||
})
|
||||
|
||||
it('skips models of other protocols on a mixed route instead of failing them', () => {
|
||||
// xai ships both completions and responses models, so a route-level switch
|
||||
// must land on the former without invalidating the latter.
|
||||
const catalog = getBuiltinModels('xai') as readonly Model<Api>[]
|
||||
const completions = catalog.find(model => model.api === 'openai-completions')
|
||||
const responses = catalog.find(model => model.api === 'openai-responses')
|
||||
if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog')
|
||||
|
||||
const models = modelsOf({
|
||||
xai: {
|
||||
compat: { supportsReasoningEffort: false },
|
||||
models: [{ id: completions.id }, { id: responses.id }],
|
||||
},
|
||||
}, 'xai')
|
||||
|
||||
expect((models.get(completions.id)?.compat as OpenAICompletionsCompat).supportsReasoningEffort).toBe(false)
|
||||
expect(models.get(responses.id)?.compat).toEqual(responses.compat)
|
||||
})
|
||||
|
||||
it('rejects a model-level switch on a protocol that has no such field', () => {
|
||||
expect(() => resolveProfiles({
|
||||
anthropic: {
|
||||
models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }],
|
||||
},
|
||||
})).toThrow(/exist only on openai-completions/)
|
||||
})
|
||||
|
||||
it('rejects route switches no model on the route can take', () => {
|
||||
expect(() => resolveProfiles({
|
||||
anthropic: { compat: { thinkingFormat: 'openai' } },
|
||||
})).toThrow(/no model on the route speaks openai-completions/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolution snapshots', () => {
|
||||
it('finishes an in-flight request under the configuration it started with', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveProfiles } from '../src/config.ts'
|
||||
import { Config, resolveProfiles } from '../src/config.ts'
|
||||
|
||||
describe('API key format', () => {
|
||||
it('trims a padded literal apiKey into the resolved profile', () => {
|
||||
@@ -22,3 +22,33 @@ describe('API key format', () => {
|
||||
.toThrow(/no HTTP header can carry/)
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it('keeps false distinguishable from an absent declaration', () => {
|
||||
type Materialized = { providers: Record<string, { models?: { reasoningEfforts?: unknown }[] }> }
|
||||
const withFalse = configWith({ reasoningEfforts: false })() as Materialized
|
||||
expect(withFalse.providers['acme-gateway']?.models?.[0]?.reasoningEfforts).toBe(false)
|
||||
const absent = configWith({})() as Materialized
|
||||
expect(absent.providers['acme-gateway']?.models?.[0]?.reasoningEfforts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a thinking format outside the offered set', () => {
|
||||
expect(configWith({ compat: { thinkingFormat: 'quantum' } })).toThrow(/expected/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"include": [
|
||||
"apps/web/tests/scaffold.ts",
|
||||
"apps/web/tests/default-model.e2e.ts",
|
||||
"apps/web/tests/declared-reasoning.e2e.ts",
|
||||
"apps/web/tests/support.ts",
|
||||
"apps/web/tests/scaffold-hermetic.e2e.ts",
|
||||
"apps/web/tests/core-web-profile.snapshot.ts",
|
||||
|
||||
Reference in New Issue
Block a user