diff --git a/packages/agent/docs/models.md b/packages/agent/docs/models.md index de3c3027..0925072e 100644 --- a/packages/agent/docs/models.md +++ b/packages/agent/docs/models.md @@ -807,8 +807,8 @@ Check items off as they land. Keep this list current; it is the working state fo ### Phase 4 — OAuth adaptation -- [ ] Adapt `utils/oauth/anthropic.ts`, `openai-codex.ts`, `github-copilot.ts` to `OAuthAuth` (`login`/`refresh`/`toAuth`) + `prompt()/notify()`; `modifyModels` baseUrl rewriting becomes `toAuth().baseUrl`. -- [ ] Remove `usesCallbackServer`; callback-server flows race a `manual_code` prompt instead. +- [x] Adapt `utils/oauth/anthropic.ts`, `openai-codex.ts`, `github-copilot.ts` to `OAuthAuth` (`login`/`refresh`/`toAuth`) + `prompt()/notify()`; `modifyModels` baseUrl rewriting becomes `toAuth().baseUrl`. New exports (`anthropicOAuth`, `openaiCodexOAuth`, `githubCopilotOAuth`) sit next to the old `OAuthProviderInterface` objects, which survive until Phase 7. +- [x] No `usesCallbackServer` on `OAuthAuth`: callback-server flows race a `manual_code` prompt (aborted via `AuthPrompt.signal` once the flow settles). The old interface keeps its flag until it dies with compat. ### Phase 5 — packaging diff --git a/packages/ai/src/utils/oauth/anthropic.ts b/packages/ai/src/utils/oauth/anthropic.ts index feeb34fa..c8a9226a 100644 --- a/packages/ai/src/utils/oauth/anthropic.ts +++ b/packages/ai/src/utils/oauth/anthropic.ts @@ -6,6 +6,7 @@ */ import type { Server } from "node:http"; +import type { OAuthAuth } from "../../auth/types.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts"; @@ -378,6 +379,42 @@ export async function refreshAnthropicToken(refreshToken: string): Promise callbacks.notify({ type: "auth_url", url: info.url, instructions: info.instructions }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + onPrompt: (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onManualCodeInput: () => + callbacks.prompt({ + type: "manual_code", + message: "Complete login in your browser, or paste the authorization code / redirect URL here:", + placeholder: REDIRECT_URI, + signal: manualAbort.signal, + }), + }); + return { ...credentials, type: "oauth" }; + } finally { + manualAbort.abort(); + } + }, + + async refresh(credential) { + return { ...(await refreshAnthropicToken(credential.refresh)), type: "oauth" }; + }, + + async toAuth(credential) { + return { apiKey: credential.access }; + }, +}; + export const anthropicOAuthProvider: OAuthProviderInterface = { id: "anthropic", name: "Anthropic (Claude Pro/Max)", diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index 6a27b9d1..cf7e293b 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -2,6 +2,7 @@ * GitHub Copilot OAuth flow */ +import type { OAuthAuth, OAuthCredential } from "../../auth/types.ts"; import { getModels } from "../../models.ts"; import type { Api, Model } from "../../types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; @@ -330,6 +331,42 @@ export async function loginGitHubCopilot(options: { return credentials; } +function copilotEnterpriseDomain(credential: OAuthCredential): string | undefined { + const enterpriseUrl = credential.enterpriseUrl; + if (typeof enterpriseUrl !== "string" || !enterpriseUrl) return undefined; + return normalizeDomain(enterpriseUrl) ?? undefined; +} + +export const githubCopilotOAuth: OAuthAuth = { + name: "GitHub Copilot", + + async login(callbacks) { + const credentials = await loginGitHubCopilot({ + onDeviceCode: (info) => callbacks.notify({ type: "device_code", ...info }), + onPrompt: (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + }, + + async refresh(credential) { + return { + ...(await refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential))), + type: "oauth", + }; + }, + + /** Per-credential baseUrl from the token's proxy endpoint replaces the old `modifyModels` rewriting. */ + async toAuth(credential) { + return { + apiKey: credential.access, + baseUrl: getGitHubCopilotBaseUrl(credential.access, copilotEnterpriseDomain(credential)), + }; + }, +}; + export const githubCopilotOAuthProvider: OAuthProviderInterface = { id: "github-copilot", name: "GitHub Copilot", diff --git a/packages/ai/src/utils/oauth/openai-codex.ts b/packages/ai/src/utils/oauth/openai-codex.ts index 2f769a84..ae9cc9f9 100644 --- a/packages/ai/src/utils/oauth/openai-codex.ts +++ b/packages/ai/src/utils/oauth/openai-codex.ts @@ -17,6 +17,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version }); } +import type { OAuthAuth } from "../../auth/types.ts"; import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts"; import { generatePKCE } from "./pkce.ts"; @@ -560,6 +561,62 @@ export async function refreshOpenAICodexToken(refreshToken: string): Promise callbacks.notify({ type: "device_code", ...info }), + signal: callbacks.signal, + }); + return { ...credentials, type: "oauth" }; + } + if (method !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) { + throw new Error(`Unknown OpenAI Codex login method: ${method}`); + } + + // The manual_code prompt races the local callback server; abort it once + // the flow settles so the UI can dismiss the pending input. + const manualAbort = new AbortController(); + try { + const credentials = await loginOpenAICodex({ + onAuth: (info) => callbacks.notify({ type: "auth_url", url: info.url, instructions: info.instructions }), + onProgress: (message) => callbacks.notify({ type: "progress", message }), + onPrompt: (prompt) => + callbacks.prompt({ type: "text", message: prompt.message, placeholder: prompt.placeholder }), + onManualCodeInput: () => + callbacks.prompt({ + type: "manual_code", + message: "Complete login in your browser, or paste the authorization code / redirect URL here:", + placeholder: REDIRECT_URI, + signal: manualAbort.signal, + }), + }); + return { ...credentials, type: "oauth" }; + } finally { + manualAbort.abort(); + } + }, + + async refresh(credential) { + return { ...(await refreshOpenAICodexToken(credential.refresh)), type: "oauth" }; + }, + + async toAuth(credential) { + return { apiKey: credential.access }; + }, +}; + export const openaiCodexOAuthProvider: OAuthProviderInterface = { id: "openai-codex", name: "ChatGPT Plus/Pro (Codex Subscription)", diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts new file mode 100644 index 00000000..689fc2cf --- /dev/null +++ b/packages/ai/test/oauth-auth.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; +import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts"; +import { createModels } from "../src/models.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", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("anthropic toAuth derives the api key from the access token", async () => { + const auth = await anthropicOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 }); + expect(auth).toEqual({ apiKey: "token" }); + }); + + it("openai-codex toAuth derives the api key from the access token", async () => { + const auth = await openaiCodexOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 }); + expect(auth).toEqual({ apiKey: "token" }); + }); + + it("github-copilot toAuth derives baseUrl from the token proxy endpoint", async () => { + const access = "tid=abc;exp=123;proxy-ep=proxy.enterprise.example;rest"; + const auth = await githubCopilotOAuth.toAuth({ type: "oauth", access, refresh: "r", expires: 0 }); + expect(auth).toEqual({ apiKey: access, baseUrl: "https://api.enterprise.example" }); + }); + + it("github-copilot toAuth falls back to the enterprise domain, then the individual endpoint", async () => { + const enterprise = await githubCopilotOAuth.toAuth({ + type: "oauth", + access: "no-proxy-ep", + refresh: "r", + expires: 0, + enterpriseUrl: "https://company.ghe.com", + }); + expect(enterprise.baseUrl).toBe("https://copilot-api.company.ghe.com"); + + const individual = await githubCopilotOAuth.toAuth({ + type: "oauth", + access: "no-proxy-ep", + refresh: "r", + expires: 0, + }); + expect(individual.baseUrl).toBe("https://api.individual.githubcopilot.com"); + }); + + it("anthropic refresh exchanges the refresh token and returns a typed credential", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + jsonResponse({ access_token: "new-access", refresh_token: "new-refresh", expires_in: 3600 }), + ), + ); + + const refreshed = await anthropicOAuth.refresh({ type: "oauth", access: "old", refresh: "old-r", expires: 0 }); + expect(refreshed.type).toBe("oauth"); + expect(refreshed.access).toBe("new-access"); + expect(refreshed.refresh).toBe("new-refresh"); + expect(refreshed.expires).toBeGreaterThan(Date.now()); + }); + + it("github-copilot refresh preserves the enterprise domain", async () => { + const fetchedUrls: string[] = []; + const fetchMock = vi.fn(async (input: unknown) => { + fetchedUrls.push(typeof input === "string" ? input : String(input)); + return jsonResponse({ token: "new-token", expires_at: 9999999999 }); + }); + vi.stubGlobal("fetch", fetchMock); + + const refreshed = await githubCopilotOAuth.refresh({ + type: "oauth", + access: "old", + refresh: "gh-token", + expires: 0, + enterpriseUrl: "company.ghe.com", + }); + expect(refreshed.access).toBe("new-token"); + expect(refreshed.enterpriseUrl).toBe("company.ghe.com"); + expect(fetchedUrls[0]).toContain("api.company.ghe.com"); + }); + + it("anthropic login resolves through the manual_code prompt and aborts it after settling", async () => { + const fetchMock = vi.fn(async (input: unknown) => { + const url = typeof input === "string" ? input : String(input); + if (url.includes("/oauth/token")) { + return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 }); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const events: AuthEvent[] = []; + const prompts: AuthPrompt[] = []; + let manualSignal: AbortSignal | undefined; + + const credential = await anthropicOAuth.login({ + notify: (event) => events.push(event), + prompt: async (prompt) => { + prompts.push(prompt); + if (prompt.type === "manual_code") { + manualSignal = prompt.signal; + return "the-code"; + } + throw new Error(`Unexpected prompt: ${prompt.type}`); + }, + }); + + expect(credential.type).toBe("oauth"); + expect(credential.access).toBe("access"); + expect(events.some((e) => e.type === "auth_url")).toBe(true); + expect(prompts.some((p) => p.type === "manual_code")).toBe(true); + // the prompt's signal is aborted once login settles, so UIs can dismiss it + expect(manualSignal?.aborted).toBe(true); + }); +}); + +describe("OAuth through Models.getAuth (lazy load chain)", () => { + it("resolves stored anthropic oauth credentials via the lazy flow import", async () => { + const credentials = new InMemoryCredentialStore(); + await credentials.modify("anthropic", async () => ({ + type: "oauth", + access: "oauth-access-token", + refresh: "r", + expires: Date.now() + 60_000, + })); + const models = createModels({ credentials }); + models.setProvider(anthropicProvider()); + + const model = (await models.getModels("anthropic"))[0]; + const result = await models.getAuth(model); + expect(result?.auth.apiKey).toBe("oauth-access-token"); + expect(result?.source).toBe("OAuth"); + }); + + it("resolves stored github-copilot oauth credentials including per-credential baseUrl", async () => { + const access = "tid=abc;exp=123;proxy-ep=proxy.business.githubcopilot.com;rest"; + const credentials = new InMemoryCredentialStore(); + await credentials.modify("github-copilot", async () => ({ + type: "oauth", + access, + refresh: "r", + expires: Date.now() + 60_000, + })); + const models = createModels({ credentials }); + models.setProvider(githubCopilotProvider()); + + const model = (await models.getModels("github-copilot"))[0]; + const result = await models.getAuth(model); + expect(result?.auth.apiKey).toBe(access); + expect(result?.auth.baseUrl).toBe("https://api.business.githubcopilot.com"); + }); +});