Q84: live model lists follow every page
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
665bf3cae1
commit
9599d0aecd
2 files changed
+73
-11
No files matched your search
@@ -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<string, string>): Promise<ProviderModelEntry[]> {
|
||||
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<string, string>): Promise<ProviderModelEntry[]> {
|
||||
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<string, string>, nextUrl: (cursor: string) => string): Promise<unknown[]> {
|
||||
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, string>): 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<string>();
|
||||
for (const item of list) {
|
||||
|
||||
@@ -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<string, Record<string, unknown>>, 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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user