feat(coding-agent): expose dynamic provider refresh

This commit is contained in:
Mario Zechner
2026-07-15 12:56:50 +02:00
parent 45203abfa0
commit bd9e09db44
13 changed files with 164 additions and 55 deletions
@@ -18,8 +18,34 @@ function model(id: string): Model<"openai-completions"> {
};
}
describe("legacy extension OAuth modifyModels", () => {
it("applies the synchronous projection after async credential initialization", async () => {
describe("extension provider model lifecycle", () => {
it("publishes refreshModels results without forcing ModelsStore persistence", async () => {
const modelsStore = new InMemoryModelsStore();
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory(),
modelsStore,
modelsPath: null,
allowModelNetwork: false,
});
runtime.registerProvider("extension-dynamic", {
baseUrl: "http://localhost:8080/v1",
apiKey: "local",
api: "openai-completions",
refreshModels: async () => [
{
...model("live"),
provider: "extension-dynamic",
baseUrl: "http://localhost:8080/v1",
},
],
});
await runtime.refresh({ allowNetwork: false });
expect(runtime.getModel("extension-dynamic", "live")).toBeDefined();
expect(await modelsStore.read("extension-dynamic")).toBeUndefined();
});
it("applies legacy OAuth modifyModels after async credential initialization", async () => {
const runtime = await ModelRuntime.create({
credentials: AuthStorage.inMemory({
"extension-oauth": {
@@ -36,15 +36,16 @@ describe("FileModelsStore", () => {
const path = join(dir, "models-store.json");
const store = new FileModelsStore(path);
await store.write("one", [model("one", "m1")]);
await store.write("two", [model("two", "m2")]);
await store.write("one", { models: [model("one", "m1")], checkedAt: 100 });
await store.write("two", { models: [model("two", "m2")], checkedAt: 200 });
const reloaded = new FileModelsStore(path);
expect((await reloaded.read("one"))?.map((entry) => entry.id)).toEqual(["m1"]);
expect((await reloaded.read("two"))?.map((entry) => entry.id)).toEqual(["m2"]);
expect((await reloaded.read("one"))?.models.map((entry) => entry.id)).toEqual(["m1"]);
expect((await reloaded.read("one"))?.checkedAt).toBe(100);
expect((await reloaded.read("two"))?.models.map((entry) => entry.id)).toEqual(["m2"]);
await reloaded.delete("one");
expect(await reloaded.read("one")).toBeUndefined();
expect((await reloaded.read("two"))?.map((entry) => entry.id)).toEqual(["m2"]);
expect((await reloaded.read("two"))?.models.map((entry) => entry.id)).toEqual(["m2"]);
});
});
+1 -1
View File
@@ -87,7 +87,7 @@ describe("Radius provider", () => {
});
expect(runtime.getModel(RADIUS_PROVIDER_ID, "auto")).toBeDefined();
expect(await modelsStore.read(RADIUS_PROVIDER_ID)).toHaveLength(1);
expect((await modelsStore.read(RADIUS_PROVIDER_ID))?.models).toHaveLength(1);
expect(vi.mocked(fetch).mock.calls[0]?.[1]?.headers).toMatchObject({ authorization: "Bearer access-token" });
});
@@ -1,5 +1,6 @@
import { createProvider, InMemoryModelsStore, type Model } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { VERSION } from "../src/config.ts";
import { withRemoteCatalog } from "../src/core/remote-catalog-provider.ts";
function model(id: string): Model<"openai-completions"> {
@@ -20,8 +21,8 @@ function model(id: string): Model<"openai-completions"> {
afterEach(() => vi.restoreAllMocks());
describe("remote catalog provider", () => {
it("parses catalogs keyed by model ID", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
it("parses keyed catalogs, sends version headers, and observes the refresh TTL", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ dynamic: model("dynamic") }), {
status: 200,
headers: { "content-type": "application/json" },
@@ -47,14 +48,27 @@ describe("remote catalog provider", () => {
credential: { type: "api_key" },
store: {
read: () => store.read(provider.id),
write: (models) => store.write(provider.id, models),
write: (entry) => store.write(provider.id, entry),
delete: () => store.delete(provider.id),
},
allowNetwork: true,
});
await provider.refreshModels?.({
credential: { type: "api_key" },
store: {
read: () => store.read(provider.id),
write: (entry) => store.write(provider.id, entry),
delete: () => store.delete(provider.id),
},
allowNetwork: true,
});
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static", "dynamic"]);
expect((await store.read(provider.id))?.map((entry) => entry.id)).toEqual(["dynamic"]);
expect((await store.read(provider.id))?.models.map((entry) => entry.id)).toEqual(["dynamic"]);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0]?.[1]?.headers).toMatchObject({
"User-Agent": expect.stringContaining(`pi/${VERSION}`),
});
});
it("treats unimplemented pi.dev catalog routes as an unavailable overlay", async () => {
@@ -81,13 +95,13 @@ describe("remote catalog provider", () => {
credential: { type: "api_key" },
store: {
read: () => store.read(provider.id),
write: (models) => store.write(provider.id, models),
write: (entry) => store.write(provider.id, entry),
delete: () => store.delete(provider.id),
},
allowNetwork: true,
}),
).resolves.toBeUndefined();
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static"]);
expect(await store.read(provider.id)).toBeUndefined();
expect(await store.read(provider.id)).toMatchObject({ models: [], checkedAt: expect.any(Number) });
});
});