diff --git a/packages/agent/docs/models.md b/packages/agent/docs/models.md index 0a31b9d7d..89b89629a 100644 --- a/packages/agent/docs/models.md +++ b/packages/agent/docs/models.md @@ -118,17 +118,31 @@ const models = builtinModels({ oauth: "node" }); `Models` is a provider collection plus auth application and stream convenience. No stream registry, no auth resolver strategy object. ```ts -export function createModels(options?: { credentials?: CredentialStore }): MutableModels; +export function createModels(options?: { + /** App-owned credential storage. Default: in-memory store. */ + credentials?: CredentialStore; + /** Environment access for auth resolution (env vars, file existence). Default: process.env/node:fs backed; injectable for tests and non-Node hosts. */ + authContext?: AuthContext; +}): MutableModels; export interface Models { getProviders(): readonly Provider[]; getProvider(id: string): Provider | undefined; + /** Best-effort aggregation: provider source failures yield the models that did list. */ + getModels(options?: { forceRefresh?: boolean }): Promise[]>; getModels(provider?: string, options?: { forceRefresh?: boolean }): Promise[]>; + /** Dynamic lists are honestly Model; narrow with the hasApi() guard. */ getModel(provider: string, id: string, options?: { forceRefresh?: boolean }): Promise | undefined>; - /** Resolve request auth for a model. Includes source label for status UI. */ - getAuth(model: Model): Promise; + /** + * Resolve request auth for a model. Includes source label for status UI. + * Resolves undefined when the provider is unknown or unconfigured. Rejects + * with ModelsError ("oauth" on refresh failure, "auth" on api-key/store + * failure); status/availability UIs catch rejections and render + * "needs re-login" instead of treating them as unconfigured. + */ + getAuth(model: Model): Promise; stream( model: Model, @@ -169,26 +183,30 @@ If an app needs different auth policy, it wraps providers (wrap auth methods or A provider is the concrete runtime unit. It owns id/name/base metadata, auth methods, model listing, and stream behavior. +`Provider` is generic over the APIs its models use. Concrete factories declare what they emit (`openaiProvider(): Provider<"openai-responses" | "openai-completions">`), giving typed model lists to direct factory users. A `Models` collection holds providers as `Provider`. + ```ts -export interface Provider { +export interface Provider { readonly id: string; readonly name: string; readonly baseUrl?: string; readonly headers?: Record; - /** Required. Empty array for no-auth providers. */ - readonly auth: readonly AuthMethod[]; + /** + * Required: at least one of apiKey/oauth. Even ambient-credential providers + * (env vars, AWS profiles, ADC) and keyless local servers provide apiKey + * auth whose resolve() reports whether the provider is configured. + * getAuth() returning undefined = not configured. + */ + readonly auth: ProviderAuth; - getModels(options?: { forceRefresh?: boolean }): Promise[]>; + /** Sync return suits static catalogs; Models always exposes a Promise. */ + getModels(options?: { forceRefresh?: boolean }): Promise[]> | readonly Model[]; - stream( - model: Model, - context: Context, - options?: ApiStreamOptions, - ): AssistantMessageEventStream; + stream(model: Model, context: Context, options?: ApiStreamOptions): AssistantMessageEventStream; - streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; + streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; } ``` @@ -221,6 +239,29 @@ export type ApiStreamOptions = TApi extends keyof ApiOptionsMa Custom api strings fall back to the generic shape. +### Typed model narrowing + +Runtime model lists are dynamic, so `models.getModel()`/`getModels()` honestly return `Model`. Typing improves at three points: + +1. **`hasApi()` type guard** — runtime-checked narrowing for dynamic lookups (no blind casts): + + ```ts + export function hasApi(model: Model, api: TApi): model is Model; + + const model = await models.getModel("anthropic", "claude-opus-4-7"); + if (model && hasApi(model, "anthropic-messages")) { + // model: Model<"anthropic-messages">, stream options fully typed + } + ``` + +2. **`getBuiltinModel()`** — sync, generated-catalog lookup with typed overloads: `(provider, id) -> Model`. The path for hardcoded known models. + +3. **`Provider` factories** — typed model lists when using a provider directly, without a `Models` collection. + +Deliberately not done: tying `models.getModel(provider, ...)` to typed provider/model ids would require statically knowing which providers are installed in a mutable runtime collection. The harness path (`streamSimple` + `SimpleStreamOptions`) is API-agnostic and unaffected. + +For comparison: Vercel AI SDK attaches the implementation to the model object, which dissolves dispatch typing but makes models non-serializable (no sessions/RPC/catalogs as plain data), and its `providerOptions` bag is `Record` checked only by `satisfies` convention. Plain-data models + provider-owned behavior keeps stronger typing where it matters. + ### Name collision `types.ts` currently exports `type Provider = KnownProvider | string` (a provider id). Rename that alias to `ProviderId` and fix call sites. The `Provider` interface above takes the name. @@ -316,7 +357,7 @@ export function openrouterProvider(): Provider { id: "openrouter", name: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", - auth: [envApiKeyMethod({ id: "api-key", name: "OpenRouter API key", env: ["OPENROUTER_API_KEY"] })], + auth: { apiKey: envApiKeyAuth("OpenRouter API key", ["OPENROUTER_API_KEY"]) }, models: OPENROUTER_MODELS, api: openAICompletionsApi(), }); @@ -339,108 +380,181 @@ export interface ModelAuth { If a value cannot be expressed as `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth (Vertex project/location, Bedrock region/profile, Azure apiVersion are provider factory options). -### Auth methods +### Provider auth -`Provider.auth` is a list of uniform auth methods. The `kind` discriminant keeps the UI's oauth-vs-api-key split and types the credential: +`Provider.auth` has exactly two slots; real providers have at most one api-key path and at most one OAuth path, and the slot names carry the UI's oauth-vs-api-key split without a `kind` discriminant or method ids: ```ts -export interface ApiKeyAuthMethod { - kind: "api-key"; - id: string; // unique within provider, e.g. "api-key" +export interface ProviderAuth { + apiKey?: ApiKeyAuth; // stored key/metadata + 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). */ - login?(callbacks: AuthLoginCallbacks): Promise; + login?(callbacks: AuthLoginCallbacks): Promise; + /** + * Resolve auth from the stored credential and/or ambient sources, merging + * per field (credential.key ?? env("..."), metadata.accountId ?? env("...")). + * undefined = not configured. + */ resolve(input: { model: Model; - ctx: ProviderAuthContext; - credential?: LocalCredential; - }): Promise; + ctx: AuthContext; + credential?: ApiKeyCredential; + }): Promise; } -export interface OAuthAuthMethod { - kind: "oauth"; - id: string; // e.g. "oauth" +export interface OAuthAuth { name: string; // "Anthropic (Claude Pro/Max)" login(callbacks: AuthLoginCallbacks): Promise; - resolve(input: { - model: Model; - ctx: ProviderAuthContext; - credential?: OAuthCredential; - }): Promise; + /** Exchange the refresh token. Network call; throws on failure (invalid_grant etc.). Runs under the store lock. */ + refresh(credential: OAuthCredential): Promise; + + /** Side-effect-free derivation of request auth from a valid credential. Covers Copilot-style per-credential baseUrl. Async so lazy wrappers can load the implementation. */ + toAuth(credential: OAuthCredential): Promise; } -export type AuthMethod = ApiKeyAuthMethod | OAuthAuthMethod; - -export interface AuthResolution { +export interface AuthResult { auth: ModelAuth; /** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */ source?: string; - /** Present when the method refreshed/updated the credential; Models persists it via the store. */ - credential?: Credential; } -export interface ProviderAuthContext { +export interface AuthContext { env(name: string): Promise; fileExists(path: string): Promise; // supports leading ~ } ``` +The OAuth split (`refresh` + `toAuth` instead of one `resolve`) matches the old `OAuthProviderInterface` (`refreshToken` + `getApiKey`) and lets `Models` own the locking pattern without closure gymnastics: refresh produces a credential, `toAuth` derives request auth from whatever credential ends up stored. + There is no `usesCallbackServer` flag. With `prompt()/notify()` callbacks the flow self-describes at runtime: a flow that runs a callback server issues a `manual_code` prompt racing the server and aborts the prompt when the callback wins. The UI needs no static foreknowledge. ### Credentials +One credential per provider, type-tagged — exactly the shape of today's auth.json (`type: "api_key" | "oauth"` per provider id): + ```ts -export interface LocalCredential { - type: "local"; +export interface ApiKeyCredential { + type: "api-key"; key?: string; metadata?: Record; // e.g. Cloudflare accountId/gatewayId } export interface OAuthCredential extends OAuthCredentials { - type: "oauth"; + type: "oauth"; // access, refresh, expires from OAuthCredentials } -export type Credential = LocalCredential | OAuthCredential; +export type Credential = ApiKeyCredential | OAuthCredential; ``` -`LocalCredential.metadata` exists for providers like Cloudflare that store non-key values (account id, gateway id) alongside or instead of a key. The method's `resolve()` merges per field: `credential.key ?? env("CLOUDFLARE_API_TOKEN")`, `credential.metadata?.accountId ?? env("CLOUDFLARE_ACCOUNT_ID")`, etc. +`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. ### Credential store -The app injects storage; `pi-ai` ships an in-memory default. +The app injects storage; `pi-ai` ships an in-memory default. Keyed by provider id, one credential per provider: ```ts export interface CredentialStore { - get(providerId: string, methodId: string): Promise; - set(providerId: string, methodId: string, credential: Credential): Promise; - delete(providerId: string, methodId: string): Promise; + /** Read the stored credential, possibly expired. Display/status use; request auth comes from Models.getAuth(). */ + read(providerId: string): Promise; + + /** + * Serialized write — the only write path. fn sees the current credential + * because correct writes (refresh, login-during-refresh) depend on it; + * return the new credential, or undefined to leave the entry unchanged. + * Mutual exclusion per provider id, cross-process too where the backing + * store supports it (file lock). Resolves with the post-write credential. + */ + modify( + providerId: string, + fn: (current: Credential | undefined) => Promise, + ): Promise; + + /** Remove (logout). Serialized against modify. */ + delete(providerId: string): Promise; } ``` -coding-agent later implements this over AuthStorage. Login/logout orchestration is app-owned: the app calls `method.login(callbacks)` and persists the returned credential itself. `Models` only *reads* the store during resolution, and *writes* refreshed credentials when `resolve()` returns an updated one (OAuth token refresh). +There is deliberately no `set`: an unserialized write path invites read-modify-write races (login-during-refresh clobbering a fresh credential, double token refresh). Call sites: + +```ts +await store.modify(pid, async () => credential); // login: store this +await store.read(pid); // status UI ("logged in via OAuth") +await store.delete(pid); // logout +// refresh RMW happens inside Models.getAuth +``` + +Error semantics: `read` resolves `undefined` for missing entries; methods reject only on storage failure, and `Models` wraps such rejections in `ModelsError` code `"auth"`. Best-effort stores that serve an in-memory view and record persistence errors internally (today's AuthStorage behavior) are valid implementations. ### Resolution policy (fixed) -`Models.getAuth(model)` resolves with a fixed policy. Precedence, highest first: +`Models.getAuth(model)` is a decision tree, not a loop. A stored credential owns the provider — ambient/env is consulted only when nothing is stored (AuthStorage parity: no silent env fallback after a failed refresh or for an unmatched credential type): -```txt -1. explicit request auth (stream options apiKey/headers) — merged per-field on top, in stream() -2. methods with a stored credential, in provider auth list order -3. methods resolving without a credential (ambient/env), in provider auth list order +```ts +const stored = await store.read(provider.id); +if (stored) { + if (stored.type === "oauth" && provider.auth.oauth) { + const oauth = provider.auth.oauth; + let credential = stored; + if (Date.now() >= credential.expires) { // optimistic check, lock-free + const post = await store.modify(provider.id, async (current) => { + if (current?.type !== "oauth") return undefined; // logged out meanwhile + return Date.now() >= current.expires // authoritative check, under lock + ? oauth.refresh(current) // throws -> ModelsError("oauth") + : undefined; // another process/request refreshed + }); + if (post?.type !== "oauth") return undefined; + credential = post; + } + return { auth: await oauth.toAuth(credential), source: "OAuth" }; + } + 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 +} +return provider.auth.apiKey?.resolve({ model, ctx, credential: undefined }); // ambient ``` -Two-pass over `provider.auth`: +Properties: -- Pass 1: for each method with a credential in the store, call `resolve({ model, ctx, credential })`; first non-undefined resolution wins. -- Pass 2: for each method, call `resolve({ model, ctx })`; first non-undefined resolution wins. +- Double-checked locking, same as today's `refreshOAuthTokenWithLock`: valid tokens cost one `read` and zero locks; expired tokens lock, re-check under the lock, refresh once globally, persist before release. +- Explicit request auth (stream options `apiKey`/`headers`) is merged per-field on top in `stream()`, winning over everything. +- Refresh failure rejects with `ModelsError("oauth")`; the stored credential is untouched (preserved for retry). Request paths surface this as a stream error with the real cause ("run /login"); status/availability UIs catch the rejection and render "needs re-login" — documented contract on `getAuth`. -So an explicit login (stored credential) beats ambient env vars regardless of list order; list order breaks ties. Per-field merging *within* one method (stored key + env account id) happens inside that method's `resolve()`. +### Replacing AuthStorage -If a resolution carries an updated `credential`, `Models` persists it via the store before returning. +The end state for coding-agent: AuthStorage is deleted; its capabilities map onto a `CredentialStore` implementation plus composition. + +Today's `getApiKey` priority and its new home: + +| AuthStorage today | New design | +|---|---| +| runtime override (CLI `--api-key`) | `withRuntimeOverrides(store, overrides)` decorator: `read` returns the override as an `ApiKeyCredential`; never persisted | +| stored `api_key` (with `$ENV`/`!command` via `resolveConfigValue`) | stored `ApiKeyCredential`; config-value resolution happens at `read` in coding-agent's adapter/decorator (command execution stays app policy) | +| stored `oauth` + locked refresh, undefined on failure | `getAuth` decision tree above; failure rejects with cause instead of silently unconfiguring | +| env var (only when nothing stored) | ambient branch of `apiKey.resolve` | +| `fallbackResolver` (models.json custom providers) | gone — custom providers carry their own `auth.apiKey` | + +```txt +FileCredentialStore ports AuthStorage's lock backend: read = memory snapshot, + modify = withLockAsync(re-read, fn, merge-write), delete, + internal error recording (drainErrors equivalent) +└─ withConfigValues $ENV / !command at read + └─ withRuntimeOverrides --api-key + └─ createModels({ credentials: store }) + +login/logout UI provider.auth.{oauth,apiKey}.login(callbacks) + store.modify/delete +status UI store.read(pid) + getAuth try/catch ("needs /login" on rejection) +getOAuthProviders presence of provider.auth.oauth across registered providers +``` ### Login callbacks @@ -448,17 +562,20 @@ One interface serves api-key and OAuth login: ```ts export interface AuthLoginCallbacks { + /** Aborts the whole login flow. Per-prompt cancellation uses AuthPrompt.signal. */ signal?: AbortSignal; - prompt(prompt: AuthPrompt, options?: { signal?: AbortSignal }): Promise; + prompt(prompt: AuthPrompt): Promise; notify(event: AuthEvent): void; } -export type AuthPrompt = +/** `signal` lets the flow cancel a pending prompt when an out-of-band event resolves the step. */ +export type AuthPrompt = { signal?: AbortSignal } & ( | { type: "text"; message: string; placeholder?: string } | { type: "secret"; message: string; placeholder?: string } | { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] } - | { type: "manual_code"; message: string; placeholder?: string }; + | { type: "manual_code"; message: string; placeholder?: string } +); export type AuthEvent = | { type: "auth_url"; url: string; instructions?: string } @@ -466,7 +583,7 @@ export type AuthEvent = | { type: "progress"; message: string }; ``` -`prompt()` returns the entered/selected string (`select` returns the option id). Flows race a `manual_code` prompt against a callback server by passing a per-prompt abort signal and aborting when the callback wins. +`prompt()` returns the entered/selected string (`select` returns the option id). Flows race a `manual_code` prompt against a callback server by setting `AuthPrompt.signal` and aborting the prompt when the callback wins. ### OAuth implementation target @@ -484,16 +601,16 @@ export function anthropicProvider(options: AnthropicProviderOptions = {}): Provi id: "anthropic", name: "Anthropic", baseUrl: "https://api.anthropic.com/v1", - auth: [ - ...(options.oauth === "node" - ? [lazyOAuthMethod({ - id: "oauth", - name: "Anthropic (Claude Pro/Max)", - load: () => import("../utils/oauth/anthropic.ts").then((m) => m.anthropicOAuthMethod), - })] - : []), - envApiKeyMethod({ id: "api-key", name: "Anthropic API key", env: ["ANTHROPIC_API_KEY"] }), - ], + auth: { + apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_API_KEY"]), + oauth: + options.oauth === "node" + ? lazyOAuth({ + name: "Anthropic (Claude Pro/Max)", + load: () => import("../utils/oauth/anthropic.ts").then((m) => m.anthropicOAuth), + }) + : undefined, + }, models: ANTHROPIC_MODELS, api: anthropicMessagesApi(), }); @@ -504,17 +621,16 @@ export function anthropicProvider(options: AnthropicProviderOptions = {}): Provi - `builtinModels({ oauth: "node" })` for pi CLI/coding-agent. - `"web"` is reserved; web flows (sitegeist-style: Web Crypto PKCE, auth tab, extension tab APIs watching the localhost redirect, fetch token exchange, device-code polling for Copilot) are a follow-up. Until implemented, passing `"web"` throws at login time with a clear message. -`lazyOAuthMethod()` wraps a dynamically imported `OAuthAuthMethod` so provider definitions can advertise OAuth without importing the implementation: +`lazyOAuth()` wraps a dynamically imported `OAuthAuth` so provider definitions can advertise OAuth without importing the implementation (`toAuth` is async for exactly this reason): ```ts -export function lazyOAuthMethod(input: { - id: string; +export function lazyOAuth(input: { name: string; - load: () => Promise; -}): OAuthAuthMethod; + load: () => Promise; +}): OAuthAuth; ``` -The existing flows in `src/utils/oauth/` (anthropic, openai-codex, github-copilot) are adapted to `OAuthAuthMethod` with the new callbacks, staying Node-targeted and lazy-loaded. +The existing flows in `src/utils/oauth/` (anthropic, openai-codex, github-copilot) are adapted to `OAuthAuth` (`login`/`refresh`/`toAuth`, replacing `login`/`refreshToken`/`getApiKey`/`modifyModels`) with the new callbacks, staying Node-targeted and lazy-loaded. Copilot's `modifyModels` baseUrl rewriting becomes `toAuth` returning `ModelAuth.baseUrl`. ## Provider wrappers and models.json @@ -541,7 +657,7 @@ function withProviderOverrides(base: Provider, overrides: ProviderOverrides): Pr This composes with dynamic providers because `getModels()` delegates to the base source. -Request-auth config from models.json (`$ENV`, `!command`, inline keys) remains app-owned sidecar state, surfaced either as explicit request auth or as a custom `ApiKeyAuthMethod` the app prepends to the wrapped provider's auth list. +Request-auth config from models.json (`$ENV`, `!command`, inline keys) remains app-owned sidecar state, surfaced either as explicit request auth or as a custom `ApiKeyAuth` the app sets on the wrapped provider's `auth.apiKey`. ## Custom providers: createProvider() @@ -553,7 +669,7 @@ export function createProvider(input: { name?: string; // default: id baseUrl?: string; headers?: Record; - auth?: readonly AuthMethod[]; // default: [] + auth: ProviderAuth; // required, at least one of apiKey/oauth (no "no-auth" providers) models: | readonly Model[] | ((options?: { forceRefresh?: boolean }) => Promise[]>); @@ -666,7 +782,7 @@ sessionModels.clearProviders(); for (const provider of layeredProviders) sessionModels.setProvider(provider); ``` -coding-agent owns: AuthStorage-backed `CredentialStore`, models.json auth sidecar (`$ENV`, `!command`), command execution policy, provider status labels (from `AuthResolution.source`), login/logout UI (driving `method.login()` with `prompt()/notify()`), extension lifecycle, provider-management slash commands. +coding-agent owns: `FileCredentialStore` + decorators replacing AuthStorage (see "Replacing AuthStorage"), models.json auth sidecar (`$ENV`, `!command`), command execution policy, provider status labels (from `AuthResult.source`), login/logout UI (driving `auth.{apiKey,oauth}.login()` with `prompt()/notify()`), extension lifecycle, provider-management slash commands. Until then, the only coding-agent changes in this pass are: @@ -680,11 +796,11 @@ Check items off as they land. Keep this list current; it is the working state fo ### Phase 1 — core types/runtime -- [ ] Rename `types.ts` `Provider` alias to `ProviderId`; fix call sites. -- [ ] Add `ApiOptionsMap` and `ApiStreamOptions` to `types.ts` (type-only imports). -- [ ] New `models.ts`: `Provider` interface, `AuthMethod` union (`ApiKeyAuthMethod`/`OAuthAuthMethod`), `LocalCredential`/`OAuthCredential`/`Credential`, `CredentialStore` (+ in-memory default), `AuthResolution`, `ProviderAuthContext`, `ModelAuth`, `ModelsError` + codes. -- [ ] `Models`/`MutableModels`/`createModels({ credentials? })` with provider map, async `getModel(s)` (per-provider failure isolation), `getAuth` (two-pass fixed policy, persists refreshed credentials), `stream/complete/streamSimple/completeSimple` with per-field auth merge. -- [ ] Keep metadata helpers: `calculateCost`, `getSupportedThinkingLevels`, `clampThinkingLevel`, `modelsAreEqual`. +- [x] Rename `types.ts` `Provider` alias to `ProviderId`; fix call sites. +- [x] Add `ApiOptionsMap` and `ApiStreamOptions` to `types.ts` (type-only imports). +- [x] New `models.ts`: `Provider` interface, `hasApi()` guard, `ModelsError` + codes. Auth types live in `src/auth/types.ts` (`ProviderAuth` = `{ apiKey?, oauth? }`, credentials, `CredentialStore` (`read`/`modify`/`delete`, one credential per provider), `AuthResult`, `AuthContext`, `ModelAuth`, login callbacks), in-memory store in `src/auth/credential-store.ts`, default context in `src/auth/context.ts` (browser-safe node:fs trick), `lazyStream()` in `src/api/lazy.ts`. +- [x] `Models`/`MutableModels`/`createModels({ credentials?, authContext? })` with provider map, async `getModel(s)` (per-provider failure isolation), `getAuth` (decision tree, double-checked locked refresh), `stream/complete/streamSimple/completeSimple` with per-field auth merge. Tests: `packages/ai/test/models-runtime.test.ts`. +- [x] Keep metadata helpers: `calculateCost`, `getSupportedThinkingLevels`, `clampThinkingLevel`, `modelsAreEqual`. ### Phase 2 — `src/api/` @@ -697,7 +813,7 @@ Check items off as they land. Keep this list current; it is the working state fo ### Phase 3 — provider factories + catalogs -- [ ] Auth helpers in `src/auth/`: `envApiKeyMethod()`, `lazyOAuthMethod()`, `OAuthTarget`, `AuthLoginCallbacks`/`AuthPrompt`/`AuthEvent`. +- [ ] Auth helpers in `src/auth/`: `envApiKeyAuth()`, `lazyOAuth()`, `OAuthTarget`. - [ ] `createProvider()` (single + mixed `api` map, dispatch on `model.api`). - [ ] Per-provider factories under `src/providers/` for all built-in catalog providers, `oauth` factory options where applicable. - [ ] `providers/all.ts`: `builtinModels({ oauth? })`, `getBuiltinModel/getBuiltinModels/getBuiltinProviders`. @@ -706,7 +822,7 @@ Check items off as they land. Keep this list current; it is the working state fo ### Phase 4 — OAuth adaptation -- [ ] Adapt `utils/oauth/anthropic.ts`, `openai-codex.ts`, `github-copilot.ts` to `OAuthAuthMethod` + `prompt()/notify()`. +- [ ] Adapt `utils/oauth/anthropic.ts`, `openai-codex.ts`, `github-copilot.ts` to `OAuthAuth` (`login`/`refresh`/`toAuth`) + `prompt()/notify()`; `modifyModels` baseUrl rewriting becomes `toAuth().baseUrl`. - [ ] Remove `usesCallbackServer`; callback-server flows race a `manual_code` prompt instead. - [ ] `oauth: "web"` reserved: throws at login with clear message. @@ -729,6 +845,8 @@ Check items off as they land. Keep this list current; it is the working state fo - [ ] Switch old-global imports to `@earendil-works/pi-ai/compat`. - [ ] Login dialog adapter for `prompt()/notify()` callbacks. +The full AuthStorage deletion (`FileCredentialStore` + decorators, see "Replacing AuthStorage") happens in the later ModelManager migration, not this pass. + ### Phase 8 — wrap-up - [ ] Update/add tests; run affected suites (`./test.sh` or per-package vitest). @@ -758,4 +876,6 @@ export type ModelsErrorCode = ``` - `Models.stream()` produces stream errors (error event + error result) for async setup failures; it does not throw after returning the stream. -- `Models.getModels()` with no provider filter isolates per-provider source failures so one dynamic provider failure does not prevent listing others. +- `Models.getModels()` is best-effort aggregation in all forms: provider source failures yield the models that did list (empty for a single failing provider). Apps that need the concrete failure call `getProvider(id).getModels()` directly. +- Auth resolution and credential store failures reject loudly (`ModelsError` codes `auth`/`oauth`); silent fallback to a different auth path after a failure risks billing surprises. A stored credential always blocks ambient/env fallback, including after a failed refresh. +- Status/availability UIs catch `getAuth` rejections and render "needs re-login"; they do not treat rejection as "unconfigured". diff --git a/packages/ai/src/api/lazy.ts b/packages/ai/src/api/lazy.ts new file mode 100644 index 000000000..8cfd2edee --- /dev/null +++ b/packages/ai/src/api/lazy.ts @@ -0,0 +1,56 @@ +import type { Api, AssistantMessage, AssistantMessageEvent, Model } from "../types.ts"; +import { AssistantMessageEventStream } from "../utils/event-stream.ts"; + +function createSetupErrorMessage(model: Model, error: unknown): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; +} + +function forwardStream(target: AssistantMessageEventStream, source: AsyncIterable): void { + (async () => { + for await (const event of source) { + target.push(event); + } + target.end(); + })(); +} + +/** + * Returns a stream synchronously while running async setup (auth resolution, + * lazy module loading) behind it. Setup failures terminate the stream with an + * error event. + */ +export function lazyStream( + model: Model, + setup: () => Promise>, +): AssistantMessageEventStream { + const outer = new AssistantMessageEventStream(); + + setup() + .then((inner) => { + forwardStream(outer, inner); + }) + .catch((error) => { + const message = createSetupErrorMessage(model, error); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + }); + + return outer; +} diff --git a/packages/ai/src/auth/context.ts b/packages/ai/src/auth/context.ts new file mode 100644 index 000000000..30e088bfa --- /dev/null +++ b/packages/ai/src/auth/context.ts @@ -0,0 +1,45 @@ +import type { AuthContext } from "./types.ts"; + +interface NodeFsModule { + access(path: string): Promise; +} + +interface NodeOsModule { + homedir(): string; +} + +// Variable specifier so browser bundlers do not try to resolve node builtins. +const importNodeModule = (specifier: string): Promise => import(specifier); + +function getProcessEnv(): Record | undefined { + const proc = (globalThis as { process?: { env?: Record } }).process; + return proc?.env; +} + +/** + * Default auth context: env vars from `process.env` (undefined in browsers), + * file existence via node:fs (always false in browsers). + */ +export function defaultProviderAuthContext(): AuthContext { + return { + async env(name: string): Promise { + const value = getProcessEnv()?.[name]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; + }, + + async fileExists(path: string): Promise { + try { + const fs = (await importNodeModule("node:fs/promises")) as NodeFsModule; + let resolved = path; + if (resolved.startsWith("~")) { + const os = (await importNodeModule("node:os")) as NodeOsModule; + resolved = os.homedir() + resolved.slice(1); + } + await fs.access(resolved); + return true; + } catch { + return false; + } + }, + }; +} diff --git a/packages/ai/src/auth/credential-store.ts b/packages/ai/src/auth/credential-store.ts new file mode 100644 index 000000000..beeb9d858 --- /dev/null +++ b/packages/ai/src/auth/credential-store.ts @@ -0,0 +1,47 @@ +import type { Credential, CredentialStore } from "./types.ts"; + +/** + * Default in-memory credential store. Apps inject persistent stores. + * Keyed by `Provider.id`, one credential per provider; see `CredentialStore`. + * Writes are serialized per provider through a promise chain. + */ +export class InMemoryCredentialStore implements CredentialStore { + private credentials = new Map(); + private chains = new Map>(); + + /** Serialize tasks per provider id. */ + private enqueue(providerId: string, task: () => Promise): Promise { + const previous = this.chains.get(providerId) ?? Promise.resolve(); + const next = (async () => { + await previous.catch(() => {}); + return task(); + })(); + this.chains.set( + providerId, + next.catch(() => {}), + ); + return next; + } + + async read(providerId: string): Promise { + return this.credentials.get(providerId); + } + + modify( + providerId: string, + fn: (current: Credential | undefined) => Promise, + ): Promise { + return this.enqueue(providerId, async () => { + const current = this.credentials.get(providerId); + const next = await fn(current); + if (next !== undefined) this.credentials.set(providerId, next); + return next ?? current; + }); + } + + delete(providerId: string): Promise { + return this.enqueue(providerId, async () => { + this.credentials.delete(providerId); + }); + } +} diff --git a/packages/ai/src/auth/types.ts b/packages/ai/src/auth/types.ts new file mode 100644 index 000000000..b308b90d4 --- /dev/null +++ b/packages/ai/src/auth/types.ts @@ -0,0 +1,179 @@ +import type { Api, Model } from "../types.ts"; +import type { OAuthCredentials } from "../utils/oauth/types.ts"; + +/** + * Request auth for a single model request. If a value cannot be expressed as + * `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth. + */ +export interface ModelAuth { + apiKey?: string; + headers?: Record; + baseUrl?: string; +} + +/** + * Stored api-key credential. `metadata` holds non-key values such as + * Cloudflare account/gateway ids. + */ +export interface ApiKeyCredential { + type: "api-key"; + key?: string; + metadata?: Record; +} + +/** Stored OAuth credential (`access`, `refresh`, `expires` from OAuthCredentials). */ +export interface OAuthCredential extends OAuthCredentials { + type: "oauth"; +} + +/** One type-tagged credential per provider — the shape of today's auth.json. */ +export type Credential = ApiKeyCredential | OAuthCredential; + +/** + * App-owned credential storage, keyed by `Provider.id`, one credential per + * provider. `modify` is the only write path, so every mutation is a + * serialized read-modify-write; `Models.getAuth()` runs OAuth refresh inside + * `modify` so concurrent requests cannot double-refresh a rotated token. The + * app persists a credential after login via + * `modify(provider.id, async () => credential)`. Login/logout orchestration + * is app-owned. + * + * Error semantics: `read` resolves `undefined` for missing entries. Methods + * reject only on storage failure; `Models` wraps such rejections in + * `ModelsError` with code "auth". Best-effort stores that serve an in-memory + * view and record persistence errors internally (like coding-agent's + * AuthStorage) are valid implementations. + */ +export interface CredentialStore { + /** + * Read the stored credential, possibly expired. Display/status use; + * resolved request auth comes from `Models.getAuth()`. + */ + read(providerId: string): Promise; + + /** + * Serialized write — the only write path. `fn` sees the current credential + * because correct writes (refresh, login-during-refresh) depend on it; + * return the new credential, or undefined to leave the entry unchanged. + * Mutual exclusion per provider id, cross-process too where the backing + * store supports it (e.g. a file lock). Resolves with the post-write + * credential. Rejections from `fn` propagate. + */ + modify( + providerId: string, + fn: (current: Credential | undefined) => Promise, + ): Promise; + + /** Remove a credential (logout). Implementations serialize this against `modify`. */ + delete(providerId: string): Promise; +} + +/** Environment access for auth resolution. Injectable for tests and browsers. */ +export interface AuthContext { + env(name: string): Promise; + /** Check whether a file exists. Supports a leading `~`. Always false in browsers. */ + fileExists(path: string): Promise; +} + +/** Result of resolving auth for a model. */ +export interface AuthResult { + auth: ModelAuth; + /** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */ + source?: string; +} + +/** + * Prompt shown to the user during login. `signal` lets the flow cancel a + * pending prompt when an out-of-band event resolves the step, e.g. a + * `manual_code` prompt raced against a callback server, aborted when the + * callback wins. + */ +export type AuthPrompt = { signal?: AbortSignal } & ( + | { type: "text"; message: string; placeholder?: string } + | { type: "secret"; message: string; placeholder?: string } + | { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] } + | { type: "manual_code"; message: string; placeholder?: string } +); + +export type AuthEvent = + | { type: "auth_url"; url: string; instructions?: string } + | { + type: "device_code"; + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; + } + | { type: "progress"; message: string }; + +/** + * Login interaction callbacks serving both api-key and OAuth flows. + * + * `prompt()` returns the entered/selected string (`select` returns the option + * id). Rejects on cancel/abort. `signal` aborts the whole login flow; + * per-prompt cancellation uses `AuthPrompt.signal`. + */ +export interface AuthLoginCallbacks { + signal?: AbortSignal; + + prompt(prompt: AuthPrompt): Promise; + notify(event: AuthEvent): void; +} + +/** + * Api-key auth: stored key/metadata 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. */ + login?(callbacks: AuthLoginCallbacks): Promise; + + /** + * Resolve auth from the stored credential and/or ambient sources, merging + * per field (`credential.key ?? env("...")`, `metadata.accountId ?? env("...")`). + * undefined = not configured. + */ + resolve(input: { + model: Model; + ctx: AuthContext; + credential?: ApiKeyCredential; + }): Promise; +} + +/** + * OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked + * refresh pattern: `refresh` produces a credential, `toAuth` derives request + * auth from whatever credential ends up stored. + */ +export interface OAuthAuth { + /** Display name, e.g. "Anthropic (Claude Pro/Max)". */ + name: string; + + login(callbacks: AuthLoginCallbacks): Promise; + + /** + * Exchange the refresh token. Network call; throws on failure + * (invalid_grant etc.). `Models` runs this under the store lock. + */ + refresh(credential: OAuthCredential): Promise; + + /** + * Side-effect-free derivation of request auth from a valid credential. + * Covers per-credential baseUrl (GitHub Copilot). Async so lazy wrappers + * can load the implementation on first use. + */ + toAuth(credential: OAuthCredential): Promise; +} + +/** + * Provider auth. At least one of `apiKey`/`oauth` must be present: even + * ambient-credential providers and keyless local servers provide `apiKey` + * auth whose `resolve()` reports whether the provider is configured. + */ +export interface ProviderAuth { + apiKey?: ApiKeyAuth; + oauth?: OAuthAuth; +} diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index ed7aeaa87..dd5842490 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -1,7 +1,11 @@ export type { Static, TSchema } from "typebox"; export { Type } from "typebox"; +export * from "./api/lazy.ts"; export * from "./api-registry.ts"; +export * from "./auth/context.ts"; +export * from "./auth/credential-store.ts"; +export * from "./auth/types.ts"; export * from "./env-api-keys.ts"; export * from "./image-models.ts"; export * from "./images.ts"; diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index e14a6c2fb..ddfdcc023 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -1,5 +1,369 @@ +import { lazyStream } from "./api/lazy.ts"; +import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts"; +import { InMemoryCredentialStore } from "./auth/credential-store.ts"; +import type { + ApiKeyAuth, + ApiKeyCredential, + AuthContext, + AuthResult, + Credential, + CredentialStore, + OAuthAuth, + OAuthCredential, + ProviderAuth, +} from "./auth/types.ts"; import { MODELS } from "./models.generated.ts"; -import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./types.ts"; +import type { + Api, + ApiStreamOptions, + AssistantMessage, + AssistantMessageEventStream, + Context, + KnownProvider, + Model, + ModelThinkingLevel, + SimpleStreamOptions, + StreamOptions, + Usage, +} from "./types.ts"; + +export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth"; + +export class ModelsError extends Error { + readonly code: ModelsErrorCode; + + constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ModelsError"; + this.code = code; + } +} + +/** + * A provider is the concrete runtime unit. It owns id/name/base metadata, + * auth methods, model listing, and stream behavior. + * + * `TApi` lets concrete provider factories declare which APIs their models + * use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`), + * giving typed model lists to direct factory users. Inside a `Models` + * collection providers are held as `Provider`. + */ +export interface Provider { + readonly id: string; + readonly name: string; + + readonly baseUrl?: string; + readonly headers?: Record; + + /** + * Required: at least one of `apiKey`/`oauth`. Every provider has auth + * semantics — even providers with only ambient credentials (env vars, AWS + * profiles, ADC files) and keyless local servers provide `apiKey` auth + * whose `resolve()` reports whether the provider is configured. + * `Models.getAuth()` returns undefined when the provider is unconfigured. + */ + readonly auth: ProviderAuth; + + /** + * List models. Async and side-effect-free discovery only; provider-specific + * model lifecycle (load/unload) belongs in app commands. + */ + getModels(options?: { forceRefresh?: boolean }): Promise[]> | readonly Model[]; + + stream( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): AssistantMessageEventStream; + + streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; +} + +/** + * Runtime collection of providers plus auth application and stream + * convenience. Providers own stream behavior; `Models` resolves auth and + * delegates each request to the provider that owns the model. + */ +export interface Models { + getProviders(): readonly Provider[]; + getProvider(id: string): Provider | undefined; + + /** + * List models from one provider or all providers. Best-effort aggregation: + * provider source failures yield the models that did list (empty for a + * single failing provider). Apps that need the failure call + * `getProvider(id).getModels()` directly. + */ + getModels(options?: { forceRefresh?: boolean }): Promise[]>; + getModels(provider?: string, options?: { forceRefresh?: boolean }): Promise[]>; + + /** + * Runtime model lookup. Dynamic model lists are typed as `Model`; + * narrow with the `hasApi()` type guard. + */ + getModel(provider: string, id: string, options?: { forceRefresh?: boolean }): Promise | undefined>; + + /** + * Resolve request auth for a model. Includes a source label for status UI. + * Resolves `undefined` when the provider is unknown or unconfigured. + * Rejects with `ModelsError`: code "oauth" when a token refresh fails (the + * stored credential is preserved for retry; re-login fixes it), code "auth" + * when api-key resolution or the credential store fails. Request paths + * surface rejections as stream errors; status/availability UIs catch them + * and render "needs re-login" instead of treating them as unconfigured. + */ + getAuth(model: Model): Promise; + + stream( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): AssistantMessageEventStream; + + complete( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): Promise; + + streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; + completeSimple(model: Model, context: Context, options?: SimpleStreamOptions): Promise; +} + +export interface MutableModels extends Models { + /** Upsert/replace by provider.id. Provider ids are unique. */ + setProvider(provider: Provider): void; + deleteProvider(id: string): void; + clearProviders(): void; +} + +export interface CreateModelsOptions { + credentials?: CredentialStore; + authContext?: AuthContext; +} + +class ModelsImpl implements MutableModels { + private providers = new Map(); + private credentials: CredentialStore; + private authContext: AuthContext; + + constructor(options?: CreateModelsOptions) { + this.credentials = options?.credentials ?? new InMemoryCredentialStore(); + this.authContext = options?.authContext ?? defaultAuthContext(); + } + + setProvider(provider: Provider): void { + this.providers.set(provider.id, provider); + } + + deleteProvider(id: string): void { + this.providers.delete(id); + } + + clearProviders(): void { + this.providers.clear(); + } + + getProviders(): readonly Provider[] { + return Array.from(this.providers.values()); + } + + getProvider(id: string): Provider | undefined { + return this.providers.get(id); + } + + async getModels( + providerOrOptions?: string | { forceRefresh?: boolean }, + maybeOptions?: { forceRefresh?: boolean }, + ): Promise[]> { + const provider = typeof providerOrOptions === "string" ? providerOrOptions : undefined; + const options = typeof providerOrOptions === "string" ? maybeOptions : providerOrOptions; + + if (provider !== undefined) { + const entry = this.providers.get(provider); + if (!entry) return []; + try { + return await entry.getModels(options); + } catch { + return []; + } + } + + // Async wrapper turns sync throws from ill-behaved providers into rejections. + const results = await Promise.allSettled( + Array.from(this.providers.values(), async (entry) => entry.getModels(options)), + ); + const models: Model[] = []; + for (const result of results) { + if (result.status === "fulfilled") models.push(...result.value); + } + return models; + } + + async getModel(provider: string, id: string, options?: { forceRefresh?: boolean }): Promise | undefined> { + const models = await this.getModels(provider, options); + return models.find((model) => model.id === id); + } + + async getAuth(model: Model): Promise { + const provider = this.providers.get(model.provider); + if (!provider) return undefined; + + // A stored credential owns the provider: ambient/env is consulted only + // when nothing is stored. No silent env fallback after a failed refresh + // or for a credential type without a matching handler. + const stored = await this.readCredential(provider.id); + if (stored) { + if (stored.type === "oauth" && provider.auth.oauth) { + return this.resolveOAuth(provider.id, provider.auth.oauth, stored); + } + if (stored.type === "api-key" && provider.auth.apiKey) { + return this.resolveApiKey(provider.auth.apiKey, model, stored); + } + return undefined; + } + + // Ambient (env vars, AWS profiles, ADC files). + return provider.auth.apiKey ? this.resolveApiKey(provider.auth.apiKey, model, undefined) : undefined; + } + + /** + * OAuth resolution with double-checked locking (same pattern as today's + * AuthStorage): valid tokens cost zero locks; expired tokens lock, + * re-check expiry under the lock, refresh once globally, and persist the + * rotated credential before release. + */ + private async resolveOAuth( + providerId: string, + oauth: OAuthAuth, + stored: OAuthCredential, + ): Promise { + let credential = stored; + + if (Date.now() >= credential.expires) { + // Optimistic check said expired; the authoritative check runs under the lock. + let post: Credential | undefined; + try { + post = await this.credentials.modify(providerId, async (current) => { + if (current?.type !== "oauth") return undefined; // logged out meanwhile + if (Date.now() < current.expires) return undefined; // another process/request refreshed + try { + return await oauth.refresh(current); + } catch (error) { + throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error }); + } + }); + } catch (error) { + if (error instanceof ModelsError) throw error; + throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error }); + } + if (post?.type !== "oauth") return undefined; // logged out meanwhile + credential = post; + } + + try { + return { auth: await oauth.toAuth(credential), source: "OAuth" }; + } catch (error) { + throw new ModelsError("oauth", `OAuth auth derivation failed for ${providerId}`, { cause: error }); + } + } + + private async resolveApiKey( + apiKey: ApiKeyAuth, + model: Model, + credential: ApiKeyCredential | undefined, + ): Promise { + try { + return await apiKey.resolve({ model, ctx: this.authContext, credential }); + } catch (error) { + throw new ModelsError("auth", `API key auth failed for provider ${model.provider}`, { cause: error }); + } + } + + private async readCredential(providerId: string): Promise { + try { + return await this.credentials.read(providerId); + } catch (error) { + throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error }); + } + } + + private requireProvider(model: Model): Provider { + const provider = this.providers.get(model.provider); + if (!provider) { + throw new ModelsError("provider", `Unknown provider: ${model.provider}`); + } + return provider; + } + + private async applyAuth( + model: Model, + options: TOptions | undefined, + ): Promise<{ requestModel: Model; requestOptions: TOptions | undefined }> { + const resolution = await this.getAuth(model); + const auth = resolution?.auth; + if (!auth) return { requestModel: model, requestOptions: options }; + + const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; + + // Explicit request options win per-field; headers merge per header. + const apiKey = options?.apiKey ?? auth.apiKey; + const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined; + const requestOptions = { ...options, apiKey, headers } as TOptions; + + return { requestModel, requestOptions }; + } + + stream( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): AssistantMessageEventStream { + return lazyStream(model, async () => { + const provider = this.requireProvider(model); + const { requestModel, requestOptions } = await this.applyAuth(model, options as StreamOptions | undefined); + return provider.stream(requestModel as Model, context, requestOptions as ApiStreamOptions); + }); + } + + async complete( + model: Model, + context: Context, + options?: ApiStreamOptions, + ): Promise { + return this.stream(model, context, options).result(); + } + + streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream { + return lazyStream(model, async () => { + const provider = this.requireProvider(model); + const { requestModel, requestOptions } = await this.applyAuth(model, options); + return provider.streamSimple(requestModel, context, requestOptions); + }); + } + + async completeSimple(model: Model, context: Context, options?: SimpleStreamOptions): Promise { + return this.streamSimple(model, context, options).result(); + } +} + +export function createModels(options?: CreateModelsOptions): MutableModels { + return new ModelsImpl(options); +} + +/** + * Runtime-checked narrowing for dynamically looked-up models: + * + * ```ts + * const model = await models.getModel("anthropic", "claude-opus-4-7"); + * if (model && hasApi(model, "anthropic-messages")) { + * // model: Model<"anthropic-messages">, stream options fully typed + * } + * ``` + */ +export function hasApi(model: Model, api: TApi): model is Model { + return model.api === api; +} const modelRegistry: Map>> = new Map(); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 802b8b395..e14c493f5 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -1,3 +1,12 @@ +import type { BedrockOptions } from "./providers/amazon-bedrock.ts"; +import type { AnthropicOptions } from "./providers/anthropic.ts"; +import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.ts"; +import type { GoogleOptions } from "./providers/google.ts"; +import type { GoogleVertexOptions } from "./providers/google-vertex.ts"; +import type { MistralOptions } from "./providers/mistral.ts"; +import type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses.ts"; +import type { OpenAICompletionsOptions } from "./providers/openai-completions.ts"; +import type { OpenAIResponsesOptions } from "./providers/openai-responses.ts"; import type { AssistantMessageDiagnostic } from "./utils/diagnostics.ts"; import type { AssistantMessageEventStream } from "./utils/event-stream.ts"; @@ -56,7 +65,7 @@ export type KnownProvider = | "xiaomi-token-plan-cn" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-sgp"; -export type Provider = KnownProvider | string; +export type ProviderId = KnownProvider | string; export type KnownImagesProvider = "openrouter"; @@ -157,6 +166,31 @@ export interface StreamOptions { export type ProviderStreamOptions = StreamOptions & Record; +/** + * Maps known APIs to their full provider-specific stream option types. + * Type-only imports from API implementation modules are erased at emit, so + * this is tree-shake safe. + */ +export interface ApiOptionsMap { + "anthropic-messages": AnthropicOptions; + "openai-completions": OpenAICompletionsOptions; + "openai-responses": OpenAIResponsesOptions; + "openai-codex-responses": OpenAICodexResponsesOptions; + "azure-openai-responses": AzureOpenAIResponsesOptions; + "google-generative-ai": GoogleOptions; + "google-vertex": GoogleVertexOptions; + "mistral-conversations": MistralOptions; + "bedrock-converse-stream": BedrockOptions; +} + +/** + * Full stream options for an API. Known APIs resolve to their concrete option + * type; custom API strings fall back to the generic shape. + */ +export type ApiStreamOptions = TApi extends keyof ApiOptionsMap + ? ApiOptionsMap[TApi] + : StreamOptions & Record; + export interface ImagesOptions { signal?: AbortSignal; apiKey?: string; @@ -289,7 +323,7 @@ export interface AssistantMessage { role: "assistant"; content: (TextContent | ThinkingContent | ToolCall)[]; api: Api; - provider: Provider; + provider: ProviderId; model: string; responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one @@ -569,7 +603,7 @@ export interface Model { id: string; name: string; api: TApi; - provider: Provider; + provider: ProviderId; baseUrl: string; reasoning: boolean; /** diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts new file mode 100644 index 000000000..7f94ccb78 --- /dev/null +++ b/packages/ai/test/models-runtime.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it } from "vitest"; +import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; +import type { ApiKeyAuth, CredentialStore, OAuthAuth, ProviderAuth } from "../src/auth/types.ts"; +import { createModels, hasApi, type Provider } from "../src/models.ts"; +import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, StreamOptions } from "../src/types.ts"; +import { AssistantMessageEventStream } from "../src/utils/event-stream.ts"; + +function testModel(provider: string, id: string): Model { + return { + id, + name: id, + api: "test-api", + provider, + baseUrl: "https://example.test/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 10000, + maxTokens: 1000, + }; +} + +function doneMessage(model: Model, text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +interface ProviderCall { + model: Model; + options: StreamOptions | undefined; +} + +/** Ambient auth for keyless test providers; reports "configured" with no auth values. */ +const ambientAuth: ApiKeyAuth = { + name: "Ambient", + resolve: async () => ({ auth: {} }), +}; + +function testProvider(input: { + id: string; + models?: Model[]; + auth?: ProviderAuth; + getModels?: () => Promise[]>; + calls?: ProviderCall[]; +}): Provider { + const models = input.models ?? [testModel(input.id, "model-a")]; + const respond = (model: Model, options: StreamOptions | undefined) => { + input.calls?.push({ model, options }); + const stream = new AssistantMessageEventStream(); + const message = doneMessage(model, "ok"); + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: "stop", message }); + stream.end(message); + return stream; + }; + return { + id: input.id, + name: input.id, + auth: input.auth ?? { apiKey: ambientAuth }, + getModels: input.getModels ?? (async () => models), + stream: (model, _context, options) => respond(model, options as StreamOptions | undefined), + streamSimple: (model, _context, options) => respond(model, options as SimpleStreamOptions | undefined), + }; +} + +const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }; + +function envKeyAuth(key: string | undefined): ApiKeyAuth { + return { + name: "Test API key", + resolve: async ({ credential }) => { + const resolved = credential?.key ?? key; + if (!resolved) return undefined; + return { auth: { apiKey: resolved }, source: credential ? "stored" : "env" }; + }, + }; +} + +function testOAuth(overrides?: Partial): OAuthAuth { + return { + name: "Test OAuth", + login: async () => { + throw new Error("not used"); + }, + refresh: async (credential) => credential, + toAuth: async (credential) => ({ apiKey: credential.access }), + ...overrides, + }; +} + +describe("Models runtime", () => { + it("registers, replaces, and deletes providers", () => { + const models = createModels(); + models.setProvider(testProvider({ id: "p1" })); + models.setProvider(testProvider({ id: "p2" })); + expect(models.getProviders().map((p) => p.id)).toEqual(["p1", "p2"]); + + const replacement = testProvider({ id: "p1" }); + models.setProvider(replacement); + expect(models.getProvider("p1")).toBe(replacement); + expect(models.getProviders()).toHaveLength(2); + + models.deleteProvider("p1"); + expect(models.getProvider("p1")).toBeUndefined(); + + models.clearProviders(); + expect(models.getProviders()).toHaveLength(0); + }); + + it("lists and finds models per provider", async () => { + const models = createModels(); + models.setProvider(testProvider({ id: "p1", models: [testModel("p1", "m1"), testModel("p1", "m2")] })); + models.setProvider(testProvider({ id: "p2", models: [testModel("p2", "m3")] })); + + expect((await models.getModels()).map((m) => m.id)).toEqual(["m1", "m2", "m3"]); + expect((await models.getModels("p1")).map((m) => m.id)).toEqual(["m1", "m2"]); + expect((await models.getModels("nope")).length).toBe(0); + expect((await models.getModel("p2", "m3"))?.id).toBe("m3"); + expect(await models.getModel("p2", "missing")).toBeUndefined(); + + // hasApi() narrows dynamically looked-up models with a runtime check + const found = await models.getModel("p2", "m3"); + expect(found && hasApi(found, "openai-completions")).toBe(false); + expect(found && hasApi(found, "test-api")).toBe(true); + if (found && hasApi(found, "test-api")) { + const _typed: Model<"test-api"> = found; + expect(_typed.id).toBe("m3"); + } + }); + + it("swallows provider source failures for both all-provider and single-provider listing", async () => { + const models = createModels(); + models.setProvider( + testProvider({ + id: "broken", + getModels: async () => { + throw new Error("boom"); + }, + }), + ); + models.setProvider(testProvider({ id: "ok", models: [testModel("ok", "m1")] })); + + expect((await models.getModels()).map((m) => m.id)).toEqual(["m1"]); + expect(await models.getModels("broken")).toEqual([]); + // precise failures come from the provider directly + await expect(models.getProvider("broken")?.getModels()).rejects.toThrow("boom"); + + // even sync-throwing (non-async) provider implementations are isolated + models.setProvider({ + ...testProvider({ id: "sync-broken" }), + getModels: () => { + throw new Error("sync boom"); + }, + }); + expect((await models.getModels()).map((m) => m.id)).toEqual(["m1"]); + }); + + it("supports getModels(options) without a provider id", async () => { + const seen: ({ forceRefresh?: boolean } | undefined)[] = []; + const models = createModels(); + models.setProvider(testProvider({ id: "p1", models: [testModel("p1", "m1")] })); + models.setProvider({ + ...testProvider({ id: "p2" }), + getModels: async (options) => { + seen.push(options); + return [testModel("p2", "m2")]; + }, + }); + + const all = await models.getModels({ forceRefresh: true }); + expect(all.map((m) => m.id)).toEqual(["m1", "m2"]); + expect(seen).toEqual([{ forceRefresh: true }]); + }); + + it("resolves auth: stored credential owns the provider, ambient only when nothing stored", async () => { + const credentials = new InMemoryCredentialStore(); + const models = createModels({ credentials }); + models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key"), oauth: testOAuth() } })); + const model = testModel("p1", "model-a"); + + // nothing stored: ambient env resolves + expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key"); + + // stored oauth credential (persisted via the single write path): beats ambient env + await credentials.modify("p1", async () => ({ + type: "oauth", + access: "oauth-token", + refresh: "r", + expires: Date.now() + 100000, + })); + const resolution = await models.getAuth(model); + expect(resolution?.auth.apiKey).toBe("oauth-token"); + 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" })); + const apiKeyResolution = await models.getAuth(model); + expect(apiKeyResolution?.auth.apiKey).toBe("stored-key"); + expect(apiKeyResolution?.source).toBe("stored"); + }); + + it("a stored credential without a matching handler blocks ambient fallback", async () => { + const credentials = new InMemoryCredentialStore(); + const models = createModels({ credentials }); + // provider has only apiKey auth, but an oauth credential is stored (stale config) + models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key") } })); + await credentials.modify("p1", async () => ({ type: "oauth", access: "a", refresh: "r", expires: 0 })); + + expect(await models.getAuth(testModel("p1", "model-a"))).toBeUndefined(); + }); + + it("refreshes expired oauth credentials and persists the rotated credential", async () => { + const credentials = new InMemoryCredentialStore(); + const oauth = testOAuth({ + refresh: async (credential) => ({ ...credential, access: "new-token", expires: Date.now() + 60_000 }), + }); + const models = createModels({ credentials }); + models.setProvider(testProvider({ id: "p1", auth: { oauth } })); + await credentials.modify("p1", async () => ({ + type: "oauth", + access: "old-token", + refresh: "r", + expires: 0, + })); + + const resolution = await models.getAuth(testModel("p1", "model-a")); + expect(resolution?.auth.apiKey).toBe("new-token"); + expect(((await credentials.read("p1")) as { access: string }).access).toBe("new-token"); + }); + + it("rejects with code oauth when refresh fails, preserving the stored credential", async () => { + const credentials = new InMemoryCredentialStore(); + const oauth = testOAuth({ + refresh: async () => { + throw new Error("invalid_grant"); + }, + }); + const models = createModels({ credentials }); + models.setProvider(testProvider({ id: "p1", auth: { oauth } })); + await credentials.modify("p1", async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 })); + + await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "oauth" }); + // credential preserved for retry / re-login + expect(((await credentials.read("p1")) as { access: string }).access).toBe("old"); + }); + + it("serializes concurrent OAuth refreshes through store.modify (no double refresh)", async () => { + const credentials = new InMemoryCredentialStore(); + await credentials.modify("p1", async () => ({ type: "oauth", access: "old", refresh: "r1", expires: 0 })); + + let refreshes = 0; + const oauth = testOAuth({ + refresh: async () => { + refreshes++; + await new Promise((resolve) => setTimeout(resolve, 10)); + return { type: "oauth", access: `new-${refreshes}`, refresh: "r2", expires: Date.now() + 60_000 }; + }, + }); + const models = createModels({ credentials }); + models.setProvider(testProvider({ id: "p1", auth: { oauth } })); + const model = testModel("p1", "model-a"); + + const [a, b] = await Promise.all([models.getAuth(model), models.getAuth(model)]); + expect(refreshes).toBe(1); + expect(a?.auth.apiKey).toBe("new-1"); + expect(b?.auth.apiKey).toBe("new-1"); + }); + + it("valid oauth tokens resolve without touching modify", async () => { + let modifies = 0; + const base = new InMemoryCredentialStore(); + const credentials: CredentialStore = { + read: (pid) => base.read(pid), + modify: (pid, fn) => { + modifies++; + return base.modify(pid, fn); + }, + delete: (pid) => base.delete(pid), + }; + await base.modify("p1", async () => ({ + type: "oauth", + access: "valid", + refresh: "r", + expires: Date.now() + 60_000, + })); + const models = createModels({ credentials }); + models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } })); + + expect((await models.getAuth(testModel("p1", "model-a")))?.auth.apiKey).toBe("valid"); + expect(modifies).toBe(0); + }); + + it("wraps credential store failures in ModelsError", async () => { + // read failure + const readFailing: CredentialStore = { + read: async () => { + throw new Error("disk on fire"); + }, + modify: async () => undefined, + delete: async () => {}, + }; + const models = createModels({ credentials: readFailing }); + models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key") } })); + await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" }); + + // modify failure during refresh + const modifyFailing: CredentialStore = { + read: async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 }), + modify: async () => { + throw new Error("disk on fire"); + }, + delete: async () => {}, + }; + const oauthModels = createModels({ credentials: modifyFailing }); + oauthModels.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } })); + await expect(oauthModels.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" }); + }); + + it("wraps api-key auth failures in ModelsError", async () => { + const failing: ApiKeyAuth = { + name: "Failing", + resolve: async () => { + throw new Error("nope"); + }, + }; + const models = createModels(); + models.setProvider(testProvider({ id: "p1", auth: { apiKey: failing } })); + await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" }); + }); + + it("merges resolved auth into stream options; explicit options win per field", async () => { + const calls: ProviderCall[] = []; + const apiKey: ApiKeyAuth = { + name: "Test", + resolve: async () => ({ + auth: { + apiKey: "resolved-key", + headers: { "x-a": "auth", "x-b": "auth" }, + baseUrl: "https://auth.test/v1", + }, + }), + }; + const models = createModels(); + models.setProvider(testProvider({ id: "p1", auth: { apiKey }, calls })); + const model = testModel("p1", "model-a"); + + const result = await models.completeSimple(model, context, { + apiKey: "explicit-key", + headers: { "x-b": "explicit" }, + }); + expect(result.stopReason).toBe("stop"); + expect(calls).toHaveLength(1); + expect(calls[0].options?.apiKey).toBe("explicit-key"); + expect(calls[0].options?.headers).toEqual({ "x-a": "auth", "x-b": "explicit" }); + expect(calls[0].model.baseUrl).toBe("https://auth.test/v1"); + + // without explicit options, resolved auth applies + const result2 = await models.completeSimple(model, context); + expect(result2.stopReason).toBe("stop"); + expect(calls[1].options?.apiKey).toBe("resolved-key"); + }); + + it("produces an error stream for unknown providers instead of throwing", async () => { + const models = createModels(); + const result = await models.completeSimple(testModel("ghost", "model-a"), context); + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("Unknown provider: ghost"); + }); + + it("streams through the provider", async () => { + const models = createModels(); + models.setProvider(testProvider({ id: "p1" })); + const model = testModel("p1", "model-a"); + + const events: string[] = []; + const stream = models.streamSimple(model, context); + for await (const event of stream) { + events.push(event.type); + } + expect(events).toEqual(["start", "done"]); + const message = await stream.result(); + expect(message.stopReason).toBe("stop"); + }); +}); diff --git a/packages/ai/test/scratch.ts b/packages/ai/test/scratch.ts new file mode 100644 index 000000000..949dccbe3 --- /dev/null +++ b/packages/ai/test/scratch.ts @@ -0,0 +1,87 @@ +// Scratch script showing real-world use of the new Models API. +// Run from packages/ai: node test/scratch.ts +// Requires ANTHROPIC_API_KEY. + +import { createModels, getModels, type Provider } from "../src/models.ts"; +import { streamAnthropic, streamSimpleAnthropic } from "../src/providers/register-builtins.ts"; +import type { Context } from "../src/types.ts"; + +// --------------------------------------------------------------------------- +// 1. Define a provider. In the final design this comes from +// `@earendil-works/pi-ai/providers/anthropic` as `anthropicProvider()`; +// until Phase 3 lands we wire it by hand from existing parts. +// --------------------------------------------------------------------------- + +const anthropic: Provider<"anthropic-messages"> = { + id: "anthropic", + name: "Anthropic", + baseUrl: "https://api.anthropic.com/v1", + + auth: { + apiKey: { + name: "Anthropic API key", + resolve: async ({ ctx, credential }) => { + // stored credential (from a /login flow) wins, env is the ambient fallback + const key = credential?.key ?? (await ctx.env("ANTHROPIC_API_KEY")); + if (!key) return undefined; + return { auth: { apiKey: key }, source: credential ? "stored credential" : "ANTHROPIC_API_KEY" }; + }, + }, + }, + + // static catalog source; a dynamic provider would fetch here + getModels: async () => getModels("anthropic"), + + // shared lazy API implementation (loads the SDK on first request) + stream: streamAnthropic, + streamSimple: streamSimpleAnthropic, +}; + +// --------------------------------------------------------------------------- +// 2. Build a Models runtime and register the provider. +// --------------------------------------------------------------------------- + +const models = createModels(); +models.setProvider(anthropic); + +// --------------------------------------------------------------------------- +// 3. Look up a model and check auth. +// --------------------------------------------------------------------------- + +const model = await models.getModel("anthropic", "claude-haiku-4-5"); +if (!model) throw new Error("model not found"); + +const auth = await models.getAuth(model); +console.log(`model: ${model.provider}/${model.id}`); +console.log(`auth: ${auth ? `configured via ${auth.source}` : "not configured"}\n`); +if (!auth) process.exit(1); + +const context: Context = { + systemPrompt: "You are terse.", + messages: [{ role: "user", content: "Say exactly: ok", timestamp: Date.now() }], +}; + +// --------------------------------------------------------------------------- +// 4. Simple completion (request-level auth resolution happens inside). +// --------------------------------------------------------------------------- + +const message = await models.completeSimple(model, context); +console.log(`completeSimple -> [${message.stopReason}]`, message.content); + +// --------------------------------------------------------------------------- +// 5. Streaming with deltas. +// --------------------------------------------------------------------------- + +context.messages.push(message, { + role: "user", + content: "Now count from 1 to 5, one number per line.", + timestamp: Date.now(), +}); + +process.stdout.write("streamSimple -> "); +const stream = models.streamSimple(model, context); +for await (const event of stream) { + if (event.type === "text_delta") process.stdout.write(event.delta.replaceAll("\n", " ")); +} +const final = await stream.result(); +console.log(`[${final.stopReason}] cost: $${final.usage.cost.total.toFixed(6)}`);