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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
|
||||
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
|
||||
import { anthropicOAuth, loginAnthropic, refreshAnthropicToken } from "../src/utils/oauth/anthropic.ts";
|
||||
|
||||
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -53,18 +53,16 @@ describe.sequential("Anthropic OAuth", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const credentials = await loginAnthropic({
|
||||
onAuth: (info) => {
|
||||
authUrl = info.url;
|
||||
const credentials = await anthropicOAuth.login({
|
||||
notify: (event) => {
|
||||
if (event.type === "auth_url") authUrl = event.url;
|
||||
},
|
||||
onPrompt: async () => "",
|
||||
onManualCodeInput: async () => {
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
|
||||
const url = new URL(authUrl);
|
||||
const state = url.searchParams.get("state");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
if (!state || !redirectUri) {
|
||||
throw new Error("Missing OAuth state or redirect_uri in auth URL");
|
||||
}
|
||||
if (!state || !redirectUri) throw new Error("Missing OAuth state or redirect_uri in auth URL");
|
||||
return `${redirectUri}?code=manual-code&state=${state}`;
|
||||
},
|
||||
});
|
||||
@@ -91,7 +89,12 @@ describe.sequential("Anthropic OAuth", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const credentials = await refreshAnthropicToken("refresh-token");
|
||||
const credentials = await anthropicOAuth.refresh({
|
||||
type: "oauth",
|
||||
access: "old-access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 0,
|
||||
});
|
||||
|
||||
expect(credentials.access).toBe("new-access-token");
|
||||
expect(credentials.refresh).toBe("new-refresh-token");
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cloudflareStreams } from "../src/providers/cloudflare-stream.ts";
|
||||
import type { Api, Context, Model } from "../src/types.ts";
|
||||
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
||||
|
||||
const model: Model<Api> = {
|
||||
id: "model",
|
||||
name: "model",
|
||||
api: "openai-completions",
|
||||
provider: "cloudflare-ai-gateway",
|
||||
baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
};
|
||||
|
||||
const context: Context = { messages: [] };
|
||||
|
||||
describe("Cloudflare provider streams", () => {
|
||||
it("materializes the model endpoint before dispatch", () => {
|
||||
const captured: string[] = [];
|
||||
const streams = cloudflareStreams({
|
||||
stream: (requestModel) => {
|
||||
captured.push(requestModel.baseUrl);
|
||||
return new AssistantMessageEventStream();
|
||||
},
|
||||
streamSimple: (requestModel) => {
|
||||
captured.push(requestModel.baseUrl);
|
||||
return new AssistantMessageEventStream();
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
CLOUDFLARE_ACCOUNT_ID: "account",
|
||||
CLOUDFLARE_GATEWAY_ID: "gateway",
|
||||
};
|
||||
|
||||
streams.stream(model, context, { env });
|
||||
streams.streamSimple(model, context, { env });
|
||||
|
||||
expect(captured).toEqual([
|
||||
"https://gateway.ai.cloudflare.com/v1/account/gateway/openai",
|
||||
"https://gateway.ai.cloudflare.com/v1/account/gateway/openai",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps placeholders when the provider env does not resolve them", () => {
|
||||
let captured: string | undefined;
|
||||
const streams = cloudflareStreams({
|
||||
stream: (requestModel) => {
|
||||
captured = requestModel.baseUrl;
|
||||
return new AssistantMessageEventStream();
|
||||
},
|
||||
streamSimple: (requestModel) => {
|
||||
captured = requestModel.baseUrl;
|
||||
return new AssistantMessageEventStream();
|
||||
},
|
||||
});
|
||||
|
||||
streams.streamSimple(model, context, {});
|
||||
|
||||
expect(captured).toBe(model.baseUrl);
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { Type } from "typebox";
|
||||
import { AuthStorage } from "../../coding-agent/src/core/auth-storage.ts";
|
||||
import { ModelRuntime } from "../../coding-agent/src/core/model-runtime.ts";
|
||||
import {
|
||||
closeOpenAICodexWebSocketSessions,
|
||||
getOpenAICodexWebSocketDebugStats,
|
||||
@@ -166,8 +166,9 @@ async function main(): Promise<void> {
|
||||
const model = getModel("openai-codex", "gpt-5.5") as Model<"openai-codex-responses"> | undefined;
|
||||
if (!model) throw new Error("Model openai-codex/gpt-5.5 not found");
|
||||
const modelWithMaxTokens = { ...model, maxTokens: args.maxTokens };
|
||||
const authStorage = AuthStorage.create();
|
||||
const apiKey = (await authStorage.getApiKey("openai-codex")) ?? (await authStorage.getApiKey("openai"));
|
||||
const modelRuntime = await ModelRuntime.create();
|
||||
const apiKey =
|
||||
(await modelRuntime.getAuth("openai-codex"))?.auth.apiKey ?? (await modelRuntime.getAuth("openai"))?.auth.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error("No OpenAI Codex API key found in coding-agent auth storage.");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { getModels } from "../src/compat.ts";
|
||||
import {
|
||||
githubCopilotOAuthProvider,
|
||||
loginGitHubCopilot,
|
||||
refreshGitHubCopilotToken,
|
||||
} from "../src/utils/oauth/github-copilot.ts";
|
||||
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
|
||||
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
|
||||
import { createModels } from "../src/models.ts";
|
||||
import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
|
||||
|
||||
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -28,6 +26,33 @@ function getUrl(input: unknown): string {
|
||||
throw new Error(`Unsupported fetch input: ${String(input)}`);
|
||||
}
|
||||
|
||||
function loginGitHubCopilotForTest(options: {
|
||||
onDeviceCode(info: {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}): void;
|
||||
onPrompt(prompt: { message: string; placeholder?: string; allowEmpty?: boolean }): Promise<string>;
|
||||
onProgress?(message: string): void;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
return githubCopilotOAuth.login({
|
||||
signal: options.signal,
|
||||
prompt: (prompt) => {
|
||||
if (prompt.type !== "text") throw new Error(`Unexpected prompt: ${prompt.type}`);
|
||||
return options.onPrompt({ message: prompt.message, placeholder: prompt.placeholder, allowEmpty: true });
|
||||
},
|
||||
notify: (event) => {
|
||||
if (event.type === "device_code") {
|
||||
const { type: _, ...info } = event;
|
||||
options.onDeviceCode(info);
|
||||
}
|
||||
if (event.type === "progress") options.onProgress?.(event.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("GitHub Copilot OAuth device flow", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -76,13 +101,19 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const credentials = await refreshGitHubCopilotToken("ghu_refresh_token");
|
||||
const credentials = await githubCopilotOAuth.refresh({
|
||||
type: "oauth",
|
||||
access: "old-access-token",
|
||||
refresh: "ghu_refresh_token",
|
||||
expires: 0,
|
||||
});
|
||||
expect(credentials.availableModelIds).toEqual(["gpt-4.1"]);
|
||||
|
||||
const modifiedModels = githubCopilotOAuthProvider.modifyModels?.(getModels("github-copilot"), credentials) ?? [];
|
||||
expect(modifiedModels.filter((model) => model.provider === "github-copilot").map((model) => model.id)).toEqual([
|
||||
"gpt-4.1",
|
||||
]);
|
||||
const store = new InMemoryCredentialStore();
|
||||
await store.modify("github-copilot", async () => ({ ...credentials, type: "oauth" }));
|
||||
const models = createModels({ credentials: store });
|
||||
models.setProvider(githubCopilotProvider());
|
||||
expect((await models.getAvailable("github-copilot")).map((model) => model.id)).toEqual(["gpt-4.1"]);
|
||||
});
|
||||
|
||||
it("reports device-code details through onDeviceCode", async () => {
|
||||
@@ -127,7 +158,7 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const onDeviceCode = vi.fn();
|
||||
const loginPromise = loginGitHubCopilot({
|
||||
const loginPromise = loginGitHubCopilotForTest({
|
||||
onDeviceCode,
|
||||
onPrompt: async () => "",
|
||||
});
|
||||
@@ -166,7 +197,7 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
|
||||
const onDeviceCode = vi.fn();
|
||||
await expect(
|
||||
loginGitHubCopilot({
|
||||
loginGitHubCopilotForTest({
|
||||
onDeviceCode,
|
||||
onPrompt: async () => "",
|
||||
}),
|
||||
@@ -220,7 +251,7 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const onDeviceCode = vi.fn();
|
||||
const loginPromise = loginGitHubCopilot({
|
||||
const loginPromise = loginGitHubCopilotForTest({
|
||||
onDeviceCode,
|
||||
onPrompt: async () => "",
|
||||
});
|
||||
@@ -308,7 +339,7 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const loginPromise = loginGitHubCopilot({
|
||||
const loginPromise = loginGitHubCopilotForTest({
|
||||
onDeviceCode: () => {},
|
||||
onPrompt: async () => "",
|
||||
onProgress: () => {},
|
||||
@@ -382,7 +413,7 @@ describe("GitHub Copilot OAuth device flow", () => {
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const loginPromise = loginGitHubCopilot({
|
||||
const loginPromise = loginGitHubCopilotForTest({
|
||||
onDeviceCode: () => {},
|
||||
onPrompt: async () => "",
|
||||
});
|
||||
|
||||
@@ -51,10 +51,10 @@ function testProvider(input: {
|
||||
auth: {
|
||||
apiKey: {
|
||||
name: "Test key",
|
||||
resolve: async ({ ctx }) => {
|
||||
resolve: async ({ ctx, credential }) => {
|
||||
if (!input.envVar) return { auth: {} };
|
||||
const key = await ctx.env(input.envVar);
|
||||
return key ? { auth: { apiKey: key }, source: input.envVar } : undefined;
|
||||
const key = credential?.key ?? (await ctx.env(input.envVar));
|
||||
return key ? { auth: { apiKey: key }, source: credential ? "stored" : input.envVar } : undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -93,6 +93,8 @@ describe("ImagesModels", () => {
|
||||
const model = models.getModel("p1", "model-a")!;
|
||||
|
||||
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key");
|
||||
expect((await models.getAuth(model.provider))?.auth.apiKey).toBe("env-key");
|
||||
expect((await models.getAuth(model, { apiKey: "explicit-key" }))?.auth.apiKey).toBe("explicit-key");
|
||||
|
||||
const result = await models.generateImages(model, context);
|
||||
expect(result.stopReason).toBe("stop");
|
||||
|
||||
@@ -106,6 +106,22 @@ function testOAuth(overrides?: Partial<OAuthAuth>): OAuthAuth {
|
||||
}
|
||||
|
||||
describe("Models runtime", () => {
|
||||
it("enumerates credential metadata without exposing secrets", async () => {
|
||||
const credentials = new InMemoryCredentialStore();
|
||||
await credentials.modify("api-provider", async () => ({ type: "api_key", key: "secret" }));
|
||||
await credentials.modify("oauth-provider", async () => ({
|
||||
type: "oauth",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
}));
|
||||
|
||||
expect(await credentials.list()).toEqual([
|
||||
{ providerId: "api-provider", type: "api_key" },
|
||||
{ providerId: "oauth-provider", type: "oauth" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies request-wide pricing tiers above the configured input threshold", () => {
|
||||
const model = testModel("openai", "gpt-5.6-sol");
|
||||
model.cost = {
|
||||
@@ -246,8 +262,10 @@ describe("Models runtime", () => {
|
||||
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key"), oauth: testOAuth() } }));
|
||||
const model = testModel("p1", "model-a");
|
||||
|
||||
// nothing stored: ambient env resolves
|
||||
// model and provider-id overloads resolve the same provider-scoped auth
|
||||
expect((await models.getAuth(model))?.auth.apiKey).toBe("env-key");
|
||||
expect((await models.getAuth(model.provider))?.auth.apiKey).toBe("env-key");
|
||||
expect((await models.getAuth(model, { apiKey: "explicit-key" }))?.auth.apiKey).toBe("explicit-key");
|
||||
|
||||
// stored oauth credential (persisted via the single write path): beats ambient env
|
||||
await credentials.modify("p1", async () => ({
|
||||
@@ -256,17 +274,69 @@ describe("Models runtime", () => {
|
||||
refresh: "r",
|
||||
expires: Date.now() + 100000,
|
||||
}));
|
||||
const resolution = await models.getAuth(model);
|
||||
const resolution = await models.getAuth(model.provider);
|
||||
expect(resolution?.auth.apiKey).toBe("oauth-token");
|
||||
expect(resolution?.source).toBe("OAuth");
|
||||
|
||||
// stored api-key credential resolves through apiKey auth, beats env
|
||||
await credentials.modify("p1", async () => ({ type: "api_key", key: "stored-key" }));
|
||||
const apiKeyResolution = await models.getAuth(model);
|
||||
const apiKeyResolution = await models.getAuth(model.provider);
|
||||
expect(apiKeyResolution?.auth.apiKey).toBe("stored-key");
|
||||
expect(apiKeyResolution?.source).toBe("stored");
|
||||
});
|
||||
|
||||
it("checks provider auth without refreshing OAuth and filters available models", async () => {
|
||||
const credentials = new InMemoryCredentialStore();
|
||||
let refreshes = 0;
|
||||
const models = createModels({ credentials });
|
||||
models.setProvider(testProvider({ id: "ambient", auth: { apiKey: envKeyAuth("env-key") } }));
|
||||
models.setProvider(testProvider({ id: "missing", auth: { apiKey: envKeyAuth(undefined) } }));
|
||||
models.setProvider(
|
||||
testProvider({
|
||||
id: "oauth",
|
||||
auth: {
|
||||
oauth: testOAuth({
|
||||
refresh: async (credential) => {
|
||||
refreshes++;
|
||||
return credential;
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
await credentials.modify("oauth", async () => ({
|
||||
type: "oauth",
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
}));
|
||||
|
||||
expect(await models.checkAuth("ambient")).toEqual({ source: "env", type: "api_key" });
|
||||
expect(await models.checkAuth("missing")).toBeUndefined();
|
||||
expect(await models.checkAuth("oauth")).toEqual({ source: "OAuth", type: "oauth" });
|
||||
expect(refreshes).toBe(0);
|
||||
expect((await models.getAvailable()).map((model) => model.provider)).toEqual(["ambient", "oauth"]);
|
||||
expect((await models.getAvailable("ambient")).map((model) => model.provider)).toEqual(["ambient"]);
|
||||
});
|
||||
|
||||
it("runs provider login and logout through the credential store", async () => {
|
||||
const credentials = new InMemoryCredentialStore();
|
||||
const apiKey = envKeyAuth(undefined);
|
||||
apiKey.login = async () => ({ type: "api_key", key: "logged-in" });
|
||||
const models = createModels({ credentials });
|
||||
models.setProvider(testProvider({ id: "p1", auth: { apiKey } }));
|
||||
|
||||
const credential = await models.login("p1", "api_key", {
|
||||
prompt: async () => "unused",
|
||||
notify: () => {},
|
||||
});
|
||||
expect(credential).toEqual({ type: "api_key", key: "logged-in" });
|
||||
expect(await credentials.read("p1")).toEqual(credential);
|
||||
|
||||
await models.logout("p1");
|
||||
expect(await credentials.read("p1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("a stored credential without a matching handler blocks ambient fallback", async () => {
|
||||
const credentials = new InMemoryCredentialStore();
|
||||
const models = createModels({ credentials });
|
||||
@@ -274,7 +344,7 @@ describe("Models runtime", () => {
|
||||
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key") } }));
|
||||
await credentials.modify("p1", async () => ({ type: "oauth", access: "a", refresh: "r", expires: 0 }));
|
||||
|
||||
expect(await models.getAuth(testModel("p1", "model-a"))).toBeUndefined();
|
||||
expect(await models.getAuth("p1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refreshes expired oauth credentials and persists the rotated credential", async () => {
|
||||
@@ -291,7 +361,7 @@ describe("Models runtime", () => {
|
||||
expires: 0,
|
||||
}));
|
||||
|
||||
const resolution = await models.getAuth(testModel("p1", "model-a"));
|
||||
const resolution = await models.getAuth("p1");
|
||||
expect(resolution?.auth.apiKey).toBe("new-token");
|
||||
expect(((await credentials.read("p1")) as { access: string }).access).toBe("new-token");
|
||||
});
|
||||
@@ -307,7 +377,7 @@ describe("Models runtime", () => {
|
||||
models.setProvider(testProvider({ id: "p1", auth: { oauth } }));
|
||||
await credentials.modify("p1", async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 }));
|
||||
|
||||
await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "oauth" });
|
||||
await expect(models.getAuth("p1")).rejects.toMatchObject({ code: "oauth" });
|
||||
// credential preserved for retry / re-login
|
||||
expect(((await credentials.read("p1")) as { access: string }).access).toBe("old");
|
||||
});
|
||||
@@ -328,7 +398,7 @@ describe("Models runtime", () => {
|
||||
models.setProvider(testProvider({ id: "p1", auth: { oauth } }));
|
||||
const model = testModel("p1", "model-a");
|
||||
|
||||
const [a, b] = await Promise.all([models.getAuth(model), models.getAuth(model)]);
|
||||
const [a, b] = await Promise.all([models.getAuth(model.provider), models.getAuth(model.provider)]);
|
||||
expect(refreshes).toBe(1);
|
||||
expect(a?.auth.apiKey).toBe("new-1");
|
||||
expect(b?.auth.apiKey).toBe("new-1");
|
||||
@@ -339,6 +409,7 @@ describe("Models runtime", () => {
|
||||
const base = new InMemoryCredentialStore();
|
||||
const credentials: CredentialStore = {
|
||||
read: (pid) => base.read(pid),
|
||||
list: () => base.list(),
|
||||
modify: (pid, fn) => {
|
||||
modifies++;
|
||||
return base.modify(pid, fn);
|
||||
@@ -354,7 +425,7 @@ describe("Models runtime", () => {
|
||||
const models = createModels({ credentials });
|
||||
models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } }));
|
||||
|
||||
expect((await models.getAuth(testModel("p1", "model-a")))?.auth.apiKey).toBe("valid");
|
||||
expect((await models.getAuth("p1"))?.auth.apiKey).toBe("valid");
|
||||
expect(modifies).toBe(0);
|
||||
});
|
||||
|
||||
@@ -364,16 +435,18 @@ describe("Models runtime", () => {
|
||||
read: async () => {
|
||||
throw new Error("disk on fire");
|
||||
},
|
||||
list: async () => [],
|
||||
modify: async () => undefined,
|
||||
delete: async () => {},
|
||||
};
|
||||
const models = createModels({ credentials: readFailing });
|
||||
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("env-key") } }));
|
||||
await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" });
|
||||
await expect(models.getAuth("p1")).rejects.toMatchObject({ code: "auth" });
|
||||
|
||||
// modify failure during refresh
|
||||
const modifyFailing: CredentialStore = {
|
||||
read: async () => ({ type: "oauth", access: "old", refresh: "r", expires: 0 }),
|
||||
list: async () => [{ providerId: "p1", type: "oauth" }],
|
||||
modify: async () => {
|
||||
throw new Error("disk on fire");
|
||||
},
|
||||
@@ -381,7 +454,7 @@ describe("Models runtime", () => {
|
||||
};
|
||||
const oauthModels = createModels({ credentials: modifyFailing });
|
||||
oauthModels.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } }));
|
||||
await expect(oauthModels.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" });
|
||||
await expect(oauthModels.getAuth("p1")).rejects.toMatchObject({ code: "auth" });
|
||||
});
|
||||
|
||||
it("wraps api-key auth failures in ModelsError", async () => {
|
||||
@@ -393,7 +466,7 @@ describe("Models runtime", () => {
|
||||
};
|
||||
const models = createModels();
|
||||
models.setProvider(testProvider({ id: "p1", auth: { apiKey: failing } }));
|
||||
await expect(models.getAuth(testModel("p1", "model-a"))).rejects.toMatchObject({ code: "auth" });
|
||||
await expect(models.getAuth("p1")).rejects.toMatchObject({ code: "auth" });
|
||||
});
|
||||
|
||||
it("uses explicit request api key and env during provider auth resolution", async () => {
|
||||
@@ -427,7 +500,7 @@ describe("Models runtime", () => {
|
||||
resolve: async () => ({
|
||||
auth: {
|
||||
apiKey: "resolved-key",
|
||||
headers: { "x-a": "auth", "x-b": "auth" },
|
||||
headers: { Authorization: "Bearer resolved-key", "x-a": "auth", "x-b": "auth" },
|
||||
baseUrl: "https://auth.test/v1",
|
||||
},
|
||||
}),
|
||||
@@ -438,12 +511,12 @@ describe("Models runtime", () => {
|
||||
|
||||
const result = await models.completeSimple(model, context, {
|
||||
apiKey: "explicit-key",
|
||||
headers: { "x-b": "explicit" },
|
||||
headers: { authorization: "Explicit token", "x-b": "explicit" },
|
||||
});
|
||||
expect(result.stopReason).toBe("stop");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].options?.apiKey).toBe("explicit-key");
|
||||
expect(calls[0].options?.headers).toEqual({ "x-a": "auth", "x-b": "explicit" });
|
||||
expect(calls[0].options?.headers).toEqual({ authorization: "Explicit token", "x-a": "auth", "x-b": "explicit" });
|
||||
expect(calls[0].model.baseUrl).toBe("https://auth.test/v1");
|
||||
|
||||
// without explicit options, resolved auth applies
|
||||
@@ -452,6 +525,36 @@ describe("Models runtime", () => {
|
||||
expect(calls[1].options?.apiKey).toBe("resolved-key");
|
||||
});
|
||||
|
||||
it("adds model headers only for model auth and transforms assembled headers once", async () => {
|
||||
const calls: ProviderCall[] = [];
|
||||
const models = createModels();
|
||||
models.setProvider(testProvider({ id: "p1", auth: { apiKey: envKeyAuth("key") }, calls }));
|
||||
const model = testModel("p1", "model-a");
|
||||
model.headers = { "x-model": "model", "x-shared": "model" };
|
||||
|
||||
expect((await models.getAuth("p1"))?.auth.headers).toBeUndefined();
|
||||
expect((await models.getAuth(model))?.auth.headers).toEqual({ "x-model": "model", "x-shared": "model" });
|
||||
|
||||
let transforms = 0;
|
||||
await models.completeSimple(model, context, {
|
||||
headers: { "x-explicit": "explicit", "X-Shared": "explicit" },
|
||||
transformHeaders: async (headers) => {
|
||||
transforms++;
|
||||
expect(headers).toEqual({ "x-model": "model", "x-explicit": "explicit", "X-Shared": "explicit" });
|
||||
return { ...headers, "x-transformed": "yes" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(transforms).toBe(1);
|
||||
expect(calls[0].options?.headers).toEqual({
|
||||
"x-model": "model",
|
||||
"x-explicit": "explicit",
|
||||
"X-Shared": "explicit",
|
||||
"x-transformed": "yes",
|
||||
});
|
||||
expect(calls[0].options).not.toHaveProperty("transformHeaders");
|
||||
});
|
||||
|
||||
it("produces an error stream for unknown providers instead of throwing", async () => {
|
||||
const models = createModels();
|
||||
const result = await models.completeSimple(testModel("ghost", "model-a"), context);
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
|
||||
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
|
||||
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
|
||||
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
|
||||
import { createModels } from "../src/models.ts";
|
||||
import * as extensionOAuthCompatibility from "../src/oauth.ts";
|
||||
import { anthropicProvider } from "../src/providers/anthropic.ts";
|
||||
import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
|
||||
import { anthropicOAuth } from "../src/utils/oauth/anthropic.ts";
|
||||
import { githubCopilotOAuth } from "../src/utils/oauth/github-copilot.ts";
|
||||
import { openaiCodexOAuth } from "../src/utils/oauth/openai-codex.ts";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
describe.sequential("OAuthAuth adapters", () => {
|
||||
it("keeps the extension OAuth barrel free of built-in flow implementations", () => {
|
||||
expect(extensionOAuthCompatibility).not.toHaveProperty("loginAnthropic");
|
||||
expect(extensionOAuthCompatibility).not.toHaveProperty("anthropicOAuth");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -104,7 +110,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
|
||||
models.setProvider(anthropicProvider());
|
||||
|
||||
const model = models.getModels("anthropic")[0];
|
||||
const result = await models.getAuth(model);
|
||||
const result = await models.getAuth(model.provider);
|
||||
expect(result?.auth.apiKey).toBe("oauth-access-token");
|
||||
expect(result?.source).toBe("OAuth");
|
||||
});
|
||||
@@ -122,7 +128,7 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => {
|
||||
models.setProvider(githubCopilotProvider());
|
||||
|
||||
const model = models.getModels("github-copilot")[0];
|
||||
const result = await models.getAuth(model);
|
||||
const result = await models.getAuth(model.provider);
|
||||
expect(result?.auth.apiKey).toBe(access);
|
||||
expect(result?.auth.baseUrl).toBe("https://api.business.githubcopilot.com");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { pollOAuthDeviceCodeFlow } from "../src/utils/oauth/device-code.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "../src/auth/oauth/device-code.ts";
|
||||
|
||||
describe("OAuth device-code polling", () => {
|
||||
afterEach(() => {
|
||||
|
||||
+11
-21
@@ -8,8 +8,8 @@
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { dirname, join } from "path";
|
||||
import { getOAuthApiKey } from "../src/utils/oauth/index.ts";
|
||||
import type { OAuthCredentials, OAuthProvider } from "../src/utils/oauth/types.ts";
|
||||
import type { OAuthCredentials } from "../src/auth/types.ts";
|
||||
import { builtinProviders } from "../src/providers/all.ts";
|
||||
|
||||
const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
|
||||
|
||||
@@ -65,28 +65,18 @@ export async function resolveApiKey(provider: string): Promise<string | undefine
|
||||
}
|
||||
|
||||
if (entry.type === "oauth") {
|
||||
// Build OAuthCredentials record for getOAuthApiKey
|
||||
const oauthCredentials: Record<string, OAuthCredentials> = {};
|
||||
for (const [key, value] of Object.entries(storage)) {
|
||||
if (value.type === "oauth") {
|
||||
const { type: _, ...creds } = value;
|
||||
oauthCredentials[key] = creds;
|
||||
}
|
||||
}
|
||||
|
||||
let result: { newCredentials: OAuthCredentials; apiKey: string } | null = null;
|
||||
const oauth = builtinProviders().find((candidate) => candidate.id === provider)?.auth.oauth;
|
||||
if (!oauth) return undefined;
|
||||
let credential = entry;
|
||||
try {
|
||||
result = await getOAuthApiKey(provider as OAuthProvider, oauthCredentials);
|
||||
} catch (e) {
|
||||
console.log(JSON.stringify(e));
|
||||
if (Date.now() >= credential.expires) credential = await oauth.refresh(credential);
|
||||
} catch (error) {
|
||||
console.log(JSON.stringify(error));
|
||||
return undefined;
|
||||
}
|
||||
if (!result) return undefined;
|
||||
|
||||
// Save refreshed credentials back to auth.json
|
||||
storage[provider] = { type: "oauth", ...result.newCredentials };
|
||||
storage[provider] = credential;
|
||||
saveAuthStorage(storage);
|
||||
|
||||
return result.apiKey;
|
||||
return (await oauth.toAuth(credential)).apiKey;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
loginOpenAICodexDeviceCode,
|
||||
openaiCodexOAuthProvider,
|
||||
refreshOpenAICodexToken,
|
||||
} from "../src/utils/oauth/openai-codex.ts";
|
||||
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
|
||||
|
||||
function jsonResponse(body: unknown, status: number = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
@@ -45,6 +41,30 @@ function deviceAuthPendingResponse(): Response {
|
||||
);
|
||||
}
|
||||
|
||||
function loginOpenAICodexDeviceCodeForTest(options: {
|
||||
onDeviceCode(info: {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}): void;
|
||||
signal?: AbortSignal;
|
||||
}) {
|
||||
return openaiCodexOAuth.login({
|
||||
signal: options.signal,
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type !== "select") throw new Error(`Unexpected prompt: ${prompt.type}`);
|
||||
return "device_code";
|
||||
},
|
||||
notify: (event) => {
|
||||
if (event.type === "device_code") {
|
||||
const { type: _, ...info } = event;
|
||||
options.onDeviceCode(info);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("OpenAI Codex OAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -125,7 +145,7 @@ describe("OpenAI Codex OAuth", () => {
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const credentialsPromise = loginOpenAICodexDeviceCode({
|
||||
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
|
||||
onDeviceCode: (info) => deviceInfos.push(info),
|
||||
});
|
||||
|
||||
@@ -159,7 +179,7 @@ describe("OpenAI Codex OAuth", () => {
|
||||
const accessToken = createAccessToken("account-456");
|
||||
const selectPrompts: Array<{
|
||||
message: string;
|
||||
options: Array<{ id: string; label: string }>;
|
||||
options: readonly { id: string; label: string }[];
|
||||
}> = [];
|
||||
const deviceInfos: Array<{
|
||||
userCode: string;
|
||||
@@ -199,20 +219,22 @@ describe("OpenAI Codex OAuth", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
openaiCodexOAuthProvider.login({
|
||||
onAuth: () => {
|
||||
throw new Error("Browser login should not start");
|
||||
},
|
||||
onDeviceCode: (info) => deviceInfos.push(info),
|
||||
onPrompt: async () => {
|
||||
throw new Error("Prompt should not be used");
|
||||
},
|
||||
onSelect: async (prompt) => {
|
||||
openaiCodexOAuth.login({
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type !== "select") throw new Error("Text prompt should not be used");
|
||||
selectPrompts.push(prompt);
|
||||
return "device_code";
|
||||
},
|
||||
notify: (event) => {
|
||||
if (event.type === "auth_url") throw new Error("Browser login should not start");
|
||||
if (event.type === "device_code") {
|
||||
const { type: _, ...info } = event;
|
||||
deviceInfos.push(info);
|
||||
}
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
type: "oauth",
|
||||
access: accessToken,
|
||||
refresh: "refresh-token",
|
||||
accountId: "account-456",
|
||||
@@ -220,6 +242,7 @@ describe("OpenAI Codex OAuth", () => {
|
||||
|
||||
expect(selectPrompts).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
message: "Select OpenAI Codex login method:",
|
||||
options: [
|
||||
{ id: "browser", label: "Browser login (default)" },
|
||||
@@ -239,11 +262,11 @@ describe("OpenAI Codex OAuth", () => {
|
||||
|
||||
it("cancels when OpenAI Codex login method selection is cancelled", async () => {
|
||||
await expect(
|
||||
openaiCodexOAuthProvider.login({
|
||||
onAuth: () => {},
|
||||
onDeviceCode: () => {},
|
||||
onPrompt: async () => "",
|
||||
onSelect: async () => undefined,
|
||||
openaiCodexOAuth.login({
|
||||
prompt: async () => {
|
||||
throw new Error("Login cancelled");
|
||||
},
|
||||
notify: () => {},
|
||||
}),
|
||||
).rejects.toThrow("Login cancelled");
|
||||
});
|
||||
@@ -273,7 +296,7 @@ describe("OpenAI Codex OAuth", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialsPromise = loginOpenAICodexDeviceCode({
|
||||
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
|
||||
onDeviceCode: () => {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
@@ -317,7 +340,7 @@ describe("OpenAI Codex OAuth", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialsPromise = loginOpenAICodexDeviceCode({
|
||||
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
|
||||
onDeviceCode: () => {},
|
||||
});
|
||||
const rejectionPromise = credentialsPromise.then(
|
||||
@@ -380,7 +403,7 @@ describe("OpenAI Codex OAuth", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const credentialsPromise = loginOpenAICodexDeviceCode({
|
||||
const credentialsPromise = loginOpenAICodexDeviceCodeForTest({
|
||||
onDeviceCode: () => {},
|
||||
});
|
||||
|
||||
@@ -418,7 +441,7 @@ describe("OpenAI Codex OAuth", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
loginOpenAICodexDeviceCode({
|
||||
loginOpenAICodexDeviceCodeForTest({
|
||||
onDeviceCode: () => {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
@@ -443,9 +466,14 @@ describe("OpenAI Codex OAuth", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(refreshOpenAICodexToken("invalid-refresh-token")).rejects.toThrow(
|
||||
/OpenAI Codex token refresh failed \(401\).*Could not validate your token/,
|
||||
);
|
||||
await expect(
|
||||
openaiCodexOAuth.refresh({
|
||||
type: "oauth",
|
||||
access: "invalid-access-token",
|
||||
refresh: "invalid-refresh-token",
|
||||
expires: 0,
|
||||
}),
|
||||
).rejects.toThrow(/OpenAI Codex token refresh failed \(401\).*Could not validate your token/);
|
||||
expect(consoleError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -252,11 +252,12 @@ describe("openai-responses provider defaults", () => {
|
||||
expect(captured).toEqual({ sessionId: null, clientRequestId: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["gpt-5.4", "priority", 2],
|
||||
["gpt-5.5", "priority", 2.5],
|
||||
["gpt-5.5", "flex", 0.5],
|
||||
] as const)("applies %s %s service-tier cost multiplier", async (modelId, serviceTier, multiplier) => {
|
||||
async function streamServiceTierUsage(
|
||||
modelId: "gpt-5.4" | "gpt-5.5",
|
||||
serviceTier: "priority" | "flex",
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
) {
|
||||
const model = getModel("openai", modelId);
|
||||
const sse = `${[
|
||||
`data: ${JSON.stringify({
|
||||
@@ -265,9 +266,9 @@ describe("openai-responses provider defaults", () => {
|
||||
status: "completed",
|
||||
service_tier: serviceTier,
|
||||
usage: {
|
||||
input_tokens: 1000000,
|
||||
output_tokens: 1000000,
|
||||
total_tokens: 2000000,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
total_tokens: inputTokens + outputTokens,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
},
|
||||
},
|
||||
@@ -290,10 +291,39 @@ describe("openai-responses provider defaults", () => {
|
||||
{ apiKey: "test-key", serviceTier },
|
||||
);
|
||||
|
||||
const result = await stream.result();
|
||||
return { model, result: await stream.result() };
|
||||
}
|
||||
|
||||
expect(result.usage.cost.input).toBe(model.cost.input * multiplier);
|
||||
expect(result.usage.cost.output).toBe(model.cost.output * multiplier);
|
||||
expect(result.usage.cost.total).toBe((model.cost.input + model.cost.output) * multiplier);
|
||||
it.each([
|
||||
["gpt-5.4", "priority", 2],
|
||||
["gpt-5.5", "priority", 2.5],
|
||||
["gpt-5.5", "flex", 0.5],
|
||||
] as const)("applies %s %s service-tier cost multiplier", async (modelId, serviceTier, multiplier) => {
|
||||
// Stay below the 272K long-context tier threshold so base rates apply.
|
||||
const inputTokens = 200000;
|
||||
const outputTokens = 100000;
|
||||
const { model, result } = await streamServiceTierUsage(modelId, serviceTier, inputTokens, outputTokens);
|
||||
|
||||
const expectedInput = (model.cost.input / 1_000_000) * inputTokens * multiplier;
|
||||
const expectedOutput = (model.cost.output / 1_000_000) * outputTokens * multiplier;
|
||||
expect(result.usage.cost.input).toBe(expectedInput);
|
||||
expect(result.usage.cost.output).toBe(expectedOutput);
|
||||
expect(result.usage.cost.total).toBe(expectedInput + expectedOutput);
|
||||
});
|
||||
|
||||
it("applies the service-tier multiplier on top of long-context tier pricing", async () => {
|
||||
// Above the 272K input threshold the long-context tier rates apply, then the multiplier.
|
||||
const inputTokens = 1000000;
|
||||
const outputTokens = 100000;
|
||||
const multiplier = 2;
|
||||
const { model, result } = await streamServiceTierUsage("gpt-5.4", "priority", inputTokens, outputTokens);
|
||||
|
||||
const tier = model.cost.tiers?.find((entry) => inputTokens > entry.inputTokensAbove);
|
||||
if (!tier) throw new Error("expected gpt-5.4 to define a long-context pricing tier");
|
||||
const expectedInput = (tier.input / 1_000_000) * inputTokens * multiplier;
|
||||
const expectedOutput = (tier.output / 1_000_000) * outputTokens * multiplier;
|
||||
expect(result.usage.cost.input).toBe(expectedInput);
|
||||
expect(result.usage.cost.output).toBe(expectedOutput);
|
||||
expect(result.usage.cost.total).toBe(expectedInput + expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ models.setProvider(anthropicProvider());
|
||||
const model = models.getModel("anthropic", "claude-haiku-4-5");
|
||||
if (!model) throw new Error("model not found");
|
||||
|
||||
const auth = await models.getAuth(model);
|
||||
const auth = await models.getAuth(model.provider);
|
||||
console.log(`model: ${model.provider}/${model.id}`);
|
||||
console.log(`auth: ${auth ? `configured via ${auth.source}` : "not configured"}\n`);
|
||||
if (!auth) process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user