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.
This commit is contained in:
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const aiEntryUrl = new URL("../src/index.ts", import.meta.url).href;
|
||||
const providersAllUrl = new URL("../src/providers/all.ts", import.meta.url).href;
|
||||
|
||||
const SDK_SPECIFIERS = [
|
||||
"@anthropic-ai/sdk",
|
||||
@@ -66,6 +67,15 @@ describe("lazy provider module loading", () => {
|
||||
expect(result.loadedSpecifiers).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not load provider SDKs when building all builtin providers", () => {
|
||||
const result = runProbe(`
|
||||
const all = await import(${JSON.stringify(providersAllUrl)});
|
||||
const models = all.builtinModels();
|
||||
await models.getModels();
|
||||
`);
|
||||
expect(result.loadedSpecifiers).toEqual([]);
|
||||
});
|
||||
|
||||
it("loads only the Anthropic SDK when streaming through the lazy API wrapper", () => {
|
||||
const result = runProbe(`
|
||||
const model = {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -2,52 +2,20 @@
|
||||
// Run from packages/ai: node test/scratch.ts
|
||||
// Requires ANTHROPIC_API_KEY.
|
||||
|
||||
import { anthropicMessagesApi } from "../src/api/anthropic-messages.lazy.ts";
|
||||
import { createModels, getModels, type Provider } from "../src/models.ts";
|
||||
import { createModels } from "../src/models.ts";
|
||||
import { anthropicProvider } from "../src/providers/anthropic.ts";
|
||||
import type { Context } from "../src/types.ts";
|
||||
|
||||
const anthropicApi = anthropicMessagesApi();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Define a provider. In the final design this comes from
|
||||
// `@earendil-works/pi-ai/providers/anthropic` as `anthropicProvider()`;
|
||||
// until Phase 3 lands we wire it by hand from existing parts.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const anthropic: Provider<"anthropic-messages"> = {
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
baseUrl: "https://api.anthropic.com/v1",
|
||||
|
||||
auth: {
|
||||
apiKey: {
|
||||
name: "Anthropic API key",
|
||||
resolve: async ({ ctx, credential }) => {
|
||||
// stored credential (from a /login flow) wins, env is the ambient fallback
|
||||
const key = credential?.key ?? (await ctx.env("ANTHROPIC_API_KEY"));
|
||||
if (!key) return undefined;
|
||||
return { auth: { apiKey: key }, source: credential ? "stored credential" : "ANTHROPIC_API_KEY" };
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// static catalog source; a dynamic provider would fetch here
|
||||
getModels: async () => getModels("anthropic"),
|
||||
|
||||
// shared lazy API implementation (loads the SDK on first request)
|
||||
stream: anthropicApi.stream,
|
||||
streamSimple: anthropicApi.streamSimple,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Build a Models runtime and register the provider.
|
||||
// 1. Build a Models runtime and register a built-in provider factory.
|
||||
// (Apps wanting everything use `builtinModels()` from providers/all.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const models = createModels();
|
||||
models.setProvider(anthropic);
|
||||
models.setProvider(anthropicProvider());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Look up a model and check auth.
|
||||
// 2. Look up a model and check auth.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const model = await models.getModel("anthropic", "claude-haiku-4-5");
|
||||
@@ -64,14 +32,14 @@ const context: Context = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Simple completion (request-level auth resolution happens inside).
|
||||
// 3. Simple completion (request-level auth resolution happens inside).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const message = await models.completeSimple(model, context);
|
||||
console.log(`completeSimple -> [${message.stopReason}]`, message.content);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Streaming with deltas.
|
||||
// 4. Streaming with deltas.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
context.messages.push(message, {
|
||||
|
||||
Reference in New Issue
Block a user