fix(ai): align api key credentials with auth json
This commit is contained in:
@@ -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<ApiKeyCredential>;
|
||||
|
||||
/**
|
||||
* 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<string, string>; // 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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<ApiKeyCredential>;
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
*/
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<Api> | ImagesModel<ImagesApi>;
|
||||
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<Api> | ImagesModel<ImagesApi>,
|
||||
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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user