返回 DeepSeek-Reasonix
provider-model-cache.test.ts
根目录 / desktop / frontend / src / __tests__ / provider-model-cache.test.ts
1 // Run: tsx src/__tests__/provider-model-cache.test.ts
2
3 import type { ProviderView } from "../lib/types";
4 import {
5 cachedFetchProviderModels,
6 clearModelCache,
7 invalidateProviderCacheByAPIKeyEnv,
8 isBackingOff,
9 } from "../lib/providerModelCache";
10
11 let passed = 0;
12 let failed = 0;
13
14 function ok(value: boolean, label: string) {
15 if (value) {
16 process.stdout.write(` PASS ${label}\n`);
17 passed += 1;
18 } else {
19 process.stdout.write(` FAIL ${label}\n`);
20 failed += 1;
21 }
22 }
23
24 async function rejects(run: () => Promise<unknown>, label: string) {
25 try {
26 await run();
27 ok(false, label);
28 } catch {
29 ok(true, label);
30 }
31 }
32
33 function provider(overrides: Partial<ProviderView> = {}): ProviderView {
34 return {
35 name: "custom",
36 builtIn: false,
37 added: true,
38 kind: "openai",
39 baseUrl: "https://example.test/v1",
40 modelsUrl: "https://example.test/v1/models",
41 models: ["model-a"],
42 visionModels: [],
43 visionModelsConfigured: false,
44 default: "model-a",
45 apiKeyEnv: "CUSTOM_API_KEY",
46 keySet: true,
47 balanceUrl: "",
48 contextWindow: 0,
49 reasoningProtocol: "",
50 thinking: "",
51 supportedEfforts: [],
52 defaultEffort: "",
53 ...overrides,
54 };
55 }
56
57 console.log("\nprovider model cache");
58
59 const realNow = Date.now;
60 let now = 10_000;
61 Date.now = () => now;
62
63 try {
64 clearModelCache();
65 let calls = 0;
66 let resolveFetch!: (models: string[]) => void;
67 const pendingFetch = () => {
68 calls += 1;
69 return new Promise<string[]>((resolve) => {
70 resolveFetch = resolve;
71 });
72 };
73 const firstPending = cachedFetchProviderModels(pendingFetch, provider());
74 const secondPending = cachedFetchProviderModels(pendingFetch, provider());
75 resolveFetch(["single-flight"]);
76 const [firstModels, secondModels] = await Promise.all([firstPending, secondPending]);
77 ok(calls === 1 && firstModels[0] === "single-flight" && secondModels[0] === "single-flight", "deduplicates concurrent requests");
78
79 const cachedModels = await cachedFetchProviderModels(async () => {
80 calls += 1;
81 return ["unexpected"];
82 }, provider());
83 ok(calls === 1 && cachedModels[0] === "single-flight", "serves a fresh TTL cache entry");
84
85 now += 60_000;
86 const refreshedModels = await cachedFetchProviderModels(async () => {
87 calls += 1;
88 return ["after-ttl"];
89 }, provider());
90 ok(calls === 2 && refreshedModels[0] === "after-ttl", "refreshes an expired TTL entry");
91
92 clearModelCache();
93 calls = 0;
94 const transient = new Error("transient model-list failure");
95 await rejects(() => cachedFetchProviderModels(async () => {
96 calls += 1;
97 throw transient;
98 }, provider()), "records the first failed request");
99 ok(isBackingOff(provider()), "enters the initial retry window");
100 await rejects(() => cachedFetchProviderModels(async () => {
101 calls += 1;
102 return ["too-early"];
103 }, provider()), "reuses the failure during cooldown");
104 ok(calls === 1, "does not call the provider during cooldown");
105 now += 1_000;
106 const recovered = await cachedFetchProviderModels(async () => {
107 calls += 1;
108 return ["recovered"];
109 }, provider());
110 ok(calls === 2 && recovered[0] === "recovered" && !isBackingOff(provider()), "retries after the deadline and resets backoff");
111
112 clearModelCache();
113 calls = 0;
114 const cappedProvider = provider({ name: "backoff-cap" });
115 let respectedEveryWindow = true;
116 for (const delay of [1_000, 2_000, 4_000, 8_000, 16_000, 32_000, 60_000, 60_000]) {
117 try {
118 await cachedFetchProviderModels(async () => {
119 calls += 1;
120 throw transient;
121 }, cappedProvider);
122 } catch {}
123 const beforeCooldownProbe = calls;
124 now += delay - 1;
125 try {
126 await cachedFetchProviderModels(async () => {
127 calls += 1;
128 return ["too-early"];
129 }, cappedProvider);
130 } catch {}
131 respectedEveryWindow = respectedEveryWindow && calls === beforeCooldownProbe;
132 now += 1;
133 }
134 const afterCap = await cachedFetchProviderModels(async () => {
135 calls += 1;
136 return ["after-cap"];
137 }, cappedProvider);
138 ok(respectedEveryWindow && calls === 9 && afterCap[0] === "after-cap", "doubles retry windows and caps them at 60 seconds");
139
140 clearModelCache();
141 calls = 0;
142 const orderedHeaders = provider({ headers: { "X-B": "2", "X-A": "1" } });
143 const reorderedHeaders = provider({ headers: { "X-A": "1", "X-B": "2" } });
144 await cachedFetchProviderModels(async () => {
145 calls += 1;
146 return ["ordered"];
147 }, orderedHeaders);
148 await cachedFetchProviderModels(async () => {
149 calls += 1;
150 return ["unexpected"];
151 }, reorderedHeaders);
152 ok(calls === 1, "normalizes header ordering in the request identity");
153
154 const otherEndpoint = await cachedFetchProviderModels(async () => {
155 calls += 1;
156 return ["other-endpoint"];
157 }, provider({ modelsUrl: "https://example.test/alternate/models", headers: { "X-A": "1", "X-B": "2" } }));
158 ok(calls === 2 && otherEndpoint[0] === "other-endpoint", "separates different modelsUrl endpoints");
159
160 clearModelCache();
161 calls = 0;
162 const oldFingerprint = provider({ modelCatalogFingerprint: "catalog-revision-old" });
163 const newFingerprint = provider({ modelCatalogFingerprint: "catalog-revision-new" });
164 await cachedFetchProviderModels(async () => {
165 calls += 1;
166 return ["old-catalog"];
167 }, oldFingerprint);
168 const afterExternalCredentialChange = await cachedFetchProviderModels(async () => {
169 calls += 1;
170 return ["new-catalog"];
171 }, newFingerprint);
172 ok(calls === 2 && afterExternalCredentialChange[0] === "new-catalog", "refetches when the opaque catalog fingerprint changes");
173
174 invalidateProviderCacheByAPIKeyEnv("CUSTOM_API_KEY");
175 const afterCredentialChange = await cachedFetchProviderModels(async () => {
176 calls += 1;
177 return ["new-credential"];
178 }, reorderedHeaders);
179 ok(calls === 3 && afterCredentialChange[0] === "new-credential", "invalidates every identity that shares a credential env");
180
181 clearModelCache();
182 calls = 0;
183 let resolveStale!: (models: string[]) => void;
184 const stalePending = cachedFetchProviderModels(() => {
185 calls += 1;
186 return new Promise<string[]>((resolve) => {
187 resolveStale = resolve;
188 });
189 }, provider());
190 invalidateProviderCacheByAPIKeyEnv("CUSTOM_API_KEY");
191 const current = await cachedFetchProviderModels(async () => {
192 calls += 1;
193 return ["current"];
194 }, provider());
195 resolveStale(["stale"]);
196 await stalePending;
197 const stillCurrent = await cachedFetchProviderModels(async () => {
198 calls += 1;
199 return ["unexpected"];
200 }, provider());
201 ok(calls === 2 && current[0] === "current" && stillCurrent[0] === "current", "prevents invalidated inflight results from repopulating the cache");
202 } finally {
203 Date.now = realNow;
204 clearModelCache();
205 }
206
207 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
208 if (failed > 0) process.exit(1);
209
209 lines TYPESCRIPT