mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree/pi-ai-per-model-reasoning
# Conflicts: # docs/config-catalog.md # docs/user/guide/providers.i18n.yaml # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/tests/config.spec.ts
This commit is contained in:
@@ -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: f208f553ab3a1f80c5b71f4792e5fc80459f9fa5
|
||||
README.zh.md: 24ae4b0e2021eeea373eacb8cc1dfc39063fee8b
|
||||
README.md: 196c347dc557d3d3993756e165f45c9212cd2d36
|
||||
README.zh.md: cee6fce7bd13d9da5fdbe5312c7c7e7f4ddf8ab5
|
||||
|
||||
@@ -8,7 +8,7 @@ The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `support
|
||||
|
||||
## Config
|
||||
|
||||
Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. Prefer `apiKeyEnv` — a credential *reference* resolved per request — over a literal `apiKey`, so no secret enters this file. Omitting **both** is what leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route.
|
||||
Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route.
|
||||
|
||||
```yaml
|
||||
- id: llm
|
||||
@@ -101,7 +101,7 @@ Resolution still fails loud, naming the offending route and model, when a route
|
||||
|
||||
The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged.
|
||||
|
||||
Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every key is trimmed and format-checked before use — a literal `apiKey` when profiles resolve (plugin load, or the next settings snapshot), a value `apiKeyEnv` resolves at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving.
|
||||
Credentials resolve per stream call through `apiKeyEnv` and the optional `ctx.credentials` seam; without that seam, the adapter reads exactly the referenced environment variable. A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every resolved key is trimmed and format-checked before use, so a value no HTTP header can carry is refused instead of surfacing as an opaque `fetch` `TypeError`; the refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving.
|
||||
|
||||
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.
|
||||
|
||||
@@ -109,7 +109,7 @@ A model that carries reasoning metadata — from the installed catalog or from i
|
||||
|
||||
A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`.
|
||||
|
||||
Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
|
||||
Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `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`.
|
||||
|
||||
@@ -119,7 +119,7 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ
|
||||
|
||||
A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand.
|
||||
|
||||
A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint.
|
||||
A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation resolves that route's `apiKeyEnv` rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint.
|
||||
|
||||
Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments.
|
||||
|
||||
@@ -189,7 +189,7 @@ Recorded response content appends to the next request and does not invalidate it
|
||||
- **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work).
|
||||
- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says, so a model list is only as current as its last edit. Nothing here queries a provider for the models it serves; a route gains a model when someone writes one.
|
||||
- **One wire protocol per route** — `api` applies to the whole route, so a mixed-protocol catalog route (an OpenAI-style catalog spanning Responses and Chat Completions) cannot host a model of the other protocol, and adding a model such a route does not describe requires naming `api` and moving every model onto it. Splitting the provider across two route keys is the workaround.
|
||||
- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder `apiKey` or an `Authorization` entry in `headers`.
|
||||
- **An unauthenticated route depends on its protocol** — naming no credential resolves the route as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`.
|
||||
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
|
||||
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
|
||||
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 配置
|
||||
|
||||
按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。优先使用 `apiKeyEnv`——按请求解析的凭据*引用*——而非字面 `apiKey`,让机密不进入该文件。**两者**都省略,才会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。
|
||||
按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。
|
||||
|
||||
```yaml
|
||||
- id: llm
|
||||
@@ -101,7 +101,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。
|
||||
|
||||
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在 profile 解析时(插件加载,或下一次 settings 快照)校验,`apiKeyEnv` 解析出的值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时的拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。
|
||||
凭据在每次 stream 调用时通过 `apiKeyEnv` 与可选的 `ctx.credentials` seam 解析;未挂载该 seam 时,适配器只读取该引用指向的环境变量。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个解析出的密钥都会在使用前去除首尾空白并校验格式,因此 HTTP 标头无法承载的值会被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;这种拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。
|
||||
|
||||
适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。
|
||||
|
||||
@@ -109,7 +109,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
**没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。
|
||||
|
||||
受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
|
||||
受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`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`。
|
||||
|
||||
@@ -119,7 +119,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。
|
||||
|
||||
草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。
|
||||
草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会解析该路由的 `apiKeyEnv`,而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。
|
||||
|
||||
询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。
|
||||
|
||||
@@ -189,7 +189,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
|
||||
- **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。
|
||||
- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。
|
||||
- **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。
|
||||
- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。
|
||||
- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个由 `apiKeyEnv` 引用的占位凭据,或在 `headers` 中给出 `Authorization` 条目。
|
||||
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
|
||||
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
|
||||
- **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-credentials": "^0.0.1",
|
||||
"@deepseek-ai/dsh-environment": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
@@ -38,6 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-environment": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
|
||||
@@ -19,7 +19,7 @@ import z from 'schemastery'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
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 { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts'
|
||||
import type { PiAiCompatProfile, PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts'
|
||||
@@ -44,12 +44,6 @@ export type {
|
||||
|
||||
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
|
||||
export interface PiAiProviderProfile {
|
||||
/**
|
||||
* Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its
|
||||
* provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a
|
||||
* value no HTTP header can carry fails there rather than inside `fetch`.
|
||||
*/
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
|
||||
apiKeyEnv?: string
|
||||
/** Name shown by configuration surfaces; defaults to the route key. */
|
||||
@@ -201,7 +195,6 @@ const modelOverride: z<PiAiModelOverride> = z.object({
|
||||
})
|
||||
|
||||
const profile = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().role('credential-ref'),
|
||||
displayName: z.string(),
|
||||
api: z.union(supportedProtocols()),
|
||||
@@ -280,19 +273,6 @@ export function resolveProfiles(
|
||||
for (const [provider, source] of entries) {
|
||||
rejectRemovedFields(provider, source)
|
||||
if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
|
||||
// Omission selects the installed provider's own auth — ambient discovery
|
||||
// or OAuth — so only a supplied key is judged.
|
||||
let apiKey: string | undefined
|
||||
if (source.apiKey !== undefined) {
|
||||
const checked = normalizeApiKey(source.apiKey)
|
||||
if (!checked.ok) {
|
||||
throw new Error(checked.reason === 'empty'
|
||||
? `llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`
|
||||
: `llm-pi-ai: provider "${provider}" has an apiKey containing characters no HTTP header can carry;`
|
||||
+ ' paste the raw key only')
|
||||
}
|
||||
apiKey = checked.value
|
||||
}
|
||||
if (source.baseURL !== undefined && source.baseURL.length === 0) {
|
||||
throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
|
||||
}
|
||||
@@ -324,7 +304,6 @@ export function resolveProfiles(
|
||||
const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source
|
||||
resolved.set(provider, {
|
||||
...rest,
|
||||
...apiKey === undefined ? {} : { apiKey },
|
||||
provider,
|
||||
displayName,
|
||||
...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
|
||||
@@ -339,7 +318,7 @@ export function resolveProfiles(
|
||||
...source.api === undefined ? {} : { api: source.api },
|
||||
...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
|
||||
models: catalog.models,
|
||||
namesCredential: source.apiKey !== undefined || apiKeyEnv !== undefined,
|
||||
namesCredential: apiKeyEnv !== undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { environmentOf } from '@deepseek-ai/dsh-environment'
|
||||
import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
|
||||
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
@@ -162,7 +163,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
provider: string,
|
||||
profile: ResolvedPiAiProviderProfile,
|
||||
): Promise<string | undefined> => {
|
||||
if (profile.apiKey !== undefined) return profile.apiKey
|
||||
const ref = profile.apiKeyEnv
|
||||
// Only a profile that names no credential at all defers to pi-ai's
|
||||
// provider-native discovery. Once one is named, a miss must fail loud:
|
||||
@@ -173,9 +173,8 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const credentials = ctx.get('credentials')
|
||||
const hit = credentials !== undefined
|
||||
? (await credentials.resolve(ref))?.value
|
||||
// Without the seam, read exactly the named variable so a plain
|
||||
// cordis.yml composition works from the environment alone.
|
||||
: process.env[ref]
|
||||
// Without the seam the environment is the whole credential plane.
|
||||
: environmentOf(ctx).get(ref)?.value
|
||||
if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref)
|
||||
throw new LlmError(
|
||||
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`
|
||||
|
||||
@@ -97,10 +97,11 @@ export interface ProviderSpec {
|
||||
/** The route's materialized models, in configuration order. */
|
||||
models: readonly Model<Api>[]
|
||||
/**
|
||||
* Whether the profile names a credential — a literal key or a reference.
|
||||
* Only that decides whether {@link routeAuth} adds the harness's own api-key
|
||||
* method to a catalog provider that offers none; the key itself still arrives
|
||||
* per request, never at construction.
|
||||
* Whether the profile names a credential, which it does through `apiKeyEnv`
|
||||
* alone: configuration carries the reference, never the secret. Only that
|
||||
* decides whether {@link routeAuth} adds the harness's own api-key method to
|
||||
* a catalog provider that offers none; the key itself still arrives per
|
||||
* request, never at construction.
|
||||
*/
|
||||
namesCredential: boolean
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
@@ -15,22 +15,32 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
async function harness(baseURL: string, overrides: Record<string, unknown> = {}): Promise<Context> {
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: { deepseek: { apiKey: 'test-key', baseURL, ...overrides } },
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL, ...overrides } },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Direct adapter over the real profile resolver, with literal-key resolution. */
|
||||
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
|
||||
/** Direct adapter over the real profile resolver, with a fixed key per call. */
|
||||
function adapterOf(
|
||||
providers: Record<string, LlmPiAi.PiAiProviderProfile>,
|
||||
apiKey: string | undefined = 'test-key',
|
||||
): PiAiAdapter {
|
||||
return new PiAiAdapter({
|
||||
profiles: () => resolveProfiles(providers),
|
||||
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
|
||||
resolveApiKey: () => Promise.resolve(apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Configuration carries only the reference; these mounts resolve it from
|
||||
// the environment, which is the whole credential plane without a seam.
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
})
|
||||
|
||||
describe('PiAiAdapter provider routing', () => {
|
||||
it('resolves a catalog model dynamically and uses a private endpoint', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
@@ -121,7 +131,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['deepseek'], adapterOf({
|
||||
deepseek: { apiKey: 'test-key', baseURL: server.url },
|
||||
deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: server.url },
|
||||
}))
|
||||
|
||||
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
@@ -131,7 +141,6 @@ describe('PiAiAdapter provider routing', () => {
|
||||
|
||||
it('names a route by its displayName, and by its own key once the profiles drop it', () => {
|
||||
const adapter = adapterOf({ 'acme-gateway': {
|
||||
apiKey: 'k',
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
@@ -167,7 +176,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
|
||||
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
|
||||
})
|
||||
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
||||
expect(result.finish.kind).toBe('error')
|
||||
@@ -187,7 +196,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: { openai: { apiKey: 'test-key', baseURL: `${server.url}/v1` } },
|
||||
providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } },
|
||||
})
|
||||
|
||||
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
||||
@@ -203,7 +212,7 @@ describe('PiAiAdapter provider routing', () => {
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: 'test-key',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
baseURL: `${server.url}/api/projects/openai/openai/v1`,
|
||||
headers: { 'api-key': 'test-key', Authorization: '' },
|
||||
},
|
||||
@@ -406,7 +415,7 @@ describe('provider profile lifecycle', () => {
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
reasoning: 'high',
|
||||
@@ -435,13 +444,14 @@ describe('provider profile lifecycle', () => {
|
||||
})
|
||||
|
||||
it('sends the declared wire spelling and refuses undeclared levels before network I/O', async () => {
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
models: [{
|
||||
@@ -477,13 +487,14 @@ describe('provider profile lifecycle', () => {
|
||||
})
|
||||
|
||||
it('dispatches the compat-switched dialect on a declared route', async () => {
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
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',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
// Without the switch pi-ai guesses the dialect from the endpoint
|
||||
@@ -514,13 +525,14 @@ describe('provider profile lifecycle', () => {
|
||||
})
|
||||
|
||||
it('holds back reasoning_effort when the endpoint cannot take it', async () => {
|
||||
vi.stubEnv('PI_TEST_KEY', 'test-key')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
apiKeyEnv: 'PI_TEST_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
compat: { supportsReasoningEffort: false },
|
||||
@@ -546,7 +558,9 @@ describe('provider profile lifecycle', () => {
|
||||
it('accepts absent credentials for pi-ai ambient authentication', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key')
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url, { apiKey: undefined })
|
||||
// A profile that names no reference at all is the one case that defers to
|
||||
// pi-ai's own provider-native discovery.
|
||||
const ctx = await harness(server.url, { apiKeyEnv: undefined })
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer ambient-key')
|
||||
})
|
||||
@@ -588,8 +602,6 @@ describe('provider profile lifecycle', () => {
|
||||
// loud with migration directions instead of half-working.
|
||||
expect(() => resolveProfiles([{ provider: 'openai' }] as never)).toThrow(/dict keyed by provider/)
|
||||
expect(() => resolveProfiles({ openai: { provider: 'openai' } as never })).toThrow(/moved to the providers dict key/)
|
||||
expect(() => resolveProfiles({ openai: { apiKey: '' } })).toThrow(/empty apiKey/)
|
||||
expect(() => resolveProfiles({ openai: { apiKey: ' ' } })).toThrow(/empty apiKey/)
|
||||
expect(() => resolveProfiles({ openai: { baseURL: '' } })).toThrow(/empty baseURL/)
|
||||
expect(() => resolveProfiles({ openai: { apiKeyEnv: 'not-a-var!' } })).toThrow(/must match/)
|
||||
})
|
||||
@@ -664,7 +676,7 @@ describe('abort wiring', () => {
|
||||
const message = Object.defineProperty({}, 'role', {
|
||||
get() { throw original },
|
||||
})
|
||||
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
|
||||
const adapter = adapterOf({ deepseek: {} })
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({
|
||||
provider: 'deepseek',
|
||||
@@ -685,7 +697,7 @@ describe('abort wiring', () => {
|
||||
throw original
|
||||
},
|
||||
})
|
||||
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
|
||||
const adapter = adapterOf({ deepseek: {} })
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({
|
||||
provider: 'deepseek',
|
||||
@@ -699,7 +711,7 @@ describe('abort wiring', () => {
|
||||
})
|
||||
|
||||
it('resolves catalog endpoints without an override before honoring pre-abort', async () => {
|
||||
const adapter = adapterOf({ deepseek: { apiKey: 'test-key' } })
|
||||
const adapter = adapterOf({ deepseek: {} })
|
||||
const controller = new AbortController()
|
||||
controller.abort('already stopped')
|
||||
const chunks = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
@@ -19,7 +19,17 @@ import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
|
||||
const homes: string[] = []
|
||||
|
||||
// Routes name their credential by reference; the value lives in the
|
||||
// environment, which is the layer the adapter falls back to without a
|
||||
// mounted credentials seam.
|
||||
const KEY_ENV = 'PI_TEST_KEY'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv(KEY_ENV, 'test-key')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs()
|
||||
await closeMockServers()
|
||||
await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
@@ -46,7 +56,7 @@ function gateway(baseURL: string, overrides: Record<string, unknown> = {}): LlmP
|
||||
return {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'gw-key',
|
||||
apiKeyEnv: KEY_ENV,
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL,
|
||||
@@ -81,7 +91,8 @@ describe('hand-declared providers', () => {
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
expect(server.paths).toEqual(['/v1/chat/completions'])
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer gw-key')
|
||||
// The reference resolved through the environment and reached the wire.
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer test-key')
|
||||
})
|
||||
|
||||
it('lists and resolves the declared models rather than a catalog', async () => {
|
||||
@@ -114,7 +125,7 @@ describe('hand-declared providers', () => {
|
||||
|
||||
// A catalog route is unaffected: its models carry the metadata that makes
|
||||
// `off` actually disable thinking.
|
||||
const withCatalog = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
|
||||
const withCatalog = await harness({ providers: { deepseek: { baseURL: server.url } } })
|
||||
const [catalogModel] = getBuiltinModels('deepseek')
|
||||
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
||||
expect((await withCatalog.llm.resolveModelInfo('deepseek', catalogModel.id)).reasoning?.efforts.map(e => e.id))
|
||||
@@ -288,7 +299,7 @@ describe('hand-declared providers', () => {
|
||||
describe('catalog routes with per-model configuration', () => {
|
||||
it('serves the installed catalog untouched when the profile lists no models', async () => {
|
||||
const server = await mockServer([])
|
||||
const ctx = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
|
||||
const ctx = await harness({ providers: { deepseek: { baseURL: server.url } } })
|
||||
|
||||
const listed = await ctx.llm.listModels('deepseek')
|
||||
expect(listed.map(model => model.id).sort())
|
||||
@@ -302,7 +313,6 @@ describe('catalog routes with per-model configuration', () => {
|
||||
const ctx = await harness({
|
||||
providers: {
|
||||
deepseek: {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
models: [{ id: catalogModel.id, contextWindow: 4096 }],
|
||||
},
|
||||
@@ -327,7 +337,6 @@ describe('catalog routes with per-model configuration', () => {
|
||||
const ctx = await harness({
|
||||
providers: {
|
||||
deepseek: {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
models: [{ id: catalogModel.id, maxTokens: 4096 }],
|
||||
},
|
||||
@@ -344,7 +353,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
const ctx = await harness({
|
||||
providers: {
|
||||
deepseek: {
|
||||
apiKey: 'k',
|
||||
apiKeyEnv: KEY_ENV,
|
||||
baseURL: `${server.url}/v1`,
|
||||
models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }],
|
||||
},
|
||||
@@ -362,7 +371,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
const server = await mockServer([])
|
||||
const ctx = await harness({
|
||||
providers: {
|
||||
deepseek: { apiKey: 'k', baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
|
||||
deepseek: { baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -390,7 +399,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
|
||||
it('delegates both stream methods back to the reused catalog provider', async () => {
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const resolved = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
|
||||
const resolved = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
|
||||
const built = resolved.get('deepseek')?.piProvider
|
||||
if (built === undefined) throw new Error('the deepseek route built no provider')
|
||||
const [model] = built.getModels()
|
||||
@@ -431,7 +440,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
// openai's catalog models speak the Responses API; naming the protocol
|
||||
// explicitly moves the whole route onto Chat Completions.
|
||||
openai: {
|
||||
apiKey: 'k',
|
||||
apiKeyEnv: KEY_ENV,
|
||||
api: 'openai-completions',
|
||||
baseURL: `${server.url}/v1`,
|
||||
models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }],
|
||||
@@ -456,7 +465,7 @@ describe('catalog routes with per-model configuration', () => {
|
||||
// declares an api-key method. `openai-codex` ships OAuth alone, so without
|
||||
// the harness method beside it the route refuses its own configured key as
|
||||
// `Provider is not configured` before any request goes out.
|
||||
const resolved = resolveProfiles({ 'openai-codex': { apiKey: 'codex-token' } })
|
||||
const resolved = resolveProfiles({ 'openai-codex': { apiKeyEnv: 'CODEX_TOKEN' } })
|
||||
const provider = resolved.get('openai-codex')?.piProvider
|
||||
expect(provider?.auth.oauth).toBeDefined()
|
||||
const models = createModels()
|
||||
@@ -720,7 +729,7 @@ describe('reasoning-dispatch compat switches', () => {
|
||||
describe('resolution snapshots', () => {
|
||||
it('finishes an in-flight request under the configuration it started with', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
|
||||
let current = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
|
||||
let release: () => void = () => {}
|
||||
const held = new Promise<void>((resolve) => { release = resolve })
|
||||
const adapter = new PiAiAdapter({
|
||||
@@ -741,7 +750,7 @@ describe('resolution snapshots', () => {
|
||||
|
||||
// The route set changes while the request waits, and something else reads
|
||||
// the adapter meanwhile, which is what would rebuild a shared collection.
|
||||
current = resolveProfiles({ openai: { apiKey: 'k', baseURL: `${server.url}/v1` } })
|
||||
current = resolveProfiles({ openai: { baseURL: `${server.url}/v1` } })
|
||||
await expect(adapter.listModels('openai')).resolves.not.toHaveLength(0)
|
||||
release()
|
||||
await inFlight
|
||||
@@ -755,7 +764,7 @@ describe('resolution snapshots', () => {
|
||||
it('serves the next request from the new configuration', async () => {
|
||||
const first = await mockServer([{ events: textEvents }])
|
||||
const second = await mockServer([{ events: textEvents }])
|
||||
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${first.url}/v1` } })
|
||||
let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } })
|
||||
const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') })
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({
|
||||
@@ -764,7 +773,7 @@ describe('resolution snapshots', () => {
|
||||
}
|
||||
|
||||
await drain()
|
||||
current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${second.url}/v1` } })
|
||||
current = resolveProfiles({ deepseek: { baseURL: `${second.url}/v1` } })
|
||||
await drain()
|
||||
|
||||
expect(first.paths).toHaveLength(1)
|
||||
@@ -786,7 +795,6 @@ describe('configurable-provider directory', () => {
|
||||
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
'deepseek-official': {
|
||||
apiKey: 'k',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
|
||||
@@ -809,7 +817,6 @@ describe('configurable-provider directory', () => {
|
||||
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKey: 'k',
|
||||
displayName: 'Acme Gateway',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://acme.test/v1',
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Config, resolveProfiles } from '../src/config.ts'
|
||||
|
||||
describe('API key format', () => {
|
||||
it('trims a padded literal apiKey into the resolved profile', () => {
|
||||
const resolved = resolveProfiles({ openai: { apiKey: ' sk-abc ', baseURL: 'https://acme.test' } })
|
||||
expect(resolved.get('openai')?.apiKey).toBe('sk-abc')
|
||||
})
|
||||
|
||||
it('keeps an omitted apiKey absent so ambient authentication still applies', () => {
|
||||
const resolved = resolveProfiles({ openai: { baseURL: 'https://acme.test' } })
|
||||
expect(resolved.get('openai')?.apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still tells an empty apiKey to omit itself', () => {
|
||||
expect(() => resolveProfiles({ openai: { apiKey: ' ', baseURL: 'https://acme.test' } }))
|
||||
.toThrow(/omit it to use ambient authentication/)
|
||||
})
|
||||
|
||||
it('rejects an apiKey no header can carry', () => {
|
||||
expect(() => resolveProfiles({ openai: { apiKey: 'sk-\u{1F600}', baseURL: 'https://acme.test' } }))
|
||||
.toThrow(/no HTTP header can carry/)
|
||||
})
|
||||
})
|
||||
import { Config } from '../src/config.ts'
|
||||
|
||||
describe('reasoning schema boundary', () => {
|
||||
const configWith = (model: Record<string, unknown>): (() => unknown) =>
|
||||
|
||||
@@ -44,7 +44,7 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
|
||||
})
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
|
||||
await ctx.plugin(LlmPiAi, config)
|
||||
return ctx
|
||||
}
|
||||
@@ -53,7 +53,11 @@ describe('request-level dynamic profiles', () => {
|
||||
it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
|
||||
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n')
|
||||
await writeFile(
|
||||
join(dir, '.credentials.yaml'),
|
||||
'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
// The exact product posture: `- id: llm-pi-ai` with no config at all.
|
||||
const ctx = await boot(dir, {})
|
||||
@@ -87,14 +91,19 @@ describe('request-level dynamic profiles', () => {
|
||||
|
||||
it('adds a provider route from settings and drops it when the user layer resets', async () => {
|
||||
const dir = await home()
|
||||
await writeFile(
|
||||
join(dir, '.credentials.yaml'),
|
||||
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await boot(dir, {
|
||||
providers: { openai: { apiKey: 'k', baseURL: 'http://127.0.0.1:1/v1' } },
|
||||
providers: { openai: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: 'http://127.0.0.1:1/v1' } },
|
||||
})
|
||||
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
||||
await ctx.settings.update(NS, {
|
||||
providers: { deepseek: { apiKey: 'live-key', baseURL: server.url } },
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: server.url } },
|
||||
})
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai', 'deepseek'])
|
||||
|
||||
@@ -113,7 +122,7 @@ describe('request-level dynamic profiles', () => {
|
||||
it('rotates the per-request credential referenced by apiKeyEnv', async () => {
|
||||
vi.stubEnv('PI_DYNAMIC_KEY', '')
|
||||
const dir = await home()
|
||||
await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-one\n')
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 })
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const ctx = await boot(dir, {
|
||||
providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
|
||||
@@ -162,15 +171,20 @@ describe('request-level dynamic profiles', () => {
|
||||
|
||||
it('keeps serving its routes when a settings-born route collides with another adapter', async () => {
|
||||
const dir = await home()
|
||||
await writeFile(
|
||||
join(dir, '.credentials.yaml'),
|
||||
'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n',
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
||||
const ctx = await boot(dir, { providers: { openai: { apiKey: 'pk', baseURL: `${server.url}/v1` } } })
|
||||
const ctx = await boot(dir, { providers: { openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` } } })
|
||||
// Another adapter owns `anthropic`; the registry must refuse to hand it over.
|
||||
ctx.llm.registerAdapter(['anthropic'], new StubAdapter())
|
||||
|
||||
await ctx.settings.update(NS, {
|
||||
providers: {
|
||||
openai: { apiKey: 'pk', baseURL: `${server.url}/v1` },
|
||||
anthropic: { apiKey: 'other' },
|
||||
openai: { apiKeyEnv: 'PI_LIVE_KEY', baseURL: `${server.url}/v1` },
|
||||
anthropic: { apiKeyEnv: 'PI_OTHER_KEY' },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* settings-local, credentials-local, and a bare `llm-pi-ai` row boot from a
|
||||
* test-only cordis.yml through the actual Loader + Include path, an external
|
||||
* edit of settings.yaml registers the route live, and the next request
|
||||
* carries the credential the .env supplies. A hand-mounted `ctx.plugin` cannot
|
||||
* carries the credential the credentials document supplies. A hand-mounted `ctx.plugin` cannot
|
||||
* catch Loader export-shape failures, which is why the twin adapter has the
|
||||
* same guard.
|
||||
*/
|
||||
@@ -40,7 +40,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-pi-composition-'))
|
||||
const settingsPath = join(root, 'settings.yaml')
|
||||
await writeFile(settingsPath, '# personal settings\n')
|
||||
await writeFile(join(root, '.env'), 'PI_COMPOSITION_KEY=key-from-store\n')
|
||||
await writeFile(join(root, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 })
|
||||
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
@@ -54,7 +54,7 @@ async function loadComposition(): Promise<{ ctx: Context; settingsPath: string }
|
||||
'- id: credentials',
|
||||
" name: '@deepseek-ai/dsh-credentials-local'",
|
||||
' config:',
|
||||
` path: ${JSON.stringify(join(root, '.env'))}`,
|
||||
` path: ${JSON.stringify(join(root, '.credentials.yaml'))}`,
|
||||
' debounceMs: 10',
|
||||
'- id: llm-pi-ai',
|
||||
" name: '@deepseek-ai/dsh-llm-pi-ai'",
|
||||
|
||||
@@ -21,7 +21,6 @@ function gatewayAdapter(): PiAiAdapter {
|
||||
return new PiAiAdapter({
|
||||
profiles: () => resolveProfiles({
|
||||
'local-gateway': {
|
||||
apiKey: 'test-key',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'http://127.0.0.1:9/v1',
|
||||
models: [{ id: 'local-model', contextWindow: 8192, maxTokens: 1024 }],
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../util/environment"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user