fix(ai): align api key credentials with auth json

This commit is contained in:
Mario Zechner
2026-06-23 22:29:46 +02:00
parent b377623435
commit 49fbe6834f
9 changed files with 70 additions and 56 deletions
+4
View File
@@ -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
+13
View File
@@ -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):
+2 -2
View File
@@ -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" };
+1 -1
View File
@@ -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;
+7 -7
View File
@@ -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`).
*/
+26 -29
View File
@@ -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: {
+1 -1
View File
@@ -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");
+2 -2
View File
@@ -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" });
});
});