Răsfoiți Sursa

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.
Yichen Jiang 1 lună în urmă
părinte
comite
0490f8bb06

+ 95 - 0
apps/web/tests/declared-reasoning.e2e.ts

@@ -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 - 0
apps/web/tests/declared-reasoning.overlay.yml

@@ -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

+ 7 - 0
apps/web/tests/snapshots/declared-reasoning/ui.expected.md

@@ -0,0 +1,7 @@
+- menu "模型与推理等级":
+  - menuitemradio "Default" [checked]:
+    - text: Default
+    - img
+  - menuitemradio "Off"
+  - menuitemradio "High"
+  - menuitemradio "Max"

+ 1 - 0
apps/web/tsconfig.json

@@ -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 - 2
packages/llm/llm-pi-ai/README.i18n.yaml

@@ -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

+ 27 - 4
packages/llm/llm-pi-ai/README.md

@@ -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`.
 

+ 27 - 4
packages/llm/llm-pi-ai/README.zh.md

@@ -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`。
 

+ 234 - 5
packages/llm/llm-pi-ai/src/catalog.ts

@@ -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 }
 }

+ 36 - 4
packages/llm/llm-pi-ai/src/config.ts

@@ -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,
     })

+ 21 - 1
packages/llm/llm-pi-ai/src/index.ts

@@ -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'

+ 143 - 0
packages/llm/llm-pi-ai/tests/adapter.spec.ts

@@ -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 }])

+ 173 - 2
packages/llm/llm-pi-ai/tests/catalog.spec.ts

@@ -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 }])

+ 31 - 1
packages/llm/llm-pi-ai/tests/config.spec.ts

@@ -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/)
+  })
+})

+ 1 - 0
tsconfig.host.json

@@ -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",