




























@@ -144,6 +144,324 @@ describe("provider-catalog-live-runtime", () => {
144144expect(release).toHaveBeenCalledTimes(1);
145145});
146146147+it("follows next_cursor pagination before projecting model ids", async () => {
148+const release = vi.fn(async () => undefined);
149+const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi
150+.fn()
151+.mockResolvedValueOnce({
152+response: new Response(
153+JSON.stringify({
154+data: [{ id: "model-a", object: "model" }],
155+has_more: true,
156+next_cursor: "cursor-2",
157+}),
158+),
159+finalUrl: "https://provider.example.test/v1/models",
160+ release,
161+})
162+.mockResolvedValueOnce({
163+response: new Response(
164+JSON.stringify({ data: [{ id: "model-b", object: "model" }], has_more: false }),
165+),
166+finalUrl: "https://provider.example.test/v1/models?after=cursor-2",
167+ release,
168+});
169+170+await expect(
171+fetchLiveProviderModelIds({
172+providerId: "provider",
173+endpoint: "https://provider.example.test/v1/models",
174+fetchGuard: fetchGuardMock,
175+}),
176+).resolves.toEqual(["model-a", "model-b"]);
177+178+expect(fetchGuardMock).toHaveBeenCalledTimes(2);
179+expect(fetchGuardMock.mock.calls[1]?.[0].url).toBe(
180+"https://provider.example.test/v1/models?after=cursor-2",
181+);
182+expect(release).toHaveBeenCalledTimes(2);
183+});
184+185+it("follows absolute next links when providers return them", async () => {
186+const release = vi.fn(async () => undefined);
187+const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi
188+.fn()
189+.mockResolvedValueOnce({
190+response: new Response(
191+JSON.stringify({
192+data: [{ id: "model-a", object: "model" }],
193+next: "https://provider.example.test/v1/models?page=2",
194+}),
195+),
196+finalUrl: "https://provider.example.test/v1/models",
197+ release,
198+})
199+.mockResolvedValueOnce({
200+response: new Response(JSON.stringify({ data: [{ id: "model-b", object: "model" }] })),
201+finalUrl: "https://provider.example.test/v1/models?page=2",
202+ release,
203+});
204+205+await expect(
206+fetchLiveProviderModelIds({
207+providerId: "provider",
208+endpoint: "https://provider.example.test/v1/models",
209+fetchGuard: fetchGuardMock,
210+}),
211+).resolves.toEqual(["model-a", "model-b"]);
212+213+expect(fetchGuardMock.mock.calls[1]?.[0].url).toBe(
214+"https://provider.example.test/v1/models?page=2",
215+);
216+});
217+218+it("follows nested links.next pagination when providers return it", async () => {
219+const release = vi.fn(async () => undefined);
220+const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi
221+.fn()
222+.mockResolvedValueOnce({
223+response: new Response(
224+JSON.stringify({
225+data: [{ id: "model-a", object: "model" }],
226+links: { next: "/v1/models?page=2" },
227+}),
228+),
229+finalUrl: "https://provider.example.test/v1/models",
230+ release,
231+})
232+.mockResolvedValueOnce({
233+response: new Response(JSON.stringify({ data: [{ id: "model-b", object: "model" }] })),
234+finalUrl: "https://provider.example.test/v1/models?page=2",
235+ release,
236+});
237+238+await expect(
239+fetchLiveProviderModelIds({
240+providerId: "provider",
241+endpoint: "https://provider.example.test/v1/models",
242+fetchGuard: fetchGuardMock,
243+}),
244+).resolves.toEqual(["model-a", "model-b"]);
245+246+expect(fetchGuardMock.mock.calls[1]?.[0].url).toBe(
247+"https://provider.example.test/v1/models?page=2",
248+);
249+});
250+251+it("resolves relative pagination links against the guarded fetch final URL", async () => {
252+const release = vi.fn(async () => undefined);
253+const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi
254+.fn()
255+.mockResolvedValueOnce({
256+response: new Response(
257+JSON.stringify({
258+data: [{ id: "model-a", object: "model" }],
259+links: { next: "?page=2" },
260+}),
261+),
262+finalUrl: "https://provider.example.test/v1/models/",
263+ release,
264+})
265+.mockResolvedValueOnce({
266+response: new Response(JSON.stringify({ data: [{ id: "model-b", object: "model" }] })),
267+finalUrl: "https://provider.example.test/v1/models/?page=2",
268+ release,
269+});
270+271+await expect(
272+fetchLiveProviderModelIds({
273+providerId: "provider",
274+endpoint: "https://provider.example.test/v1/models",
275+fetchGuard: fetchGuardMock,
276+}),
277+).resolves.toEqual(["model-a", "model-b"]);
278+279+expect(fetchGuardMock.mock.calls[1]?.[0].url).toBe(
280+"https://provider.example.test/v1/models/?page=2",
281+);
282+});
283+284+it("does not re-add credentials to redirected-origin pagination requests", async () => {
285+const release = vi.fn(async () => undefined);
286+const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi
287+.fn()
288+.mockResolvedValueOnce({
289+response: new Response(
290+JSON.stringify({
291+data: [{ id: "model-a", object: "model" }],
292+links: { next: "?page=2" },
293+}),
294+),
295+finalUrl: "https://redirected.example.test/v1/models/",
296+ release,
297+})
298+.mockResolvedValueOnce({
299+response: new Response(JSON.stringify({ data: [{ id: "model-b", object: "model" }] })),
300+finalUrl: "https://redirected.example.test/v1/models/?page=2",
301+ release,
302+});
303+304+await expect(
305+fetchLiveProviderModelIds({
306+providerId: "provider",
307+endpoint: "https://provider.example.test/v1/models",
308+discoveryApiKey: "provider-token",
309+fetchGuard: fetchGuardMock,
310+buildRequestHeaders: ({ discoveryApiKey }) => ({
311+Accept: "application/json",
312+ ...(discoveryApiKey ? { Authorization: `Bearer ${discoveryApiKey}` } : {}),
313+"ChatGPT-Account-ID": "acct-1",
314+}),
315+}),
316+).resolves.toEqual(["model-a", "model-b"]);
317+318+const firstHeaders = fetchGuardMock.mock.calls[0]?.[0].init?.headers;
319+const secondHeaders = fetchGuardMock.mock.calls[1]?.[0].init?.headers;
320+expect(firstHeaders).toBeInstanceOf(Headers);
321+expect(secondHeaders).toBeInstanceOf(Headers);
322+expect((firstHeaders as Headers).get("authorization")).toBe("Bearer provider-token");
323+expect((firstHeaders as Headers).get("chatgpt-account-id")).toBe("acct-1");
324+expect(fetchGuardMock.mock.calls[1]?.[0].url).toBe(
325+"https://redirected.example.test/v1/models/?page=2",
326+);
327+expect((secondHeaders as Headers).get("authorization")).toBeNull();
328+expect((secondHeaders as Headers).get("chatgpt-account-id")).toBeNull();
329+expect((secondHeaders as Headers).get("accept")).toBe("application/json");
330+});
331+332+it("follows nextPageToken pagination before projecting model ids", async () => {
333+const release = vi.fn(async () => undefined);
334+const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi
335+.fn()
336+.mockResolvedValueOnce({
337+response: new Response(
338+JSON.stringify({
339+data: [{ id: "model-a", object: "model" }],
340+nextPageToken: "page-2",
341+}),
342+),
343+finalUrl: "https://provider.example.test/v1/models",
344+ release,
345+})
346+.mockResolvedValueOnce({
347+response: new Response(JSON.stringify({ data: [{ id: "model-b", object: "model" }] })),
348+finalUrl: "https://provider.example.test/v1/models?pageToken=page-2",
349+ release,
350+});
351+352+await expect(
353+fetchLiveProviderModelIds({
354+providerId: "provider",
355+endpoint: "https://provider.example.test/v1/models",
356+fetchGuard: fetchGuardMock,
357+}),
358+).resolves.toEqual(["model-a", "model-b"]);
359+360+expect(fetchGuardMock.mock.calls[1]?.[0].url).toBe(
361+"https://provider.example.test/v1/models?pageToken=page-2",
362+);
363+});
364+365+it("fails truncated live catalog pagination instead of returning partial rows", async () => {
366+const release = vi.fn(async () => undefined);
367+const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi.fn(async ({ url }) => {
368+const page = Number(new URL(url).searchParams.get("after") ?? "0");
369+return {
370+response: new Response(
371+JSON.stringify({
372+data: [{ id: `model-${page}`, object: "model" }],
373+has_more: true,
374+next_cursor: String(page + 1),
375+}),
376+),
377+finalUrl: url,
378+ release,
379+};
380+});
381+382+await expect(
383+fetchLiveProviderModelIds({
384+providerId: "provider",
385+endpoint: "https://provider.example.test/v1/models",
386+fetchGuard: fetchGuardMock,
387+}),
388+).rejects.toThrow("provider model discovery exceeded 50 pages before the catalog completed");
389+390+expect(fetchGuardMock).toHaveBeenCalledTimes(50);
391+expect(release).toHaveBeenCalledTimes(50);
392+});
393+394+it("fails explicit incomplete live catalog pagination without a supported next page", async () => {
395+const release = vi.fn(async () => undefined);
396+const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi.fn(async () => ({
397+response: new Response(
398+JSON.stringify({
399+data: [{ id: "model-a", object: "model" }],
400+has_more: true,
401+}),
402+),
403+finalUrl: "https://provider.example.test/v1/models",
404+ release,
405+}));
406+407+await expect(
408+fetchLiveProviderModelIds({
409+providerId: "provider",
410+endpoint: "https://provider.example.test/v1/models",
411+fetchGuard: fetchGuardMock,
412+}),
413+).rejects.toThrow(
414+"provider model discovery did not include a supported next page before the catalog completed",
415+);
416+417+expect(fetchGuardMock).toHaveBeenCalledTimes(1);
418+expect(release).toHaveBeenCalledTimes(1);
419+});
420+421+it("uses one timeout budget across paginated live catalog discovery", async () => {
422+vi.useFakeTimers();
423+try {
424+const release = vi.fn(async () => undefined);
425+const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi
426+.fn()
427+.mockImplementationOnce(async () => {
428+await vi.advanceTimersByTimeAsync(800);
429+return {
430+response: new Response(
431+JSON.stringify({
432+data: [{ id: "model-a", object: "model" }],
433+has_more: true,
434+next_cursor: "cursor-2",
435+}),
436+),
437+finalUrl: "https://provider.example.test/v1/models",
438+ release,
439+};
440+})
441+.mockImplementationOnce(async () => ({
442+response: new Response(JSON.stringify({ data: [{ id: "model-b", object: "model" }] })),
443+finalUrl: "https://provider.example.test/v1/models?after=cursor-2",
444+ release,
445+}));
446+447+await expect(
448+fetchLiveProviderModelIds({
449+providerId: "provider",
450+endpoint: "https://provider.example.test/v1/models",
451+fetchGuard: fetchGuardMock,
452+timeoutMs: 1_000,
453+}),
454+).resolves.toEqual(["model-a", "model-b"]);
455+456+expect(fetchGuardMock).toHaveBeenCalledTimes(2);
457+expect(fetchGuardMock.mock.calls[0]?.[0].timeoutMs).toBe(1_000);
458+expect(fetchGuardMock.mock.calls[1]?.[0].timeoutMs).toBe(200);
459+expect(release).toHaveBeenCalledTimes(2);
460+} finally {
461+vi.useRealTimers();
462+}
463+});
464+147465it("caches raw live model rows for provider-specific projection", async () => {
148466const { fetchGuard, fetchGuardMock } = buildFetchGuard({
149467models: [{ slug: "custom-a" }, { slug: "custom-b" }],
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。