返回 DeepSeek-Reasonix
providerModelCache.ts
根目录 / desktop / frontend / src / lib / providerModelCache.ts
1 import type { 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 function cacheKey(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 Boolean(p.authHeader),
33 normalizedHeaders(p.headers),
34 (p.keySource ?? "").trim(),
35 (p.keySourcePath ?? "").trim(),
36 (p.modelCatalogFingerprint ?? "").trim(),
37 ]);
38 }
39
40 function cacheKeyAPIKeyEnv(key: string): string {
41 try {
42 const parsed = JSON.parse(key) as unknown[];
43 return typeof parsed[0] === "string" ? parsed[0] : "";
44 } catch {
45 return "";
46 }
47 }
48
49 function generation(key: string): number {
50 return generations.get(key) ?? 0;
51 }
52
53 function invalidateKey(key: string): void {
54 generations.set(key, generation(key) + 1);
55 cache.delete(key);
56 backoff.delete(key);
57 // Do not cancel an active request, but stop new callers from joining it.
58 // Its generation guard prevents a stale result from repopulating the cache.
59 inflight.delete(key);
60 }
61
62 function knownKeys(): Set<string> {
63 return new Set([
64 ...cache.keys(),
65 ...inflight.keys(),
66 ...backoff.keys(),
67 ...generations.keys(),
68 ]);
69 }
70
71 export async function cachedFetchProviderModels(
72 fetchFn: (provider: ProviderView) => Promise<string[]>,
73 provider: ProviderView,
74 force = false,
75 ): Promise<string[]> {
76 const key = cacheKey(provider);
77 const now = Date.now();
78
79 if (!force) {
80 const hit = cache.get(key);
81 if (hit && now - hit.at < TTL) return [...hit.models];
82 }
83
84 const pending = inflight.get(key);
85 if (pending) return pending;
86
87 const cooldown = backoff.get(key);
88 if (!force && cooldown && now < cooldown.retryAt) {
89 throw cooldown.error;
90 }
91
92 const requestEpoch = cacheEpoch;
93 const requestGeneration = generation(key);
94 let request: Promise<string[]>;
95 request = fetchFn(provider)
96 .then((models) => {
97 if (cacheEpoch === requestEpoch && generation(key) === requestGeneration) {
98 cache.set(key, { at: Date.now(), models: [...models] });
99 backoff.delete(key);
100 }
101 return models;
102 })
103 .catch((error) => {
104 if (cacheEpoch === requestEpoch && generation(key) === requestGeneration) {
105 const previous = backoff.get(key);
106 const delay = previous ? Math.min(previous.delay * 2, BACKOFF_CAP) : BACKOFF_INITIAL;
107 backoff.set(key, { delay, retryAt: Date.now() + delay, error });
108 }
109 throw error;
110 })
111 .finally(() => {
112 if (inflight.get(key) === request) inflight.delete(key);
113 });
114
115 inflight.set(key, request);
116 return request;
117 }
118
119 /** Tell the caller whether this provider is still inside its retry window. */
120 export function isBackingOff(provider: ProviderView): boolean {
121 const state = backoff.get(cacheKey(provider));
122 return Boolean(state && Date.now() < state.retryAt);
123 }
124
125 /** Clear cache/backoff for one exact provider request identity. */
126 export function invalidateProviderCache(provider: ProviderView): void {
127 invalidateKey(cacheKey(provider));
128 }
129
130 /** Clear every provider identity that resolves credentials through this env. */
131 export function invalidateProviderCacheByAPIKeyEnv(apiKeyEnv: string): void {
132 const normalized = apiKeyEnv.trim();
133 if (!normalized) return;
134 for (const key of knownKeys()) {
135 if (cacheKeyAPIKeyEnv(key) === normalized) invalidateKey(key);
136 }
137 }
138
139 /** Clear the entire model cache without allowing stale inflight writes back in. */
140 export function clearModelCache(): void {
141 cacheEpoch += 1;
142 cache.clear();
143 inflight.clear();
144 backoff.clear();
145 generations.clear();
146 }
147
148 /** Return true when the network is too slow for background model discovery. */
149 export function shouldSkipAutoRefresh(): boolean {
150 const nav = navigator as Navigator & {
151 connection?: { saveData?: boolean; effectiveType?: string };
152 };
153 const conn = nav.connection;
154 return Boolean(conn?.saveData || conn?.effectiveType === "slow-2g" || conn?.effectiveType === "2g");
155 }
156
156 lines TYPESCRIPT