fix(llm-pi-ai): withhold OAuth-only providers from the configurable directory
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.
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: e6d04133f3dfa4eb4cd9aa997ad1f9bb93fa50a0
|
||||
README.zh.md: e33bcac7081006573f5c6a8a8a3221fa57839c4f
|
||||
README.md: 6120f8d982c6d475cd508e6cf9e41cabfc9ba159
|
||||
README.zh.md: 4b47976c6c6c67968b5b93edbdfd5dfa9530eb1d
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)一并暂缓。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, ResolvedPiAiProviderPro
|
||||
}
|
||||
|
||||
/**
|
||||
* The configurable-provider directory: every installed catalog route, plus
|
||||
* every route the current profiles declare. A hand-declared route has no
|
||||
* catalog entry, so without this union it would have no settings address and
|
||||
* configuration surfaces could neither show nor edit it.
|
||||
* The configurable-provider directory: every installed catalog route this
|
||||
* adapter can authenticate, plus every route the current profiles declare. A
|
||||
* hand-declared route has no catalog entry, so without this union it would
|
||||
* have no settings address and configuration surfaces could neither show nor
|
||||
* edit it.
|
||||
*
|
||||
* The profile half is unconditional, which is what keeps a route already
|
||||
* stored against a withheld provider editable and deletable rather than
|
||||
* stranded in the settings document with nothing on the page to remove it.
|
||||
* @param profiles - the currently resolved provider profiles.
|
||||
* @returns the directory entries in catalog order, declared routes last.
|
||||
*/
|
||||
@@ -129,7 +134,14 @@ function directoryEntries(
|
||||
declared: !catalog.has(provider),
|
||||
})
|
||||
}
|
||||
for (const provider of catalog) declare(provider, provider)
|
||||
// A provider whose only native method is OAuth leaves this adapter nothing
|
||||
// to authenticate with, so offering it would put a card on the settings page
|
||||
// whose own posture — no key, credentials discovered by the provider — fails
|
||||
// every request. Catalog *membership* is unaffected, so `declare` above still
|
||||
// answers what pi-ai ships.
|
||||
for (const provider of catalog) {
|
||||
if (catalogProviderTakesApiKey(provider)) declare(provider, provider)
|
||||
}
|
||||
for (const [provider, profile] of profiles) declare(provider, profile.displayName)
|
||||
return [...entries.values()]
|
||||
}
|
||||
|
||||
@@ -933,4 +933,39 @@ describe('configurable-provider directory', () => {
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user