diff --git a/packages/agent/docs/models.md b/packages/agent/docs/models.md index 6a5cd8f7..c634a267 100644 --- a/packages/agent/docs/models.md +++ b/packages/agent/docs/models.md @@ -399,19 +399,19 @@ If a value cannot be expressed as `apiKey`, `headers`, or `baseUrl`, it is provi ```ts export interface ProviderAuth { - apiKey?: ApiKeyAuth; // stored key/metadata + ambient env/files/ADC/IAM + apiKey?: ApiKeyAuth; // stored key/provider env + ambient env/files/ADC/IAM oauth?: OAuthAuth; // login flow + refresh } export interface ApiKeyAuth { name: string; // "Anthropic API key" - /** Interactive setup (prompt for key/metadata). Absent = ambient-only (env, ADC, IAM). */ + /** Interactive setup (prompt for key/provider env). Absent = ambient-only (env, ADC, IAM). */ login?(callbacks: AuthLoginCallbacks): Promise; /** * Resolve auth from the stored credential and/or ambient sources, merging - * per field (credential.key ?? env("..."), metadata.accountId ?? env("...")). + * per field (credential.key ?? env("..."), credential.env?.NAME ?? env("...")). * undefined = not configured. */ resolve(input: { @@ -455,9 +455,9 @@ One credential per provider, type-tagged — exactly the shape of today's auth.j ```ts export interface ApiKeyCredential { - type: "api-key"; + type: "api_key"; key?: string; - metadata?: Record; // e.g. Cloudflare accountId/gatewayId + env?: ProviderEnv; // e.g. Cloudflare account/gateway ids, Azure/Vertex/Bedrock scoped config } export interface OAuthCredential extends OAuthCredentials { @@ -467,7 +467,7 @@ export interface OAuthCredential extends OAuthCredentials { export type Credential = ApiKeyCredential | OAuthCredential; ``` -`ApiKeyCredential.metadata` exists for providers like Cloudflare that store non-key values (account id, gateway id) alongside or instead of a key. `ApiKeyAuth.resolve()` merges per field: `credential.key ?? env("CLOUDFLARE_API_TOKEN")`, `credential.metadata?.accountId ?? env("CLOUDFLARE_ACCOUNT_ID")`, etc. +`ApiKeyCredential.env` stores provider-scoped environment/config values alongside or instead of a key. `ApiKeyAuth.resolve()` merges per field: `credential.key ?? env("CLOUDFLARE_API_KEY")`, `credential.env?.CLOUDFLARE_ACCOUNT_ID ?? env("CLOUDFLARE_ACCOUNT_ID")`, etc. The credential discriminator intentionally matches today's `auth.json` (`api_key`) so the file-backed store does not need lossy type translation. ### Credential store @@ -528,7 +528,7 @@ if (stored) { } return { auth: await oauth.toAuth(credential), source: "OAuth" }; } - if (stored.type === "api-key" && provider.auth.apiKey) { + if (stored.type === "api_key" && provider.auth.apiKey) { return provider.auth.apiKey.resolve({ model, ctx, credential: stored }); } return undefined; // stored credential without matching handler blocks ambient @@ -877,22 +877,22 @@ Decisions: - models.json keeps FULL feature parity, implemented as provider decoration: builtin factories wrapped so `getModels()` applies provider `baseUrl`/`compat` overlays, `modelOverrides`, and custom-model merges (async-safe); provider `apiKey`/`headers`/`authHeader` configs become that provider's `ApiKeyAuth` (config first, factory auth fallback); parse errors keep `getError()` semantics. - Extension `ProviderConfig` parity: provider-keyed `streamSimple`, old-style `oauth` adapted to `OAuthAuth` (`modifyModels` -> `getModels` wrap + `toAuth`), full model replacement per provider. Legacy `registerApiProvider` writes stay compat-local for consumers that call global `complete()`; they die with compat. - Copilot: stored-credential baseUrl applied in the wrapped `getModels()` (extension-visible models stay correct) plus per-request `toAuth().baseUrl`. -- Cloudflare: provider-auth substitution (key + `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_GATEWAY_ID` from credential metadata/env -> `ModelAuth.baseUrl`). Built-in compat calls route through `Models`, so they use the same provider auth path. +- Cloudflare: provider-auth substitution (key + `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_GATEWAY_ID` from credential `env` or ambient `AuthContext.env()` -> `ModelAuth.baseUrl`). Built-in compat calls route through `Models`, so they use the same provider auth path. Ordering for new sessions: 1. [x] pi-ai rework first: `Provider.getModels()` sync + optional `refreshModels()`; `Models.getModels`/`getModel` sync, `Models.refresh(provider?)` async; `createProvider` takes `models` array + optional `refreshModels` fetcher (in-flight dedupe). Reverses Phase 1's async-listing decision — see "Provider model listing" for rationale (sync-or-async unions breed latent sync assumptions; async-only breaks sync consumer surfaces like extension `find`/`getAll`). -2. [x] Cloudflare provider auth in pi-ai factories: Workers AI and AI Gateway validate their required account/gateway metadata and return resolved `baseUrl`, provider-scoped env, and header suppression/override metadata from provider auth. +2. [x] Cloudflare provider auth in pi-ai factories: Workers AI and AI Gateway validate their required account/gateway env/config and return resolved `baseUrl`, provider-scoped env, and header suppression/override metadata from provider auth. 3. [ ] Add `FileCredentialStore` in coding-agent. - Implement the pi-ai `CredentialStore` interface over the existing `auth.json` lock backend (`FileAuthStorageBackend` / `InMemoryAuthStorageBackend` can be reused or renamed). - - Preserve the existing file format where possible, but normalize legacy `{ type: "api_key", key, env? }` to pi-ai `{ type: "api-key", key, metadata? }` on read. `env` becomes provider metadata/env sidecar data; do not lose unknown keys. + - Preserve the existing file format. `ApiKeyCredential` uses `{ type: "api_key", key?, env? }`, matching today's `auth.json`; do not translate `env` into metadata or rewrite discriminators. - `read(provider)` returns the current credential snapshot and records parse/storage errors for status UI parity. - `modify(provider, fn)` must lock, re-read, run `fn`, merge-write the provider entry, chmod `0600`, and return the post-write credential. - `delete(provider)` must lock and remove only that provider's entry. - - Add file-backed and in-memory tests covering lock/RMW behavior, legacy `api_key` reads, OAuth reads, metadata/env preservation, delete, parse errors, and concurrent refresh-style modifications. + - Add file-backed and in-memory tests covering lock/RMW behavior, `api_key` reads, OAuth reads, provider `env` preservation, delete, parse errors, and concurrent refresh-style modifications. 4. [ ] Add store decorators for coding-agent policy. - - `withConfigValues(store, policy)` resolves stored API-key credentials whose `key` or metadata values use `$ENV` or `!command`, using existing `resolve-config-value.ts` semantics. Command execution stays in coding-agent, not pi-ai. - - `withRuntimeOverrides(store, overrides)` implements CLI `--api-key`: read returns an ephemeral `{ type: "api-key", key }` for each overridden provider, masking stored OAuth/API credentials without persisting. + - `withConfigValues(store, policy)` resolves stored API-key credentials whose `key` or `env` values use `$ENV` or `!command`, using existing `resolve-config-value.ts` semantics. Command execution stays in coding-agent, not pi-ai. + - `withRuntimeOverrides(store, overrides)` implements CLI `--api-key`: read returns an ephemeral `{ type: "api_key", key }` for each overridden provider, masking stored OAuth/API credentials without persisting. - Runtime overrides must apply even to OAuth-capable providers; every provider registered in coding-agent must retain or gain an `apiKey` auth slot so the overlay is meaningful. - Tests cover precedence: runtime override > stored credential > models.json config auth > ambient provider env, with stored credential blocking ambient fallback. 5. [ ] Build provider decoration helpers for `models.json`. @@ -929,7 +929,7 @@ Ordering for new sessions: - Keep extension loader root-to-compat alias until Phase 10, but expose the new collection/facade as the forward API. 10. [ ] Test migration and real-provider validation. - Unit tests for `FileCredentialStore`, config-value decorators, provider decoration, extension OAuth adapter, ModelRegistry async facade, and consumer rewiring. - - Regression tests for Cloudflare account/gateway metadata, Copilot OAuth baseUrl wrapping, runtime `--api-key` precedence, `$ENV`/`!command` resolution, and stored credential blocking ambient fallback. + - Regression tests for Cloudflare account/gateway env, Copilot OAuth baseUrl wrapping, runtime `--api-key` precedence, `$ENV`/`!command` resolution, and stored credential blocking ambient fallback. - Update existing tests that assume sync `ModelRegistry.getAll/find/getAvailable`. - Run targeted non-e2e suites plus tmux validation of login flows against real providers (Anthropic OAuth/API key, OpenAI Codex OAuth, GitHub Copilot OAuth, Cloudflare AI Gateway, Bedrock if credentials are available). diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6a8d1ba5..69d3990d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Breaking Changes + +- Changed `ApiKeyCredential` to use the `auth.json`-compatible discriminator `type: "api_key"` and provider-scoped `env` values instead of `type: "api-key"` and metadata. + ## [0.80.1] - 2026-06-23 ### Fixed diff --git a/packages/ai/README.md b/packages/ai/README.md index d600f648..00e01b34 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -361,6 +361,19 @@ const models = createModels({ credentials: myFileBackedStore }); The contract is small: `read(providerId)`, `modify(providerId, fn)` (the only write path — a serialized read-modify-write), and `delete(providerId)`. OAuth token refresh runs inside `modify`, so concurrent requests and processes cannot double-refresh a rotated token. A stored credential *owns* its provider: environment variables are only consulted when nothing is stored, and a failed refresh never silently falls back to an env key. +API-key credentials use the same discriminator as pi's `auth.json` and can carry provider-scoped env/config values: + +```typescript +const credential = { + type: 'api_key', + key: '...', + env: { + CLOUDFLARE_ACCOUNT_ID: 'account-id', + CLOUDFLARE_GATEWAY_ID: 'gateway-id' + } +} as const; +``` + ### Environment Variables Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` explicitly): diff --git a/packages/ai/src/auth/helpers.ts b/packages/ai/src/auth/helpers.ts index 0362c364..d9a34ad2 100644 --- a/packages/ai/src/auth/helpers.ts +++ b/packages/ai/src/auth/helpers.ts @@ -3,7 +3,7 @@ import type { ApiKeyAuth, OAuthAuth } from "./types.ts"; /** * Standard api-key auth: a stored credential key wins, otherwise the first * set env var resolves. Includes a `login` that prompts for the key. - * Providers with non-standard resolution (metadata, ambient files, IAM) + * Providers with non-standard resolution (provider env, ambient files, IAM) * write their own `ApiKeyAuth`. */ export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth { @@ -11,7 +11,7 @@ export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyA name, login: async (callbacks) => { const key = await callbacks.prompt({ type: "secret", message: `Enter ${name}` }); - return { type: "api-key", key }; + return { type: "api_key", key }; }, resolve: async ({ ctx, credential }) => { if (credential?.key) return { auth: { apiKey: credential.key }, source: "stored credential" }; diff --git a/packages/ai/src/auth/resolve.ts b/packages/ai/src/auth/resolve.ts index 3adec6b4..8ed866fd 100644 --- a/packages/ai/src/auth/resolve.ts +++ b/packages/ai/src/auth/resolve.ts @@ -43,7 +43,7 @@ export async function resolveProviderAuth( if (stored.type === "oauth" && provider.auth.oauth) { return resolveStoredOAuth(credentials, provider.id, provider.auth.oauth, stored); } - if (stored.type === "api-key" && provider.auth.apiKey) { + if (stored.type === "api_key" && provider.auth.apiKey) { return resolveApiKey(authContext, provider.auth.apiKey, model, stored); } return undefined; diff --git a/packages/ai/src/auth/types.ts b/packages/ai/src/auth/types.ts index ffda2858..9710dbcb 100644 --- a/packages/ai/src/auth/types.ts +++ b/packages/ai/src/auth/types.ts @@ -12,13 +12,13 @@ export interface ModelAuth { } /** - * Stored api-key credential. `metadata` holds non-key values such as - * Cloudflare account/gateway ids. + * Stored api-key credential. `env` holds provider-scoped environment/config + * values such as Cloudflare account/gateway ids. */ export interface ApiKeyCredential { - type: "api-key"; + type: "api_key"; key?: string; - metadata?: Record; + env?: ProviderEnv; } /** Stored OAuth credential (`access`, `refresh`, `expires` from OAuthCredentials). */ @@ -123,19 +123,19 @@ export interface AuthLoginCallbacks { } /** - * Api-key auth: stored key/metadata plus ambient sources (env vars, AWS + * Api-key auth: stored key/provider env plus ambient sources (env vars, AWS * profiles, ADC files). Ambient-only providers omit `login`. */ export interface ApiKeyAuth { /** Display name, e.g. "Anthropic API key". */ name: string; - /** Interactive setup (prompt for key/metadata). Absent = ambient-only. */ + /** Interactive setup (prompt for key/provider env). Absent = ambient-only. */ login?(callbacks: AuthLoginCallbacks): Promise; /** * Resolve auth from the stored credential and/or ambient sources, merging - * per field (`credential.key ?? env("...")`, `metadata.accountId ?? env("...")`). + * per field (`credential.key ?? env("...")`, `credential.env?.NAME ?? env("...")`). * undefined = not configured. Receives the chat or image-generation model * the request is for (both carry `provider` and `baseUrl`). */ diff --git a/packages/ai/src/providers/cloudflare-auth.ts b/packages/ai/src/providers/cloudflare-auth.ts index 7bf909cd..511e8d73 100644 --- a/packages/ai/src/providers/cloudflare-auth.ts +++ b/packages/ai/src/providers/cloudflare-auth.ts @@ -7,16 +7,16 @@ const CLOUDFLARE_GATEWAY_ID = "CLOUDFLARE_GATEWAY_ID"; type CloudflareAuthKind = "workers-ai" | "ai-gateway"; -async function resolveValue(input: { - name: string; - ctx: AuthContext; - credential: ApiKeyCredential | undefined; -}): Promise { - if (input.credential) { - if (input.name === CLOUDFLARE_API_KEY) return input.credential.key; - return input.credential.metadata?.[input.name]; +async function resolveValue( + name: string, + ctx: AuthContext, + credential: ApiKeyCredential | undefined, +): Promise { + if (credential) { + if (name === CLOUDFLARE_API_KEY) return credential.key; + return credential.env?.[name]; } - return input.ctx.env(input.name); + return ctx.env(name); } function resolveCloudflareBaseUrl( @@ -29,20 +29,17 @@ function resolveCloudflareBaseUrl( .replaceAll(`{${CLOUDFLARE_GATEWAY_ID}}`, gatewayId ?? ""); } -async function resolveCloudflareEnv(input: { - kind: CloudflareAuthKind; - model: Model | ImagesModel; - ctx: AuthContext; - credential: ApiKeyCredential | undefined; -}): Promise<{ apiKey: string; env: ProviderEnv; baseUrl: string; source: string } | undefined> { - const apiKey = await resolveValue({ name: CLOUDFLARE_API_KEY, ctx: input.ctx, credential: input.credential }); - const accountId = await resolveValue({ name: CLOUDFLARE_ACCOUNT_ID, ctx: input.ctx, credential: input.credential }); - const gatewayId = - input.kind === "ai-gateway" - ? await resolveValue({ name: CLOUDFLARE_GATEWAY_ID, ctx: input.ctx, credential: input.credential }) - : undefined; +async function resolveCloudflareEnv( + kind: CloudflareAuthKind, + model: Model | ImagesModel, + ctx: AuthContext, + credential: ApiKeyCredential | undefined, +): Promise<{ apiKey: string; env: ProviderEnv; baseUrl: string; source: string } | undefined> { + const apiKey = await resolveValue(CLOUDFLARE_API_KEY, ctx, credential); + const accountId = await resolveValue(CLOUDFLARE_ACCOUNT_ID, ctx, credential); + const gatewayId = kind === "ai-gateway" ? await resolveValue(CLOUDFLARE_GATEWAY_ID, ctx, credential) : undefined; - if (!apiKey || !accountId || (input.kind === "ai-gateway" && !gatewayId)) return undefined; + if (!apiKey || !accountId || (kind === "ai-gateway" && !gatewayId)) return undefined; return { apiKey, @@ -50,8 +47,8 @@ async function resolveCloudflareEnv(input: { CLOUDFLARE_ACCOUNT_ID: accountId, ...(gatewayId ? { CLOUDFLARE_GATEWAY_ID: gatewayId } : {}), }, - baseUrl: resolveCloudflareBaseUrl(input.model, accountId, gatewayId), - source: input.credential ? "stored credential" : CLOUDFLARE_API_KEY, + baseUrl: resolveCloudflareBaseUrl(model, accountId, gatewayId), + source: credential ? "stored credential" : CLOUDFLARE_API_KEY, }; } @@ -61,10 +58,10 @@ export function cloudflareWorkersAIAuth(): ApiKeyAuth { login: async (callbacks) => { const key = await callbacks.prompt({ type: "secret", message: "Enter Cloudflare API key" }); const accountId = await callbacks.prompt({ type: "text", message: "Enter Cloudflare account ID" }); - return { type: "api-key", key, metadata: { CLOUDFLARE_ACCOUNT_ID: accountId } }; + return { type: "api_key", key, env: { CLOUDFLARE_ACCOUNT_ID: accountId } }; }, resolve: async ({ model, ctx, credential }) => { - const resolved = await resolveCloudflareEnv({ kind: "workers-ai", model, ctx, credential }); + const resolved = await resolveCloudflareEnv("workers-ai", model, ctx, credential); if (!resolved) return undefined; return { auth: { apiKey: resolved.apiKey, baseUrl: resolved.baseUrl }, @@ -83,13 +80,13 @@ export function cloudflareAIGatewayAuth(): ApiKeyAuth { const accountId = await callbacks.prompt({ type: "text", message: "Enter Cloudflare account ID" }); const gatewayId = await callbacks.prompt({ type: "text", message: "Enter Cloudflare AI Gateway ID" }); return { - type: "api-key", + type: "api_key", key, - metadata: { CLOUDFLARE_ACCOUNT_ID: accountId, CLOUDFLARE_GATEWAY_ID: gatewayId }, + env: { CLOUDFLARE_ACCOUNT_ID: accountId, CLOUDFLARE_GATEWAY_ID: gatewayId }, }; }, resolve: async ({ model, ctx, credential }) => { - const resolved = await resolveCloudflareEnv({ kind: "ai-gateway", model, ctx, credential }); + const resolved = await resolveCloudflareEnv("ai-gateway", model, ctx, credential); if (!resolved) return undefined; return { auth: { diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index cafcb17f..f55e035e 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -225,7 +225,7 @@ describe("Models runtime", () => { expect(resolution?.source).toBe("OAuth"); // stored api-key credential resolves through apiKey auth, beats env - await credentials.modify("p1", async () => ({ type: "api-key", key: "stored-key" })); + await credentials.modify("p1", async () => ({ type: "api_key", key: "stored-key" })); const apiKeyResolution = await models.getAuth(model); expect(apiKeyResolution?.auth.apiKey).toBe("stored-key"); expect(apiKeyResolution?.source).toBe("stored"); diff --git a/packages/ai/test/providers.test.ts b/packages/ai/test/providers.test.ts index e3e8c66d..0f6e25bd 100644 --- a/packages/ai/test/providers.test.ts +++ b/packages/ai/test/providers.test.ts @@ -149,7 +149,7 @@ describe("envApiKeyAuth", () => { const stored = await auth.resolve({ model, ctx: fakeAuthContext({ FIRST_KEY: "env" }), - credential: { type: "api-key", key: "stored" }, + credential: { type: "api_key", key: "stored" }, }); expect(stored?.auth.apiKey).toBe("stored"); expect(stored?.source).toBe("stored credential"); @@ -170,7 +170,7 @@ describe("envApiKeyAuth", () => { }, notify: () => {}, }); - expect(credential).toEqual({ type: "api-key", key: "entered-key" }); + expect(credential).toEqual({ type: "api_key", key: "entered-key" }); }); });