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:
Mario Zechner
2026-07-14 17:48:45 +02:00
parent 6731a0ba9e
commit 9993c96907
133 changed files with 5103 additions and 4340 deletions
+13 -10
View File
@@ -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.");
}
+47 -16
View File
@@ -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 () => "",
});
+5 -3
View File
@@ -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");
+117 -14
View File
@@ -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);
+11 -5
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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;
+56 -28
View File
@@ -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);
});
});
+87 -20
View File
@@ -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 () => {
+1 -1
View File
@@ -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);