feat(ai): sync model reads, explicit async refresh

Provider.getModels() is sync-only (last-known list; must not throw) with
an optional refreshModels() where dynamic providers fetch. The
sync-or-async union invited latent sync assumptions that would detonate
on the first dynamic provider; async-only reads would force sync
consumer surfaces (extension find/getAll) through Promises. Sync reads
plus an explicit refresh verb keeps the contract single and the
staleness visible.

Models.getModels()/getModel() are sync best-effort reads;
Models.refresh(provider?) rejects with ModelsError(model_source) for a
single provider and is concurrent best-effort across all providers.
createProvider() takes a models array plus an optional refreshModels
fetcher (stored on success, in-flight calls deduped, list unchanged on
rejection). forceRefresh options are gone.

Also finishes the in-progress AuthStorage fallbackResolver removal
(drops the now-unused includeFallback option from getApiKey).
This commit is contained in:
Mario Zechner
2026-06-10 23:30:04 +02:00
parent 9ab1292679
commit 6e98573f24
10 changed files with 237 additions and 139 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ describe("lazy provider module loading", () => {
const result = runProbe(`
const all = await import(${JSON.stringify(providersAllUrl)});
const models = all.builtinModels();
await models.getModels();
models.getModels();
`);
expect(result.loadedSpecifiers).toEqual([]);
});
+52 -35
View File
@@ -55,7 +55,8 @@ function testProvider(input: {
id: string;
models?: Model<Api>[];
auth?: ProviderAuth;
getModels?: () => Promise<readonly Model<Api>[]>;
getModels?: () => readonly Model<Api>[];
refreshModels?: () => Promise<void>;
calls?: ProviderCall[];
}): Provider {
const models = input.models ?? [testModel(input.id, "model-a")];
@@ -72,7 +73,8 @@ function testProvider(input: {
id: input.id,
name: input.id,
auth: input.auth ?? { apiKey: ambientAuth },
getModels: input.getModels ?? (async () => models),
getModels: input.getModels ?? (() => models),
refreshModels: input.refreshModels,
stream: (model, _context, options) => respond(model, options as StreamOptions | undefined),
streamSimple: (model, _context, options) => respond(model, options as SimpleStreamOptions | undefined),
};
@@ -127,14 +129,14 @@ describe("Models runtime", () => {
models.setProvider(testProvider({ id: "p1", models: [testModel("p1", "m1"), testModel("p1", "m2")] }));
models.setProvider(testProvider({ id: "p2", models: [testModel("p2", "m3")] }));
expect((await models.getModels()).map((m) => m.id)).toEqual(["m1", "m2", "m3"]);
expect((await models.getModels("p1")).map((m) => m.id)).toEqual(["m1", "m2"]);
expect((await models.getModels("nope")).length).toBe(0);
expect((await models.getModel("p2", "m3"))?.id).toBe("m3");
expect(await models.getModel("p2", "missing")).toBeUndefined();
expect(models.getModels().map((m) => m.id)).toEqual(["m1", "m2", "m3"]);
expect(models.getModels("p1").map((m) => m.id)).toEqual(["m1", "m2"]);
expect(models.getModels("nope").length).toBe(0);
expect(models.getModel("p2", "m3")?.id).toBe("m3");
expect(models.getModel("p2", "missing")).toBeUndefined();
// hasApi() narrows dynamically looked-up models with a runtime check
const found = await models.getModel("p2", "m3");
const found = models.getModel("p2", "m3");
expect(found && hasApi(found, "openai-completions")).toBe(false);
expect(found && hasApi(found, "test-api")).toBe(true);
if (found && hasApi(found, "test-api")) {
@@ -143,48 +145,63 @@ describe("Models runtime", () => {
}
});
it("swallows provider source failures for both all-provider and single-provider listing", async () => {
it("swallows provider source failures for both all-provider and single-provider listing", () => {
const models = createModels();
models.setProvider(
testProvider({
id: "broken",
getModels: async () => {
getModels: () => {
throw new Error("boom");
},
}),
);
models.setProvider(testProvider({ id: "ok", models: [testModel("ok", "m1")] }));
expect((await models.getModels()).map((m) => m.id)).toEqual(["m1"]);
expect(await models.getModels("broken")).toEqual([]);
expect(models.getModels().map((m) => m.id)).toEqual(["m1"]);
expect(models.getModels("broken")).toEqual([]);
// precise failures come from the provider directly
await expect(models.getProvider("broken")?.getModels()).rejects.toThrow("boom");
// even sync-throwing (non-async) provider implementations are isolated
models.setProvider({
...testProvider({ id: "sync-broken" }),
getModels: () => {
throw new Error("sync boom");
},
});
expect((await models.getModels()).map((m) => m.id)).toEqual(["m1"]);
expect(() => models.getProvider("broken")?.getModels()).toThrow("boom");
});
it("supports getModels(options) without a provider id", async () => {
const seen: ({ forceRefresh?: boolean } | undefined)[] = [];
it("refresh() updates dynamic providers; single-provider refresh failures reject", async () => {
let list = [testModel("dyn", "before")];
let refreshes = 0;
const models = createModels();
models.setProvider(testProvider({ id: "p1", models: [testModel("p1", "m1")] }));
models.setProvider({
...testProvider({ id: "p2" }),
getModels: async (options) => {
seen.push(options);
return [testModel("p2", "m2")];
},
});
models.setProvider(
testProvider({
id: "dyn",
getModels: () => list,
refreshModels: async () => {
refreshes++;
list = [testModel("dyn", "after")];
},
}),
);
models.setProvider(testProvider({ id: "static", models: [testModel("static", "s1")] }));
const all = await models.getModels({ forceRefresh: true });
expect(all.map((m) => m.id)).toEqual(["m1", "m2"]);
expect(seen).toEqual([{ forceRefresh: true }]);
expect(models.getModel("dyn", "before")).toBeDefined();
await models.refresh("dyn");
expect(refreshes).toBe(1);
expect(models.getModel("dyn", "after")).toBeDefined();
expect(models.getModel("dyn", "before")).toBeUndefined();
// static providers are no-ops; refresh-all is best-effort
await models.refresh("static");
await models.refresh();
expect(refreshes).toBe(2);
// single-provider refresh failures reject with ModelsError
models.setProvider(
testProvider({
id: "flaky",
refreshModels: async () => {
throw new Error("fetch failed");
},
}),
);
await expect(models.refresh("flaky")).rejects.toMatchObject({ code: "model_source" });
// refresh-all swallows the same failure
await expect(models.refresh()).resolves.toBeUndefined();
});
it("resolves auth: stored credential owns the provider, ambient only when nothing stored", async () => {
+2 -2
View File
@@ -99,7 +99,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
const models = createModels({ credentials });
models.setProvider(anthropicProvider());
const model = (await models.getModels("anthropic"))[0];
const model = models.getModels("anthropic")[0];
const result = await models.getAuth(model);
expect(result?.auth.apiKey).toBe("oauth-access-token");
expect(result?.source).toBe("OAuth");
@@ -117,7 +117,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
const models = createModels({ credentials });
models.setProvider(githubCopilotProvider());
const model = (await models.getModels("github-copilot"))[0];
const model = models.getModels("github-copilot")[0];
const result = await models.getAuth(model);
expect(result?.auth.apiKey).toBe(access);
expect(result?.auth.baseUrl).toBe("https://api.business.githubcopilot.com");
+24 -11
View File
@@ -26,15 +26,15 @@ describe("builtin providers", () => {
expect(providers.length).toBe(builtinProviders().length);
expect(providers.map((p) => p.id)).toContain("anthropic");
const anthropic = await models.getModel("anthropic", "claude-haiku-4-5");
const anthropic = models.getModel("anthropic", "claude-haiku-4-5");
expect(anthropic?.api).toBe("anthropic-messages");
const all = await models.getModels();
const all = models.getModels();
expect(all.length).toBeGreaterThan(500);
// every provider lists at least one model and owns its models
for (const provider of providers) {
const list = await models.getModels(provider.id);
const list = models.getModels(provider.id);
expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.provider === provider.id)).toBe(true);
}
@@ -45,7 +45,7 @@ describe("builtin providers", () => {
authContext: fakeAuthContext({ ANTHROPIC_API_KEY: "key", ANTHROPIC_OAUTH_TOKEN: "oauth-token" }),
});
models.setProvider(anthropicProvider());
const model = (await models.getModel("anthropic", "claude-haiku-4-5"))!;
const model = models.getModel("anthropic", "claude-haiku-4-5")!;
const result = await models.getAuth(model);
expect(result?.auth.apiKey).toBe("oauth-token");
@@ -55,7 +55,7 @@ describe("builtin providers", () => {
it("reports bedrock as configured from ambient AWS credentials without an api key", async () => {
const models = createModels({ authContext: fakeAuthContext({ AWS_PROFILE: "dev" }) });
models.setProvider(amazonBedrockProvider());
const model = (await models.getModels("amazon-bedrock"))[0];
const model = models.getModels("amazon-bedrock")[0];
const result = await models.getAuth(model);
expect(result?.auth).toEqual({});
@@ -72,7 +72,7 @@ describe("builtin providers", () => {
authContext: fakeAuthContext({ GOOGLE_CLOUD_PROJECT: "proj", GOOGLE_CLOUD_LOCATION: "us-central1" }, [adc]),
});
configured.setProvider(googleVertexProvider());
const model = (await configured.getModels("google-vertex"))[0];
const model = configured.getModels("google-vertex")[0];
const result = await configured.getAuth(model);
expect(result?.auth).toEqual({});
@@ -180,15 +180,28 @@ describe("createProvider", () => {
expect(result.errorMessage).toContain("no API implementation");
});
it("supports async model listers", async () => {
it("supports dynamic providers: empty until refreshed, in-flight refreshes deduped", async () => {
let fetches = 0;
const provider = createProvider({
id: "dynamic",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: async () => [testModel("api-a", "listed")],
models: [],
refreshModels: async () => {
fetches++;
await new Promise((resolve) => setTimeout(resolve, 5));
return [testModel("api-a", "listed")];
},
api: recordingStreams("a", []),
});
const models = await provider.getModels();
expect(models.map((m) => m.id)).toEqual(["listed"]);
expect(provider.getModels()).toEqual([]);
await Promise.all([provider.refreshModels?.(), provider.refreshModels?.()]);
expect(fetches).toBe(1);
expect(provider.getModels().map((m) => m.id)).toEqual(["listed"]);
// a later refresh fetches again
await provider.refreshModels?.();
expect(fetches).toBe(2);
});
});
@@ -199,7 +212,7 @@ describe("fauxProvider", () => {
models.setProvider(faux.provider);
faux.setResponses([fauxAssistantMessage("hello from faux")]);
const model = (await models.getModels(faux.provider.id))[0];
const model = models.getModels(faux.provider.id)[0];
const result = await models.completeSimple(model, context);
expect(result.stopReason).toBe("stop");
expect(result.content).toEqual([{ type: "text", text: "hello from faux" }]);
+1 -1
View File
@@ -18,7 +18,7 @@ models.setProvider(anthropicProvider());
// 2. Look up a model and check auth.
// ---------------------------------------------------------------------------
const model = await models.getModel("anthropic", "claude-haiku-4-5");
const model = models.getModel("anthropic", "claude-haiku-4-5");
if (!model) throw new Error("model not found");
const auth = await models.getAuth(model);