fec0c3d12f
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.
209 lines
8.0 KiB
TypeScript
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);
|
|
});
|
|
});
|