mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
feat(ai): add Models runtime with provider-owned auth (phase 1)
New Models/MutableModels/createModels collection: provider map, async
model listing (best-effort aggregation), getAuth decision tree with
double-checked locked OAuth refresh, stream/complete with per-field
auth merge over lazyStream.
Auth substrate: ProviderAuth { apiKey?, oauth? }, one type-tagged
credential per provider, CredentialStore (read/modify/delete; modify
is the only write path, serialized RMW), OAuthAuth login/refresh/toAuth
split, prompt()/notify() login callbacks, browser-safe default
AuthContext.
types.ts: Provider alias renamed to ProviderId; ApiOptionsMap and
ApiStreamOptions<TApi> for typed per-API stream options; hasApi()
runtime narrowing guard.
This commit is contained in:
+208
-88
@@ -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<readonly Model<Api>[]>;
|
||||
getModels(provider?: string, options?: { forceRefresh?: boolean }): Promise<readonly Model<Api>[]>;
|
||||
/** Dynamic lists are honestly Model<Api>; narrow with the hasApi() guard. */
|
||||
getModel(provider: string, id: string, options?: { forceRefresh?: boolean }): Promise<Model<Api> | undefined>;
|
||||
|
||||
/** Resolve request auth for a model. Includes source label for status UI. */
|
||||
getAuth(model: Model<Api>): Promise<AuthResolution | undefined>;
|
||||
/**
|
||||
* 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<Api>): Promise<AuthResult | undefined>;
|
||||
|
||||
stream<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
@@ -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<Api>`.
|
||||
|
||||
```ts
|
||||
export interface Provider {
|
||||
export interface Provider<TApi extends Api = Api> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
||||
readonly baseUrl?: string;
|
||||
readonly headers?: Record<string, string>;
|
||||
|
||||
/** 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<readonly Model<Api>[]>;
|
||||
/** Sync return suits static catalogs; Models always exposes a Promise. */
|
||||
getModels(options?: { forceRefresh?: boolean }): Promise<readonly Model<TApi>[]> | readonly Model<TApi>[];
|
||||
|
||||
stream<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ApiStreamOptions<TApi>,
|
||||
): AssistantMessageEventStream;
|
||||
stream<T extends TApi>(model: Model<T>, context: Context, options?: ApiStreamOptions<T>): AssistantMessageEventStream;
|
||||
|
||||
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
||||
streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -221,6 +239,29 @@ export type ApiStreamOptions<TApi extends Api> = 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<Api>`. Typing improves at three points:
|
||||
|
||||
1. **`hasApi()` type guard** — runtime-checked narrowing for dynamic lookups (no blind casts):
|
||||
|
||||
```ts
|
||||
export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi>;
|
||||
|
||||
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<exact-api-literal>`. The path for hardcoded known models.
|
||||
|
||||
3. **`Provider<TApi>` 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<string, JSON>` 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<LocalCredential>;
|
||||
login?(callbacks: AuthLoginCallbacks): Promise<ApiKeyCredential>;
|
||||
|
||||
/**
|
||||
* 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<Api>;
|
||||
ctx: ProviderAuthContext;
|
||||
credential?: LocalCredential;
|
||||
}): Promise<AuthResolution | undefined>;
|
||||
ctx: AuthContext;
|
||||
credential?: ApiKeyCredential;
|
||||
}): Promise<AuthResult | undefined>;
|
||||
}
|
||||
|
||||
export interface OAuthAuthMethod {
|
||||
kind: "oauth";
|
||||
id: string; // e.g. "oauth"
|
||||
export interface OAuthAuth {
|
||||
name: string; // "Anthropic (Claude Pro/Max)"
|
||||
|
||||
login(callbacks: AuthLoginCallbacks): Promise<OAuthCredential>;
|
||||
|
||||
resolve(input: {
|
||||
model: Model<Api>;
|
||||
ctx: ProviderAuthContext;
|
||||
credential?: OAuthCredential;
|
||||
}): Promise<AuthResolution | undefined>;
|
||||
/** Exchange the refresh token. Network call; throws on failure (invalid_grant etc.). Runs under the store lock. */
|
||||
refresh(credential: OAuthCredential): Promise<OAuthCredential>;
|
||||
|
||||
/** 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<ModelAuth>;
|
||||
}
|
||||
|
||||
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<string | undefined>;
|
||||
fileExists(path: string): Promise<boolean>; // 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<string, string>; // 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<Credential | undefined>;
|
||||
set(providerId: string, methodId: string, credential: Credential): Promise<void>;
|
||||
delete(providerId: string, methodId: string): Promise<void>;
|
||||
/** Read the stored credential, possibly expired. Display/status use; request auth comes from Models.getAuth(). */
|
||||
read(providerId: string): Promise<Credential | undefined>;
|
||||
|
||||
/**
|
||||
* 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<Credential | undefined>,
|
||||
): Promise<Credential | undefined>;
|
||||
|
||||
/** Remove (logout). Serialized against modify. */
|
||||
delete(providerId: string): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
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<string>;
|
||||
prompt(prompt: AuthPrompt): Promise<string>;
|
||||
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>;
|
||||
}): OAuthAuthMethod;
|
||||
load: () => Promise<OAuthAuth>;
|
||||
}): 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<string, string>;
|
||||
auth?: readonly AuthMethod[]; // default: []
|
||||
auth: ProviderAuth; // required, at least one of apiKey/oauth (no "no-auth" providers)
|
||||
models:
|
||||
| readonly Model<Api>[]
|
||||
| ((options?: { forceRefresh?: boolean }) => Promise<readonly Model<Api>[]>);
|
||||
@@ -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<TApi>` 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<TApi>` to `types.ts` (type-only imports).
|
||||
- [x] New `models.ts`: `Provider<TApi>` 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".
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Api, AssistantMessage, AssistantMessageEvent, Model } from "../types.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
|
||||
function createSetupErrorMessage(model: Model<Api>, 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<AssistantMessageEvent>): 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<Api>,
|
||||
setup: () => Promise<AsyncIterable<AssistantMessageEvent>>,
|
||||
): 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;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AuthContext } from "./types.ts";
|
||||
|
||||
interface NodeFsModule {
|
||||
access(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface NodeOsModule {
|
||||
homedir(): string;
|
||||
}
|
||||
|
||||
// Variable specifier so browser bundlers do not try to resolve node builtins.
|
||||
const importNodeModule = (specifier: string): Promise<unknown> => import(specifier);
|
||||
|
||||
function getProcessEnv(): Record<string, string | undefined> | undefined {
|
||||
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).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<string | undefined> {
|
||||
const value = getProcessEnv()?.[name];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
},
|
||||
|
||||
async fileExists(path: string): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, Credential>();
|
||||
private chains = new Map<string, Promise<unknown>>();
|
||||
|
||||
/** Serialize tasks per provider id. */
|
||||
private enqueue<T>(providerId: string, task: () => Promise<T>): Promise<T> {
|
||||
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<Credential | undefined> {
|
||||
return this.credentials.get(providerId);
|
||||
}
|
||||
|
||||
modify(
|
||||
providerId: string,
|
||||
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
|
||||
): Promise<Credential | undefined> {
|
||||
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<void> {
|
||||
return this.enqueue(providerId, async () => {
|
||||
this.credentials.delete(providerId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
/** 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<Credential | undefined>;
|
||||
|
||||
/**
|
||||
* 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<Credential | undefined>,
|
||||
): Promise<Credential | undefined>;
|
||||
|
||||
/** Remove a credential (logout). Implementations serialize this against `modify`. */
|
||||
delete(providerId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Environment access for auth resolution. Injectable for tests and browsers. */
|
||||
export interface AuthContext {
|
||||
env(name: string): Promise<string | undefined>;
|
||||
/** Check whether a file exists. Supports a leading `~`. Always false in browsers. */
|
||||
fileExists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** 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<string>;
|
||||
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<ApiKeyCredential>;
|
||||
|
||||
/**
|
||||
* 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<Api>;
|
||||
ctx: AuthContext;
|
||||
credential?: ApiKeyCredential;
|
||||
}): Promise<AuthResult | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<OAuthCredential>;
|
||||
|
||||
/**
|
||||
* Exchange the refresh token. Network call; throws on failure
|
||||
* (invalid_grant etc.). `Models` runs this under the store lock.
|
||||
*/
|
||||
refresh(credential: OAuthCredential): Promise<OAuthCredential>;
|
||||
|
||||
/**
|
||||
* 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<ModelAuth>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+365
-1
@@ -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<Api>`.
|
||||
*/
|
||||
export interface Provider<TApi extends Api = Api> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
||||
readonly baseUrl?: string;
|
||||
readonly headers?: Record<string, string>;
|
||||
|
||||
/**
|
||||
* 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<TApi>[]> | readonly Model<TApi>[];
|
||||
|
||||
stream<T extends TApi>(
|
||||
model: Model<T>,
|
||||
context: Context,
|
||||
options?: ApiStreamOptions<T>,
|
||||
): AssistantMessageEventStream;
|
||||
|
||||
streamSimple(model: Model<TApi>, 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<readonly Model<Api>[]>;
|
||||
getModels(provider?: string, options?: { forceRefresh?: boolean }): Promise<readonly Model<Api>[]>;
|
||||
|
||||
/**
|
||||
* Runtime model lookup. Dynamic model lists are typed as `Model<Api>`;
|
||||
* narrow with the `hasApi()` type guard.
|
||||
*/
|
||||
getModel(provider: string, id: string, options?: { forceRefresh?: boolean }): Promise<Model<Api> | 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<Api>): Promise<AuthResult | undefined>;
|
||||
|
||||
stream<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ApiStreamOptions<TApi>,
|
||||
): AssistantMessageEventStream;
|
||||
|
||||
complete<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ApiStreamOptions<TApi>,
|
||||
): Promise<AssistantMessage>;
|
||||
|
||||
streamSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
||||
completeSimple(model: Model<Api>, context: Context, options?: SimpleStreamOptions): Promise<AssistantMessage>;
|
||||
}
|
||||
|
||||
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<string, Provider>();
|
||||
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<readonly Model<Api>[]> {
|
||||
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<Api>[] = [];
|
||||
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<Model<Api> | undefined> {
|
||||
const models = await this.getModels(provider, options);
|
||||
return models.find((model) => model.id === id);
|
||||
}
|
||||
|
||||
async getAuth(model: Model<Api>): Promise<AuthResult | undefined> {
|
||||
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<AuthResult | undefined> {
|
||||
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<Api>,
|
||||
credential: ApiKeyCredential | undefined,
|
||||
): Promise<AuthResult | undefined> {
|
||||
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<Credential | undefined> {
|
||||
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<Api>): Provider {
|
||||
const provider = this.providers.get(model.provider);
|
||||
if (!provider) {
|
||||
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private async applyAuth<TOptions extends StreamOptions>(
|
||||
model: Model<Api>,
|
||||
options: TOptions | undefined,
|
||||
): Promise<{ requestModel: Model<Api>; 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<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ApiStreamOptions<TApi>,
|
||||
): 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<TApi>, context, requestOptions as ApiStreamOptions<TApi>);
|
||||
});
|
||||
}
|
||||
|
||||
async complete<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ApiStreamOptions<TApi>,
|
||||
): Promise<AssistantMessage> {
|
||||
return this.stream(model, context, options).result();
|
||||
}
|
||||
|
||||
streamSimple(model: Model<Api>, 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<Api>, context: Context, options?: SimpleStreamOptions): Promise<AssistantMessage> {
|
||||
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<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi> {
|
||||
return model.api === api;
|
||||
}
|
||||
|
||||
const modelRegistry: Map<string, Map<string, Model<Api>>> = new Map();
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
/**
|
||||
* 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 Api> = TApi extends keyof ApiOptionsMap
|
||||
? ApiOptionsMap[TApi]
|
||||
: StreamOptions & Record<string, unknown>;
|
||||
|
||||
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<TApi extends Api> {
|
||||
id: string;
|
||||
name: string;
|
||||
api: TApi;
|
||||
provider: Provider;
|
||||
provider: ProviderId;
|
||||
baseUrl: string;
|
||||
reasoning: boolean;
|
||||
/**
|
||||
|
||||
@@ -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<Api> {
|
||||
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<Api>, 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<Api>;
|
||||
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<Api>[];
|
||||
auth?: ProviderAuth;
|
||||
getModels?: () => Promise<readonly Model<Api>[]>;
|
||||
calls?: ProviderCall[];
|
||||
}): Provider {
|
||||
const models = input.models ?? [testModel(input.id, "model-a")];
|
||||
const respond = (model: Model<Api>, 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>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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)}`);
|
||||
Reference in New Issue
Block a user