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.
This commit is contained in:
Armin Ronacher 2026-10-06 18:37:12 +02:00
parent 36a686ee8d
commit 43d3763991
3 changed files with 29 additions and 14 deletions

View file

@ -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

View file

@ -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: () => {

View file

@ -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([]);
});
});