返回源码地图

packages/ai/src/models.ts

v1.0.0 · a13d35a742c6 · 04:Models 和 Provider 契约;未逐分支审核所有模型操作

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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 AuthOperationOptions,
10 AuthResult,
11 AuthType,
12 Credential,
13 CredentialStore,
14 LoginOptions,
15 ProviderAuth,
16} from "./auth/types.ts";
17import { InMemoryModelsStore, type ModelsStore, type ModelsStoreEntry } from "./models-store.ts";
18import type {
19 AnyModel,
20 Api,
21 ApiStreamOptions,
22 AssistantImages,
23 AssistantMessage,
24 AssistantMessageEventStream,
25 ClassifierApi,
26 ClassifierContext,
27 ClassifierModel,
28 ClassifierOptions,
29 ClassifierResult,
30 Context,
31 DeferredCancelOptions,
32 DeferredFetchOptions,
33 DeferredHandle,
34 ImageApi,
35 ImageModel,
36 ImagesContext,
37 ImagesOptions,
38 Model,
39 ModelCostRates,
40 ModelThinkingLevel,
41 ModelType,
42 ModelTypeMap,
43 ProviderClassifier,
44 ProviderHeaders,
45 ProviderImages,
46 ProviderRequestOptions,
47 ProviderStreams,
48 SimpleStreamOptions,
49 TranscriptContext,
50 Usage,
51} from "./types.ts";
52import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts";
53import {
54 assertChatModel,
55 assertClassifierModel,
56 assertImageModel,
57 classifierErrorResult,
58 getModelType,
59 imageErrorResult,
60 isModelType,
61} from "./utils/model-operations.ts";
62import { normalizeContext } from "./utils/transcript.ts";
63
64export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
65export { getModelType, isModelType } from "./utils/model-operations.ts";
66
67export interface ModelsPublication {
68 /** Provider-selected persisted catalog. Omit to leave storage unchanged; null deletes it. */
69 persist?: ModelsStoreEntry | null;
70 /** Optional synchronous update of provider-private in-memory catalog state. */
71 update?: () => void;
72}
73
74export interface RefreshModelsContext {
75 /** Effective configured credential. OAuth credentials are refreshed before network access. */
76 credential?: Credential;
77 /** Immutable provider-scoped catalog snapshot captured before this refresh phase. */
78 stored?: Readonly<ModelsStoreEntry>;
79 /**
80 * Generation-checked publication. Persistence policy remains provider-owned;
81 * the update runs synchronously only after the selected persistence mutation.
82 */
83 publish(publication: ModelsPublication): Promise<boolean>;
84 /** False during offline/cache-only initialization. */
85 allowNetwork: boolean;
86 /** Bypass provider freshness checks and fetch immediately when network access is allowed. */
87 force?: boolean;
88 /** Always present, including when the public refresh caller omits its optional signal. */
89 signal: AbortSignal;
90}
91
92export interface ModelsRefreshOptions {
93 allowNetwork?: boolean;
94 /** Restrict refresh to these provider IDs. Unknown and static providers are ignored. */
95 providers?: readonly string[];
96 /** Bypass provider freshness checks and fetch immediately when network access is allowed. */
97 force?: boolean;
98 signal?: AbortSignal;
99}
100
101export interface ModelsRefreshResult {
102 aborted: boolean;
103 errors: ReadonlyMap<string, Error>;
104}
105
106export interface ModelsRequestTransforms {
107 /** Transform fully assembled model/auth/request headers before provider dispatch. */
108 transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise<ProviderHeaders>;
109}
110
111export type ModelsApiStreamOptions<TApi extends Api> = ApiStreamOptions<TApi> & ModelsRequestTransforms;
112export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsRequestTransforms;
113export type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms;
114export type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms;
115export type ModelsImagesOptions = ImagesOptions & ModelsRequestTransforms;
116export type ModelsClassifierOptions = ClassifierOptions & ModelsRequestTransforms;
117
118const KNOWN_MODEL_TYPES: Record<ModelType, true> = { chat: true, image: true, classifier: true };
119
120/** Models from stores and remote sources may have types that only newer versions know. */
121function hasKnownModelType(model: AnyModel): boolean {
122 return Object.hasOwn(KNOWN_MODEL_TYPES, getModelType(model));
123}
124
125/** Drops stored models whose type this version does not know. */
126function withKnownModelTypes(entry: ModelsStoreEntry): ModelsStoreEntry {
127 return { ...entry, models: entry.models.filter(hasKnownModelType) };
128}
129
130/** Any model a provider with chat APIs `TApi` can list. */
131type ProviderModel<TApi extends Api> = Model<TApi> | ImageModel<ImageApi> | ClassifierModel<ClassifierApi>;
132
133/**
134 * A provider is the concrete runtime unit. It owns id/name/base metadata,
135 * auth methods, model listing, and the operations its models support
136 * (streaming, image generation, classification).
137 *
138 * `TApi` lets concrete provider factories declare which chat APIs their models
139 * use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`),
140 * giving typed chat model lists to direct factory users. Other model types use
141 * their operation-specific API unions. Inside a `Models` collection providers
142 * are held as `Provider<Api>`.
143 */
144export interface Provider<TApi extends Api = Api> {
145 readonly id: string;
146 readonly name: string;
147
148 readonly baseUrl?: string;
149 readonly headers?: ProviderHeaders;
150
151 /**
152 * Required: at least one of `apiKey`/`oauth`. Every provider has auth
153 * semantics — even providers with only ambient credentials (env vars, AWS
154 * profiles, ADC files) and keyless local servers provide `apiKey` auth
155 * whose `resolve()` reports whether the provider is configured.
156 * `Models.getAuth()` returns undefined when the provider is unconfigured.
157 */
158 readonly auth: ProviderAuth;
159
160 /**
161 * Current known chat models, sync. Static providers return their catalog;
162 * dynamic providers return the list as of the last `refreshModels()` (empty
163 * before the first). Must not throw; `Models` treats a throwing
164 * implementation as having no models.
165 */
166 getModels(): readonly Model<TApi>[];
167
168 /**
169 * Current known models of every type, sync, with the same contract as
170 * `getModels()`. Providers with only chat models may omit it; `Models` then
171 * uses `getModels()`. Model ids are unique within each type; one upstream
172 * model may have separate entries for different operations.
173 */
174 getAllModels?(): readonly ProviderModel<TApi>[];
175
176 /**
177 * Dynamic providers only: restore `context.stored` and optionally fetch a newer list using
178 * the effective credential. Implementations retain their previous list on failure, publish
179 * persistence and synchronous state changes through `context.publish()`, and honor the
180 * shared abort signal for blocking work.
181 */
182 refreshModels?(context: RefreshModelsContext): Promise<void>;
183
184 /**
185 * Optional provider policy for credential-specific model availability.
186 * `getModels()` remains the complete synchronous chat catalog; `Models.getAvailable()`
187 * applies this filter after confirming that provider auth is configured.
188 */
189 filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
190
191 /**
192 * Optional credential-specific availability policy across every model type.
193 * Without it, `Models.getAllAvailable()` applies `filterModels` to chat models
194 * and keeps every other model.
195 */
196 filterAllModels?(
197 models: readonly ProviderModel<TApi>[],
198 credential: Credential | undefined,
199 ): readonly ProviderModel<TApi>[];
200
201 /** Stream a normalized transcript. `Models` normalizes the caller's `Context` before dispatching here. */
202 stream<T extends TApi>(
203 model: Model<T>,
204 context: TranscriptContext,
205 options?: ApiStreamOptions<T>,
206 ): AssistantMessageEventStream;
207
208 streamSimple(
209 model: Model<TApi>,
210 context: TranscriptContext,
211 options?: SimpleStreamOptions,
212 ): AssistantMessageEventStream;
213 fetchDeferred?(
214 model: Model<TApi>,
215 handle: DeferredHandle,
216 options?: DeferredFetchOptions,
217 ): AssistantMessageEventStream;
218 cancelDeferred?(model: Model<TApi>, handle: DeferredHandle, options?: DeferredCancelOptions): Promise<void>;
219
220 /** Present when the provider supports dedicated image models. Never rejects. */
221 generateImages?(
222 model: ImageModel<ImageApi>,
223 context: ImagesContext,
224 options?: ImagesOptions,
225 ): Promise<AssistantImages>;
226
227 /** Present when the provider supports structured classifier models. Never rejects. */
228 classify?(
229 model: ClassifierModel<ClassifierApi>,
230 context: ClassifierContext,
231 options?: ClassifierOptions,
232 ): Promise<ClassifierResult>;
233}
234
235/**
236 * Runtime collection of providers plus auth application and request
237 * convenience. Providers own request behavior; `Models` resolves auth and
238 * delegates each request to the provider that owns the model.
239 *
240 * Read accessors come in three flavors: the unqualified ones (`getModels`,
241 * `getModel`, `getAvailable`) return chat models, the `*OfType` accessors
242 * return one model type, and `getAllModels`/`getAllAvailable` return every type.
243 */
244export interface Models {
245 getProviders(): readonly Provider[];
246 getProvider(id: string): Provider | undefined;
247
248 /**
249 * Sync read of last-known chat models from one provider or all providers.
250 * Best-effort: a provider whose `getModels()` throws yields no models.
251 */
252 getModels(provider?: string): readonly Model<Api>[];
253
254 /**
255 * Sync runtime chat model lookup against last-known lists. Dynamic model lists
256 * are typed as `Model<Api>`; narrow with the `hasApi()` type guard.
257 */
258 getModel(provider: string, id: string): Model<Api> | undefined;
259
260 /** Sync read of last-known models of one type from one provider or all providers. */
261 getModelsOfType<TType extends ModelType>(type: TType, provider?: string): readonly ModelTypeMap[TType][];
262
263 /** Sync runtime lookup of a model of one type against last-known lists. */
264 getModelOfType<TType extends ModelType>(type: TType, provider: string, id: string): ModelTypeMap[TType] | undefined;
265
266 /** Sync read of last-known models of every type from one provider or all providers. */
267 getAllModels(provider?: string): readonly AnyModel[];
268
269 /**
270 * Refresh selected configured dynamic providers concurrently (all when `providers` is omitted).
271 * Provider errors and cancellation are returned without rejecting; static, unknown, and
272 * unconfigured providers are skipped.
273 */
274 refresh(options?: ModelsRefreshOptions): Promise<ModelsRefreshResult>;
275
276 /** Check whether a provider has complete auth configuration without refreshing OAuth. */
277 checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined>;
278
279 /** Return chat models whose providers have complete auth configuration. */
280 getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]>;
281
282 /** Return models of one type whose providers have complete auth configuration. */
283 getAvailableOfType<TType extends ModelType>(
284 type: TType,
285 providerId?: string,
286 options?: AuthOperationOptions,
287 ): Promise<readonly ModelTypeMap[TType][]>;
288
289 /** Return models of every type whose providers have complete auth configuration. */
290 getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]>;
291
292 /**
293 * Resolve provider-scoped auth by provider id, or provider auth plus static
294 * model headers when passed a model. Includes a source label for status UI.
295 * Resolves `undefined` when the provider is unknown or unconfigured.
296 * Rejects with `ModelsError`: code "oauth" when a token refresh fails (the
297 * stored credential is preserved for retry; re-login fixes it), code "auth"
298 * when api-key resolution or the credential store fails. Request paths
299 * surface rejections as stream errors.
300 */
301 getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
302 getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
303
304 /** Run a provider-owned login flow and persist its returned credential. */
305 login(providerId: string, type: AuthType, interaction: AuthInteraction, options?: LoginOptions): Promise<Credential>;
306
307 /** Remove the stored credential for a provider. */
308 logout(providerId: string, options?: AuthOperationOptions): Promise<void>;
309
310 stream<TApi extends Api>(
311 model: Model<TApi>,
312 context: Context,
313 options?: ModelsApiStreamOptions<TApi>,
314 ): AssistantMessageEventStream;
315
316 complete<TApi extends Api>(
317 model: Model<TApi>,
318 context: Context,
319 options?: ModelsApiStreamOptions<TApi>,
320 ): Promise<AssistantMessage>;
321
322 streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream;
323 completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage>;
324 streamDeferred(
325 model: Model<Api>,
326 handle: DeferredHandle,
327 options?: ModelsDeferredFetchOptions,
328 ): AssistantMessageEventStream;
329 fetchDeferred(
330 model: Model<Api>,
331 handle: DeferredHandle,
332 options?: ModelsDeferredFetchOptions,
333 ): Promise<AssistantMessage>;
334 cancelDeferred(model: Model<Api>, handle: DeferredHandle, options?: ModelsDeferredCancelOptions): Promise<void>;
335
336 /**
337 * Generate images through the owning provider with auth resolved like
338 * `stream()`. Never rejects: unknown providers, unconfigured auth, and
339 * providers without `generateImages` return an error `AssistantImages`.
340 */
341 generateImages(
342 model: ImageModel<ImageApi>,
343 context: ImagesContext,
344 options?: ModelsImagesOptions,
345 ): Promise<AssistantImages>;
346
347 /** Classify structured state through the owning provider. Never rejects. */
348 classify(
349 model: ClassifierModel<ClassifierApi>,
350 context: ClassifierContext,
351 options?: ModelsClassifierOptions,
352 ): Promise<ClassifierResult>;
353}
354
355export interface MutableModels extends Models {
356 /** Upsert/replace by provider.id. Provider ids are unique. */
357 setProvider(provider: Provider): void;
358 deleteProvider(id: string): void;
359 clearProviders(): void;
360}
361
362export interface CreateModelsOptions {
363 credentials?: CredentialStore;
364 modelsStore?: ModelsStore;
365 authContext?: AuthContext;
366}
367
368function mergeHeaders(
369 base: ProviderHeaders | undefined,
370 override: ProviderHeaders | undefined,
371): ProviderHeaders | undefined {
372 if (!base && !override) return undefined;
373 const merged = { ...base };
374 for (const [name, value] of Object.entries(override ?? {})) {
375 const lowerName = name.toLowerCase();
376 for (const existingName of Object.keys(merged)) {
377 if (existingName.toLowerCase() === lowerName) delete merged[existingName];
378 }
379 merged[name] = value;
380 }
381 return merged;
382}
383
384class ModelsImpl implements MutableModels {
385 private providers = new Map<string, Provider>();
386 private credentials: CredentialStore;
387 private modelsStore: ModelsStore;
388 private authContext: AuthContext;
389 private refreshGenerations = new Map<string, number>();
390 private refreshControllers = new Map<string, AbortController>();
391 private publicationChains = new Map<string, Promise<unknown>>();
392
393 constructor(options?: CreateModelsOptions) {
394 this.credentials = options?.credentials ?? new InMemoryCredentialStore();
395 this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();
396 this.authContext = options?.authContext ?? defaultAuthContext();
397 }
398
399 setProvider(provider: Provider): void {
400 this.supersedeProviderRefresh(provider.id);
401 this.providers.set(provider.id, provider);
402 }
403
404 deleteProvider(id: string): void {
405 this.supersedeProviderRefresh(id);
406 this.providers.delete(id);
407 }
408
409 clearProviders(): void {
410 for (const id of new Set([...this.providers.keys(), ...this.refreshControllers.keys()])) {
411 this.supersedeProviderRefresh(id);
412 }
413 this.providers.clear();
414 }
415
416 getProviders(): readonly Provider[] {
417 return Array.from(this.providers.values());
418 }
419
420 getProvider(id: string): Provider | undefined {
421 return this.providers.get(id);
422 }
423
424 getModels(provider?: string): readonly Model<Api>[] {
425 if (provider !== undefined) {
426 const entry = this.providers.get(provider);
427 if (!entry) return [];
428 try {
429 return entry.getModels();
430 } catch {
431 return [];
432 }
433 }
434
435 const models: Model<Api>[] = [];
436 for (const entry of this.providers.values()) {
437 try {
438 models.push(...entry.getModels());
439 } catch {
440 // Best-effort: ill-behaved providers yield no models.
441 }
442 }
443 return models;
444 }
445
446 getAllModels(provider?: string): readonly AnyModel[] {
447 if (provider !== undefined) {
448 const entry = this.providers.get(provider);
449 if (!entry) return [];
450 try {
451 return entry.getAllModels?.() ?? entry.getModels();
452 } catch {
453 return [];
454 }
455 }
456
457 const models: AnyModel[] = [];
458 for (const entry of this.providers.values()) {
459 try {
460 models.push(...(entry.getAllModels?.() ?? entry.getModels()));
461 } catch {
462 // Best-effort: ill-behaved providers yield no models.
463 }
464 }
465 return models;
466 }
467
468 getModelsOfType<TType extends ModelType>(type: TType, provider?: string): readonly ModelTypeMap[TType][] {
469 return this.getAllModels(provider).filter((model): model is ModelTypeMap[TType] => isModelType(model, type));
470 }
471
472 getModel(provider: string, id: string): Model<Api> | undefined {
473 return this.getModels(provider).find((model) => model.id === id);
474 }
475
476 getModelOfType<TType extends ModelType>(type: TType, provider: string, id: string): ModelTypeMap[TType] | undefined {
477 return this.getModelsOfType(type, provider).find((model) => model.id === id);
478 }
479
480 private supersedeProviderRefresh(providerId: string): number {
481 const generation = (this.refreshGenerations.get(providerId) ?? 0) + 1;
482 this.refreshGenerations.set(providerId, generation);
483 const previous = this.refreshControllers.get(providerId);
484 if (previous) {
485 this.refreshControllers.delete(providerId);
486 previous.abort();
487 }
488 return generation;
489 }
490
491 private beginProviderRefresh(providerId: string): { generation: number; controller: AbortController } {
492 const generation = this.supersedeProviderRefresh(providerId);
493 const controller = new AbortController();
494 this.refreshControllers.set(providerId, controller);
495 return { generation, controller };
496 }
497
498 private publishProviderModels(
499 providerId: string,
500 generation: number,
501 signal: AbortSignal,
502 publication: ModelsPublication,
503 ): Promise<boolean> {
504 const previous = this.publicationChains.get(providerId) ?? Promise.resolve();
505 const queued = (async () => {
506 await previous.catch(() => {});
507 if (signal.aborted || this.refreshGenerations.get(providerId) !== generation) return false;
508
509 if (publication.persist === null) {
510 await this.modelsStore.delete(providerId, { signal });
511 } else if (publication.persist !== undefined) {
512 await this.modelsStore.write(providerId, structuredClone(publication.persist), { signal });
513 }
514
515 if (signal.aborted || this.refreshGenerations.get(providerId) !== generation) return false;
516 publication.update?.();
517 return true;
518 })();
519 const tail = queued.catch(() => {});
520 this.publicationChains.set(providerId, tail);
521 void tail.then(() => {
522 if (this.publicationChains.get(providerId) === tail) this.publicationChains.delete(providerId);
523 });
524 return raceWithAbortSignal(queued, signal);
525 }
526
527 private async runProviderRefreshPhase(
528 provider: Provider & Required<Pick<Provider, "refreshModels">>,
529 credential: Credential | undefined,
530 allowNetwork: boolean,
531 force: boolean | undefined,
532 generation: number,
533 signal: AbortSignal,
534 ): Promise<void> {
535 const stored = await this.modelsStore.read(provider.id, { signal });
536 await provider.refreshModels({
537 credential,
538 stored: stored ? withKnownModelTypes(structuredClone(stored)) : undefined,
539 publish: (publication) => this.publishProviderModels(provider.id, generation, signal, publication),
540 allowNetwork,
541 force: allowNetwork ? force : undefined,
542 signal,
543 });
544 }
545
546 async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
547 const allowNetwork = options.allowNetwork ?? true;
548 const callerSignal = operationSignal(options.signal);
549 const errors = new Map<string, Error>();
550 if (callerSignal.aborted) return { aborted: true, errors };
551 const selected = options.providers ? new Set(options.providers) : undefined;
552 const refreshable = Array.from(this.providers.values()).filter(
553 (provider): provider is Provider & Required<Pick<Provider, "refreshModels">> =>
554 provider.refreshModels !== undefined && (!selected || selected.has(provider.id)),
555 );
556
557 const refresh = Promise.all(
558 refreshable.map(async (provider) => {
559 const { generation, controller } = this.beginProviderRefresh(provider.id);
560 const signal = AbortSignal.any([callerSignal, controller.signal]);
561 const operation = (async () => {
562 let storedCredential: Credential | undefined;
563 let credentialError: unknown;
564 try {
565 storedCredential = await this.readCredential(provider.id, signal);
566 } catch (error) {
567 credentialError = error;
568 }
569
570 // Restore cached provider state before auth resolution or network access.
571 await this.runProviderRefreshPhase(provider, storedCredential, false, undefined, generation, signal);
572 if (credentialError !== undefined) throw credentialError;
573 if (!allowNetwork || signal.aborted) return;
574
575 const credential = await this.resolveRefreshCredential(provider, storedCredential, signal);
576 if (!credential) return;
577 await this.runProviderRefreshPhase(provider, credential, true, options.force, generation, signal);
578 })();
579
580 try {
581 await raceWithAbortSignal(operation, signal);
582 } catch (error) {
583 if (!signal.aborted) {
584 errors.set(
585 provider.id,
586 error instanceof Error
587 ? error
588 : new ModelsError("model_source", `Model refresh failed for ${provider.id}`, { cause: error }),
589 );
590 }
591 } finally {
592 if (this.refreshControllers.get(provider.id) === controller) {
593 this.refreshControllers.delete(provider.id);
594 }
595 }
596 }),
597 );
598
599 try {
600 await raceWithAbortSignal(refresh, callerSignal);
601 } catch (error) {
602 if (!callerSignal.aborted) throw error;
603 }
604
605 return { aborted: callerSignal.aborted, errors: new Map(errors) };
606 }
607
608 private async resolveRefreshCredential(
609 provider: Provider,
610 stored: Credential | undefined,
611 signal: AbortSignal,
612 ): Promise<Credential | undefined> {
613 if (stored?.type === "oauth") {
614 const oauth = provider.auth.oauth;
615 if (!oauth) return undefined;
616 if (Date.now() < stored.expires) return stored;
617 if (signal.aborted) return undefined;
618 const post = await this.credentials.modify(
619 provider.id,
620 async (current) => {
621 if (current?.type !== "oauth" || Date.now() < current.expires) return undefined;
622 return oauth.refresh(current, signal);
623 },
624 { signal },
625 );
626 return post?.type === "oauth" ? post : undefined;
627 }
628
629 const apiKey = provider.auth.apiKey;
630 if (!apiKey) return undefined;
631 const credential = stored?.type === "api_key" ? stored : undefined;
632 const result = await apiKey.resolve({ ctx: this.authContext, credential, signal });
633 if (!result) return undefined;
634 return { type: "api_key", key: result.auth.apiKey, env: result.env };
635 }
636
637 private async readCredential(providerId: string, signal: AbortSignal): Promise<Credential | undefined> {
638 try {
639 return await this.credentials.read(providerId, { signal });
640 } catch (error) {
641 throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
642 }
643 }
644
645 private async checkProviderAuth(
646 provider: Provider,
647 credential: Credential | undefined,
648 signal: AbortSignal,
649 ): Promise<AuthCheck | undefined> {
650 if (credential?.type === "oauth") {
651 return provider.auth.oauth ? { source: "OAuth", type: "oauth" } : undefined;
652 }
653 const apiKey = provider.auth.apiKey;
654 if (!apiKey) return undefined;
655 if (apiKey.check) {
656 try {
657 return await apiKey.check({
658 ctx: this.authContext,
659 credential: credential?.type === "api_key" ? credential : undefined,
660 signal,
661 });
662 } catch (error) {
663 throw new ModelsError("auth", `API key auth check failed for provider ${provider.id}`, { cause: error });
664 }
665 }
666
667 const resolution = await resolveProviderAuth(provider, this.credentials, this.authContext, { signal });
668 return resolution ? { source: resolution.source, type: "api_key" } : undefined;
669 }
670
671 checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined> {
672 const signal = operationSignal(options?.signal);
673 const check = (async () => {
674 signal.throwIfAborted();
675 const provider = this.providers.get(providerId);
676 if (!provider) return undefined;
677 return this.checkProviderAuth(provider, await this.readCredential(providerId, signal), signal);
678 })();
679 return raceWithAbortSignal(check, signal);
680 }
681
682 private async getAuthenticatedProviders(providerId: string | undefined, signal: AbortSignal) {
683 signal.throwIfAborted();
684 const providers = providerId
685 ? [this.providers.get(providerId)].filter((entry) => entry !== undefined)
686 : this.getProviders();
687 const checks = await Promise.all(
688 providers.map(async (provider) => {
689 const credential = await this.readCredential(provider.id, signal);
690 return { provider, credential, auth: await this.checkProviderAuth(provider, credential, signal) };
691 }),
692 );
693 return checks.filter((entry) => entry.auth !== undefined);
694 }
695
696 getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]> {
697 const signal = operationSignal(options?.signal);
698 const available = (async () => {
699 const providers = await this.getAuthenticatedProviders(providerId, signal);
700 return providers.flatMap(({ provider, credential }) => {
701 const models = provider.getModels();
702 return provider.filterModels?.(models, credential) ?? models;
703 });
704 })();
705 return raceWithAbortSignal(available, signal);
706 }
707
708 async getAvailableOfType<TType extends ModelType>(
709 type: TType,
710 providerId?: string,
711 options?: AuthOperationOptions,
712 ): Promise<readonly ModelTypeMap[TType][]> {
713 return (await this.getAllAvailable(providerId, options)).filter((model): model is ModelTypeMap[TType] =>
714 isModelType(model, type),
715 );
716 }
717
718 getAllAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly AnyModel[]> {
719 const signal = operationSignal(options?.signal);
720 const available = (async () => {
721 const providers = await this.getAuthenticatedProviders(providerId, signal);
722 return providers.flatMap(({ provider, credential }) => {
723 const models = provider.getAllModels?.() ?? provider.getModels();
724 if (provider.filterAllModels) return provider.filterAllModels(models, credential);
725 if (!provider.filterModels) return models;
726 const availableChatIds = new Set(
727 provider.filterModels(provider.getModels(), credential).map((model) => model.id),
728 );
729 return models.filter((model) => !isModelType(model, "chat") || availableChatIds.has(model.id));
730 });
731 })();
732 return raceWithAbortSignal(available, signal);
733 }
734
735 getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
736 getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
737 async getAuth(
738 providerOrModel: string | AnyModel,
739 overrides?: AuthResolutionOverrides,
740 ): Promise<AuthResult | undefined> {
741 const signal = operationSignal(overrides?.signal);
742 const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;
743 const provider = this.providers.get(providerId);
744 if (!provider) return undefined;
745 const result = await resolveProviderAuth(provider, this.credentials, this.authContext, { ...overrides, signal });
746 if (!result || typeof providerOrModel === "string" || !providerOrModel.headers) return result;
747 return {
748 ...result,
749 auth: {
750 ...result.auth,
751 headers: mergeHeaders(result.auth.headers, providerOrModel.headers),
752 },
753 };
754 }
755
756 async login(
757 providerId: string,
758 type: AuthType,
759 interaction: AuthInteraction,
760 options?: LoginOptions,
761 ): Promise<Credential> {
762 const signal = operationSignal(interaction.signal);
763 signal.throwIfAborted();
764 const provider = this.providers.get(providerId);
765 if (!provider) throw new ModelsError("provider", `Unknown provider: ${providerId}`);
766 const method = type === "oauth" ? provider.auth.oauth : provider.auth.apiKey;
767 if (!method?.login) {
768 throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
769 }
770 const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, options);
771 const credential = await raceWithAbortSignal(loginOperation, signal);
772 let mutationStarted = false;
773 let markMutationStarted: (() => void) | undefined;
774 const started = new Promise<void>((resolve) => {
775 markMutationStarted = resolve;
776 });
777 const mutation = this.credentials.modify(
778 providerId,
779 async () => {
780 mutationStarted = true;
781 markMutationStarted?.();
782 return credential;
783 },
784 { signal },
785 );
786 void mutation.catch(() => {});
787 try {
788 await new Promise<void>((resolve, reject) => {
789 const onAbort = () => {
790 if (!mutationStarted) reject(signal.reason);
791 };
792 signal.addEventListener("abort", onAbort, { once: true });
793 void Promise.race([started, mutation]).then(
794 () => {
795 signal.removeEventListener("abort", onAbort);
796 resolve();
797 },
798 (error: unknown) => {
799 signal.removeEventListener("abort", onAbort);
800 reject(error);
801 },
802 );
803 if (signal.aborted) onAbort();
804 });
805 await mutation;
806 } catch (error) {
807 signal.throwIfAborted();
808 throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
809 }
810 return credential;
811 }
812
813 async logout(providerId: string, options?: AuthOperationOptions): Promise<void> {
814 const signal = operationSignal(options?.signal);
815 signal.throwIfAborted();
816 try {
817 await this.credentials.delete(providerId, { signal });
818 } catch (error) {
819 signal.throwIfAborted();
820 throw new ModelsError("auth", `Credential store delete failed for ${providerId}`, { cause: error });
821 }
822 }
823
824 private requireProvider(model: AnyModel): Provider {
825 const provider = this.providers.get(model.provider);
826 if (!provider) {
827 throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
828 }
829 return provider;
830 }
831
832 private requireChatProvider(model: Model<Api>): Provider {
833 assertChatModel(model);
834 return this.requireProvider(model);
835 }
836
837 private async applyAuth<
838 TModel extends AnyModel,
839 TOptions extends ProviderRequestOptions<TModel> & ModelsRequestTransforms,
840 >(
841 model: TModel,
842 options: TOptions | undefined,
843 ): Promise<{
844 requestModel: TModel;
845 requestOptions: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions<TModel>;
846 }> {
847 this.requireProvider(model);
848 const resolution = await this.getAuth(model, {
849 apiKey: options?.apiKey,
850 env: options?.env,
851 signal: options?.signal,
852 });
853 if (!resolution) {
854 throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
855 }
856 const auth = resolution.auth;
857
858 // Explicit request options win per-field; the Models-only transform runs last.
859 const apiKey = options?.apiKey ?? auth.apiKey;
860 let headers = mergeHeaders(auth.headers, options?.headers);
861 if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});
862 const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
863 const requestModel: TModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
864 const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
865 const requestOptions = { ...providerOptions, apiKey, headers, env } as Omit<TOptions, "transformHeaders"> &
866 ProviderRequestOptions<TModel>;
867
868 return { requestModel, requestOptions };
869 }
870
871 stream<TApi extends Api>(
872 model: Model<TApi>,
873 context: Context,
874 options?: ModelsApiStreamOptions<TApi>,
875 ): AssistantMessageEventStream {
876 const transcript = normalizeContext(context);
877 return lazyStream(model, async () => {
878 const provider = this.requireChatProvider(model);
879 const { requestModel, requestOptions } = await this.applyAuth(
880 model,
881 options as ModelsApiStreamOptions<Api> | undefined,
882 );
883 return provider.stream(requestModel, transcript, requestOptions as ApiStreamOptions<TApi>);
884 });
885 }
886
887 async complete<TApi extends Api>(
888 model: Model<TApi>,
889 context: Context,
890 options?: ModelsApiStreamOptions<TApi>,
891 ): Promise<AssistantMessage> {
892 return this.stream(model, context, options).result();
893 }
894
895 streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
896 const transcript = normalizeContext(context);
897 return lazyStream(model, async () => {
898 const provider = this.requireChatProvider(model);
899 const { requestModel, requestOptions } = await this.applyAuth(model, options);
900 return provider.streamSimple(requestModel, transcript, requestOptions as SimpleStreamOptions);
901 });
902 }
903
904 async completeSimple(
905 model: Model<Api>,
906 context: Context,
907 options?: ModelsSimpleStreamOptions,
908 ): Promise<AssistantMessage> {
909 return this.streamSimple(model, context, options).result();
910 }
911
912 streamDeferred(
913 model: Model<Api>,
914 handle: DeferredHandle,
915 options?: ModelsDeferredFetchOptions,
916 ): AssistantMessageEventStream {
917 return lazyStream(model, async () => {
918 const provider = this.requireChatProvider(model);
919 if (!provider.fetchDeferred) {
920 throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
921 }
922 const { requestModel, requestOptions } = await this.applyAuth(model, options);
923 return provider.fetchDeferred(requestModel, handle, requestOptions as DeferredFetchOptions);
924 });
925 }
926
927 async fetchDeferred(
928 model: Model<Api>,
929 handle: DeferredHandle,
930 options?: ModelsDeferredFetchOptions,
931 ): Promise<AssistantMessage> {
932 return this.streamDeferred(model, handle, options).result();
933 }
934
935 async cancelDeferred(
936 model: Model<Api>,
937 handle: DeferredHandle,
938 options?: ModelsDeferredCancelOptions,
939 ): Promise<void> {
940 const provider = this.requireChatProvider(model);
941 if (!provider.cancelDeferred) {
942 throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
943 }
944 const { requestModel, requestOptions } = await this.applyAuth(model, options);
945 await provider.cancelDeferred(requestModel, handle, requestOptions);
946 }
947
948 async generateImages(
949 model: ImageModel<ImageApi>,
950 context: ImagesContext,
951 options?: ModelsImagesOptions,
952 ): Promise<AssistantImages> {
953 try {
954 assertImageModel(model);
955 const provider = this.requireProvider(model);
956 if (!provider.generateImages) {
957 throw new ModelsError("provider", `Provider ${model.provider} does not support image generation`);
958 }
959 const { requestModel, requestOptions } = await this.applyAuth(model, options);
960 return await provider.generateImages(requestModel, context, requestOptions);
961 } catch (error) {
962 return imageErrorResult(model, error, options?.signal?.aborted);
963 }
964 }
965
966 async classify(
967 model: ClassifierModel<ClassifierApi>,
968 context: ClassifierContext,
969 options?: ModelsClassifierOptions,
970 ): Promise<ClassifierResult> {
971 try {
972 assertClassifierModel(model);
973 const provider = this.requireProvider(model);
974 if (!provider.classify) {
975 throw new ModelsError("provider", `Provider ${model.provider} does not support classification`);
976 }
977 const { requestModel, requestOptions } = await this.applyAuth(model, options);
978 return await provider.classify(requestModel, context, requestOptions);
979 } catch (error) {
980 return classifierErrorResult(model, error, options?.signal?.aborted);
981 }
982 }
983}
984
985export function createModels(options?: CreateModelsOptions): MutableModels {
986 return new ModelsImpl(options);
987}
988
989export interface CreateProviderOptions<TApi extends Api = Api> {
990 id: string;
991 /** Display name. Default: `id`. */
992 name?: string;
993 baseUrl?: string;
994 headers?: ProviderHeaders;
995 /** Required — every provider has auth semantics, even ambient/keyless ones. */
996 auth: ProviderAuth;
997 /**
998 * Static baseline models of every type (empty for purely dynamic providers).
999 * Models without `type` are chat models.
1000 */
1001 models: readonly ProviderModel<TApi>[];
1002 /**
1003 * Fetch a dynamic model overlay of every type. createProvider restores and
1004 * publishes it transactionally and drops models of unknown types.
1005 */
1006 fetchModels?: (context: RefreshModelsContext) => Promise<readonly ProviderModel<TApi>[]>;
1007 /** Credential-specific chat model availability. See `Provider.filterModels`. */
1008 filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];
1009 /** Credential-specific availability across every model type. See `Provider.filterAllModels`. */
1010 filterAllModels?: (
1011 models: readonly ProviderModel<TApi>[],
1012 credential: Credential | undefined,
1013 ) => readonly ProviderModel<TApi>[];
1014 /**
1015 * Chat implementation: a single one for all chat models, or a map keyed by
1016 * `model.api` for mixed-API providers. Optional when `images` or
1017 * `classifiers` is given.
1018 */
1019 api?: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
1020 /** Image-generation implementations keyed by `model.api`. */
1021 images?: Partial<Record<ImageApi, ProviderImages>>;
1022 /** Classifier implementations keyed by `model.api`. */
1023 classifiers?: Partial<Record<ClassifierApi, ProviderClassifier>>;
1024}
1025
1026/**
1027 * Builds a provider from parts. Built-in provider factories and models.json
1028 * custom providers both go through this. A single `api` streams all chat
1029 * models; an `api` map dispatches on `model.api`, and a model whose api has
1030 * no entry produces a stream error. One-shot operation maps dispatch on
1031 * `model.api` the same way. At least one concrete implementation across
1032 * `api`/`images`/`classifiers` is required; empty maps are rejected.
1033 */
1034export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
1035 const single =
1036 input.api && typeof (input.api as ProviderStreams).stream === "function"
1037 ? (input.api as ProviderStreams)
1038 : undefined;
1039 const byApi = single || !input.api ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
1040 const images = input.images as Partial<Record<string, ProviderImages>> | undefined;
1041 const classifiers = input.classifiers as Partial<Record<string, ProviderClassifier>> | undefined;
1042 const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);
1043 const imageImplementations = Object.values(images ?? {}).filter((entry) => entry !== undefined);
1044 const classifierImplementations = Object.values(classifiers ?? {}).filter((entry) => entry !== undefined);
1045 if (streams.length === 0 && imageImplementations.length === 0 && classifierImplementations.length === 0) {
1046 throw new Error(`Provider ${input.id}: at least one of "api", "images", or "classifiers" is required.`);
1047 }
1048
1049 const baselineModels = input.models;
1050 let dynamicModels: readonly ProviderModel<TApi>[] = [];
1051 const fetchModels = input.fetchModels;
1052 const currentModels = (): readonly ProviderModel<TApi>[] => {
1053 const merged = [...baselineModels];
1054 for (const model of dynamicModels) {
1055 const index = merged.findIndex(
1056 (entry) => getModelType(entry) === getModelType(model) && entry.id === model.id,
1057 );
1058 if (index >= 0) merged[index] = model;
1059 else merged.push(model);
1060 }
1061 return merged;
1062 };
1063 const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];
1064
1065 const dispatch = (
1066 model: Model<Api>,
1067 run: (streams: ProviderStreams) => AssistantMessageEventStream,
1068 ): AssistantMessageEventStream => {
1069 const streams = apiFor(model);
1070 if (!streams) {
1071 return lazyStream(model, async () => {
1072 throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`);
1073 });
1074 }
1075 return run(streams);
1076 };
1077
1078 const provider: Provider<TApi> = {
1079 id: input.id,
1080 name: input.name ?? input.id,
1081 baseUrl: input.baseUrl,
1082 headers: input.headers,
1083 auth: input.auth,
1084 getModels: () => currentModels().filter((model): model is Model<TApi> => isModelType(model, "chat")),
1085 getAllModels: currentModels,
1086 refreshModels: fetchModels
1087 ? async (context) => {
1088 if (context.stored) {
1089 const restored = context.stored.models
1090 .filter((model) => model.provider === input.id)
1091 .map((model) => model as ProviderModel<TApi>);
1092 if (
1093 !(await context.publish({
1094 update: () => {
1095 dynamicModels = restored;
1096 },
1097 }))
1098 ) {
1099 return;
1100 }
1101 }
1102 if (!context.allowNetwork || context.signal.aborted) return;
1103 const fetched = await fetchModels(context);
1104 if (context.signal.aborted) return;
1105 const refreshed = fetched.filter(hasKnownModelType);
1106 await context.publish({
1107 persist: { models: refreshed, checkedAt: Date.now() },
1108 update: () => {
1109 dynamicModels = refreshed;
1110 },
1111 });
1112 }
1113 : undefined,
1114 filterModels: input.filterModels,
1115 filterAllModels: input.filterAllModels,
1116 stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
1117 streamSimple: (model, context, options) =>
1118 dispatch(model, (streams) => streams.streamSimple(model, context, options)),
1119 };
1120
1121 if (streams.some((entry) => entry.fetchDeferred !== undefined)) {
1122 provider.fetchDeferred = (model, handle, options) =>
1123 lazyStream(model, async () => {
1124 const implementation = apiFor(model);
1125 if (!implementation?.fetchDeferred) {
1126 throw new ModelsError(
1127 "provider",
1128 `Provider ${input.id} does not support deferred responses for "${model.api}"`,
1129 );
1130 }
1131 return implementation.fetchDeferred(model, handle, options);
1132 });
1133 }
1134 if (streams.some((entry) => entry.cancelDeferred !== undefined)) {
1135 provider.cancelDeferred = async (model, handle, options) => {
1136 const implementation = apiFor(model);
1137 if (!implementation?.cancelDeferred) {
1138 throw new ModelsError(
1139 "provider",
1140 `Provider ${input.id} cannot cancel deferred responses for "${model.api}"`,
1141 );
1142 }
1143 await implementation.cancelDeferred(model, handle, options);
1144 };
1145 }
1146 if (images && imageImplementations.length > 0) {
1147 provider.generateImages = async (model, context, options) => {
1148 const implementation = images[model.api];
1149 if (!implementation) {
1150 return imageErrorResult(
1151 model,
1152 new ModelsError(
1153 "provider",
1154 `Provider ${input.id} has no image generation implementation for "${model.api}"`,
1155 ),
1156 );
1157 }
1158 return implementation.generateImages(model, context, options);
1159 };
1160 }
1161 if (classifiers && classifierImplementations.length > 0) {
1162 provider.classify = async (model, context, options) => {
1163 const implementation = classifiers[model.api];
1164 if (!implementation) {
1165 return classifierErrorResult(
1166 model,
1167 new ModelsError("provider", `Provider ${input.id} has no classifier implementation for "${model.api}"`),
1168 );
1169 }
1170 return implementation.classify(model, context, options);
1171 };
1172 }
1173
1174 return provider;
1175}
1176
1177/**
1178 * Runtime-checked narrowing for dynamically looked-up models:
1179 *
1180 * ```ts
1181 * const model = models.getModel("anthropic", "claude-opus-4-7");
1182 * if (model && hasApi(model, "anthropic-messages")) {
1183 * // model: Model<"anthropic-messages">, stream options fully typed
1184 * }
1185 * ```
1186 *
1187 * Non-chat models never match, even when their api id equals `api`.
1188 */
1189export function hasApi<TApi extends Api>(model: AnyModel, api: TApi): model is Model<TApi> {
1190 return isModelType(model, "chat") && model.api === api;
1191}
1192
1193export function calculateCost(model: AnyModel, usage: Usage): Usage["cost"] {
1194 const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
1195 let rates: ModelCostRates = model.cost;
1196 let matchedThreshold = -1;
1197 for (const tier of model.cost.tiers ?? []) {
1198 if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {
1199 rates = tier;
1200 matchedThreshold = tier.inputTokensAbove;
1201 }
1202 }
1203
1204 // Anthropic charges 2x base input for 1h cache writes.
1205 const longWrite = usage.cacheWrite1h ?? 0;
1206 const shortWrite = usage.cacheWrite - longWrite;
1207 usage.cost.input = (rates.input / 1000000) * usage.input;
1208 usage.cost.output = (rates.output / 1000000) * usage.output;
1209 usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead;
1210 usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;
1211 usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
1212 return usage.cost;
1213}
1214
1215const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
1216
1217export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] {
1218 if (!model.reasoning) return ["off"];
1219
1220 return EXTENDED_THINKING_LEVELS.filter((level) => {
1221 const mapped = model.thinkingLevelMap?.[level];
1222 if (mapped === null) return false;
1223 if (level === "xhigh" || level === "max") return mapped !== undefined;
1224 return true;
1225 });
1226}
1227
1228export function clampThinkingLevel<TApi extends Api>(
1229 model: Model<TApi>,
1230 level: ModelThinkingLevel,
1231): ModelThinkingLevel {
1232 const availableLevels = getSupportedThinkingLevels(model);
1233 if (availableLevels.includes(level)) return level;
1234
1235 const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);
1236 if (requestedIndex === -1) return availableLevels[0] ?? "off";
1237
1238 for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {
1239 const candidate = EXTENDED_THINKING_LEVELS[i];
1240 if (availableLevels.includes(candidate)) return candidate;
1241 }
1242 for (let i = requestedIndex - 1; i >= 0; i--) {
1243 const candidate = EXTENDED_THINKING_LEVELS[i];
1244 if (availableLevels.includes(candidate)) return candidate;
1245 }
1246 return availableLevels[0] ?? "off";
1247}
1248
1249/**
1250 * Check if two models are equal by comparing their type, id, and provider.
1251 * Returns false if either model is null or undefined.
1252 */
1253export function modelsAreEqual(a: AnyModel | null | undefined, b: AnyModel | null | undefined): boolean {
1254 if (!a || !b) return false;
1255 return getModelType(a) === getModelType(b) && a.id === b.id && a.provider === b.provider;
1256}