From 9599d0aecd579a285bc90c5bef6df5e85f28a476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 14:23:01 -0400 Subject: [PATCH] Q84: live model lists follow every page Co-Authored-By: Claude Opus 5.5 --- src/main/services/provider-models-fetch.ts | 43 +++++++++++++++++----- tests/unit/provider-models-fetch.test.ts | 41 ++++++++++++++++++++- 2 files changed, 73 insertions(+), 11 deletions(-) diff --git a/src/main/services/provider-models-fetch.ts b/src/main/services/provider-models-fetch.ts index 113931de..f5e0484d 100644 --- a/src/main/services/provider-models-fetch.ts +++ b/src/main/services/provider-models-fetch.ts @@ -9,30 +9,55 @@ import { getJson, type JsonRecord } from "./provider-http"; * rather than falling back to a stale static list. */ +const MAX_MODEL_PAGES = 50; + export function mapOpenAiModels(data: unknown): ProviderModelEntry[] { - return collectModels(data, (item, id) => ({ id, name: id })); + return collectModels(modelItems(data), (item, id) => ({ id, name: id })); } export function mapAnthropicModels(data: unknown): ProviderModelEntry[] { - return collectModels(data, (item, id) => ({ + return collectModels(modelItems(data), (item, id) => ({ id, name: typeof item.display_name === "string" && item.display_name.trim() ? item.display_name.trim() : id, })); } +/** OpenAI-style lists come whole; one that says has_more is followed through OpenAI's `after` cursor. */ export async function fetchOpenAiModels(modelsUrl: string, headers: Record): Promise { - const response = await getJson(modelsUrl, headers); - return mapOpenAiModels(response.data); + return mapOpenAiModels({ data: await fetchAllPages(modelsUrl, headers, (cursor) => withQuery(modelsUrl, { after: cursor })) }); } +/** Anthropic pages 20 models by default; ask for its largest page and follow `after_id` to the end. */ export async function fetchAnthropicModels(modelsUrl: string, headers: Record): Promise { - const response = await getJson(modelsUrl, headers); - return mapAnthropicModels(response.data); + const first = withQuery(modelsUrl, { limit: "1000" }); + return mapAnthropicModels({ data: await fetchAllPages(first, headers, (cursor) => withQuery(modelsUrl, { limit: "1000", after_id: cursor })) }); } -function collectModels(data: unknown, toEntry: (item: JsonRecord, id: string) => ProviderModelEntry): ProviderModelEntry[] { - const list = isRecord(data) && Array.isArray(data.data) ? data.data : null; - if (!list) throw new Error("Provider model list response was not in the expected shape."); +async function fetchAllPages(firstUrl: string, headers: Record, nextUrl: (cursor: string) => string): Promise { + const items: unknown[] = []; + let url = firstUrl; + for (let page = 1; page <= MAX_MODEL_PAGES; page += 1) { + const { data } = await getJson(url, headers); + items.push(...modelItems(data)); + if (!isRecord(data) || data.has_more !== true) return items; + if (typeof data.last_id !== "string" || !data.last_id) throw new Error("The provider said it has more models but no cursor to fetch them."); + url = nextUrl(data.last_id); + } + throw new Error(`The provider's model list ran past ${MAX_MODEL_PAGES} pages.`); +} + +function withQuery(url: string, params: Record): string { + const target = new URL(url); + for (const [name, value] of Object.entries(params)) target.searchParams.set(name, value); + return target.toString(); +} + +function modelItems(data: unknown): unknown[] { + if (!isRecord(data) || !Array.isArray(data.data)) throw new Error("Provider model list response was not in the expected shape."); + return data.data; +} + +function collectModels(list: unknown[], toEntry: (item: JsonRecord, id: string) => ProviderModelEntry): ProviderModelEntry[] { const entries: ProviderModelEntry[] = []; const seen = new Set(); for (const item of list) { diff --git a/tests/unit/provider-models-fetch.test.ts b/tests/unit/provider-models-fetch.test.ts index c3f556b5..66e728ce 100644 --- a/tests/unit/provider-models-fetch.test.ts +++ b/tests/unit/provider-models-fetch.test.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, test } from "vitest"; -import { mapAnthropicModels, mapOpenAiModels } from "../../src/main/services/provider-models-fetch"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { fetchAnthropicModels, fetchOpenAiModels, mapAnthropicModels, mapOpenAiModels } from "../../src/main/services/provider-models-fetch"; import { DatabaseConnection } from "../../src/main/database/connection"; import { ProviderService } from "../../src/main/services/provider-service"; import { providerAdapters } from "../../src/main/services/provider-adapters"; @@ -56,3 +56,40 @@ describe("ProviderService.listModels routing", () => { connection.close(); }); }); + +describe("live model lists follow every page", () => { + afterEach(() => vi.unstubAllGlobals()); + + function pages(byCursor: Record>, seen: string[]) { + vi.stubGlobal("fetch", async (url: string) => { + seen.push(url); + const cursor = new URL(url).searchParams.get("after_id") ?? new URL(url).searchParams.get("after") ?? ""; + return new Response(JSON.stringify(byCursor[cursor]), { status: 200, headers: { "content-type": "application/json" } }); + }); + } + + test("Anthropic pages through after_id at the largest page size until has_more is false", async () => { + const seen: string[] = []; + pages({ + "": { data: [{ id: "claude-a", display_name: "A" }], has_more: true, last_id: "claude-a" }, + "claude-a": { data: [{ id: "claude-b", display_name: "B" }], has_more: false, last_id: "claude-b" }, + }, seen); + expect((await fetchAnthropicModels("https://api.anthropic.com/v1/models", {})).map((model) => model.id)).toEqual(["claude-a", "claude-b"]); + expect(seen).toEqual(["https://api.anthropic.com/v1/models?limit=1000", "https://api.anthropic.com/v1/models?limit=1000&after_id=claude-a"]); + }); + + test("an OpenAI-style list that says has_more is followed through after; one without it is whole", async () => { + const seen: string[] = []; + pages({ + "": { data: [{ id: "m1" }], has_more: true, last_id: "m1" }, + m1: { data: [{ id: "m2" }], has_more: false }, + }, seen); + expect((await fetchOpenAiModels("https://api.example.test/v1/models", {})).map((model) => model.id)).toEqual(["m1", "m2"]); + expect(seen).toEqual(["https://api.example.test/v1/models", "https://api.example.test/v1/models?after=m1"]); + }); + + test("a page that says has_more without a cursor fails instead of returning part of the list", async () => { + pages({ "": { data: [{ id: "claude-a" }], has_more: true, last_id: null } }, []); + await expect(fetchAnthropicModels("https://api.anthropic.com/v1/models", {})).rejects.toThrow("more models but no cursor"); + }); +});