From a5a83bd1d92159094faa82adc7ceeba444f32e9a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 13 Aug 2026 13:15:47 +0800 Subject: [PATCH] fix(llm-pi-ai): withhold OAuth-only providers from the configurable directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi-ai resolves an OAuth provider from a stored OAuth credential alone, and this adapter builds its Models collection with no credential store and runs no login flow. `openai-codex` — the one installed provider declaring `auth.oauth` with no `auth.apiKey` — was therefore offered on the Models page with the keyless placeholder every pi-ai route carries, and every request on it failed `Provider is not configured` before going out. `catalogProviderTakesApiKey()` answers whether pi-ai's installed provider for a route declares the one method this adapter can supply, and the directory skips the catalog routes that fail it. Catalog membership is unchanged, so `declared` still answers what pi-ai ships; the profile half of the union stays unconditional, so a route a settings document already names keeps its entry and can be edited or deleted. Resolution is untouched: a profile naming `apiKeyEnv` on such a route still builds a working provider. --- ...13-oauth-only-providers-withheld.i18n.yaml | 6 +++ ...026-08-13-oauth-only-providers-withheld.md | 43 +++++++++++++++++++ ...-08-13-oauth-only-providers-withheld.zh.md | 43 +++++++++++++++++++ .../models-settings/empty.expected.md | 1 - .../dismissed.expected.md | 1 - packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/README.zh.md | 2 + packages/llm/llm-pi-ai/src/catalog.ts | 20 +++++++++ packages/llm/llm-pi-ai/src/index.ts | 24 ++++++++--- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 35 +++++++++++++++ 11 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.i18n.yaml new file mode 100644 index 0000000000..f5765160ac --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md +2026-08-13-oauth-only-providers-withheld.md: 8c85c2900fd0af7c97a3ac8f04d3c6a2f469f8b0 +2026-08-13-oauth-only-providers-withheld.zh.md: 8f05d096a5d474407585bcbd84bd47da8a1abb2e diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md b/.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md new file mode 100644 index 0000000000..8c85c2900f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md @@ -0,0 +1,43 @@ +# Agent Note: The configurable-provider directory withholds OAuth-only providers + +Status: implemented + +English | [中文](2026-08-13-oauth-only-providers-withheld.zh.md) + +## Problem + +The Models page offered `openai-codex` like any other pi-ai route, with the placeholder every pi-ai provider carries: enter a key, or leave it blank to authenticate from the environment. Configuring it that way and sending a message failed the turn with `Provider is not configured: openai-codex`, reported as the adapter's catch-all `PI_AI_ERROR`. + +The posture the placeholder invited could not work on that route. pi-ai's `resolveProviderAuth` reaches an OAuth provider through one path — a credential already in the collection's `CredentialStore` — and has no ambient fallback for it, while `openai-codex` is the one installed provider declaring `auth.oauth` with no `auth.apiKey` beside it. `PiAiAdapter.current()` constructs its collection with `createModels()` and no options, so the store is pi-ai's default `InMemoryCredentialStore`: empty at every boot, and rebuilt from scratch each time a configuration change produces a new snapshot. No code here runs `Models.login()`, and pi-ai's library half does not read Codex's own `~/.codex/auth.json` either — its OAuth module is a PKCE login flow whose credential the *host* application persists, which is what the pi CLI supplies and this adapter does not. + +So the page advertised, with the keyless posture its own placeholder describes, a provider that has no keyless posture — and the failure named the configuration key rather than the missing capability. The one thing that does authenticate the route is a ChatGPT OAuth token pasted into the key field, which is not what the offer describes and expires with nothing here to refresh it. + +## Decision + +The directory offers only what this adapter can authenticate. `catalogProviderTakesApiKey(provider)` answers whether pi-ai's installed provider for a route declares an api-key method — the one method the harness can feed, since it resolves a key through its own credential seam and hands it over as the request's `apiKey` override — and `directoryEntries()` skips the catalog routes that fail it. + +OAuth support is not attempted. It needs a persistent credential store, a login flow, and a surface to run it from; none of those is a release-blocking fix, and shipping the offer without them is what produced the report. + +Two boundaries keep the withholding narrow: + +- **Catalog membership is unchanged.** `catalogProviderIds()` still answers what pi-ai ships, so the `declared` flag on a directory entry keeps meaning "no installed provider answers for this route" rather than "this route is not offered". +- **The profile half of the union is unconditional.** A route a settings document already names keeps its entry, so a stored `openai-codex` profile stays visible, editable, and deletable instead of being stranded in the document with nothing on the page to remove it. + +Resolution is untouched. A profile naming `apiKeyEnv` on an OAuth-only route still builds a working provider — `routeAuth` adds the harness api-key method beside the catalog's OAuth, and pi-ai's Codex API derives the account id from the token itself — so a deployment that writes one into `settings.yaml` or `cordis.yml` keeps that path. Enforcing the withholding in `resolveProfiles` instead would have refused such a profile at registration, and because `validate` runs at boot as well as at write time, a document already naming a keyless OAuth route would fail the whole namespace's registration rather than one provider. + +## Alternatives considered + +- **Rejecting a keyless OAuth-only route in `resolveProfiles`.** This is where the repo normally enforces a decision, and the directory filter is a surface that a `cordis.yml` entry bypasses. It was refused for the boot behavior above: an existing stored profile would take down every other route in the namespace with it, which for a release trades a one-provider defect for a total one. The gap is that the offer, not the capability, is what got fixed — a deployment can still hand-write the route it can no longer add from the page. +- **Keeping the offer and correcting only the placeholder text.** The field would then have to say the provider needs a login this build cannot run, which is a card whose only honest content is that it does not work. +- **Mapping `Provider is not configured` to a named `LlmError`.** Worth doing, and reachable for reasons this change does not remove — any api-key route left blank whose provider finds nothing in the process environment produces the same message. Deferred as a separate change: it improves a diagnostic rather than removing a broken offer. +- **Reading `~/.codex/auth.json` into a pi-ai `CredentialStore`.** It makes Codex work without a login flow, and pi-ai owns the refresh. It also binds the harness to another tool's private file format for one provider, which is a decision for the OAuth work rather than a release fix. + +## Consequences + +`openai-codex` disappears from the provider picker and from the directory the Models page joins; every other installed provider is unaffected, including the six that offer OAuth *beside* an api-key method (`anthropic`, `github-copilot`, `kimi-coding`, `openrouter`, `radius`, `xai`), which keep their entries and their key path. A future provider that ships OAuth alone is withheld automatically rather than by name. + +Two adjacent gaps remain and are recorded in the package README: a route naming no credential still resolves through the catalog provider's own discovery, which reads process environment variables only — not `~/.aws/credentials`, and not the harness credential seam — and the resulting failure is still the catch-all `PI_AI_ERROR`. + +## Testing + +Package tests pin both halves of the union: the withheld route is absent from `listConfigurableProviders()` while `anthropic` and `openai` stay, and a stored `openai-codex` profile still produces a full entry with `declared: false`. The existing resolution tests are unchanged and still pass, which is what shows the withholding did not narrow what a hand-written profile can serve. The `models-settings` and `onboarding-usable-provider` web e2e goldens lost exactly the `openai-codex` option line, recorded against the real assembled application. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.zh.md new file mode 100644 index 0000000000..8f05d096a5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.zh.md @@ -0,0 +1,43 @@ +# Agent Note:可配置提供方目录不再提供仅以 OAuth 认证的提供方 + +Status: implemented + +[English](2026-08-13-oauth-only-providers-withheld.md) | 中文 + +## 问题 + +模型设置页把 `openai-codex` 当作普通 pi-ai 路由提供出来,配的还是每个 pi-ai 提供方共用的那句占位文案:填入 API 密钥,或留空使用环境认证。照此配置后发送消息,本轮以 `Provider is not configured: openai-codex` 失败,并被适配器归入兜底的 `PI_AI_ERROR`。 + +占位文案所邀请的那种配置姿态在这条路由上不可能工作。pi-ai 的 `resolveProviderAuth` 抵达 OAuth 提供方只有一条路径——集合的 `CredentialStore` 里已经存着的凭据——对它没有任何 ambient 回退;而 `openai-codex` 正是已安装 catalog 中唯一只声明 `auth.oauth`、没有 `auth.apiKey` 的提供方。`PiAiAdapter.current()` 以不带参数的 `createModels()` 构造集合,于是用的是 pi-ai 默认的 `InMemoryCredentialStore`:每次启动都是空的,每次配置变更产生新快照时又重建一份。本仓库没有任何位置调用 `Models.login()`;pi-ai 库这一半也不会去读 Codex 自己的 `~/.codex/auth.json`——它的 OAuth 模块是一套 PKCE 登录流程,凭据由*宿主*应用持久化,这正是 pi CLI 提供、而本适配器没有提供的东西。 + +于是页面用自己占位文案所描述的「留空」姿态,提供了一个根本没有「留空」姿态的提供方——而失败信息指向的是配置键,不是缺失的能力。唯一能让这条路由完成认证的,是把一个 ChatGPT OAuth token 粘进密钥框,那既不是这个提供所描述的用法,也会过期且这里没有任何环节会去刷新它。 + +## 决策 + +目录只提供本适配器认得的东西。`catalogProviderTakesApiKey(provider)` 回答 pi-ai 为某路由安装的提供方是否声明了 api-key 方法——这是 harness 唯一能供给的方法,因为它通过自己的凭据 seam 解析密钥,再作为请求的 `apiKey` 覆盖交给 pi-ai——`directoryEntries()` 跳过不满足它的 catalog 路由。 + +不尝试实现 OAuth。它需要持久化凭据存储、登录流程,以及运行登录的界面;这三样都不是发布阻塞项的修复,而在它们缺席时仍把提供方摆出来,正是这次报告的成因。 + +两条边界把「不提供」的范围收窄: + +- **catalog 成员身份不变。** `catalogProviderIds()` 仍回答 pi-ai 装了什么,因此目录条目上的 `declared` 标记仍然表示「没有已安装提供方对应这条路由」,而不是「这条路由不被提供」。 +- **联合的 profile 那一半无条件保留。** settings 文档已经写过的路由保留条目,因此已存储的 `openai-codex` profile 仍然可见、可编辑、可删除,而不会滞留在文档里、页面上却没有任何入口能移除它。 + +resolution 未被触动。在仅 OAuth 的路由上指定 `apiKeyEnv` 的 profile 仍会构造出可用的提供方——`routeAuth` 会在 catalog 的 OAuth 旁边补上 harness 的 api-key 方法,而 pi-ai 的 Codex API 从 token 本身推导 account id——因此把它写进 `settings.yaml` 或 `cordis.yml` 的部署保留这条路径。改为在 `resolveProfiles` 里强制拒绝会在注册时就否掉这类 profile;又因为 `validate` 在启动时与写入时同样运行,一份已经写有无密钥 OAuth 路由的文档会让整个 namespace 注册失败,而不只是一个提供方失败。 + +## 备选方案 + +- **在 `resolveProfiles` 里拒绝无密钥的仅 OAuth 路由。** 这才是本仓库通常强制决策的位置,而目录过滤是一层 `cordis.yml` entry 可以绕过的表面。因上述启动行为被否决:已存储的 profile 会连带拖垮该 namespace 中其他所有路由,对一次发布而言,这是拿一个提供方的缺陷换取全体的缺陷。留下的缺口是:被修的是「提供」而不是「能力」——部署仍可手写一条页面上已经无法添加的路由。 +- **保留提供,只修正占位文案。** 那么该输入框只能写「此提供方需要本构建无法运行的登录」,等于一张唯一诚实内容就是「它不能用」的卡片。 +- **把 `Provider is not configured` 映射成具名 `LlmError`。** 值得做,而且触发原因本次改动并未消除——任何留空密钥、其提供方又在进程环境里找不到东西的 api-key 路由,都会产生同一句话。作为独立改动暂缓:它改进的是诊断,而不是移除一个坏掉的提供。 +- **把 `~/.codex/auth.json` 读进 pi-ai 的 `CredentialStore`。** 这能让 Codex 在没有登录流程的情况下可用,刷新也由 pi-ai 负责。但它为一个提供方把 harness 绑定到另一个工具的私有文件格式上,这属于 OAuth 那项工作的决策,而不是发布期的修复。 + +## 影响 + +`openai-codex` 从提供方选择器、以及模型设置页所 join 的目录中消失;其余已安装提供方一概不受影响,包括在 api-key 方法*之外*另提供 OAuth 的那六个(`anthropic`、`github-copilot`、`kimi-coding`、`openrouter`、`radius`、`xai`),它们保留条目也保留密钥路径。将来若出现只带 OAuth 的提供方,会被自动排除,而不是靠列名。 + +两处相邻缺口仍在,并记录在包 README 中:不指定凭据的路由仍走 catalog 提供方自带的发现,而它只读进程环境变量——不读 `~/.aws/credentials`,也不读 harness 凭据 seam——且由此产生的失败仍是兜底的 `PI_AI_ERROR`。 + +## 测试 + +包测试钉住联合的两半:不予提供的路由不出现在 `listConfigurableProviders()` 中,而 `anthropic` 与 `openai` 仍在;已存储的 `openai-codex` profile 仍产出完整条目且 `declared: false`。既有的 resolution 测试未改动且依然通过,这正是「不提供」没有收窄手写 profile 可服务范围的证据。`models-settings` 与 `onboarding-usable-provider` 两条 web e2e golden 恰好各少了 `openai-codex` 这一行选项,录自真实装配的应用。 diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index cea14113ac..2c928e2f0d 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -44,7 +44,6 @@ - option "moonshotai-cn" - option "nvidia" - option "openai" - - option "openai-codex" - option "opencode" - option "opencode-go" - option "openrouter" diff --git a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md index 84c7358b54..809e7880ff 100644 --- a/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md @@ -48,7 +48,6 @@ - option "moonshotai-cn" - option "nvidia" - option "openai" - - option "openai-codex" - option "opencode" - option "opencode-go" - option "openrouter" diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index cf879dc6be..31e6ac3b8a 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/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: e6d04133f3dfa4eb4cd9aa997ad1f9bb93fa50a0 -README.zh.md: e33bcac7081006573f5c6a8a8a3221fa57839c4f +README.md: 6120f8d982c6d475cd508e6cf9e41cabfc9ba159 +README.zh.md: 4b47976c6c6c67968b5b93edbdfd5dfa9530eb1d diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index e6d04133f3..6120f8d982 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -189,6 +189,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work +- **A provider that authenticates through OAuth alone is not offered** — pi-ai resolves OAuth from a *stored* OAuth credential, and this adapter builds its `Models` collection with no credential store and runs no login flow, so every request on such a route fails `Provider is not configured` before it goes out. The configurable-provider directory withholds them; `openai-codex` is the only one the installed catalog ships. A route a settings document already names keeps its entry so a configuration surface can edit or delete it, and `apiKeyEnv` still authenticates it with that key — which for Codex is a token that expires with nothing here to refresh it. +- **Provider-native discovery reads the process environment only** — a route naming no credential defers to the catalog provider's own resolution, which interrogates environment variables (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, and each provider's own set). It reads no local credential directory, so `~/.aws/credentials` without an exported `AWS_PROFILE` resolves as unconfigured, and a value held by the harness credential seam is invisible to it unless the process environment carries it too. - **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. - **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. This only triggers when a `cordis.yml` entry config declares per-model reasoning fields for the same model the user layer edits; the supported posture is to leave those to the settings document (the shipped composition mounts the adapter dormant), and a `models` list is an array replacing wholesale, which is the in-band escape. - **`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). diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index e33bcac708..4b47976c6c 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -190,6 +190,8 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 +- **仅以 OAuth 认证的提供方不予提供**:pi-ai 的 OAuth 只从*已存储*的 OAuth 凭据解析,而本适配器构造 `Models` 集合时不注入凭据存储、也不运行登录流程,因此这类路由的每个请求都会在发出之前以 `Provider is not configured` 失败。可配置提供方目录因此不列出它们;已安装 catalog 中只有 `openai-codex` 属于此类。settings 文档已经写过的路由仍保留目录条目,配置界面据此可以编辑或删除;`apiKeyEnv` 也仍能用该密钥完成认证——对 Codex 而言那是一个会过期、且这里没有任何环节会去刷新的 token。 +- **提供方自带的凭据发现只读进程环境**:不指定凭据的路由交由 catalog 提供方自行解析,而它探测的是环境变量(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE`、`AWS_ACCESS_KEY_ID` 以及各提供方自己的那一组)。它不读任何本地凭据目录,因此只有 `~/.aws/credentials` 而未导出 `AWS_PROFILE` 会被解析为未配置;由 harness 凭据 seam 保管的值,除非进程环境里也有,否则对它不可见。 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。 - **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。只有 `cordis.yml` entry config 为用户层正在编辑的同一模型声明了按模型推理字段才会触发;受支持的姿态是把这些字段留给 settings 文档(shipped 组合以 dormant 方式挂载该适配器),且 `models` 列表是数组、整体替换,这是带内的解决办法。 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 2cc2debafc..7a4ff7e8a2 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -141,6 +141,26 @@ export function catalogProviderIds(): readonly string[] { return getBuiltinProviders() } +/** + * Whether the installed catalog provider for one route declares an api-key + * method — the only authentication this adapter obtains on its own. + * + * A key is what the harness resolves through its own credential seam and hands + * pi-ai per request. pi-ai's other method, OAuth, resolves from a *stored* + * OAuth credential alone: `resolveProviderAuth` has no ambient path for it, + * this adapter builds its `Models` collection with no credential store, and + * nothing here runs a login flow. So a provider offering OAuth by itself + * leaves nothing for this adapter to authenticate with, and the posture such a + * provider invites — no key configured, credentials discovered by the provider + * — fails every request with `Provider is not configured`. + * @param provider - provider route key. + * @returns whether the catalog provider takes an api key; false for a route + * pi-ai does not ship, which the caller answers for separately. + */ +export function catalogProviderTakesApiKey(provider: string): boolean { + return catalogProvider(provider)?.auth.apiKey !== undefined +} + /** * The installed catalog models for one route, indexed by model id. * @param provider - provider route key. diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index df1e3a2936..2e550771fc 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -61,7 +61,7 @@ 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' import { PiAiAdapter } from './adapter.ts' -import { catalogProviderIds } from './catalog.ts' +import { catalogProviderIds, catalogProviderTakesApiKey } from './catalog.ts' import { assertServiceable, Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' import { discoverModels } from './discovery.ts' @@ -105,10 +105,15 @@ function registrationFacts(profiles: ReadonlyMap { await ctx.settings.replace(settingsNamespace('llm-pi-ai'), {}) expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly) }) + + it('withholds a catalog route this adapter cannot authenticate', async () => { + const ctx = await harness({}) + const offered = ctx.llm.listConfigurableProviders().map(entry => entry.provider) + + // `openai-codex` is the one installed provider that authenticates through + // OAuth alone. pi-ai resolves OAuth only from a *stored* credential, this + // adapter constructs its collection with no credential store, and nothing + // here runs a login flow — so every request on such a route fails with + // `Provider is not configured` before it goes out. Offering it would put a + // provider on the settings page that no amount of configuration can make + // work. + expect(offered).not.toContain('openai-codex') + // A provider that offers OAuth *beside* an api-key method keeps its entry: + // the key is a path this adapter can serve. + expect(offered).toContain('anthropic') + expect(offered).toContain('openai') + }) + + it('still lists a withheld route a stored profile names, as a catalog route', async () => { + // Withholding the offer must not strand a profile someone already stored: + // the route keeps its entry so a configuration surface can edit or delete + // it, and `declared` still answers catalog membership rather than the + // offer, so the page does not mislabel it as a route this deployment + // invented. + const ctx = await harness({ providers: { 'openai-codex': { apiKeyEnv: KEY_ENV } } }) + + expect(ctx.llm.listConfigurableProviders()).toContainEqual({ + provider: 'openai-codex', + displayName: 'openai-codex', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'openai-codex'], + declared: false, + }) + }) })