返回 DeepSeek-Reasonix
providerModelCache.ts
根目录 / desktop / frontend / src / lib / providerModelCache.ts
1 import type { ProviderModelCapabilityView, ProviderView } from "./types";
2
3 // Provider model cache with single-flight deduplication and time-based
4 // exponential backoff. The memory-only cache identity mirrors every ProviderView
5 // field that can change model discovery or credential resolution; persistent
6 // cooldowns use the backend's opaque fingerprint instead.
7
8 type CacheEntry = { at: number; models: string[] };
9 type BackoffEntry = { delay: number; retryAt: number; error: unknown };
10
11 const cache = new Map<string, CacheEntry>();
12 const inflight = new Map<string, Promise<string[]>>();
13 const backoff = new Map<string, BackoffEntry>();
14 const generations = new Map<string, number>();
15
16 const TTL = 60_000;
17 const BACKOFF_INITIAL = 1_000;
18 const BACKOFF_CAP = 60_000;
19 let cacheEpoch = 0;
20
21 function normalizedHeaders(headers?: Record<string, string> | null): [string, string][] {
22 return Object.entries(headers ?? {}).sort(([a], [b]) => a.localeCompare(b));
23 }
24
25 export function providerDiscoveryIdentity(p: ProviderView): string {
26 return JSON.stringify([
27 p.apiKeyEnv.trim(),
28 p.name.trim(),
29 p.kind.trim(),
30 p.baseUrl.trim(),
31 p.modelsUrl.trim(),
32 p.chatUrl?.trim() ?? "",
33 p.requestUrl?.trim() ?? "",
34 Boolean(p.noProxy),
35 Boolean(p.authHeader),
36 normalizedHeaders(p.headers),
37 (p.keySource ?? "").trim(),
38 (p.keySourcePath ?? "").trim(),
39 (p.modelCatalogFingerprint ?? "").trim(),
40 ]);
41 }
42
43 const cacheKey = providerDiscoveryIdentity;
44
45 function cacheKeyAPIKeyEnv(key: string): string {
46 try {
47 const parsed = JSON.parse(key) as unknown[];
48 return typeof parsed[0] === "string" ? parsed[0] : "";
49 } catch {
50 return "";
51 }
52 }
53
54 function generation(key: string): number {
55 return generations.get(key) ?? 0;
56 }
57
58 function invalidateKey(key: string): void {
59 generations.set(key, generation(key) + 1);
60 cache.delete(key);
61 backoff.delete(key);
62 // Do not cancel an active request, but stop new callers from joining it.
63 // Its generation guard prevents a stale result from repopulating the cache.
64 inflight.delete(key);
65 }
66
67 function knownKeys(): Set<string> {
68 return new Set([
69 ...cache.keys(),
70 ...inflight.keys(),
71 ...backoff.keys(),
72 ...generations.keys(),
73 ]);
74 }
75
76 export async function cachedFetchProviderModels(
77 fetchFn: (provider: ProviderView) => Promise<string[]>,
78 provider: ProviderView,
79 force = false,
80 ): Promise<string[]> {
81 const key = cacheKey(provider);
82 const now = Date.now();
83
84 if (!force) {
85 const hit = cache.get(key);
86 if (hit && now - hit.at < TTL) return [...hit.models];
87 }
88
89 const pending = inflight.get(key);
90 if (pending) return pending;
91
92 const cooldown = backoff.get(key);
93 if (!force && cooldown && now < cooldown.retryAt) {
94 throw cooldown.error;
95 }
96
97 const requestEpoch = cacheEpoch;
98 const requestGeneration = generation(key);
99 let request: Promise<string[]>;
100 request = fetchFn(provider)
101 .then((models) => {
102 if (cacheEpoch === requestEpoch && generation(key) === requestGeneration) {
103 cache.set(key, { at: Date.now(), models: [...models] });
104 backoff.delete(key);
105 }
106 return models;
107 })
108 .catch((error) => {
109 if (cacheEpoch === requestEpoch && generation(key) === requestGeneration) {
110 const previous = backoff.get(key);
111 const delay = previous ? Math.min(previous.delay * 2, BACKOFF_CAP) : BACKOFF_INITIAL;
112 backoff.set(key, { delay, retryAt: Date.now() + delay, error });
113 }
114 throw error;
115 })
116 .finally(() => {
117 if (inflight.get(key) === request) inflight.delete(key);
118 });
119
120 inflight.set(key, request);
121 return request;
122 }
123
124 /** Metadata-preserving companion to cachedFetchProviderModels. */
125 export async function cachedFetchProviderModelCatalog(
126 fetchFn: (provider: ProviderView) => Promise<ProviderModelCapabilityView[]>,
127 provider: ProviderView,
128 force = false,
129 ): Promise<ProviderModelCapabilityView[]> {
130 void force;
131 const fetched = await fetchFn(provider);
132 return fetched.map((item) => ({
133 ...item,
134 model: item.model,
135 inputModalities: [...(item.inputModalities ?? [])],
136 state: item.state,
137 source: item.source,
138 }));
139 }
140
141 /** Tell the caller whether this provider is still inside its retry window. */
142 export function isBackingOff(provider: ProviderView): boolean {
143 const state = backoff.get(cacheKey(provider));
144 return Boolean(state && Date.now() < state.retryAt);
145 }
146
147 /** Clear cache/backoff for one exact provider request identity. */
148 export function invalidateProviderCache(provider: ProviderView): void {
149 invalidateKey(cacheKey(provider));
150 }
151
152 /** Clear every provider identity that resolves credentials through this env. */
153 export function invalidateProviderCacheByAPIKeyEnv(apiKeyEnv: string): void {
154 const normalized = apiKeyEnv.trim();
155 if (!normalized) return;
156 for (const key of knownKeys()) {
157 if (cacheKeyAPIKeyEnv(key) === normalized) invalidateKey(key);
158 }
159 }
160
161 /** Clear the entire model cache without allowing stale inflight writes back in. */
162 export function clearModelCache(): void {
163 cacheEpoch += 1;
164 cache.clear();
165 inflight.clear();
166 backoff.clear();
167 generations.clear();
168 }
169
170 /** Return true when the network is too slow for background model discovery. */
171 export function shouldSkipAutoRefresh(): boolean {
172 const nav = navigator as Navigator & {
173 connection?: { saveData?: boolean; effectiveType?: string };
174 };
175 const conn = nav.connection;
176 return Boolean(conn?.saveData || conn?.effectiveType === "slow-2g" || conn?.effectiveType === "2g");
177 }
178
178 lines TYPESCRIPT