Files
pi_harness/packages/ai/test/providers.test.ts
T
Mario Zechner fec0c3d12f feat(ai): provider factories, per-provider catalogs, createProvider (phase 3)
Auth helpers in src/auth/helpers.ts: envApiKeyAuth() (stored key wins,
then env vars in order, with secret-prompt login) and lazyOAuth()
(flow loads on first use through bundler-opaque dynamic imports in
utils/oauth/load.ts; the OAuthAuth flow exports land in phase 4).
There is no OAuth factory toggle: providers that support OAuth always
attach it, advertising costs nothing until login/refresh runs.

createProvider() in models.ts builds providers from parts: single API
implementation or a map dispatched on model.api (mixed-API providers
like opencode and github-copilot); unknown api yields a stream error.

generate-models.ts now emits one providers/<id>.models.ts catalog per
provider (35 files, biome-excluded like models.generated.ts) and
models.generated.ts becomes a generated aggregator, so importing one
provider factory pulls one catalog. Typed getModel globals unchanged.

One factory per built-in provider under src/providers/: envApiKeyAuth
for standard providers, OAuth for anthropic/openai-codex/github-copilot,
ambient ApiKeyAuth for amazon-bedrock (AWS env/profile/IAM) and
google-vertex (explicit key or ADC+project+location).

providers/all.ts: builtinProviders(), builtinModels(), getBuiltin*
re-exports. fauxProvider() factory returns a real Provider for tests;
legacy registerFauxProvider() unchanged.
2026-06-10 20:33:20 +02:00

