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:
Mario Zechner
2026-06-10 18:49:10 +02:00
parent 7498b216d9
commit f63095cfff
10 changed files with 1427 additions and 92 deletions
+56
View File
@@ -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;
}
+45
View File
@@ -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;
}
},
};
}
+47
View File
@@ -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);
});
}
}
+179
View File
@@ -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;
}
+4
View File
@@ -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
View File
@@ -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();
+37 -3
View File
@@ -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;
/**
+399
View File
@@ -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");
});
});
+87
View File
@@ -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)}`);