ππ Agent

models.ts

25 KB706 lines
models.ts
1import { lazyStream } from "./api/lazy.ts";
2import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
3import { InMemoryCredentialStore } from "./auth/credential-store.ts";
4import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
5import type {
6 AuthCheck,
7 AuthContext,
8 AuthInteraction,
9 AuthResult,
10 AuthType,
11 Credential,
12 CredentialStore,
13 ProviderAuth,
14} from "./auth/types.ts";
15import { InMemoryModelsStore, type ModelsStore, type ProviderModelsStore } from "./models-store.ts";
16import type {
17 Api,
18 ApiStreamOptions,
19 AssistantMessage,
20 AssistantMessageEventStream,
21 Context,
22 Model,
23 ModelCostRates,
24 ModelThinkingLevel,
25 ProviderHeaders,
26 ProviderStreams,
27 SimpleStreamOptions,
28 StreamOptions,
29 Usage,
30} from "./types.ts";
31
32export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
33
34export interface RefreshModelsContext {
35 /** Effective configured credential. OAuth credentials are refreshed before network access. */
36 credential?: Credential;
37 /** Persistent model storage scoped to this provider ID. */
38 store: ProviderModelsStore;
39 /** False during offline/cache-only initialization. */
40 allowNetwork: boolean;
41 /** Bypass provider freshness checks and fetch immediately when network access is allowed. */
42 force?: boolean;
43 signal?: AbortSignal;
44}
45
46export interface ModelsRefreshOptions {
47 allowNetwork?: boolean;
48 /** Bypass provider freshness checks and fetch immediately when network access is allowed. */
49 force?: boolean;
50 signal?: AbortSignal;
51}
52
53export interface ModelsRefreshResult {
54 aborted: boolean;
55 errors: ReadonlyMap<string, Error>;
56}
57
58export interface ModelsStreamTransforms {
59 /** Transform fully assembled model/auth/request headers before provider dispatch. */
60 transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise<ProviderHeaders>;
61}
62
63export type ModelsApiStreamOptions<TApi extends Api> = ApiStreamOptions<TApi> & ModelsStreamTransforms;
64export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsStreamTransforms;
65
66/**
67 * A provider is the concrete runtime unit. It owns id/name/base metadata,
68 * auth methods, model listing, and stream behavior.
69 *
70 * `TApi` lets concrete provider factories declare which APIs their models
71 * use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`),
72 * giving typed model lists to direct factory users. Inside a `Models`
73 * collection providers are held as `Provider<Api>`.
74 */
75export interface Provider<TApi extends Api = Api> {
76 readonly id: string;
77 readonly name: string;
78
79 readonly baseUrl?: string;
80 readonly headers?: ProviderHeaders;
81
82 /**
83 * Required: at least one of `apiKey`/`oauth`. Every provider has auth
84 * semantics — even providers with only ambient credentials (env vars, AWS
85 * profiles, ADC files) and keyless local servers provide `apiKey` auth
86 * whose `resolve()` reports whether the provider is configured.
87 * `Models.getAuth()` returns undefined when the provider is unconfigured.
88 */
89 readonly auth: ProviderAuth;
90
91 /**
92 * Current known models, sync. Static providers return their catalog;
93 * dynamic providers return the list as of the last `refreshModels()`
94 * (empty before the first). Must not throw; `Models` treats a throwing
95 * implementation as having no models.
96 */
97 getModels(): readonly Model<TApi>[];
98
99 /**
100 * Dynamic providers only: restore the provider-scoped stored catalog and optionally fetch
101 * a newer list using the effective credential. Implementations must retain their previous
102 * list on failure and honor the shared abort signal for network requests.
103 */
104 refreshModels?(context: RefreshModelsContext): Promise<void>;
105
106 /**
107 * Optional provider policy for credential-specific model availability.
108 * `getModels()` remains the complete synchronous catalog; `Models.getAvailable()`
109 * applies this filter after confirming that provider auth is configured.
110 */
111 filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
112
113 stream<T extends TApi>(
114 model: Model<T>,
115 context: Context,
116 options?: ApiStreamOptions<T>,
117 ): AssistantMessageEventStream;
118
119 streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
120}
121
122/**
123 * Runtime collection of providers plus auth application and stream
124 * convenience. Providers own stream behavior; `Models` resolves auth and
125 * delegates each request to the provider that owns the model.
126 */
127export interface Models {
128 getProviders(): readonly Provider[];
129 getProvider(id: string): Provider | undefined;
130
131 /**
132 * Sync read of last-known models from one provider or all providers.
133 * Best-effort: a provider whose `getModels()` throws yields no models.
134 */
135 getModels(provider?: string): readonly Model<Api>[];
136
137 /**
138 * Sync runtime model lookup against last-known lists. Dynamic model lists
139 * are typed as `Model<Api>`; narrow with the `hasApi()` type guard.
140 */
141 getModel(provider: string, id: string): Model<Api> | undefined;
142
143 /**
144 * Refresh every configured dynamic provider concurrently. Provider errors and cancellation
145 * are returned without rejecting; static and unconfigured providers are skipped.
146 */
147 refresh(options?: ModelsRefreshOptions): Promise<ModelsRefreshResult>;
148
149 /** Check whether a provider has complete auth configuration without refreshing OAuth. */
150 checkAuth(providerId: string): Promise<AuthCheck | undefined>;
151
152 /** Return models whose providers have complete auth configuration. */
153 getAvailable(providerId?: string): Promise<readonly Model<Api>[]>;
154
155 /**
156 * Resolve provider-scoped auth by provider id, or provider auth plus static
157 * model headers when passed a model. Includes a source label for status UI.
158 * Resolves `undefined` when the provider is unknown or unconfigured.
159 * Rejects with `ModelsError`: code "oauth" when a token refresh fails (the
160 * stored credential is preserved for retry; re-login fixes it), code "auth"
161 * when api-key resolution or the credential store fails. Request paths
162 * surface rejections as stream errors.
163 */
164 getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
165 getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
166
167 /** Run a provider-owned login flow and persist its returned credential. */
168 login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential>;
169
170 /** Remove the stored credential for a provider. */
171 logout(providerId: string): Promise<void>;
172
173 stream<TApi extends Api>(
174 model: Model<TApi>,
175 context: Context,
176 options?: ModelsApiStreamOptions<TApi>,
177 ): AssistantMessageEventStream;
178
179 complete<TApi extends Api>(
180 model: Model<TApi>,
181 context: Context,
182 options?: ModelsApiStreamOptions<TApi>,
183 ): Promise<AssistantMessage>;
184
185 streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream;
186 completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage>;
187}
188
189export interface MutableModels extends Models {
190 /** Upsert/replace by provider.id. Provider ids are unique. */
191 setProvider(provider: Provider): void;
192 deleteProvider(id: string): void;
193 clearProviders(): void;
194}
195
196export interface CreateModelsOptions {
197 credentials?: CredentialStore;
198 modelsStore?: ModelsStore;
199 authContext?: AuthContext;
200}
201
202function mergeHeaders(
203 base: ProviderHeaders | undefined,
204 override: ProviderHeaders | undefined,
205): ProviderHeaders | undefined {
206 if (!base && !override) return undefined;
207 const merged = { ...base };
208 for (const [name, value] of Object.entries(override ?? {})) {
209 const lowerName = name.toLowerCase();
210 for (const existingName of Object.keys(merged)) {
211 if (existingName.toLowerCase() === lowerName) delete merged[existingName];
212 }
213 merged[name] = value;
214 }
215 return merged;
216}
217
218class ModelsImpl implements MutableModels {
219 private providers = new Map<string, Provider>();
220 private credentials: CredentialStore;
221 private modelsStore: ModelsStore;
222 private authContext: AuthContext;
223
224 constructor(options?: CreateModelsOptions) {
225 this.credentials = options?.credentials ?? new InMemoryCredentialStore();
226 this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();
227 this.authContext = options?.authContext ?? defaultAuthContext();
228 }
229
230 setProvider(provider: Provider): void {
231 this.providers.set(provider.id, provider);
232 }
233
234 deleteProvider(id: string): void {
235 this.providers.delete(id);
236 }
237
238 clearProviders(): void {
239 this.providers.clear();
240 }
241
242 getProviders(): readonly Provider[] {
243 return Array.from(this.providers.values());
244 }
245
246 getProvider(id: string): Provider | undefined {
247 return this.providers.get(id);
248 }
249
250 getModels(provider?: string): readonly Model<Api>[] {
251 if (provider !== undefined) {
252 const entry = this.providers.get(provider);
253 if (!entry) return [];
254 try {
255 return entry.getModels();
256 } catch {
257 return [];
258 }
259 }
260
261 const models: Model<Api>[] = [];
262 for (const entry of this.providers.values()) {
263 try {
264 models.push(...entry.getModels());
265 } catch {
266 // Best-effort: ill-behaved providers yield no models.
267 }
268 }
269 return models;
270 }
271
272 getModel(provider: string, id: string): Model<Api> | undefined {
273 return this.getModels(provider).find((model) => model.id === id);
274 }
275
276 async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
277 const allowNetwork = options.allowNetwork ?? true;
278 const errors = new Map<string, Error>();
279 const refreshable = Array.from(this.providers.values()).filter(
280 (provider): provider is Provider & Required<Pick<Provider, "refreshModels">> =>
281 provider.refreshModels !== undefined,
282 );
283
284 await Promise.all(
285 refreshable.map(async (provider) => {
286 if (options.signal?.aborted) return;
287 const store: ProviderModelsStore = {
288 read: () => this.modelsStore.read(provider.id),
289 write: (entry) => this.modelsStore.write(provider.id, entry),
290 delete: () => this.modelsStore.delete(provider.id),
291 };
292 let stored: Credential | undefined;
293 try {
294 stored = await this.readCredential(provider.id);
295 const credential = await this.resolveRefreshCredential(provider, stored, allowNetwork, options.signal);
296 if (!credential) return;
297 await provider.refreshModels({
298 credential,
299 store,
300 allowNetwork,
301 force: options.force,
302 signal: options.signal,
303 });
304 } catch (error) {
305 if (!options.signal?.aborted) {
306 errors.set(
307 provider.id,
308 error instanceof Error
309 ? error
310 : new ModelsError("model_source", `Model refresh failed for ${provider.id}`, { cause: error }),
311 );
312 }
313 try {
314 await provider.refreshModels({
315 credential: stored,
316 store,
317 allowNetwork: false,
318 signal: options.signal,
319 });
320 } catch {
321 // Preserve the original auth/network error; cache restoration is best-effort here.
322 }
323 }
324 }),
325 );
326
327 return { aborted: options.signal?.aborted ?? false, errors };
328 }
329
330 private async resolveRefreshCredential(
331 provider: Provider,
332 stored: Credential | undefined,
333 allowNetwork: boolean,
334 signal?: AbortSignal,
335 ): Promise<Credential | undefined> {
336 if (stored?.type === "oauth") {
337 const oauth = provider.auth.oauth;
338 if (!oauth) return undefined;
339 if (!allowNetwork || Date.now() < stored.expires) return stored;
340 if (signal?.aborted) return undefined;
341 const post = await this.credentials.modify(provider.id, async (current) => {
342 if (current?.type !== "oauth" || Date.now() < current.expires) return undefined;
343 return oauth.refresh(current, signal);
344 });
345 return post?.type === "oauth" ? post : undefined;
346 }
347
348 const apiKey = provider.auth.apiKey;
349 if (!apiKey) return undefined;
350 const credential = stored?.type === "api_key" ? stored : undefined;
351 const result = await apiKey.resolve({ ctx: this.authContext, credential });
352 if (!result) return undefined;
353 return { type: "api_key", key: result.auth.apiKey, env: result.env };
354 }
355
356 private async readCredential(providerId: string): Promise<Credential | undefined> {
357 try {
358 return await this.credentials.read(providerId);
359 } catch (error) {
360 throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
361 }
362 }
363
364 private async checkProviderAuth(
365 provider: Provider,
366 credential: Credential | undefined,
367 ): Promise<AuthCheck | undefined> {
368 if (credential?.type === "oauth") {
369 return provider.auth.oauth ? { source: "OAuth", type: "oauth" } : undefined;
370 }
371 const apiKey = provider.auth.apiKey;
372 if (!apiKey) return undefined;
373 if (apiKey.check) {
374 try {
375 return await apiKey.check({
376 ctx: this.authContext,
377 credential: credential?.type === "api_key" ? credential : undefined,
378 });
379 } catch (error) {
380 throw new ModelsError("auth", `API key auth check failed for provider ${provider.id}`, { cause: error });
381 }
382 }
383
384 const resolution = await resolveProviderAuth(provider, this.credentials, this.authContext);
385 return resolution ? { source: resolution.source, type: "api_key" } : undefined;
386 }
387
388 async checkAuth(providerId: string): Promise<AuthCheck | undefined> {
389 const provider = this.providers.get(providerId);
390 if (!provider) return undefined;
391 return this.checkProviderAuth(provider, await this.readCredential(providerId));
392 }
393
394 async getAvailable(providerId?: string): Promise<readonly Model<Api>[]> {
395 const providers = providerId
396 ? [this.providers.get(providerId)].filter((entry) => entry !== undefined)
397 : this.getProviders();
398 const checks = await Promise.all(
399 providers.map(async (provider) => {
400 const credential = await this.readCredential(provider.id);
401 return { provider, credential, auth: await this.checkProviderAuth(provider, credential) };
402 }),
403 );
404 return checks.flatMap(({ provider, credential, auth }) => {
405 if (!auth) return [];
406 const models = provider.getModels();
407 return provider.filterModels?.(models, credential) ?? models;
408 });
409 }
410
411 getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
412 getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
413 async getAuth(
414 providerOrModel: string | Model<Api>,
415 overrides?: AuthResolutionOverrides,
416 ): Promise<AuthResult | undefined> {
417 const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;
418 const provider = this.providers.get(providerId);
419 if (!provider) return undefined;
420 const result = await resolveProviderAuth(provider, this.credentials, this.authContext, overrides);
421 if (!result || typeof providerOrModel === "string" || !providerOrModel.headers) return result;
422 return {
423 ...result,
424 auth: {
425 ...result.auth,
426 headers: mergeHeaders(result.auth.headers, providerOrModel.headers),
427 },
428 };
429 }
430
431 async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
432 const provider = this.providers.get(providerId);
433 if (!provider) throw new ModelsError("provider", `Unknown provider: ${providerId}`);
434 const method = type === "oauth" ? provider.auth.oauth : provider.auth.apiKey;
435 if (!method?.login) {
436 throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
437 }
438 const credential = await method.login(interaction);
439 try {
440 await this.credentials.modify(providerId, async () => credential);
441 } catch (error) {
442 throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
443 }
444 return credential;
445 }
446
447 async logout(providerId: string): Promise<void> {
448 try {
449 await this.credentials.delete(providerId);
450 } catch (error) {
451 throw new ModelsError("auth", `Credential store delete failed for ${providerId}`, { cause: error });
452 }
453 }
454
455 private requireProvider(model: Model<Api>): Provider {
456 const provider = this.providers.get(model.provider);
457 if (!provider) {
458 throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
459 }
460 return provider;
461 }
462
463 private async applyAuth<TOptions extends StreamOptions & ModelsStreamTransforms>(
464 model: Model<Api>,
465 options: TOptions | undefined,
466 ): Promise<{ requestModel: Model<Api>; requestOptions: StreamOptions | undefined }> {
467 this.requireProvider(model);
468 const resolution = await this.getAuth(model, {
469 apiKey: options?.apiKey,
470 env: options?.env,
471 });
472 if (!resolution) {
473 throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
474 }
475 const auth = resolution.auth;
476
477 // Explicit request options win per-field; the Models-only transform runs last.
478 const apiKey = options?.apiKey ?? auth.apiKey;
479 let headers = mergeHeaders(auth.headers, options?.headers);
480 if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});
481 const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
482 const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
483 const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
484 const requestOptions = { ...providerOptions, apiKey, headers, env } as StreamOptions;
485
486 return { requestModel, requestOptions };
487 }
488
489 stream<TApi extends Api>(
490 model: Model<TApi>,
491 context: Context,
492 options?: ModelsApiStreamOptions<TApi>,
493 ): AssistantMessageEventStream {
494 return lazyStream(model, async () => {
495 const provider = this.requireProvider(model);
496 const { requestModel, requestOptions } = await this.applyAuth(
497 model,
498 options as ModelsApiStreamOptions<Api> | undefined,
499 );
500 return provider.stream(requestModel as Model<TApi>, context, requestOptions as ApiStreamOptions<TApi>);
501 });
502 }
503
504 async complete<TApi extends Api>(
505 model: Model<TApi>,
506 context: Context,
507 options?: ModelsApiStreamOptions<TApi>,
508 ): Promise<AssistantMessage> {
509 return this.stream(model, context, options).result();
510 }
511
512 streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
513 return lazyStream(model, async () => {
514 const provider = this.requireProvider(model);
515 const { requestModel, requestOptions } = await this.applyAuth(model, options);
516 return provider.streamSimple(requestModel, context, requestOptions as SimpleStreamOptions);
517 });
518 }
519
520 async completeSimple(
521 model: Model<Api>,
522 context: Context,
523 options?: ModelsSimpleStreamOptions,
524 ): Promise<AssistantMessage> {
525 return this.streamSimple(model, context, options).result();
526 }
527}
528
529export function createModels(options?: CreateModelsOptions): MutableModels {
530 return new ModelsImpl(options);
531}
532
533export interface CreateProviderOptions<TApi extends Api = Api> {
534 id: string;
535 /** Display name. Default: `id`. */
536 name?: string;
537 baseUrl?: string;
538 headers?: ProviderHeaders;
539 /** Required — every provider has auth semantics, even ambient/keyless ones. */
540 auth: ProviderAuth;
541 /** Static baseline model list (empty for purely dynamic providers). */
542 models: readonly Model<TApi>[];
543 /** Fetch a dynamic model overlay. createProvider restores/persists it through ModelsStore. */
544 fetchModels?: (context: RefreshModelsContext) => Promise<readonly Model<TApi>[]>;
545 filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];
546 /** Single implementation, or map keyed by `model.api` for mixed-API providers. */
547 api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
548}
549
550/**
551 * Builds a provider from parts. Built-in provider factories and models.json
552 * custom providers both go through this. A single `api` streams all models;
553 * an `api` map dispatches on `model.api`, and a model whose api has no entry
554 * produces a stream error.
555 */
556export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
557 const baselineModels = input.models;
558 let dynamicModels: readonly Model<TApi>[] = [];
559 let inflightRefresh: Promise<void> | undefined;
560 const fetchModels = input.fetchModels;
561 const currentModels = (): readonly Model<TApi>[] => {
562 const merged = [...baselineModels];
563 for (const model of dynamicModels) {
564 const index = merged.findIndex((entry) => entry.id === model.id);
565 if (index >= 0) merged[index] = model;
566 else merged.push(model);
567 }
568 return merged;
569 };
570 const single =
571 typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined;
572 const byApi = single ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
573
574 const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];
575
576 const dispatch = (
577 model: Model<Api>,
578 run: (streams: ProviderStreams) => AssistantMessageEventStream,
579 ): AssistantMessageEventStream => {
580 const streams = apiFor(model);
581 if (!streams) {
582 return lazyStream(model, async () => {
583 throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`);
584 });
585 }
586 return run(streams);
587 };
588
589 return {
590 id: input.id,
591 name: input.name ?? input.id,
592 baseUrl: input.baseUrl,
593 headers: input.headers,
594 auth: input.auth,
595 getModels: currentModels,
596 refreshModels: fetchModels
597 ? (context) => {
598 inflightRefresh ??= (async () => {
599 try {
600 const stored = await context.store.read();
601 if (stored) {
602 dynamicModels = stored.models
603 .filter((model) => model.provider === input.id)
604 .map((model) => model as Model<TApi>);
605 }
606 if (!context.allowNetwork || context.signal?.aborted) return;
607 const refreshed = await fetchModels(context);
608 if (context.signal?.aborted) return;
609 dynamicModels = refreshed;
610 await context.store.write({ models: refreshed, checkedAt: Date.now() });
611 } finally {
612 inflightRefresh = undefined;
613 }
614 })();
615 return inflightRefresh;
616 }
617 : undefined,
618 filterModels: input.filterModels,
619 stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
620 streamSimple: (model, context, options) =>
621 dispatch(model, (streams) => streams.streamSimple(model, context, options)),
622 };
623}
624
625/**
626 * Runtime-checked narrowing for dynamically looked-up models:
627 *
628 * ```ts
629 * const model = models.getModel("anthropic", "claude-opus-4-7");
630 * if (model && hasApi(model, "anthropic-messages")) {
631 * // model: Model<"anthropic-messages">, stream options fully typed
632 * }
633 * ```
634 */
635export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi> {
636 return model.api === api;
637}
638
639export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
640 const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
641 let rates: ModelCostRates = model.cost;
642 let matchedThreshold = -1;
643 for (const tier of model.cost.tiers ?? []) {
644 if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {
645 rates = tier;
646 matchedThreshold = tier.inputTokensAbove;
647 }
648 }
649
650 // Anthropic charges 2x base input for 1h cache writes.
651 const longWrite = usage.cacheWrite1h ?? 0;
652 const shortWrite = usage.cacheWrite - longWrite;
653 usage.cost.input = (rates.input / 1000000) * usage.input;
654 usage.cost.output = (rates.output / 1000000) * usage.output;
655 usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead;
656 usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;
657 usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
658 return usage.cost;
659}
660
661const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
662
663export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] {
664 if (!model.reasoning) return ["off"];
665
666 return EXTENDED_THINKING_LEVELS.filter((level) => {
667 const mapped = model.thinkingLevelMap?.[level];
668 if (mapped === null) return false;
669 if (level === "xhigh" || level === "max") return mapped !== undefined;
670 return true;
671 });
672}
673
674export function clampThinkingLevel<TApi extends Api>(
675 model: Model<TApi>,
676 level: ModelThinkingLevel,
677): ModelThinkingLevel {
678 const availableLevels = getSupportedThinkingLevels(model);
679 if (availableLevels.includes(level)) return level;
680
681 const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);
682 if (requestedIndex === -1) return availableLevels[0] ?? "off";
683
684 for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {
685 const candidate = EXTENDED_THINKING_LEVELS[i];
686 if (availableLevels.includes(candidate)) return candidate;
687 }
688 for (let i = requestedIndex - 1; i >= 0; i--) {
689 const candidate = EXTENDED_THINKING_LEVELS[i];
690 if (availableLevels.includes(candidate)) return candidate;
691 }
692 return availableLevels[0] ?? "off";
693}
694
695/**
696 * Check if two models are equal by comparing both their id and provider.
697 * Returns false if either model is null or undefined.
698 */
699export function modelsAreEqual<TApi extends Api>(
700 a: Model<TApi> | null | undefined,
701 b: Model<TApi> | null | undefined,
702): boolean {
703 if (!a || !b) return false;
704 return a.id === b.id && a.provider === b.provider;
705}
706