From 43d37639912fe15c0926ca5bc7965822ced5615c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 6 Oct 2026 18:37:12 +0200 Subject: [PATCH] fix(ai): replace bundled Radius catalog with gateway catalog Models disabled by an organization owner were still listed because the fetched catalog was merged into the shipped default catalog. --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/radius.ts | 16 +++++---------- packages/ai/test/radius-provider.test.ts | 26 +++++++++++++++++++++--- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c109d5334..d15bac1ce 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -12,6 +12,7 @@ - Fixed `server_busy` and `servers are currently busy` provider errors ending the turn instead of being retried ([#10543](https://github.com/earendil-works/pi/issues/10543)) - Fixed the error message of a failed lazy API setup, such as a module load or auth failure, using its failure time as `timestamp` instead of the request start - Reduced context-limit request failures by estimating input at 3.5 characters per token instead of 4 when calculating output limits ([#10497](https://github.com/earendil-works/pi/issues/10497)) +- Fixed Radius models disabled by an organization owner still being listed: a fetched or cached Radius catalog now replaces the shipped default catalog instead of being merged into it ## [1.0.4] - 2026-10-05 diff --git a/packages/ai/src/providers/radius.ts b/packages/ai/src/providers/radius.ts index 785962560..b5f9ed060 100644 --- a/packages/ai/src/providers/radius.ts +++ b/packages/ai/src/providers/radius.ts @@ -27,7 +27,9 @@ export function radiusProvider(options: RadiusProviderOptions = {}): Provider<"p gateway === normalizeRadiusGatewayUrl(DEFAULT_RADIUS_GATEWAY) ? Object.values(RADIUS_MODELS).map((model) => ({ ...model, provider: id })) : []; - let dynamicModels = getRadiusModels(id, undefined); + // Gateway catalog for this account. Radius org owners can disable models, so once known it replaces + // the shipped baseline instead of overlaying it. The baseline only covers the time before any catalog exists. + let dynamicModels: Model<"pi-messages">[] | undefined; const streams = piMessagesApi(); return { @@ -37,19 +39,11 @@ export function radiusProvider(options: RadiusProviderOptions = {}): Provider<"p apiKey: envApiKeyAuth("Radius API key", ["RADIUS_API_KEY"]), oauth: lazyOAuth({ name, load: () => loadRadiusOAuth({ name, gateway }) }), }, - getModels: () => { - const merged = [...baselineModels]; - for (const model of dynamicModels) { - const index = merged.findIndex((entry) => entry.id === model.id); - if (index >= 0) merged[index] = model; - else merged.push(model); - } - return merged; - }, + getModels: () => dynamicModels ?? baselineModels, refreshModels: async (context) => { const stored = context.stored; if (stored) { - const restored = stored.models.filter((model) => model.provider === id) as typeof dynamicModels; + const restored = stored.models.filter((model) => model.provider === id) as Model<"pi-messages">[]; if ( !(await context.publish({ update: () => { diff --git a/packages/ai/test/radius-provider.test.ts b/packages/ai/test/radius-provider.test.ts index 3d184ec8f..9f1051c26 100644 --- a/packages/ai/test/radius-provider.test.ts +++ b/packages/ai/test/radius-provider.test.ts @@ -49,7 +49,7 @@ describe("Radius provider catalogs", () => { expect(provider.getModels()).toEqual([]); }); - it("overlays refreshed models on the static public catalog", async () => { + it("replaces the static public catalog with refreshed models", async () => { vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(JSON.stringify(radiusConfig()), { status: 200, @@ -70,10 +70,11 @@ describe("Radius provider catalogs", () => { contextWindow: 424242, }); expect(models.getModel("radius", "organization-only")).toBeDefined(); - expect(models.getModels("radius").length).toBeGreaterThan(radiusConfig().models.length); + // Models disabled by the organization must not reappear from the shipped catalog. + expect(models.getModels("radius").map((model) => model.id)).toEqual(["balanced", "organization-only"]); }); - it("overlays a cached effective catalog without network access", async () => { + it("replaces the static public catalog with a cached catalog without network access", async () => { const store = new InMemoryModelsStore(); await store.write("radius", { models: getRadiusModelsFromConfig("radius", radiusConfig()), @@ -86,5 +87,24 @@ describe("Radius provider catalogs", () => { expect(models.getModel("radius", "balanced")?.name).toBe("Fresh Balanced"); expect(models.getModel("radius", "organization-only")).toBeDefined(); + expect(models.getModels("radius").map((model) => model.id)).toEqual(["balanced", "organization-only"]); + }); + + it("exposes no models when the organization disabled all of them", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ baseUrl: "https://radius.example/v1", models: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const credentials = new InMemoryCredentialStore(); + await credentials.modify("radius", async () => ({ type: "api_key", key: "radius-key" })); + const models = createModels({ credentials }); + models.setProvider(radiusProvider()); + + const result = await models.refresh({ providers: ["radius"] }); + + expect(result.errors).toEqual(new Map()); + expect(models.getModels("radius")).toEqual([]); }); });