209 lines
8.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { envApiKeyAuth } from "../src/auth/helpers.ts";
import type { AuthContext } from "../src/auth/types.ts";
import { createModels, createProvider } from "../src/models.ts";
import { builtinModels, builtinProviders } from "../src/providers/all.ts";
import { amazonBedrockProvider } from "../src/providers/amazon-bedrock.ts";
import { anthropicProvider } from "../src/providers/anthropic.ts";
import { fauxAssistantMessage, fauxProvider } from "../src/providers/faux.ts";
import { googleVertexProvider } from "../src/providers/google-vertex.ts";
import type { Api, Context, Model, ProviderStreams } from "../src/types.ts";
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
function fakeAuthContext(env: Record<string, string>, files: string[] = []): AuthContext {
return {
env: async (name) => env[name],
fileExists: async (path) => files.includes(path),
};
}
const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
describe("builtin providers", () => {
it("builtinModels registers every builtin provider with models", async () => {
const models = builtinModels();
const providers = models.getProviders();
expect(providers.length).toBe(builtinProviders().length);
expect(providers.map((p) => p.id)).toContain("anthropic");
const anthropic = await models.getModel("anthropic", "claude-haiku-4-5");
expect(anthropic?.api).toBe("anthropic-messages");
const all = await models.getModels();
expect(all.length).toBeGreaterThan(500);
// every provider lists at least one model and owns its models
for (const provider of providers) {
const list = await models.getModels(provider.id);
expect(list.length).toBeGreaterThan(0);
expect(list.every((m) => m.provider === provider.id)).toBe(true);
}
});
it("resolves anthropic auth from env with OAuth token precedence", async () => {
const models = createModels({
authContext: fakeAuthContext({ ANTHROPIC_API_KEY: "key", ANTHROPIC_OAUTH_TOKEN: "oauth-token" }),
});
models.setProvider(anthropicProvider());
const model = (await models.getModel("anthropic", "claude-haiku-4-5"))!;
const result = await models.getAuth(model);
expect(result?.auth.apiKey).toBe("oauth-token");
expect(result?.source).toBe("ANTHROPIC_OAUTH_TOKEN");
});
it("reports bedrock as configured from ambient AWS credentials without an api key", async () => {
const models = createModels({ authContext: fakeAuthContext({ AWS_PROFILE: "dev" }) });
models.setProvider(amazonBedrockProvider());
const model = (await models.getModels("amazon-bedrock"))[0];
const result = await models.getAuth(model);
expect(result?.auth).toEqual({});
expect(result?.source).toBe("AWS_PROFILE");
const unconfigured = createModels({ authContext: fakeAuthContext({}) });
unconfigured.setProvider(amazonBedrockProvider());
expect(await unconfigured.getAuth(model)).toBeUndefined();
});
it("resolves vertex via ADC file plus project and location", async () => {
const adc = "~/.config/gcloud/application_default_credentials.json";
const configured = createModels({
authContext: fakeAuthContext({ GOOGLE_CLOUD_PROJECT: "proj", GOOGLE_CLOUD_LOCATION: "us-central1" }, [adc]),
});
configured.setProvider(googleVertexProvider());
const model = (await configured.getModels("google-vertex"))[0];
const result = await configured.getAuth(model);
expect(result?.auth).toEqual({});
expect(result?.source).toContain("application default");
// ADC without project/location is not configured
const partial = createModels({ authContext: fakeAuthContext({ GOOGLE_CLOUD_PROJECT: "proj" }, [adc]) });
partial.setProvider(googleVertexProvider());
expect(await partial.getAuth(model)).toBeUndefined();
// explicit key wins over ADC
const keyed = createModels({ authContext: fakeAuthContext({ GOOGLE_CLOUD_API_KEY: "vertex-key" }) });
keyed.setProvider(googleVertexProvider());
expect((await keyed.getAuth(model))?.auth.apiKey).toBe("vertex-key");
});
});
describe("envApiKeyAuth", () => {
it("prefers the stored credential key and falls back through env vars in order", async () => {
const auth = envApiKeyAuth("Test key", ["FIRST_KEY", "SECOND_KEY"]);
const model = { provider: "p1" } as Model<Api>;
const stored = await auth.resolve({
model,
ctx: fakeAuthContext({ FIRST_KEY: "env" }),
credential: { type: "api-key", key: "stored" },
});
expect(stored?.auth.apiKey).toBe("stored");
expect(stored?.source).toBe("stored credential");
const second = await auth.resolve({ model, ctx: fakeAuthContext({ SECOND_KEY: "second" }) });
expect(second?.auth.apiKey).toBe("second");
expect(second?.source).toBe("SECOND_KEY");
expect(await auth.resolve({ model, ctx: fakeAuthContext({}) })).toBeUndefined();
});
it("login prompts for a secret and returns an api-key credential", async () => {
const auth = envApiKeyAuth("Test key", ["TEST_KEY"]);
const credential = await auth.login?.({
prompt: async (prompt) => {
expect(prompt.type).toBe("secret");
return "entered-key";
},
notify: () => {},
});
expect(credential).toEqual({ type: "api-key", key: "entered-key" });
});
});
describe("createProvider", () => {
function recordingStreams(label: string, calls: string[]): ProviderStreams {
const respond = (model: Model<Api>) => {
calls.push(`${label}:${model.id}`);
const stream = new AssistantMessageEventStream();
const message = fauxAssistantMessage("ok");
stream.push({ type: "start", partial: message });
stream.push({ type: "done", reason: "stop", message });
stream.end(message);
return stream;
};
return { stream: respond, streamSimple: respond };
}
function testModel(api: string, id: string): Model<Api> {
return {
id,
name: id,
api,
provider: "mixed",
baseUrl: "https://example.test/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 10000,
maxTokens: 1000,
};
}
it("dispatches on model.api for mixed-API providers", async () => {
const calls: string[] = [];
const provider = createProvider({
id: "mixed",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [testModel("api-a", "model-a"), testModel("api-b", "model-b")],
api: { "api-a": recordingStreams("a", calls), "api-b": recordingStreams("b", calls) },
});
const models = createModels();
models.setProvider(provider);
await models.completeSimple(testModel("api-a", "model-a"), context);
await models.completeSimple(testModel("api-b", "model-b"), context);
expect(calls).toEqual(["a:model-a", "b:model-b"]);
});
it("produces a stream error for a model whose api has no implementation", async () => {
const provider = createProvider({
id: "mixed",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: [testModel("api-a", "model-a")],
api: { "api-a": recordingStreams("a", []) },
});
const result = await provider.streamSimple(testModel("api-ghost", "model-x"), context).result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("no API implementation");
});
it("supports async model listers", async () => {
const provider = createProvider({
id: "dynamic",
auth: { apiKey: { name: "Test", resolve: async () => ({ auth: {} }) } },
models: async () => [testModel("api-a", "listed")],
api: recordingStreams("a", []),
});
const models = await provider.getModels();
expect(models.map((m) => m.id)).toEqual(["listed"]);
});
});
describe("fauxProvider", () => {
it("streams queued responses through a Models collection", async () => {
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses([fauxAssistantMessage("hello from faux")]);
const model = (await models.getModels(faux.provider.id))[0];
const result = await models.completeSimple(model, context);
expect(result.stopReason).toBe("stop");
expect(result.content).toEqual([{ type: "text", text: "hello from faux" }]);
expect(faux.state.callCount).toBe(1);
});
});