chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createModels,
|
||||
createProvider,
|
||||
type ImageModel,
|
||||
InMemoryModelsStore,
|
||||
type Model,
|
||||
type ModelsPublication,
|
||||
@@ -9,7 +10,12 @@ import {
|
||||
} 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";
|
||||
import {
|
||||
type PersonalizeCatalog,
|
||||
REMOTE_CATALOG_MODEL_TYPES,
|
||||
withPersonalizedCatalog,
|
||||
withRemoteCatalog,
|
||||
} from "../src/core/remote-catalog-provider.ts";
|
||||
|
||||
const neverAbortedSignal = new AbortController().signal;
|
||||
|
||||
@@ -28,24 +34,24 @@ function model(id: string): Model<"openai-completions"> {
|
||||
};
|
||||
}
|
||||
|
||||
function testProvider(localGeneratedAt?: number) {
|
||||
return withRemoteCatalog(
|
||||
createProvider({
|
||||
id: "test-provider",
|
||||
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
|
||||
models: [model("static")],
|
||||
api: {
|
||||
stream: () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
streamSimple: () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
function builtinProvider(): Provider {
|
||||
return createProvider({
|
||||
id: "test-provider",
|
||||
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
|
||||
models: [model("static")],
|
||||
api: {
|
||||
stream: () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
}),
|
||||
"https://pi.dev",
|
||||
localGeneratedAt,
|
||||
);
|
||||
streamSimple: () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function testProvider(localGeneratedAt?: number) {
|
||||
return withRemoteCatalog(builtinProvider(), "https://pi.dev", localGeneratedAt);
|
||||
}
|
||||
|
||||
async function refreshProvider(
|
||||
@@ -92,6 +98,56 @@ describe("remote catalog provider", () => {
|
||||
expect(fetchSpy.mock.calls[0]?.[1]?.headers).toMatchObject({
|
||||
"User-Agent": expect.stringContaining(`pi/${VERSION}`),
|
||||
});
|
||||
const requested = new URL(String(fetchSpy.mock.calls[0]?.[0]));
|
||||
expect(requested.pathname).toBe("/api/models/providers/test-provider");
|
||||
expect(requested.searchParams.get("types")).toBe(REMOTE_CATALOG_MODEL_TYPES.join(","));
|
||||
});
|
||||
|
||||
it("overlays image and classifier models and drops unknown model types", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
chat: { ...model("chat"), type: "chat" },
|
||||
flux: {
|
||||
type: "image",
|
||||
id: "flux",
|
||||
name: "FLUX",
|
||||
api: "openrouter-images",
|
||||
provider: "test-provider",
|
||||
baseUrl: "https://example.test/v1",
|
||||
input: ["text"],
|
||||
output: ["image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
jev: {
|
||||
type: "classifier",
|
||||
id: "jev",
|
||||
name: "Jev",
|
||||
api: "typesafe-system-one",
|
||||
provider: "test-provider",
|
||||
baseUrl: "https://example.test/v1",
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 64000,
|
||||
},
|
||||
clip: { ...model("clip"), type: "video" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
const provider = testProvider();
|
||||
const store = new InMemoryModelsStore();
|
||||
await refreshProvider(provider, store);
|
||||
|
||||
const models = createModels({ modelsStore: store });
|
||||
models.setProvider(provider);
|
||||
expect(models.getAllModels("test-provider").map((entry) => entry.id)).toEqual(["static", "chat", "flux", "jev"]);
|
||||
expect(models.getModelOfType("image", "test-provider", "flux")?.type).toBe("image");
|
||||
expect(models.getModelOfType("classifier", "test-provider", "jev")?.type).toBe("classifier");
|
||||
expect(models.getModel("test-provider", "flux")).toBeUndefined();
|
||||
const stored = await store.read(provider.id);
|
||||
expect(stored?.models.map((entry) => entry.id)).toEqual(["chat", "flux", "jev"]);
|
||||
});
|
||||
|
||||
it("prefers the newer of the generated and remote catalogs", async () => {
|
||||
@@ -239,3 +295,53 @@ describe("remote catalog provider", () => {
|
||||
expect(await store.read(provider.id)).toMatchObject({ models: [], checkedAt: expect.any(Number) });
|
||||
});
|
||||
});
|
||||
|
||||
describe("personalized catalog provider", () => {
|
||||
const imageModel: ImageModel<"openrouter-images"> = {
|
||||
type: "image",
|
||||
id: "image",
|
||||
name: "image",
|
||||
api: "openrouter-images",
|
||||
provider: "test-provider",
|
||||
baseUrl: "https://example.test/v1",
|
||||
input: ["text"],
|
||||
output: ["image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
};
|
||||
|
||||
it("keeps the saved list and saves nothing when personalization fails", async () => {
|
||||
// Saving the catalog without personalization would replace the key's list with every model.
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("not found", { status: 404 }));
|
||||
const store = new InMemoryModelsStore();
|
||||
const saved = { models: [model("allowed")], checkedAt: 1 };
|
||||
await store.write("test-provider", saved);
|
||||
const personalize: PersonalizeCatalog = async () => {
|
||||
throw new Error("user models request failed");
|
||||
};
|
||||
const provider = withPersonalizedCatalog(builtinProvider(), personalize);
|
||||
|
||||
await expect(refreshProvider(provider, store)).rejects.toThrow("user models request failed");
|
||||
expect(provider.getModels().map((entry) => entry.id)).toEqual(["allowed"]);
|
||||
expect(await store.read(provider.id)).toEqual(saved);
|
||||
});
|
||||
|
||||
it("restores a saved list as is, and falls back to the built-in models only when it is empty", async () => {
|
||||
const personalize: PersonalizeCatalog = async () => {
|
||||
throw new Error("not used offline");
|
||||
};
|
||||
const store = new InMemoryModelsStore();
|
||||
const provider = withPersonalizedCatalog(builtinProvider(), personalize);
|
||||
|
||||
// pi versions before per-key lists stored the plain pi.dev catalog here, and saved an empty list
|
||||
// after a missing pi.dev catalog or a failed first request. Only such an entry can be empty.
|
||||
await store.write(provider.id, { models: [], checkedAt: 1 });
|
||||
await refreshProvider(provider, store, { allowNetwork: false });
|
||||
expect(provider.getModels().map((entry) => entry.id)).toEqual(["static"]);
|
||||
|
||||
// A key that may use no chat models keeps its image models and shows no chat models.
|
||||
await store.write(provider.id, { models: [imageModel], checkedAt: 1 });
|
||||
await refreshProvider(provider, store, { allowNetwork: false });
|
||||
expect(provider.getModels()).toEqual([]);
|
||||
expect(provider.getAllModels?.().map((entry) => entry.id)).toEqual(["image"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user