fix(llm): let a catalog route keep the auth its provider actually declares
pi-ai resolves a request's apiKey override only through a provider that
declares an api-key method: resolveProviderAuth short-circuits to that
method when the override is present, and otherwise falls through to the
credential store and then to ambient discovery. A provider with no
api-key method at all therefore resolves to nothing, and the request
fails with "Provider is not configured" before any network I/O.
Two routes hit that. openai-codex ships OAuth alone, so moving off the
/compat dispatch broke a profile that names a key for it — the old path
handed the token straight to the provider. And a catalog route naming an
api was being rebuilt with the harness's own auth, so `openai: {api:
openai-completions}` stopped reading OPENAI_API_KEY, contradicting the
documented promise that omitting a credential keeps provider-native
discovery.
Auth is now one decision for both constructions. A catalog route keeps
its installed provider's auth, through an api override too: which
environment a provider reads belongs to the provider, not to the wire
format its models speak. A catalog provider with no api-key method gets
the harness method beside its own, but only when the profile names a
credential — a keyless codex profile keeps the honest refusal, since
this adapter holds no OAuth store to resolve through.
Materialization now spreads the installed entry instead of enumerating
the result, so a Model field this package does not model survives a
pi-ai upgrade; headers went missing from an nvidia route exactly that
way once already. providerInfo reports the configured displayName, which
also joins the registration facts so a rename re-registers rather than
leaving the old label in every selector. A refused registration swap
gets its own diagnostic naming the route, matching the directory swap
beside it.
The README documented endpoint interrogation this layer does not
implement, and still described unknown providers as kept-last-good after
they became legal declarations refused at the write point. The Agent
Note claimed per-model reasoning configurability the schema never had,
required capacities the route now defaults, and stated an apiKey
override that short-circuits unconditionally.
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: 884eadbde73f17ffd50994e09c975cca561d81ed
|
||||
README.zh.md: af8a620eeccb2b971c4550bdcd7c8af93d5d4f90
|
||||
README.md: b8e5ed7b056295d975629ffdf444d353b306fcf7
|
||||
README.zh.md: bf8ba431d13f7bf90c75ca2000e0320a8506253b
|
||||
|
||||
@@ -67,7 +67,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. 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 live settings snapshot naming an unknown provider (or failing any other resolver bound) keeps the last good profiles and logs the failure; the entry config itself still fails plugin load.
|
||||
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. 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.
|
||||
|
||||
@@ -79,16 +79,6 @@ Supported profile fields are `apiKey`, `apiKeyEnv`, `displayName`, `api`, `baseU
|
||||
|
||||
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`.
|
||||
|
||||
## Endpoint interrogation
|
||||
|
||||
The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. It is deliberately *not* a catalog refresh: nothing is stored, and the reply is candidates the surface offers for adoption. `settings.yaml` remains the only thing that decides what a route serves.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Most listings disclose an id and nothing else; `context_window`/`context_length` and `max_output_tokens`/`max_tokens` are read when a gateway supplies them, entries without a usable id are skipped rather than failing the whole listing, and everything else the adopting surface still owes. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared length is checked first but never trusted as the bound. An unreachable endpoint, a refused credential, a non-JSON body, and a body with no `data` array all fail with `DISCOVERY_FAILED` and a message naming the endpoint and, for a 401 or 403 alone, the credential. Cancellation during the body read surfaces as `ABORTED`, like a cancellation before the request went out.
|
||||
|
||||
## Provider/model routing and replay
|
||||
|
||||
Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use.
|
||||
@@ -151,7 +141,7 @@ Recorded response content appends to the next request and does not invalidate it
|
||||
|
||||
- **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer.
|
||||
- **`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. Endpoint interrogation is an explicit action a configuration surface takes over a draft; nothing re-runs it, and adopting its result is a settings write like any other.
|
||||
- **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`.
|
||||
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
|
||||
|
||||
@@ -67,7 +67,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 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。存活 settings 快照若点名未知提供方(或违反任何其他 resolver 约束),则保留最后可用 profile 并记录失败;entry 配置本身仍会使插件加载失败。
|
||||
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `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 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。
|
||||
|
||||
@@ -79,16 +79,6 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。
|
||||
|
||||
## 端点询问
|
||||
|
||||
插件提供 `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`,用来回答「这个提供方能服务哪些模型?」——针对配置界面正在编辑或起草的路由。它刻意**不是** catalog 刷新:什么都不存储,回复是界面供用户采纳的候选。`settings.yaml` 始终是唯一决定路由服务什么的东西。
|
||||
|
||||
点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。
|
||||
|
||||
询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。
|
||||
|
||||
多数列表只公布 id;`context_window`/`context_length` 与 `max_output_tokens`/`max_tokens` 在网关提供时会被读取,没有可用 id 的条目会被跳过而不是让整份列表失败,其余仍由采纳方补齐。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明长度,但绝不把它当作边界。端点不可达、凭据被拒、响应非 JSON、以及响应没有 `data` 数组,都会以 `DISCOVERY_FAILED` 失败,消息点名端点;仅当 401 或 403 时才点名凭据。读取响应体期间被取消会呈现为 `ABORTED`,与请求发出之前被取消一致。
|
||||
|
||||
## 提供方/模型路由与回放
|
||||
|
||||
每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。
|
||||
@@ -151,7 +141,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
|
||||
|
||||
- **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。
|
||||
- **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。
|
||||
- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。端点询问是配置界面针对草稿主动发起的动作;没有任何环节会重跑它,采纳其结果与任何其他 settings 写入无异。
|
||||
- **路由的 catalog 不会自我刷新**:catalog 就是 `settings.yaml` 所写的内容,因此模型列表的新鲜度只到最近一次编辑为止。这里没有任何环节会去问提供方它服务哪些模型;路由要多一个模型,得有人写进去。
|
||||
- **每条路由只有一种协议格式**:`api` 作用于整条路由,因此混合协议的 catalog 路由(跨 Responses 与 Chat Completions 的 OpenAI 式 catalog)无法承载另一种协议的模型,向这类路由添加它未描述的模型必须点名 `api` 并把全部模型一起迁过去。把该提供方拆成两个路由键是变通办法。
|
||||
- **未认证路由取决于其协议**:不点名凭据会让路由解析为「已配置但无密钥」,但 pi-ai 的 OpenAI 兼容实现仍要求 API key 或 `Authorization` 标头,因此无鉴权的本地服务需要一个占位 `apiKey`,或在 `headers` 中给出 `Authorization` 条目。
|
||||
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmModelInfo,
|
||||
LlmProviderInfo,
|
||||
LlmResolvedModelInfo,
|
||||
ReasoningEffortId as ReasoningEffortIdType,
|
||||
ResolvedRetryPolicy,
|
||||
@@ -196,6 +197,13 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
return resolved
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
// The configured name, not the route key: `displayName` exists so a
|
||||
// deployment can label a route, and a label only the configuration surface
|
||||
// reads would leave every selector showing the raw key.
|
||||
return { id: provider, name: this.current().profiles.get(provider)?.displayName ?? provider }
|
||||
}
|
||||
|
||||
override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
|
||||
return this.current().profiles.get(provider)?.retryPolicy
|
||||
}
|
||||
|
||||
@@ -196,6 +196,14 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
|
||||
// the model's capability and stays out of request defaults.
|
||||
if (entry.maxTokens !== undefined) configuredMaxTokens.set(entry.id, entry.maxTokens)
|
||||
return {
|
||||
// The installed entry lays the floor, and the fields below override it.
|
||||
// Enumerating instead would silently drop every `Model` field this
|
||||
// package does not model — reasoning-level spellings, compatibility
|
||||
// quirks, model headers, and whatever a pi-ai upgrade adds next. That is
|
||||
// not hypothetical: `headers` reached this file only after an nvidia
|
||||
// route lost it, and a rebuild keeps re-earning that bug on every
|
||||
// upgrade.
|
||||
...base,
|
||||
id: entry.id,
|
||||
name: entry.name ?? base?.name ?? entry.id,
|
||||
api,
|
||||
@@ -209,12 +217,6 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
|
||||
cost: base?.cost ?? NO_COST,
|
||||
contextWindow,
|
||||
maxTokens,
|
||||
// Catalog-only metadata: reasoning-level spellings and OpenAI-compatibility
|
||||
// quirks have no configuration surface, so they ride the catalog entry or
|
||||
// are absent for a model pi-ai has never described.
|
||||
...base?.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: base.thinkingLevelMap },
|
||||
...base?.compat === undefined ? {} : { compat: base.compat },
|
||||
...base?.headers === undefined ? {} : { headers: base.headers },
|
||||
}
|
||||
})
|
||||
return { models, configuredMaxTokens }
|
||||
|
||||
@@ -266,6 +266,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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,7 +69,14 @@ const NS = settingsNamespace('llm-pi-ai')
|
||||
*/
|
||||
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
|
||||
return [...profiles.entries()]
|
||||
.map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
|
||||
// `displayName` rides along because the registry hands it to every selector
|
||||
// through `providerInfo()`: a rename that did not re-register would leave
|
||||
// the old label showing until some unrelated fact happened to change.
|
||||
.map(([provider, profile]) => ({
|
||||
provider,
|
||||
displayName: profile.displayName,
|
||||
retryPolicy: profile.retryPolicy,
|
||||
}))
|
||||
.sort((left, right) => left.provider.localeCompare(right.provider))
|
||||
}
|
||||
|
||||
@@ -97,7 +104,7 @@ function directoryEntries(
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
let current: () => Config = () => config
|
||||
let lastRaw: Config | undefined
|
||||
let lastGood: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined
|
||||
let memoized: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined
|
||||
/**
|
||||
* The resolved profiles for the current configuration, memoized by the raw
|
||||
* snapshot's identity — which is also what makes the adapter's own snapshot
|
||||
@@ -111,10 +118,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
*/
|
||||
const profiles = (): ReadonlyMap<string, ResolvedPiAiProviderProfile> => {
|
||||
const raw = current()
|
||||
if (raw === lastRaw && lastGood !== undefined) return lastGood
|
||||
if (raw === lastRaw && memoized !== undefined) return memoized
|
||||
const next = resolveProfiles(raw.providers)
|
||||
lastRaw = raw
|
||||
lastGood = next
|
||||
memoized = next
|
||||
return next
|
||||
}
|
||||
profiles()
|
||||
@@ -209,7 +216,18 @@ export function apply(ctx: Context, config: Config): void {
|
||||
current = source
|
||||
},
|
||||
onChange: () => {
|
||||
ensureRegistrationFacts()
|
||||
// Named here rather than left to the settings watcher: `assertServiceable`
|
||||
// cannot see the llm registry, so a profile claiming a route another
|
||||
// adapter family owns is stored successfully and only fails at this swap.
|
||||
// Without its own diagnostic that refusal reaches the operator as a
|
||||
// generic "settings: watcher failed", naming neither the route nor why it
|
||||
// is not serving. The previous routes keep serving either way.
|
||||
try {
|
||||
ensureRegistrationFacts()
|
||||
} catch (error) {
|
||||
ctx.logger.error('llm-pi-ai: keeping the previously registered routes after a refused update')
|
||||
ctx.logger.error(error)
|
||||
}
|
||||
// The directory follows the profiles the registry accepted, so a route
|
||||
// that failed to register is not advertised as configurable. A refused
|
||||
// directory swap is contained here for the same reason the registry's
|
||||
|
||||
@@ -96,6 +96,41 @@ export interface ProviderSpec {
|
||||
baseURL?: string
|
||||
/** 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.
|
||||
*/
|
||||
namesCredential: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth one route resolves its credential through.
|
||||
*
|
||||
* A catalog route keeps the installed provider's own auth, which is what
|
||||
* preserves provider-native ambient discovery for a profile naming no
|
||||
* credential. That holds even when the profile repoints the protocol: which
|
||||
* environment a provider reads is a property of the provider, not of the wire
|
||||
* format its models speak.
|
||||
*
|
||||
* The single addition covers a catalog provider that offers no api-key method
|
||||
* at all. pi-ai resolves a request's `apiKey` override only when the provider
|
||||
* declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before
|
||||
* honouring the override), so an OAuth-only provider — `openai-codex` is the
|
||||
* one the installed catalog ships — would refuse a profile's explicit key with
|
||||
* `Provider is not configured` before any request went out. Adding the harness
|
||||
* method beside the provider's own restores that route. A keyless profile adds
|
||||
* nothing and still reports the honest refusal, because this adapter resolves
|
||||
* credentials through its own seam and holds no OAuth store to fall back on.
|
||||
* @param spec - the resolved route facts.
|
||||
* @param catalog - the installed catalog provider, when pi-ai ships one.
|
||||
* @returns the auth to construct this route's provider with.
|
||||
*/
|
||||
function routeAuth(spec: ProviderSpec, catalog: Provider | undefined): Provider['auth'] {
|
||||
if (catalog === undefined) return { apiKey: harnessApiKeyAuth(spec.displayName) }
|
||||
if (catalog.auth.apiKey !== undefined || !spec.namesCredential) return catalog.auth
|
||||
return { ...catalog.auth, apiKey: harnessApiKeyAuth(spec.displayName) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,7 +148,7 @@ function reuseCatalogProvider(base: Provider, spec: ProviderSpec): Provider {
|
||||
id: spec.provider,
|
||||
name: spec.displayName,
|
||||
...baseUrl === undefined ? {} : { baseUrl },
|
||||
auth: base.auth,
|
||||
auth: routeAuth(spec, base),
|
||||
getModels: () => spec.models,
|
||||
// Delegated rather than copied: the catalog provider stays the receiver, so
|
||||
// an implementation holding state on itself keeps working.
|
||||
@@ -149,7 +184,7 @@ export function buildProvider(spec: ProviderSpec): Provider {
|
||||
id: spec.provider,
|
||||
name: spec.displayName,
|
||||
...spec.baseURL === undefined ? {} : { baseUrl: spec.baseURL },
|
||||
auth: { apiKey: harnessApiKeyAuth(spec.displayName) },
|
||||
auth: routeAuth(spec, catalog),
|
||||
models: spec.models,
|
||||
api: factory(),
|
||||
})
|
||||
|
||||
@@ -10,6 +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 { resolveProfiles } from '../src/config.ts'
|
||||
import { buildProvider, supportedProtocols } from '../src/provider.ts'
|
||||
import { assemble } from './assemble.ts'
|
||||
@@ -195,13 +197,13 @@ describe('hand-declared providers', () => {
|
||||
// endpoint, and headers can carry, so a route naming one would be built
|
||||
// unable to authenticate.
|
||||
expect(supportedProtocols()).not.toContain(api)
|
||||
expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [] }))
|
||||
expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [], namesCredential: true }))
|
||||
.toThrow(/cannot serve; supported protocols are/)
|
||||
},
|
||||
)
|
||||
|
||||
it('rejects a protocol this build cannot serve, and a route that names none', () => {
|
||||
const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [] }
|
||||
const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [], namesCredential: true }
|
||||
expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' }))
|
||||
.toThrow(/cannot serve; supported protocols are/)
|
||||
expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/)
|
||||
@@ -430,6 +432,37 @@ describe('catalog routes with per-model configuration', () => {
|
||||
await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
||||
expect(server.paths).toEqual(['/v1/chat/completions'])
|
||||
})
|
||||
|
||||
it('keeps the catalog provider’s own auth when the route repoints its protocol', () => {
|
||||
// Which environment a provider reads is a property of the provider, not of
|
||||
// the wire format its models speak: naming an api must not cost a profile
|
||||
// its provider-native discovery.
|
||||
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
|
||||
expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key')
|
||||
})
|
||||
|
||||
it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => {
|
||||
// pi-ai honours a request's `apiKey` override only when the provider
|
||||
// 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 provider = resolved.get('openai-codex')?.piProvider
|
||||
expect(provider?.auth.oauth).toBeDefined()
|
||||
const models = createModels()
|
||||
models.setProvider(provider as Provider)
|
||||
const model = provider?.getModels()[0] as Model<Api>
|
||||
const auth = await models.getAuth(model, { apiKey: 'codex-token' })
|
||||
expect(auth?.auth.apiKey).toBe('codex-token')
|
||||
})
|
||||
|
||||
it('leaves an OAuth-only catalog route unconfigured when its profile names no key', () => {
|
||||
// Nothing to add: this adapter resolves credentials through its own seam
|
||||
// and holds no OAuth store, so declaring the provider configured would
|
||||
// trade a truthful refusal for an endpoint's 401.
|
||||
const resolved = resolveProfiles({ 'openai-codex': {} })
|
||||
expect(resolved.get('openai-codex')?.piProvider.auth.apiKey).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolution snapshots', () => {
|
||||
|
||||
Reference in New Issue
Block a user