diff --git a/docs/subsystems/credentials.i18n.yaml b/docs/subsystems/credentials.i18n.yaml index 6c56090ca1..e2526345a5 100644 --- a/docs/subsystems/credentials.i18n.yaml +++ b/docs/subsystems/credentials.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 docs/subsystems/credentials.md -credentials.md: aa3230379205eabec0b9e51596bbbbfb7ef491b9 -credentials.zh.md: ce9db1709555950d4f4e30798c560922a54d8674 +credentials.md: dd0f950b50fa4bcb9094f7b85227262b42e3b385 +credentials.zh.md: 3e906449db31d4889d8c87f27c765cccfc9ff539 diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md index aa32303792..dd0f950b50 100644 --- a/docs/subsystems/credentials.md +++ b/docs/subsystems/credentials.md @@ -208,7 +208,7 @@ abstract modifyRecord( key: CredentialKey, mutate: (current: CredentialRecord | abstract deleteRecord(key: CredentialKey): Promise ``` -Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:164`](../../packages/credentials/credentials/src/index.ts) diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md index ce9db17095..3e906449db 100644 --- a/docs/subsystems/credentials.zh.md +++ b/docs/subsystems/credentials.zh.md @@ -208,7 +208,7 @@ abstract modifyRecord( key: CredentialKey, mutate: (current: CredentialRecord | abstract deleteRecord(key: CredentialKey): Promise ``` -Source: [`packages/credentials/credentials/src/index.ts:141`](../../packages/credentials/credentials/src/index.ts) +Source: [`packages/credentials/credentials/src/index.ts:164`](../../packages/credentials/credentials/src/index.ts) diff --git a/packages/credentials/authorization/src/index.ts b/packages/credentials/authorization/src/index.ts index c88129b7cf..9cff052feb 100644 --- a/packages/credentials/authorization/src/index.ts +++ b/packages/credentials/authorization/src/index.ts @@ -297,13 +297,30 @@ export class AuthorizationService extends Service { signal: AbortSignal, interaction: AuthorizationInteraction, ): Promise { + // Withdrawal settles the attempt whether or not the flow reacts to it. A + // flow is supposed to stop when its signal fires, but one that does not + // would otherwise hold the key for the life of the process, and a wedged + // key is indistinguishable from a busy one from the outside. The orphaned + // run is left to finish on its own; nothing waits on it, and a record it + // still manages to commit is a record the human did authorize. + const withdrawn = new Promise<'withdrawn'>((resolve) => { + // `begin()` returns before claiming the key when its caller has already + // withdrawn, so this signal cannot already be aborted here. + signal.addEventListener('abort', () => { resolve('withdrawn') }, { once: true }) + }) + const running = flow.run({ + method, + signal, + notify: (notice) => { interaction.notify(notice) }, + prompt: prompt => interaction.prompt(prompt), + }) try { - await flow.run({ - method, - signal, - notify: (notice) => { interaction.notify(notice) }, - prompt: prompt => interaction.prompt(prompt), - }) + if (await Promise.race([running.then(() => 'ran' as const), withdrawn]) === 'withdrawn') { + // Nothing awaits the orphan any more, so its eventual failure has to be + // marked handled or it would take down the process. + void running.catch(() => { this.ctx.logger.debug('authorization: withdrawn flow failed after the fact') }) + return { status: 'cancelled' } + } } catch (error) { // A withdrawn attempt is an outcome, not a failure: the human said no, or // closed the page. Anything else is the flow failing and belongs to the diff --git a/packages/credentials/authorization/tests/authorization.spec.ts b/packages/credentials/authorization/tests/authorization.spec.ts index f5428f3b75..286c99898b 100644 --- a/packages/credentials/authorization/tests/authorization.spec.ts +++ b/packages/credentials/authorization/tests/authorization.spec.ts @@ -254,6 +254,29 @@ describe('AuthorizationService.begin', () => { await expect(attempt).resolves.toEqual({ status: 'cancelled' }) }) + it('settles a withdrawn attempt even when its flow never reacts to the signal', async () => { + const ctx = await harness() + const orphan = Promise.withResolvers() + const started = Promise.withResolvers() + ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => { + started.resolve(undefined) + return orphan.promise + })) + + const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() }) + await started.promise + ctx.authorization.cancel(KEY) + + await expect(attempt).resolves.toEqual({ status: 'cancelled' }) + // The key is free again immediately, rather than at the mercy of a flow + // that may never settle. + expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false) + // The orphan's own failure is nobody's to await, and must not surface as an + // unhandled rejection. + orphan.reject(new Error('gave up long after the human left')) + await expect(orphan.promise).rejects.toThrow('gave up long after the human left') + }) + it('propagates a flow failure to its caller and settles the key as failed', async () => { const ctx = await harness() ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index 951448f8a3..3934f51754 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -24,12 +24,25 @@ const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/ * @returns the branded reference. */ export function credentialRef(value: string): CredentialRef { - if (!REF_PATTERN.test(value)) { + if (!isCredentialRefName(value)) { throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`) } return value as CredentialRef } +/** + * Whether a raw string could name a reference at all. Consumers that receive + * environment-variable names from somewhere else — a provider library's own + * ambient discovery, a hook payload — ask this before resolving, because a name + * outside the grammar has no reference to miss and should read as "not set" + * rather than as a thrown error. + * @param value - candidate reference. + * @returns true when {@link credentialRef} would accept it. + */ +export function isCredentialRefName(value: string): boolean { + return REF_PATTERN.test(value) +} + /** * Brand a scope and an id as a {@link CredentialKey}. * @param scope - the owning plugin's registered name, such as `llm-pi-ai`. @@ -75,6 +88,16 @@ export function credentialKeyScope(key: CredentialKey): string { return key.slice(0, key.indexOf('/')) } +/** + * The owning plugin's own addressing unit for one key — the half that plugin + * chose, such as a provider route. + * @param key - the key to read. + * @returns the id segment. + */ +export function credentialKeyId(key: CredentialKey): string { + return key.slice(key.indexOf('/') + 1) +} + /** One resolved credential value and the source layer that supplied it. */ export interface ResolvedCredential { /** The non-empty secret value. */ diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 19ce411406..a50606b568 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -72,7 +72,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () try { // JSON.stringify quotes the value: YAML is a JSON superset, so a real // key survives whatever characters it happens to carry. - await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 }) // Scrub the ambient variable so only the credential seam can supply the // key: this request proves the per-request resolution path end to end. vi.stubEnv('DEEPSEEK_API_KEY', '') diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index be7d4e3688..d28c853c3a 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -100,7 +100,7 @@ describe('request-level dynamic configuration', () => { it('routes the next request with the freshly resolved base URL and credential', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 }) const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx } = await boot(dir, { baseURL: serverA.url }) @@ -258,7 +258,7 @@ describe('request-level dynamic configuration', () => { it('falls back to the composition entry when settings detach', async () => { vi.stubEnv('DEEPSEEK_API_KEY', '') const dir = await home() - await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 }) const serverA = await mockServer([{ kind: 'sse', events: textEvents }]) const serverB = await mockServer([{ kind: 'sse', events: textEvents }]) const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url }) diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 1398d36110..ec83345b61 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -53,7 +53,7 @@ async function loadComposition( const credentialsPath = join(root, '.credentials.yaml') if (options.withDynamic && fresh) { await writeFile(settingsPath, '# personal settings\n') - await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 }) + await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 }) } const configPath = join(root, 'cordis.yml') @@ -124,7 +124,7 @@ describe('llm-deepseek real dynamic composition', () => { await vi.waitFor(() => { expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url) }, { timeout: 5000 }) - await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 }) + await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 }) await vi.waitFor(async () => { expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' }) }, { timeout: 5000 }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6ea69058b9..19db6eb0fb 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: 6d62aabf954a80120bacc9049a7498af269dd067 -README.zh.md: e7e832fa356c48bf14d7aebac7351b259b924214 +README.md: 45b4815cb35e8709a6dc6cf4c50b778d2c2113f9 +README.zh.md: a1e952b70d9361e945b37ed146035fa274185003 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 6d62aabf95..45b4815cb3 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -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. Each profile may set a `retryPolicy`; omission uses normal mode with five retries. `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. +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 @@ -120,7 +120,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 `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, `maxRequestImageBytes`, and `retryPolicy`. Each resolved profile retry policy is captured with that provider route; omission uses the shared bounded normal default of five retries. 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. `maxRequestImageBytes` bounds one request's base64-encoded image payload (default 20MiB, a positive integer): every image in history is re-encoded into every request, so when the accumulated payload exceeds the bound, the oldest images are replaced by a fixed text placeholder until the request fits, keeping an image-heavy session serviceable instead of permanently rejected by a gateway request-size cap. The default leaves capacity for system prompts, history, tools, and JSON; deployments behind stricter gateways lower it per route. Harness app attribution wins a conflicting configured header name. +Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `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`. @@ -170,15 +170,15 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata #### What the model sees -The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose, with one exception: when a request's accumulated base64 image payload exceeds the route's `maxRequestImageBytes`, each offloaded image (oldest first) is replaced by fixed text. The text tells the model to read the file again when a path is available or ask the user to attach the image again. Provider-native replay metadata is restored only when the adapter validates it for the historical content. +The selected catalog model receives `GenerateOptions.system`, history, tools, and sampling fields supported by pi-ai's common streaming API. This package adds no prompt prose. Provider-native replay metadata is restored only when the adapter validates it for the historical content. #### Token effect -Provider tokenization governs exact input. Conversion adds no model-visible text beyond the image-offload placeholder, which replaces the offloaded image's visual tokens with a short fixed sentence; replay metadata may let a native API reuse provider-side state. +Provider tokenization governs exact input. Conversion adds no model-visible text; replay metadata may let a native API reuse provider-side state. #### KV Cache effect -Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference. Crossing the image bound rewrites an early message (the newly offloaded image becomes placeholder text), so reuse ends at that message until the offloaded prefix stabilizes. +Conversion preserves logical request order without adding text, while the selected provider's serialization and replay state determine reuse. Changing adapter instance, provider, model, or any upstream request token may prevent reuse from the first difference. ### Provider response @@ -196,9 +196,8 @@ Recorded response content appends to the next request and does not invalidate it ## Known Limitations and Deferred Work -- **`maxRequestImageBytes` counts base64 image payload only** — text, tools, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom. Offload is decided at request conversion as a pure function of history and configuration and is not recorded as a session event; per-route capability metadata (image count, per-image size, total request size) driving admission and assembly together is deferred design 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. +- **A sign-in lives only in the process that started it** — an authorization attempt is not durable, so reloading the page mid-login abandons it and the human starts over. Signing out is `deleteRecord` on the stored record, which forgets it locally without telling the issuer. +- **Provider-native discovery answers through this plugin's ambient context** — a route naming no credential defers to the catalog provider's own resolution, which asks for environment values (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, and each provider's own set) and for local credential files. Both questions are answered here: the credential seam is consulted before the process environment, and file existence is checked against the host process's filesystem with `~` expanded. What it cannot do is *read* a credential file's contents — a provider that parses `~/.aws/credentials` itself does so directly, outside the seam. - **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). @@ -209,4 +208,4 @@ Recorded response content appends to the next request and does not invalidate it - **`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. -- **Retry policy is provider-owned, not an SDK retry** — each provider profile may supply nested `retryPolicy`; omission resolves to normal mode with five retries, and the effective route policy is what `dsh-llm-retry` executes at the agent failed-step extension point. pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. +- **Retry policy is provider-owned, not an SDK retry** — each provider profile may configure nested `retryPolicy`, which `dsh-llm-retry` executes at the agent failed-step extension point; pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index e7e832fa35..a1e952b70d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -8,7 +8,7 @@ ## 配置 -按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。每个 profile 都可以设置 `retryPolicy`;省略时使用 normal 模式并重试五次。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 +按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。 ```yaml - id: llm @@ -121,7 +121,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示 **没有**这份元数据的模型——条目未声明 `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 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs`、`maxRequestImageBytes` 和 `retryPolicy`。每条 profile 解析后的重试策略会随该提供方路由一同捕获;省略时使用共享的有界 normal 默认值并重试五次。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。`maxRequestImageBytes` 约束单个请求的 base64 编码图片载荷(默认 20MiB,正整数):历史中的每张图片都会重新编码进每个请求,累积载荷超过上限时,从最老的图片开始替换为固定文本占位,直到请求装得下,使图片较多的会话保持可用,而不是被网关请求体上限永久拒绝。默认值为系统提示词、历史、工具与 JSON 保留请求容量;网关更严格的部署按路由调低该值。若已配置标头中有同名项,则以 Harness 应用归因为准。 +受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`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`。 @@ -171,15 +171,15 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK #### 模型看到的内容 -所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。本包不添加提示词文本,仅有一个例外:请求累积的 base64 图片载荷超过路由的 `maxRequestImageBytes` 时,被 offload 的图片(从最老开始)会被替换为一段固定文本。该文本要求模型在有路径时重新读取文件,否则请用户重新附上图片。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 +所选 catalog 模型会收到 `GenerateOptions.system`、历史、工具,以及 pi-ai 通用流式 API 支持的采样字段。本包不添加提示词文本。只有当适配器验证提供方原生回放元数据与历史内容匹配时,才会恢复这些元数据。 #### Token 影响 -精确输入取决于提供方 tokenization。除图片 offload 占位文本外,转换不添加模型可见文本;占位文本用一句固定短句替代被省略图片的视觉 token。回放元数据可能让原生 API 复用提供方侧状态。 +精确输入取决于提供方 tokenization。转换不添加模型可见文本;回放元数据可能让原生 API 复用提供方侧状态。 #### KV Cache 影响 -转换保留逻辑请求顺序,不添加文本;复用取决于所选提供方的序列化与回放状态。更改适配器实例、提供方、模型或任何上游请求 token,都可能使复用从首个出现差异的 token 起失效。跨过图片上限会改写较早的一条消息(新被 offload 的图片变为占位文本),复用在该消息处截止,直到被 offload 的前缀稳定。 +转换保留逻辑请求顺序,不添加文本;复用取决于所选提供方的序列化与回放状态。更改适配器实例、提供方、模型或任何上游请求 token,都可能使复用从首个出现差异的 token 起失效。 ### 提供方响应 @@ -197,9 +197,8 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish ## 已知限制与暂缓事项 -- **`maxRequestImageBytes` 只统计 base64 图片载荷**:文本、工具与 JSON 结构不计入上限,因此该值必须低于网关请求体上限并留出余量。offload 在请求转换时决定,是历史与配置的纯函数,不记录为会话事件;由按路由能力元数据(图片数量、单图大小、请求总大小)同时驱动准入与组装的完整设计属于暂缓工作。 -- **仅以 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 保管的值,除非进程环境里也有,否则对它不可见。 +- **一次登录只存活于发起它的进程中**:授权尝试不可持久,登录途中刷新页面会丢弃它,人需要重来。登出即对已存储记录执行 `deleteRecord`,它只在本地遗忘而不通知签发方。 +- **提供方自带的凭据发现经由本插件的 ambient context 作答**:不指定凭据的路由交由 catalog 提供方自行解析,它会询问环境值(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE` 以及各提供方自己的那一组)与本地凭据文件是否存在。两类问题都在这里作答:先查凭据 seam 再查进程环境,文件存在性则按宿主进程的文件系统判断并展开 `~`。它做不到的是*读取*凭据文件的内容——自行解析 `~/.aws/credentials` 的提供方是直接读盘的,不经过 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)一并暂缓。 @@ -210,4 +209,4 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish - **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。 - **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。 - **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。 -- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以提供嵌套的 `retryPolicy`;省略时解析为 normal 模式并重试五次,`dsh-llm-retry` 会在 agent 的失败步骤扩展点上执行有效路由策略。pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。 +- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以配置嵌套的 `retryPolicy`,由 `dsh-llm-retry` 在 agent 的失败步骤扩展点上执行;pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。 diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index be02e941df..ab286f006f 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -33,6 +33,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -47,6 +48,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 5ecec593da..3c7ecd4a91 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -13,10 +13,15 @@ * way down: switching models mid-reply takes effect on the next step, never * inside the one in flight. * - * Credentials stay outside that collection. The harness resolves a route's key - * through its own seam and passes it as the request's `apiKey` option, which - * pi-ai treats as the highest-priority auth override — so `Models` never holds - * a credential store and the harness keeps its fail-loud reference semantics. + * A route naming a credential reference still resolves it through the harness + * seam and passes it as the request's `apiKey` option, which pi-ai treats as + * the highest-priority auth override — that is what keeps the fail-loud + * reference semantics. Everything that override does not cover reaches pi-ai + * through the collection's own auth: the credential store holds the records a + * login wrote and a refresh rotates, and the auth context answers the ambient + * questions a provider asks while resolving. Both are stable across snapshots, + * so a configuration change rebuilds the collection without forgetting who is + * signed in. * * @module dsh-llm-pi-ai/adapter */ @@ -24,6 +29,8 @@ import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai' import type { Api, + AuthContext, + CredentialStore, Model, Models, ModelThinkingLevel, @@ -74,6 +81,15 @@ export interface PiAiAdapterOptions { * `MISSING_CREDENTIAL` rather than falling back. */ resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise + /** + * How every collection this adapter builds resolves auth the request-level + * `apiKey` override does not cover. Required rather than optional: a + * collection built without them gets pi-ai's in-memory default store, which + * is empty at every boot and discarded on every configuration change, so a + * route whose only method is a login would report itself unconfigured on + * every request no matter how often the human signed in. + */ + auth: PiAiAuthInjection /** Resolve the optional durable attachment service at request time. */ resolveAttachments?: () => AttachmentStore | undefined /** @@ -83,6 +99,14 @@ export interface PiAiAdapterOptions { onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void } +/** The two auth injectables a pi-ai collection is built with. */ +export interface PiAiAuthInjection { + /** Durable storage for credentials pi-ai itself writes: logins, and the refreshes it runs under its own lock. */ + credentials: CredentialStore + /** Ambient lookups a provider performs while resolving its own auth. */ + authContext: AuthContext +} + /** Copy profile stream knobs into pi-ai's common option vocabulary. */ function profileOptions( profile: ResolvedPiAiProviderProfile, @@ -204,7 +228,7 @@ export class PiAiAdapter extends LlmAdapter { private current(): PiAiSnapshot { const profiles = this.config.profiles() if (this.snapshot?.profiles === profiles) return this.snapshot - const models: MutableModels = createModels() + const models: MutableModels = createModels(this.config.auth) for (const profile of profiles.values()) models.setProvider(profile.piProvider) this.snapshot = { profiles, models } return this.snapshot diff --git a/packages/llm/llm-pi-ai/src/auth.ts b/packages/llm/llm-pi-ai/src/auth.ts new file mode 100644 index 0000000000..8b718cdd20 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/auth.ts @@ -0,0 +1,190 @@ +/** + * The three adapters between pi-ai's auth model and the harness credential + * plane. Every pi-ai-specific concept stays on this side of them: the harness + * seams they consume — `ctx.credentials` records and `ctx.authorization` flows — + * name nothing from this library, so another adapter family can arrive with a + * different auth model and share the same two seams. + * + * @module dsh-llm-pi-ai/auth + */ + +import { homedir } from 'node:os' +import { access } from 'node:fs/promises' +import { resolve as resolvePath } from 'node:path' +import type { AuthContext, Credential, CredentialInfo, CredentialStore } from '@earendil-works/pi-ai' +import type { Context } from '@deepseek-ai/cordis' +import { + credentialKey, credentialKeyId, credentialKeyScope, credentialRef, isCredentialRefName, +} from '@deepseek-ai/dsh-credentials' +import type { CredentialKey, CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { LlmError } from '@deepseek-ai/dsh-llm' + +/** + * The record scope every credential this adapter family stores is written + * under. It is the plugin's registered name, which is what tells a later + * reader — a configuration UI, or a second adapter family serving the same + * provider name — that this plugin owns the format inside the record. + */ +export const RECORD_SCOPE = 'llm-pi-ai' + +/** + * The record address for one pi-ai provider id. + * @param providerId - pi-ai's own provider id, which is also the harness route key. + * @returns the scoped credential key this adapter family reads and writes. + */ +export function recordKeyFor(providerId: string): CredentialKey { + return credentialKey(RECORD_SCOPE, providerId) +} + +/** + * Translate a stored record into the credential pi-ai expects. + * + * An `api-key` record is structural on both sides, so it is rebuilt field by + * field. A `grant` payload is pi-ai's own OAuth credential, stored verbatim: + * the seam treats it as opaque JSON precisely so a library that owns a token + * format keeps owning it, refresh fields and all. + * @param record - the stored record, or undefined when nothing is stored. + * @returns the pi-ai credential, or undefined for an absent record. + */ +function toPiCredential(record: CredentialRecord | undefined): Credential | undefined { + if (record === undefined) return undefined + if (record.kind === 'api-key') { + return { + type: 'api_key', + ...record.key === undefined ? {} : { key: record.key }, + ...record.env === undefined ? {} : { env: { ...record.env } }, + } + } + return record.payload as Credential +} + +/** + * Translate a pi-ai credential into the record to store. + * @param credential - what a login or refresh produced. + * @returns the record to commit, in the union the credential seam stores. + */ +function toRecord(credential: Credential): CredentialRecord { + if (credential.type === 'api_key') { + return { + kind: 'api-key', + ...credential.key === undefined ? {} : { key: credential.key }, + ...credential.env === undefined ? {} : { env: { ...credential.env } }, + } + } + return { kind: 'grant', payload: credential } +} + +/** + * The credential service, or the failure that names what is missing. Reads + * answer "nothing stored" without a service, because a composition with no + * credential plane genuinely holds no credential; writes refuse, because a + * login whose grant silently evaporated would report success and then fail + * every request. + * @param ctx - the plugin context. + * @returns the live service. + * @throws {LlmError} code `NO_CREDENTIAL_STORE` when none is mounted. + */ +function writableStore(ctx: Context): CredentialProvider { + const credentials = ctx.get('credentials') + if (credentials === undefined) { + throw new LlmError( + 'llm-pi-ai: this composition mounts no credentials service, so there is nowhere to store the' + + ' credential a sign-in produces; mount one (dsh-credentials-local) to sign in', + 'NO_CREDENTIAL_STORE', + ) + } + return credentials +} + +/** + * A pi-ai `CredentialStore` over the harness credential records. + * + * pi-ai runs OAuth refresh *inside* `modify()`, so this store's exclusion has + * to cover a network round trip rather than a file rename — which is why the + * record write path takes a wait limit of its own rather than the short one a + * local write would need. + * @param ctx - the plugin context carrying the optional `ctx.credentials`. + * @returns the store to hand `createModels()`. + */ +export function credentialStoreFrom(ctx: Context): CredentialStore { + return { + async read(providerId) { + const credentials = ctx.get('credentials') + if (credentials === undefined) return undefined + return toPiCredential(await credentials.readRecord(recordKeyFor(providerId))) + }, + async list(): Promise { + const stored = await ctx.get('credentials')?.listRecords() ?? [] + const mine: CredentialInfo[] = [] + for (const entry of stored) { + // Records another plugin owns are not this collection's to report: + // their payloads are written in a format pi-ai never agreed to. + if (credentialKeyScope(entry.key) !== RECORD_SCOPE) continue + mine.push({ + providerId: credentialKeyId(entry.key), + type: entry.kind === 'api-key' ? 'api_key' : 'oauth', + }) + } + return mine + }, + async modify(providerId, mutate) { + const stored = await writableStore(ctx).modifyRecord(recordKeyFor(providerId), async (current) => { + const next = await mutate(toPiCredential(current)) + return next === undefined ? undefined : toRecord(next) + }) + return toPiCredential(stored) + }, + // `async` so a missing service reaches the caller as a rejection: pi-ai's + // store contract is promise-returning, and a synchronous throw would + // escape the `ModelsError` wrapper every other storage failure gets. + async delete(providerId) { + await writableStore(ctx).deleteRecord(recordKeyFor(providerId)) + }, + } +} + +/** + * A pi-ai `AuthContext` over the harness credential plane and the host + * filesystem. + * + * `env()` answers from the credential seam first, so a value a deployment + * stored through the harness is found by a provider's own ambient discovery — + * without this, that discovery reads only the process environment and a stored + * `AWS_ACCESS_KEY_ID` is invisible to it. `fileExists()` answers about the host + * process's own filesystem rather than the workspace `ctx.fs` seam, because the + * paths it is asked about (`~/.aws/credentials`, application-default + * credentials) are facts about where this process runs, not about the project + * under edit. + * @param ctx - the plugin context carrying the optional `ctx.credentials`. + * @returns the auth context to hand `createModels()`. + */ +export function authContextFrom(ctx: Context): AuthContext { + return { + async env(name) { + // pi-ai asks about arbitrary provider-declared names; one that is not a + // POSIX identifier can never have been stored as a reference, and asking + // the seam would throw instead of answering "not set". + if (isCredentialRefName(name)) { + const credentials = ctx.get('credentials') + const hit = await credentials?.resolve(credentialRef(name)) + if (hit !== undefined) return hit.value + } + return launchEnvironmentOf(ctx).get(name)?.value + }, + async fileExists(path) { + const expanded = path.startsWith('~/') || path === '~' + ? resolvePath(homedir(), path.slice(1).replace(/^\//, '')) + : path + try { + await access(expanded) + return true + } catch { + // Absent, unreadable, or a broken symlink — every one of which means + // this ambient credential source cannot be used, which is the only + // distinction the caller makes. + return false + } + }, + } +} diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index 229e881c98..1e9b126dd5 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -176,26 +176,6 @@ 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 1bbeec79db..846509f8c6 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -61,10 +61,12 @@ 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, catalogProviderTakesApiKey } from './catalog.ts' +import { authContextFrom, credentialStoreFrom } from './auth.ts' +import { catalogProviderIds } from './catalog.ts' import { assertServiceable, Config, resolveProfiles } from './config.ts' import type { ResolvedPiAiProviderProfile } from './config.ts' import { discoverModels } from './discovery.ts' +import { registerPiAiFlows } from './login.ts' export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions } from './adapter.ts' @@ -79,6 +81,7 @@ export type { PiAiThinkingFormat, ResolvedPiAiProviderProfile, } from './config.ts' +export { recordKeyFor } from './auth.ts' export { supportedProtocols } from './provider.ts' export const name = 'llm-pi-ai' @@ -105,15 +108,10 @@ function registrationFacts(profiles: ReadonlyMap ctx.get('attachments'), onReplayDegrade: ({ provider, model, reason }) => { ctx.logger.warn( @@ -208,6 +204,12 @@ export function apply(ctx: Context, config: Config): void { ) }, }) + // Independent of the route set: signing in is what makes a route worth + // adding, so the flows are offered before any profile names their provider. + // Scoped to the authorization seam rather than injected outright, because a + // composition without it (headless, ACP) simply has no surface to sign in + // from, while everything else this plugin does still works. + ctx.inject(['authorization'], (authorized) => { registerPiAiFlows(authorized, auth) }) // The full installed catalog is configurable from the moment the plugin // mounts — dormant or not — so configuration surfaces can offer every // pi-ai provider before any route exists. Hand-declared routes join it as diff --git a/packages/llm/llm-pi-ai/src/login.ts b/packages/llm/llm-pi-ai/src/login.ts new file mode 100644 index 0000000000..c71d68e3b5 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/login.ts @@ -0,0 +1,150 @@ +/** + * Authorization flows for the pi-ai providers that ship a login. This is the + * whole of the translation between the harness's neutral notice/prompt + * vocabulary and pi-ai's `AuthInteraction`; nothing above it knows which + * library ran the conversation. + * + * @module dsh-llm-pi-ai/login + */ + +import { createModels } from '@earendil-works/pi-ai' +import type { AuthEvent, AuthPrompt, AuthType, Provider } from '@earendil-works/pi-ai' +import type { Context } from '@deepseek-ai/cordis' +import type { AuthorizationMethod, AuthorizationPrompt, AuthorizationSession } from '@deepseek-ai/dsh-authorization' +import { catalogProvider, catalogProviderIds } from './catalog.ts' +import { recordKeyFor } from './auth.ts' +import type { PiAiAuthInjection } from './adapter.ts' + +/** + * The login methods one catalog provider offers. + * + * A method appears only when pi-ai can actually run it: `oauth` always carries + * a `login`, while an api-key method has one only when the provider collects + * its key interactively — which every installed one currently does, so a key is + * typed into pi-ai's own prompt rather than into the settings form. + * @param provider - the installed catalog provider, if pi-ai ships one. + * @returns its methods, most preferred first; empty when it offers no login. + */ +function loginMethods(provider: Provider | undefined): AuthorizationMethod[] { + const methods: AuthorizationMethod[] = [] + const oauth = provider?.auth.oauth + if (oauth !== undefined) methods.push({ id: 'oauth', label: oauth.loginLabel ?? oauth.name }) + const apiKey = provider?.auth.apiKey + if (apiKey?.login !== undefined) methods.push({ id: 'api-key', label: apiKey.name }) + return methods +} + +/** + * Restate one pi-ai login event in the seam's vocabulary. + * + * A device-code grant is the one event carrying two things the human needs at + * once — where to go and what to type there — which is why the neutral notice + * has a `code` beside its `url` rather than folding the code into the message. + * @param event - what pi-ai reported. + * @param session - the attempt to report it to. + */ +function relay(event: AuthEvent, session: AuthorizationSession): void { + switch (event.type) { + case 'info': { + const link = event.links?.[0] + session.notify({ message: event.message, ...link === undefined ? {} : { url: link.url } }) + return + } + case 'auth_url': + session.notify({ + message: event.instructions ?? 'Open this page to continue signing in.', + url: event.url, + }) + return + case 'device_code': + session.notify({ + message: 'Enter this code on the verification page to finish signing in.', + url: event.verificationUri, + code: event.userCode, + }) + return + case 'progress': + session.notify({ message: event.message }) + return + default: + // pi-ai's event union is open to new members: a build that meets one it + // does not know still shows the human that something is happening rather + // than going silent mid-login. + session.notify({ message: 'Signing in…' }) + } +} + +/** + * Restate one pi-ai prompt in the seam's vocabulary. + * + * `manual_code` becomes a plain text question because the difference pi-ai + * draws — a code the human copies from a browser rather than a value they know + * — changes nothing a surface renders. Its own `signal` is carried through, and + * that is the part which matters: it is how a flow racing a typed code against + * a browser callback withdraws the losing question. + * @param prompt - what pi-ai asked. + * @returns the neutral prompt to put to the human. + */ +function restate(prompt: AuthPrompt): AuthorizationPrompt { + const signal = prompt.signal === undefined ? {} : { signal: prompt.signal } + switch (prompt.type) { + case 'select': + return { ...signal, kind: 'select', message: prompt.message, options: prompt.options } + case 'secret': + return { + ...signal, + kind: 'secret', + message: prompt.message, + ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }, + } + default: + return { + ...signal, + kind: 'text', + message: prompt.message, + ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }, + } + } +} + +/** + * Register one authorization flow per installed provider that ships a login. + * + * Registration is unconditional on configuration: a provider has to be signed + * into before a route for it is worth adding, so the flow exists from the + * moment the plugin mounts rather than appearing once a profile does. + * @param ctx - the plugin context carrying `ctx.authorization`. + * @param auth - the injectables every collection here is built with. + */ +export function registerPiAiFlows(ctx: Context, auth: PiAiAuthInjection): void { + for (const providerId of catalogProviderIds()) { + const provider = catalogProvider(providerId) + const [first, ...rest] = loginMethods(provider) + /* v8 ignore next 3 -- every id here names an installed provider and every + installed provider ships a login, so nothing is skipped today; the guard + is what keeps that from becoming a crash if either stops being true. */ + if (provider === undefined || first === undefined) continue + ctx.authorization.registerFlow({ + key: recordKeyFor(providerId), + label: provider.name, + methods: [first, ...rest], + async run(session) { + // A collection of its own, holding only the provider being signed + // into: login is not serving requests, and the credential it produces + // lands in the shared store either way. + const models = createModels(auth) + models.setProvider(provider) + // Total over the two ids declared above, and the seam only ever hands + // back one a flow declared. + const type: AuthType = session.method === 'oauth' ? 'oauth' : 'api_key' + // pi-ai persists what the login returns through that same store, which + // is what makes it the single writer of this record. + await models.login(providerId, type, { + signal: session.signal, + notify: (event) => { relay(event, session) }, + prompt: prompt => session.prompt(restate(prompt)), + }) + }, + }) + } +} diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 2446b10286..a36a180f7d 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -13,6 +13,7 @@ import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import { DEFAULT_MAX_REQUEST_IMAGE_BYTES, resolveProfiles } from '../src/config.ts' +import { memoryAuth } from './auth-double.ts' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -47,6 +48,7 @@ function adapterOf( return new PiAiAdapter({ profiles: () => resolveProfiles(providers), resolveApiKey: () => Promise.resolve(apiKey), + auth: memoryAuth(), }) } diff --git a/packages/llm/llm-pi-ai/tests/auth-double.ts b/packages/llm/llm-pi-ai/tests/auth-double.ts new file mode 100644 index 0000000000..a05c017d04 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/auth-double.ts @@ -0,0 +1,39 @@ +import type { Credential } from '@earendil-works/pi-ai' +import type { PiAiAuthInjection } from '../src/adapter.ts' + +/** + * The auth injectables for tests that exercise streaming rather than + * authentication: an in-process credential store and an ambient context that + * finds nothing. A test needing real records builds the store over + * `ctx.credentials` instead, through `credentialStoreFrom`. + * @param seed - credentials to start with, by pi-ai provider id. + * @returns the injection to hand `PiAiAdapter`, with its store readable. + */ +export function memoryAuth(seed: Record = {}): PiAiAuthInjection & { + stored: Map +} { + const stored = new Map(Object.entries(seed)) + return { + stored, + credentials: { + read: id => Promise.resolve(stored.get(id)), + list: () => Promise.resolve([...stored].map(([providerId, credential]) => ({ + providerId, + type: credential.type, + }))), + async modify(id, mutate) { + const next = await mutate(stored.get(id)) + if (next !== undefined) stored.set(id, next) + return stored.get(id) + }, + delete: (id) => { + stored.delete(id) + return Promise.resolve() + }, + }, + authContext: { + env: () => Promise.resolve(undefined), + fileExists: () => Promise.resolve(false), + }, + } +} diff --git a/packages/llm/llm-pi-ai/tests/auth.spec.ts b/packages/llm/llm-pi-ai/tests/auth.spec.ts new file mode 100644 index 0000000000..55a20fbb66 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/auth.spec.ts @@ -0,0 +1,155 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' +import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials' +import { authContextFrom, credentialStoreFrom, recordKeyFor } from '../src/auth.ts' + +const CODEX = recordKeyFor('openai-codex') + +const dirs: string[] = [] + +/** A context whose credential records live in a throwaway `$DSH_HOME`. */ +async function stored(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-auth-')) + dirs.push(dir) + const ctx = new Context() + await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + return ctx +} + +afterEach(async () => { + vi.unstubAllEnvs() + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('pi-ai credential store over harness records', () => { + it('reads nothing for a provider with no record', async () => { + const store = credentialStoreFrom(await stored()) + + await expect(store.read('openai-codex')).resolves.toBeUndefined() + }) + + it('round-trips an api-key credential field by field', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + + await store.modify('cloudflare', () => + Promise.resolve({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } })) + + await expect(store.read('cloudflare')) + .resolves.toEqual({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } }) + await expect(ctx.credentials.readRecord(recordKeyFor('cloudflare'))) + .resolves.toEqual({ kind: 'api-key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } }) + }) + + it('stores an api-key credential carrying neither a key nor env', async () => { + const store = credentialStoreFrom(await stored()) + + await store.modify('bedrock', () => Promise.resolve({ type: 'api_key' })) + + await expect(store.read('bedrock')).resolves.toEqual({ type: 'api_key' }) + }) + + it('keeps an OAuth credential verbatim, refresh fields and all', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + const granted = { type: 'oauth' as const, access: 'at', refresh: 'rt', expires: 42, accountId: 'acc' } + + await store.modify('openai-codex', () => Promise.resolve(granted)) + + await expect(store.read('openai-codex')).resolves.toEqual(granted) + await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ kind: 'grant', payload: granted }) + }) + + it('shows the mutation the current credential and leaves it alone when declined', async () => { + const store = credentialStoreFrom(await stored()) + await store.modify('openai-codex', () => + Promise.resolve({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 })) + const seen: unknown[] = [] + + const unchanged = await store.modify('openai-codex', (current) => { + seen.push(current) + return Promise.resolve(undefined) + }) + + expect(seen).toEqual([{ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }]) + expect(unchanged).toEqual({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }) + }) + + it('lists only the records this adapter family owns', async () => { + const ctx = await stored() + const store = credentialStoreFrom(ctx) + await store.modify('openai-codex', () => + Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 })) + await store.modify('cloudflare', () => Promise.resolve({ type: 'api_key', key: 'k' })) + // Another plugin's record for a provider name this one also serves: its + // payload is written in a format pi-ai never agreed to. + await ctx.credentials.modifyRecord(credentialKey('llm-kimi', 'openai-codex'), () => + Promise.resolve({ kind: 'grant', payload: { theirs: true } })) + + await expect(store.list()).resolves.toEqual([ + { providerId: 'openai-codex', type: 'oauth' }, + { providerId: 'cloudflare', type: 'api_key' }, + ]) + }) + + it('forgets a credential on delete, and stays quiet when there was none', async () => { + const store = credentialStoreFrom(await stored()) + await store.modify('openai-codex', () => + Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 })) + + await store.delete('openai-codex') + await store.delete('openai-codex') + + await expect(store.read('openai-codex')).resolves.toBeUndefined() + }) + + it('reads empty but refuses to write without a credentials service', async () => { + const store = credentialStoreFrom(new Context()) + + await expect(store.read('openai-codex')).resolves.toBeUndefined() + await expect(store.list()).resolves.toEqual([]) + await expect(store.modify('openai-codex', () => Promise.resolve({ type: 'api_key', key: 'k' }))) + .rejects.toThrow(/mounts no credentials service/) + await expect(store.delete('openai-codex')).rejects.toThrow(/mounts no credentials service/) + }) +}) + +describe('pi-ai ambient auth context', () => { + beforeEach(() => { + vi.stubEnv('PI_AUTH_AMBIENT', 'from-environment') + }) + + it('answers an environment name from the credential seam first', async () => { + const ctx = await stored() + await ctx.credentials.set(credentialRef('PI_AUTH_SEAM'), 'from-seam') + + await expect(authContextFrom(ctx).env('PI_AUTH_SEAM')).resolves.toBe('from-seam') + }) + + it('falls back to the launch environment when nothing is stored', async () => { + await expect(authContextFrom(await stored()).env('PI_AUTH_AMBIENT')).resolves.toBe('from-environment') + }) + + it('answers "not set" for a name no reference could ever address', async () => { + // pi-ai asks about provider-declared names; one outside the reference + // grammar has no reference to miss, and must not throw. + await expect(authContextFrom(await stored()).env('not a var')).resolves.toBeUndefined() + }) + + it('answers about the host filesystem, expanding a leading ~', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-home-')) + dirs.push(dir) + await writeFile(join(dir, 'creds'), 'x') + vi.stubEnv('HOME', dir) + const context = authContextFrom(await stored()) + + await expect(context.fileExists('~/creds')).resolves.toBe(true) + await expect(context.fileExists('~/missing')).resolves.toBe(false) + await expect(context.fileExists(join(dir, 'creds'))).resolves.toBe(true) + await expect(context.fileExists('~')).resolves.toBe(true) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 0322b81edf..99886cdbaf 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -15,6 +15,7 @@ import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-wo import { resolveProfiles } from '../src/config.ts' import { buildProvider, supportedProtocols } from '../src/provider.ts' import { assemble } from './assemble.ts' +import { memoryAuth } from './auth-double.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' const homes: string[] = [] @@ -1065,6 +1066,7 @@ describe('resolution snapshots', () => { // Credential resolution is the real await inside a stream call, and the // window a configuration change has to land in. resolveApiKey: async () => { await held; return 'k' }, + auth: memoryAuth(), }) const chunks: StreamChunk[] = [] @@ -1093,7 +1095,11 @@ describe('resolution snapshots', () => { const first = await mockServer([{ events: textEvents }]) const second = await mockServer([{ events: textEvents }]) let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } }) - const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') }) + const adapter = new PiAiAdapter({ + profiles: () => current, + resolveApiKey: () => Promise.resolve('k'), + auth: memoryAuth(), + }) const drain = async (): Promise => { for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'deepseek-v4-flash', messages: [], @@ -1160,30 +1166,22 @@ describe('configurable-provider directory', () => { expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly) }) - it('withholds a catalog route this adapter cannot authenticate', async () => { + it('offers every installed catalog route, including one that only signs in', 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. + // OAuth alone. It is offered like any other because the collection now + // carries a durable credential store and a login flow writes into it, so + // the route has a posture that works rather than only one that fails. + expect(offered).toContain('openai-codex') 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. + it('lists a route a stored profile names as a catalog route, not a declared one', async () => { + // `declared` answers catalog membership, so a profile stored against a + // route pi-ai ships is not mislabelled as one this deployment invented. const ctx = await harness({ providers: { 'openai-codex': { apiKeyEnv: KEY_ENV } } }) expect(ctx.llm.listConfigurableProviders()).toContainEqual({ diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 1019354156..3decff3749 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -9,6 +9,7 @@ import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { FileSettingsProvider } from '@deepseek-ai/dsh-settings-file' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import AuthorizationService from '@deepseek-ai/dsh-authorization' import { assemble } from './assemble.ts' import { closeMockServers, mockServer, textEvents } from './mock-server.ts' @@ -37,7 +38,11 @@ async function home(): Promise { } /** Real dynamic composition mirroring the deepseek twin's harness. */ -async function boot(dir: string, config: LlmPiAi.Config): Promise { +async function boot( + dir: string, + config: LlmPiAi.Config, + options: { authorization?: boolean } = {}, +): Promise { const ctx = new Context() cleanups.push(async () => { await ctx.fiber.dispose() @@ -45,17 +50,38 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false }) await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + if (options.authorization === true) await ctx.plugin(AuthorizationService) await ctx.plugin(LlmPiAi, config) return ctx } +describe('login flows in a real composition', () => { + it('offers a sign-in for a provider no route names, once the seam is mounted', async () => { + const ctx = await boot(await home(), {}, { authorization: true }) + + // Zero routes configured: signing in is what makes a route worth adding, + // so the offer cannot wait for a profile to name the provider. + const codex = ctx.authorization.describe(LlmPiAi.recordKeyFor('openai-codex')) + expect(codex?.methods.map(method => method.id)).toEqual(['oauth']) + }) + + it('mounts without the seam, and simply offers no sign-in', async () => { + const ctx = await boot(await home(), {}) + + // A headless or ACP composition has no surface to sign in from; everything + // else this plugin does still works. + expect(ctx.get('authorization')).toBeUndefined() + expect(ctx.llm.listConfigurableProviders().length).toBeGreaterThan(0) + }) +}) + 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, '.credentials.yaml'), - 'PI_DYNAMIC_KEY: pk-from-settings\nPI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + 'version: 1\nrefs:\n PI_DYNAMIC_KEY: pk-from-settings\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n', { mode: 0o600 }, ) const server = await mockServer([{ events: textEvents }]) @@ -93,7 +119,7 @@ describe('request-level dynamic profiles', () => { const dir = await home() await writeFile( join(dir, '.credentials.yaml'), - 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n', { mode: 0o600 }, ) const server = await mockServer([{ events: textEvents }]) @@ -122,7 +148,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, '.credentials.yaml'), 'PI_DYNAMIC_KEY: pk-one\n', { mode: 0o600 }) + await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n 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 } }, @@ -173,7 +199,7 @@ describe('request-level dynamic profiles', () => { const dir = await home() await writeFile( join(dir, '.credentials.yaml'), - 'PI_LIVE_KEY: live-key\nPI_OTHER_KEY: other\n', + 'version: 1\nrefs:\n PI_LIVE_KEY: live-key\n PI_OTHER_KEY: other\n', { mode: 0o600 }, ) const server = await mockServer([{ events: textEvents }, { events: textEvents }]) diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index a4e89424f6..6ca02f281a 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -49,7 +49,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, '.credentials.yaml'), 'PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 }) + await writeFile(join(root, '.credentials.yaml'), 'version: 1\nrefs:\n PI_COMPOSITION_KEY: key-from-store\n', { mode: 0o600 }) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ diff --git a/packages/llm/llm-pi-ai/tests/login.spec.ts b/packages/llm/llm-pi-ai/tests/login.spec.ts new file mode 100644 index 0000000000..beb65ce188 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/login.spec.ts @@ -0,0 +1,198 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AuthorizationService from '@deepseek-ai/dsh-authorization' +import type { AuthorizationInteraction, AuthorizationNotice, AuthorizationPrompt } from '@deepseek-ai/dsh-authorization' +import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local' +import type { CredentialKey } from '@deepseek-ai/dsh-credentials' +import type { AuthEvent, AuthInteraction, AuthPrompt, AuthType, Credential } from '@earendil-works/pi-ai' + +const login = vi.hoisted(() => vi.fn()) + +// The whole of what this module does with pi-ai is run one provider's login +// against a collection built with the harness store, so the collection is the +// boundary worth observing; a real login would open a browser. +vi.mock('@earendil-works/pi-ai', async importOriginal => ({ + ...await importOriginal(), + createModels: () => ({ setProvider: () => {}, login }), +})) + +const { credentialStoreFrom, authContextFrom, recordKeyFor } = await import('../src/auth.ts') +const { registerPiAiFlows } = await import('../src/login.ts') + +const CODEX = recordKeyFor('openai-codex') +const dirs: string[] = [] + +/** A context with the record store, the seam, and every pi-ai login flow. */ +async function harness(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-login-')) + dirs.push(dir) + const ctx = new Context() + await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false }) + await ctx.plugin(AuthorizationService) + registerPiAiFlows(ctx, { credentials: credentialStoreFrom(ctx), authContext: authContextFrom(ctx) }) + return ctx +} + +/** An interaction recording everything a flow says, answering every question. */ +function surface(answer = 'typed'): AuthorizationInteraction & { + notices: AuthorizationNotice[] + prompts: AuthorizationPrompt[] +} { + const notices: AuthorizationNotice[] = [] + const prompts: AuthorizationPrompt[] = [] + return { + notices, + prompts, + notify: (notice) => { notices.push(notice) }, + prompt: (prompt) => { + prompts.push(prompt) + return Promise.resolve(answer) + }, + } +} + +/** Drive one attempt, letting the mocked login talk back through `converse`. */ +async function attempt( + ctx: Context, + converse: (interaction: AuthInteraction) => Promise, + request: { key?: CredentialKey; method?: string } = {}, +): Promise> { + const ui = surface() + login.mockImplementation(async (providerId: string, _type: AuthType, interaction: AuthInteraction) => { + await converse(interaction) + const granted: Credential = { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 } + await credentialStoreFrom(ctx).modify(providerId, () => Promise.resolve(granted)) + return granted + }) + await expect(ctx.authorization.begin({ + key: request.key ?? CODEX, + interaction: ui, + ...request.method === undefined ? {} : { method: request.method }, + })).resolves.toEqual({ status: 'authorized' }) + return ui +} + +afterEach(async () => { + login.mockReset() + await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('pi-ai login flows', () => { + it('offers one flow per installed provider, with the methods that provider ships', async () => { + const ctx = await harness() + const offered = ctx.authorization.list() + + // The OAuth-only provider is exactly the case this exists for: nothing + // else could ever configure it. + expect(offered.find(entry => entry.key === CODEX)?.methods) + .toEqual([{ id: 'oauth', label: expect.stringContaining('ChatGPT') as string }]) + // A provider offering both keeps both, the subscription login first. + expect(offered.find(entry => entry.key === recordKeyFor('anthropic'))?.methods.map(one => one.id)) + .toEqual(['oauth', 'api-key']) + // A key-only provider still gets a flow, because pi-ai collects the key + // through its own prompt rather than leaving it to the settings form. + expect(offered.find(entry => entry.key === recordKeyFor('deepseek'))?.methods.map(one => one.id)) + .toEqual(['api-key']) + }) + + it('runs the pi-ai auth type the chosen method names', async () => { + const ctx = await harness() + + await attempt(ctx, () => Promise.resolve()) + expect(login).toHaveBeenLastCalledWith('openai-codex', 'oauth', expect.anything()) + + await attempt(ctx, () => Promise.resolve(), { key: recordKeyFor('anthropic'), method: 'api-key' }) + expect(login).toHaveBeenLastCalledWith('anthropic', 'api_key', expect.anything()) + }) + + it('commits what the login produced, where the adapter reads it back', async () => { + const ctx = await harness() + + await attempt(ctx, () => Promise.resolve()) + + await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ + kind: 'grant', + payload: { type: 'oauth', access: 'at', refresh: 'rt', expires: 1 }, + }) + }) + + it('restates every pi-ai login event in the neutral vocabulary', async () => { + const ctx = await harness() + const events: AuthEvent[] = [ + { type: 'info', message: 'Read this first', links: [{ url: 'https://help.example' }] }, + { type: 'info', message: 'Nothing to open' }, + { type: 'auth_url', url: 'https://auth.example/start', instructions: 'Approve in the tab' }, + { type: 'auth_url', url: 'https://auth.example/plain' }, + { type: 'device_code', userCode: 'WXYZ-1234', verificationUri: 'https://device.example' }, + { type: 'progress', message: 'Exchanging the code' }, + // pi-ai's event union is open; an unrecognised member must still show + // the human that something is happening. + { type: 'quantum-handshake' } as unknown as AuthEvent, + ] + + const ui = await attempt(ctx, (interaction) => { + for (const event of events) interaction.notify(event) + return Promise.resolve() + }) + + expect(ui.notices).toEqual([ + { message: 'Read this first', url: 'https://help.example' }, + { message: 'Nothing to open' }, + { message: 'Approve in the tab', url: 'https://auth.example/start' }, + { message: 'Open this page to continue signing in.', url: 'https://auth.example/plain' }, + { + message: 'Enter this code on the verification page to finish signing in.', + url: 'https://device.example', + code: 'WXYZ-1234', + }, + { message: 'Exchanging the code' }, + { message: 'Signing in…' }, + ]) + }) + + it('restates every pi-ai prompt, carrying the per-prompt withdrawal signal', async () => { + const ctx = await harness() + const withdraw = new AbortController() + const prompts: AuthPrompt[] = [ + { type: 'text', message: 'Your workspace', placeholder: 'acme' }, + { type: 'secret', message: 'Paste the key' }, + { type: 'secret', message: 'Paste the token', placeholder: 'sk-…' }, + { type: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] }, + // The manual-code question a browser callback can win the race against. + { type: 'manual_code', message: 'Paste the code', signal: withdraw.signal }, + ] + + const ui = await attempt(ctx, async (interaction) => { + for (const prompt of prompts) await interaction.prompt(prompt) + }) + + expect(ui.prompts).toEqual([ + { kind: 'text', message: 'Your workspace', placeholder: 'acme' }, + { kind: 'secret', message: 'Paste the key' }, + { kind: 'secret', message: 'Paste the token', placeholder: 'sk-…' }, + { kind: 'select', message: 'Which account?', options: [{ id: 'a', label: 'Work' }] }, + { kind: 'text', message: 'Paste the code', signal: withdraw.signal }, + ]) + }) + + it('hands the flow the attempt-wide cancellation signal', async () => { + const ctx = await harness() + let seen: AbortSignal | undefined + const controller = new AbortController() + login.mockImplementation((_id: string, _type: AuthType, interaction: AuthInteraction) => { + seen = interaction.signal + controller.abort() + return new Promise(() => {}) + }) + + await expect(ctx.authorization.begin({ + key: CODEX, + interaction: surface(), + signal: controller.signal, + })).resolves.toEqual({ status: 'cancelled' }) + expect(seen?.aborted).toBe(true) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts index 317b803f0c..2f409c6d9d 100644 --- a/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts +++ b/packages/llm/llm-pi-ai/tests/sdk-options.spec.ts @@ -13,6 +13,7 @@ vi.mock('@earendil-works/pi-ai/api/openai-completions.lazy', () => ({ import { PiAiAdapter } from '../src/adapter.ts' import { resolveProfiles } from '../src/config.ts' +import { memoryAuth } from './auth-double.ts' afterEach(() => { streamSimple.mockReset() }) @@ -27,6 +28,7 @@ function gatewayAdapter(): PiAiAdapter { }, }), resolveApiKey: () => Promise.resolve('test-key'), + auth: memoryAuth(), }) } diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 6f0b821d36..172dbe8a6e 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../credentials/credentials" }, + { + "path": "../../credentials/authorization" + }, { "path": "../../settings/settings" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c5eab9c8f..f993dc3672 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5535,6 +5535,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-authorization': + specifier: workspace:^ + version: link:../../credentials/authorization '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials @@ -8714,6 +8717,9 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../packages/attachment/attachment + '@deepseek-ai/dsh-authorization': + specifier: workspace:^ + version: link:../../packages/credentials/authorization '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/shell/bash-local diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 86689a6bf6..befad374c1 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^",