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";3132export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";3334export 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}4546export 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}5253export interface ModelsRefreshResult {54 aborted: boolean;55 errors: ReadonlyMap<string, Error>;56}5758export interface ModelsStreamTransforms {59 /** Transform fully assembled model/auth/request headers before provider dispatch. */60 transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise<ProviderHeaders>;61}6263export type ModelsApiStreamOptions<TApi extends Api> = ApiStreamOptions<TApi> & ModelsStreamTransforms;64export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsStreamTransforms;6566/**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 models71 * 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;7879 readonly baseUrl?: string;80 readonly headers?: ProviderHeaders;8182 /**83 * Required: at least one of `apiKey`/`oauth`. Every provider has auth84 * semantics — even providers with only ambient credentials (env vars, AWS85 * profiles, ADC files) and keyless local servers provide `apiKey` auth86 * whose `resolve()` reports whether the provider is configured.87 * `Models.getAuth()` returns undefined when the provider is unconfigured.88 */89 readonly auth: ProviderAuth;9091 /**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 throwing95 * implementation as having no models.96 */97 getModels(): readonly Model<TApi>[];9899 /**100 * Dynamic providers only: restore the provider-scoped stored catalog and optionally fetch101 * a newer list using the effective credential. Implementations must retain their previous102 * list on failure and honor the shared abort signal for network requests.103 */104 refreshModels?(context: RefreshModelsContext): Promise<void>;105106 /**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>[];112113 stream<T extends TApi>(114 model: Model<T>,115 context: Context,116 options?: ApiStreamOptions<T>,117 ): AssistantMessageEventStream;118119 streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;120}121122/**123 * Runtime collection of providers plus auth application and stream124 * convenience. Providers own stream behavior; `Models` resolves auth and125 * delegates each request to the provider that owns the model.126 */127export interface Models {128 getProviders(): readonly Provider[];129 getProvider(id: string): Provider | undefined;130131 /**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>[];136137 /**138 * Sync runtime model lookup against last-known lists. Dynamic model lists139 * are typed as `Model<Api>`; narrow with the `hasApi()` type guard.140 */141 getModel(provider: string, id: string): Model<Api> | undefined;142143 /**144 * Refresh every configured dynamic provider concurrently. Provider errors and cancellation145 * are returned without rejecting; static and unconfigured providers are skipped.146 */147 refresh(options?: ModelsRefreshOptions): Promise<ModelsRefreshResult>;148149 /** Check whether a provider has complete auth configuration without refreshing OAuth. */150 checkAuth(providerId: string): Promise<AuthCheck | undefined>;151152 /** Return models whose providers have complete auth configuration. */153 getAvailable(providerId?: string): Promise<readonly Model<Api>[]>;154155 /**156 * Resolve provider-scoped auth by provider id, or provider auth plus static157 * 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 (the160 * stored credential is preserved for retry; re-login fixes it), code "auth"161 * when api-key resolution or the credential store fails. Request paths162 * 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>;166167 /** Run a provider-owned login flow and persist its returned credential. */168 login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential>;169170 /** Remove the stored credential for a provider. */171 logout(providerId: string): Promise<void>;172173 stream<TApi extends Api>(174 model: Model<TApi>,175 context: Context,176 options?: ModelsApiStreamOptions<TApi>,177 ): AssistantMessageEventStream;178179 complete<TApi extends Api>(180 model: Model<TApi>,181 context: Context,182 options?: ModelsApiStreamOptions<TApi>,183 ): Promise<AssistantMessage>;184185 streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream;186 completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage>;187}188189export 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}195196export interface CreateModelsOptions {197 credentials?: CredentialStore;198 modelsStore?: ModelsStore;199 authContext?: AuthContext;200}201202function 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}217218class ModelsImpl implements MutableModels {219 private providers = new Map<string, Provider>();220 private credentials: CredentialStore;221 private modelsStore: ModelsStore;222 private authContext: AuthContext;223224 constructor(options?: CreateModelsOptions) {225 this.credentials = options?.credentials ?? new InMemoryCredentialStore();226 this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();227 this.authContext = options?.authContext ?? defaultAuthContext();228 }229230 setProvider(provider: Provider): void {231 this.providers.set(provider.id, provider);232 }233234 deleteProvider(id: string): void {235 this.providers.delete(id);236 }237238 clearProviders(): void {239 this.providers.clear();240 }241242 getProviders(): readonly Provider[] {243 return Array.from(this.providers.values());244 }245246 getProvider(id: string): Provider | undefined {247 return this.providers.get(id);248 }249250 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 }260261 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 }271272 getModel(provider: string, id: string): Model<Api> | undefined {273 return this.getModels(provider).find((model) => model.id === id);274 }275276 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 );283284 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 Error309 ? error310 : 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 );326327 return { aborted: options.signal?.aborted ?? false, errors };328 }329330 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 }347348 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 }355356 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 }363364 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 }383384 const resolution = await resolveProviderAuth(provider, this.credentials, this.authContext);385 return resolution ? { source: resolution.source, type: "api_key" } : undefined;386 }387388 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 }393394 async getAvailable(providerId?: string): Promise<readonly Model<Api>[]> {395 const providers = providerId396 ? [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 }410411 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 }430431 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 }446447 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 }454455 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 }462463 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;476477 // 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;485486 return { requestModel, requestOptions };487 }488489 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 }503504 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 }511512 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 }519520 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}528529export function createModels(options?: CreateModelsOptions): MutableModels {530 return new ModelsImpl(options);531}532533export 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}549550/**551 * Builds a provider from parts. Built-in provider factories and models.json552 * 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 entry554 * 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>>);573574 const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];575576 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 };588589 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: fetchModels597 ? (context) => {598 inflightRefresh ??= (async () => {599 try {600 const stored = await context.store.read();601 if (stored) {602 dynamicModels = stored.models603 .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}624625/**626 * Runtime-checked narrowing for dynamically looked-up models:627 *628 * ```ts629 * const model = models.getModel("anthropic", "claude-opus-4-7");630 * if (model && hasApi(model, "anthropic-messages")) {631 * // model: Model<"anthropic-messages">, stream options fully typed632 * }633 * ```634 */635export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi> {636 return model.api === api;637}638639export 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 }649650 // 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}660661const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];662663export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] {664 if (!model.reasoning) return ["off"];665666 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}673674export 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;680681 const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);682 if (requestedIndex === -1) return availableLevels[0] ?? "off";683684 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}694695/**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