feat(coding-agent): replace model registry with model runtime
Move provider auth and OAuth flows onto pi-ai Models, compose models.json and extension overlays through ModelRuntime, and retain ModelRegistry as an extension compatibility facade.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { envApiKeyAuth } from "../src/auth/helpers.ts";
|
||||
import type { AuthContext } from "../src/auth/types.ts";
|
||||
import type { AuthContext, AuthEvent } 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";
|
||||
@@ -49,40 +49,69 @@ describe("builtin providers", () => {
|
||||
models.setProvider(anthropicProvider());
|
||||
const model = models.getModel("anthropic", "claude-haiku-4-5")!;
|
||||
|
||||
const result = await models.getAuth(model);
|
||||
const result = await models.getAuth(model.provider);
|
||||
expect(result?.auth.apiKey).toBe("oauth-token");
|
||||
expect(result?.source).toBe("ANTHROPIC_OAUTH_TOKEN");
|
||||
});
|
||||
|
||||
it("runs provider-owned Bedrock bearer token and AWS profile login flows", async () => {
|
||||
const auth = amazonBedrockProvider().auth.apiKey!;
|
||||
const bearerAnswers = ["bearer-token", "bedrock-token"];
|
||||
expect(
|
||||
await auth.login?.({
|
||||
prompt: async () => bearerAnswers.shift()!,
|
||||
notify: () => {},
|
||||
}),
|
||||
).toEqual({ type: "api_key", key: "bedrock-token" });
|
||||
|
||||
const profileAnswers = ["aws-profile", "work"];
|
||||
const events: AuthEvent[] = [];
|
||||
expect(
|
||||
await auth.login?.({
|
||||
prompt: async () => profileAnswers.shift()!,
|
||||
notify: (event) => events.push(event),
|
||||
}),
|
||||
).toEqual({ type: "api_key", env: { AWS_PROFILE: "work" } });
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "info",
|
||||
links: [expect.objectContaining({ label: "AWS credential provider chain" })],
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
await auth.resolve({
|
||||
ctx: fakeAuthContext({}),
|
||||
credential: { type: "api_key", env: { AWS_PROFILE: "work" } },
|
||||
}),
|
||||
).toMatchObject({ auth: {}, env: { AWS_PROFILE: "work" } });
|
||||
});
|
||||
|
||||
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 = models.getModels("amazon-bedrock")[0];
|
||||
|
||||
const result = await models.getAuth(model);
|
||||
const result = await models.getAuth(model.provider);
|
||||
expect(result?.auth).toEqual({});
|
||||
expect(result?.source).toBe("AWS_PROFILE");
|
||||
|
||||
const unconfigured = createModels({ authContext: fakeAuthContext({}) });
|
||||
unconfigured.setProvider(amazonBedrockProvider());
|
||||
expect(await unconfigured.getAuth(model)).toBeUndefined();
|
||||
expect(await unconfigured.getAuth(model.provider)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires Cloudflare Workers AI account config and returns scoped env", async () => {
|
||||
const missingAccount = createModels({ authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key" }) });
|
||||
missingAccount.setProvider(cloudflareWorkersAIProvider());
|
||||
const model = missingAccount.getModels("cloudflare-workers-ai")[0];
|
||||
expect(await missingAccount.getAuth(model)).toBeUndefined();
|
||||
expect(await missingAccount.getAuth(model.provider)).toBeUndefined();
|
||||
|
||||
const configured = createModels({
|
||||
authContext: fakeAuthContext({ CLOUDFLARE_API_KEY: "cf-key", CLOUDFLARE_ACCOUNT_ID: "account-id" }),
|
||||
});
|
||||
configured.setProvider(cloudflareWorkersAIProvider());
|
||||
const result = await configured.getAuth(model);
|
||||
expect(result?.auth).toEqual({
|
||||
apiKey: "cf-key",
|
||||
baseUrl: "https://api.cloudflare.com/client/v4/accounts/account-id/ai/v1",
|
||||
});
|
||||
const result = await configured.getAuth(model.provider);
|
||||
expect(result?.auth).toEqual({ apiKey: "cf-key" });
|
||||
expect(result?.env).toEqual({ CLOUDFLARE_ACCOUNT_ID: "account-id" });
|
||||
});
|
||||
|
||||
@@ -92,7 +121,7 @@ describe("builtin providers", () => {
|
||||
});
|
||||
missingGateway.setProvider(cloudflareAIGatewayProvider());
|
||||
const model = missingGateway.getModels("cloudflare-ai-gateway")[0];
|
||||
expect(await missingGateway.getAuth(model)).toBeUndefined();
|
||||
expect(await missingGateway.getAuth(model.provider)).toBeUndefined();
|
||||
|
||||
const configured = createModels({
|
||||
authContext: fakeAuthContext({
|
||||
@@ -102,14 +131,13 @@ describe("builtin providers", () => {
|
||||
}),
|
||||
});
|
||||
configured.setProvider(cloudflareAIGatewayProvider());
|
||||
const result = await configured.getAuth(model);
|
||||
const result = await configured.getAuth(model.provider);
|
||||
expect(result?.auth).toEqual({
|
||||
headers: {
|
||||
"cf-aig-authorization": "Bearer cf-key",
|
||||
Authorization: null,
|
||||
"x-api-key": null,
|
||||
},
|
||||
baseUrl: "https://gateway.ai.cloudflare.com/v1/account-id/gateway-id/anthropic",
|
||||
});
|
||||
expect(result?.env).toEqual({
|
||||
CLOUDFLARE_ACCOUNT_ID: "account-id",
|
||||
@@ -117,6 +145,47 @@ describe("builtin providers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("runs provider-owned Vertex API key and ADC login flows", async () => {
|
||||
const auth = googleVertexProvider().auth.apiKey!;
|
||||
const keyAnswers = ["api-key", "vertex-key"];
|
||||
expect(
|
||||
await auth.login?.({
|
||||
prompt: async () => keyAnswers.shift()!,
|
||||
notify: () => {},
|
||||
}),
|
||||
).toEqual({ type: "api_key", key: "vertex-key" });
|
||||
|
||||
const adcAnswers = ["adc", "project-id", "us-central1"];
|
||||
const events: AuthEvent[] = [];
|
||||
expect(
|
||||
await auth.login?.({
|
||||
prompt: async () => adcAnswers.shift()!,
|
||||
notify: (event) => events.push(event),
|
||||
}),
|
||||
).toEqual({
|
||||
type: "api_key",
|
||||
env: { GOOGLE_CLOUD_PROJECT: "project-id", GOOGLE_CLOUD_LOCATION: "us-central1" },
|
||||
});
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "info",
|
||||
links: [expect.objectContaining({ label: "Application Default Credentials" })],
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
await auth.resolve({
|
||||
ctx: fakeAuthContext({}, ["~/.config/gcloud/application_default_credentials.json"]),
|
||||
credential: {
|
||||
type: "api_key",
|
||||
env: { GOOGLE_CLOUD_PROJECT: "project-id", GOOGLE_CLOUD_LOCATION: "us-central1" },
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
auth: {},
|
||||
env: { GOOGLE_CLOUD_PROJECT: "project-id", GOOGLE_CLOUD_LOCATION: "us-central1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves vertex via ADC file plus project and location", async () => {
|
||||
const adc = "~/.config/gcloud/application_default_credentials.json";
|
||||
const configured = createModels({
|
||||
@@ -125,40 +194,38 @@ describe("builtin providers", () => {
|
||||
configured.setProvider(googleVertexProvider());
|
||||
const model = configured.getModels("google-vertex")[0];
|
||||
|
||||
const result = await configured.getAuth(model);
|
||||
const result = await configured.getAuth(model.provider);
|
||||
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();
|
||||
expect(await partial.getAuth(model.provider)).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");
|
||||
expect((await keyed.getAuth(model.provider))?.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" }) });
|
||||
const second = await auth.resolve({ 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();
|
||||
expect(await auth.resolve({ ctx: fakeAuthContext({}) })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("login prompts for a secret and returns an api-key credential", async () => {
|
||||
|
||||
Reference in New Issue
Block a user