Files
pi_harness/packages/ai/test/images-models.test.ts
T
Mario Zechner 827fe1e2c4 feat(ai): ImagesModels collections mirroring the chat-side design
createImagesModels()/ImagesProvider/createImagesProvider() give image
generation the same shape as chat: sync model reads, explicit async
refresh(provider?) with in-flight dedupe, provider-resolved auth, and
never-rejecting generateImages() (failures return AssistantImages with
stopReason error). Auth resolution is shared with the chat side via the
free-standing resolveProviderAuth() in auth/resolve.ts, which also owns
ModelsError; both collections pass their store/context as arguments.

The OpenRouter implementation moves to api/openrouter-images.ts with a
lazy wrapper; openrouterImagesProvider() factory plus
builtinImagesProviders()/builtinImagesModels() land in providers/all.
The ImagesProvider id type alias is renamed to ImagesProviderId
(mirror of Provider -> ProviderId). The old global image API
(getImageModel*, generateImages, registerImagesApiProvider) stays on
/compat, its registration shim repointed at the moved implementation.

README: Quick Start uses builtinModels(), the full streaming event
switch, image generation and the development checklist are restored in
full, image generation documents the new collections with compat noted
for the old API, plus the review fixes (builtinModels options,
credential-store mention for browsers, ImagesModels notes).
2026-06-11 00:27:43 +02:00

169 lines
5.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { AuthContext } from "../src/auth/types.ts";
import { createImagesModels, createImagesProvider, type ImagesProvider } from "../src/images-models.ts";
import { builtinImagesModels } from "../src/providers/all.ts";
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ImagesOptions } from "../src/types.ts";
function fakeAuthContext(env: Record<string, string>): AuthContext {
return {
env: async (name) => env[name],
fileExists: async () => false,
};
}
function testImageModel(provider: string, id: string): ImagesModel<ImagesApi> {
return {
id,
name: id,
api: "test-images",
provider,
baseUrl: "https://example.test/v1",
input: ["text"],
output: ["image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
}
function okResult(model: ImagesModel<ImagesApi>): AssistantImages {
return {
api: model.api,
provider: model.provider,
model: model.id,
output: [{ type: "image", data: "aGk=", mimeType: "image/png" }],
stopReason: "stop",
timestamp: Date.now(),
};
}
interface GenerateCall {
model: ImagesModel<ImagesApi>;
options: ImagesOptions | undefined;
}
function testProvider(input: {
id: string;
models?: ImagesModel<ImagesApi>[];
envVar?: string;
calls?: GenerateCall[];
}): ImagesProvider {
return createImagesProvider({
id: input.id,
auth: {
apiKey: {
name: "Test key",
resolve: async ({ ctx }) => {
if (!input.envVar) return { auth: {} };
const key = await ctx.env(input.envVar);
return key ? { auth: { apiKey: key }, source: input.envVar } : undefined;
},
},
},
models: input.models ?? [testImageModel(input.id, "model-a")],
api: {
generateImages: async (model, _context, options) => {
input.calls?.push({ model, options });
return okResult(model);
},
},
});
}
const context: ImagesContext = { input: [{ type: "text", text: "a red circle" }] };
describe("ImagesModels", () => {
it("registers providers and reads models synchronously", () => {
const models = createImagesModels();
models.setProvider(testProvider({ id: "p1", models: [testImageModel("p1", "m1"), testImageModel("p1", "m2")] }));
models.setProvider(testProvider({ id: "p2", models: [testImageModel("p2", "m3")] }));
expect(models.getProviders().map((p) => p.id)).toEqual(["p1", "p2"]);
expect(models.getModels().map((m) => m.id)).toEqual(["m1", "m2", "m3"]);
expect(models.getModels("p1").map((m) => m.id)).toEqual(["m1", "m2"]);
expect(models.getModel("p2", "m3")?.id).toBe("m3");
expect(models.getModel("p2", "missing")).toBeUndefined();
models.deleteProvider("p1");
expect(models.getProvider("p1")).toBeUndefined();
});
it("resolves auth through the provider and merges it into requests; explicit options win", async () => {
const calls: GenerateCall[] = [];
const models = createImagesModels({ authContext: fakeAuthContext({ TEST_KEY: "env-key" }) });
models.setProvider(testProvider({ id: "p1", envVar: "TEST_KEY", calls }));
const model = models.getModel("p1", "model-a")!;
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key");
const result = await models.generateImages(model, context);
expect(result.stopReason).toBe("stop");
expect(calls[0].options?.apiKey).toBe("env-key");
await models.generateImages(model, context, { apiKey: "explicit" });
expect(calls[1].options?.apiKey).toBe("explicit");
});
it("returns an error result for unknown providers and unconfigured auth rejections", async () => {
const models = createImagesModels({ authContext: fakeAuthContext({}) });
const ghost = await models.generateImages(testImageModel("ghost", "m"), context);
expect(ghost.stopReason).toBe("error");
expect(ghost.errorMessage).toContain("Unknown provider: ghost");
// unconfigured (resolve -> undefined) still dispatches; provider decides what to do
const calls: GenerateCall[] = [];
models.setProvider(testProvider({ id: "p1", envVar: "MISSING", calls }));
const model = models.getModel("p1", "model-a")!;
expect(await models.getAuth(model)).toBeUndefined();
await models.generateImages(model, context);
expect(calls[0].options?.apiKey).toBeUndefined();
});
it("supports dynamic providers via refresh with in-flight dedupe", async () => {
let fetches = 0;
const provider = createImagesProvider({
id: "dyn",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
fetches++;
await new Promise((resolve) => setTimeout(resolve, 5));
return [testImageModel("dyn", "listed")];
},
api: { generateImages: async (model) => okResult(model) },
});
const models = createImagesModels();
models.setProvider(provider);
expect(models.getModels("dyn")).toEqual([]);
await Promise.all([models.refresh("dyn"), models.refresh("dyn")]);
expect(fetches).toBe(1);
expect(models.getModel("dyn", "listed")).toBeDefined();
// failures reject with ModelsError for a single provider
models.setProvider(
createImagesProvider({
id: "flaky",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [],
refreshModels: async () => {
throw new Error("fetch failed");
},
api: { generateImages: async (model) => okResult(model) },
}),
);
await expect(models.refresh("flaky")).rejects.toMatchObject({ code: "model_source" });
await expect(models.refresh()).resolves.toBeUndefined();
});
it("builtinImagesModels registers the openrouter provider with its catalog", async () => {
const models = builtinImagesModels({ authContext: fakeAuthContext({ OPENROUTER_API_KEY: "or-key" }) });
const providers = models.getProviders();
expect(providers.map((p) => p.id)).toEqual(["openrouter"]);
const list = models.getModels("openrouter");
expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.api === "openrouter-images")).toBe(true);
expect((await models.getAuth(list[0]))?.auth.apiKey).toBe("or-key");
});
});