diff --git a/packages/ai/src/auth/oauth/aimlapi.ts b/packages/ai/src/auth/oauth/aimlapi.ts new file mode 100644 index 00000000000..3a97838de12 --- /dev/null +++ b/packages/ai/src/auth/oauth/aimlapi.ts @@ -0,0 +1,161 @@ +/** + * AI/ML API passwordless email sign-in. + * + * Unlike OpenRouter's PKCE browser-redirect flow, AI/ML API's login is a + * terminal-native email + one-time-code exchange (no browser or loopback + * server involved): resolve whether the email signs in or signs up, collect + * a verification code (existing accounts only), exchange it for a session + * token, then mint a permanent API key scoped to this login. The result is + * wrapped as an "oauth" credential the same way OpenRouter's key exchange + * is — a permanent key, not an expiring access/refresh pair. + */ + +import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; + +const AUTH_BASE_URL = "https://auth.aimlapi.com"; +const APP_BASE_URL = "https://app.aimlapi.com"; +const REQUEST_TIMEOUT_MS = 30_000; +const KEY_NAME = "pi CLI"; + +type JsonObject = Record; + +function isRecord(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null; +} + +function errorDetail(body: JsonObject): string | undefined { + if (typeof body.message === "string") return body.message; + if (typeof body.error === "string") return body.error; + return undefined; +} + +async function request( + method: "GET" | "POST" | "PATCH", + url: string, + options: { body?: unknown; bearer?: string; expectJson?: boolean; signal: AbortSignal }, +): Promise { + const controller = new AbortController(); + const onAbort = () => controller.abort(options.signal.reason); + options.signal.addEventListener("abort", onAbort, { once: true }); + const timeout = setTimeout( + () => controller.abort(new Error(`AI/ML API request to ${url} timed out`)), + REQUEST_TIMEOUT_MS, + ); + + let response: Response; + try { + response = await fetch(url, { + method, + headers: { + accept: "application/json", + ...(options.body !== undefined ? { "content-type": "application/json" } : {}), + ...(options.bearer ? { authorization: `Bearer ${options.bearer}` } : {}), + }, + ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}), + signal: controller.signal, + }); + } catch (error) { + if (options.signal.aborted) throw new Error("Login cancelled"); + if (controller.signal.aborted) throw new Error(`AI/ML API request to ${url} timed out`); + throw error; + } finally { + clearTimeout(timeout); + options.signal.removeEventListener("abort", onAbort); + } + + if (options.expectJson === false) { + if (!response.ok) throw new Error(`AI/ML API request failed (HTTP ${response.status})`); + return undefined; + } + + let body: JsonObject = {}; + try { + const parsed = (await response.json()) as unknown; + if (isRecord(parsed)) body = parsed; + } catch { + if (response.ok) throw new Error("AI/ML API returned invalid JSON"); + } + + if (!response.ok) { + const detail = errorDetail(body); + throw new Error(`AI/ML API request failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`); + } + return body; +} + +async function checkAccount( + email: string, + signal: AbortSignal, +): Promise<{ action: "sign-in" | "sign-up"; provider?: string }> { + const body = await request("PATCH", `${AUTH_BASE_URL}/v1/auth/account`, { body: { email }, signal }); + const action = body?.action; + if (action !== "sign-in" && action !== "sign-up") throw new Error("AI/ML API returned an invalid account response"); + return { action, provider: typeof body?.provider === "string" ? body.provider : undefined }; +} + +async function sendSignInCode(email: string, signal: AbortSignal): Promise { + await request("POST", `${AUTH_BASE_URL}/v1/auth/sign-in/code`, { body: { email }, expectJson: false, signal }); +} + +async function exchangeForToken(path: string, body: JsonObject, signal: AbortSignal): Promise { + const result = await request("POST", `${AUTH_BASE_URL}${path}`, { body, signal }); + const token = result?.token; + if (typeof token !== "string" || token.length === 0) throw new Error("AI/ML API did not return an auth token"); + return token; +} + +async function createKey(bearer: string, signal: AbortSignal): Promise { + const result = await request("POST", `${APP_BASE_URL}/v1/keys`, { body: { name: KEY_NAME }, bearer, signal }); + const key = result?.key; + if (typeof key !== "string" || key.length === 0) throw new Error("AI/ML API did not return an API key"); + return key; +} + +async function loginAimlapi(interaction: ProviderAuthInteraction): Promise { + const rawEmail = await interaction.prompt({ type: "text", message: "Enter your AI/ML API account email" }); + const email = rawEmail.trim(); + if (!email) throw new Error("Email is required"); + + interaction.notify({ type: "progress", message: "Checking your AI/ML API account..." }); + const account = await checkAccount(email, interaction.signal); + + let sessionToken: string; + if (account.action === "sign-up") { + interaction.notify({ type: "progress", message: "Creating your AI/ML API account..." }); + sessionToken = await exchangeForToken("/v1/auth/account/passwordless", { email }, interaction.signal); + } else { + if (account.provider) { + throw new Error( + `This email signs in via ${account.provider} on AI/ML API — sign in at https://aimlapi.com/app and create an API key manually instead.`, + ); + } + await sendSignInCode(email, interaction.signal); + interaction.notify({ type: "info", message: `A 6-digit code was sent to ${email}.` }); + const rawCode = await interaction.prompt({ type: "text", message: "Enter the 6-digit code" }); + const code = rawCode.trim(); + if (!code) throw new Error("Code is required"); + sessionToken = await exchangeForToken("/v1/auth/sign-in/code/verify", { email, code }, interaction.signal); + } + + interaction.notify({ type: "progress", message: "Creating an AI/ML API key for pi..." }); + const key = await createKey(sessionToken, interaction.signal); + + return { + type: "oauth", + access: key, + refresh: "", + expires: Number.MAX_SAFE_INTEGER, + }; +} + +export const aimlapiOAuth: OAuthAuth = { + name: "AI/ML API sign-in", + loginLabel: "Sign in with AI/ML API", + login: loginAimlapi, + async refresh(credential, _signal) { + return credential; + }, + async toAuth(credential) { + return { apiKey: credential.access }; + }, +}; diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index e9c72d87d67..46e6061f153 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -12,6 +12,7 @@ const importOAuthModule = (specifier: string): Promise => { }; type OAuthFlowLoaders = { + aimlapi: () => OAuthAuth | Promise; anthropic: () => OAuthAuth | Promise; openaiCodex: () => OAuthAuth | Promise; githubCopilot: () => OAuthAuth | Promise; @@ -28,6 +29,11 @@ export function registerBundledOAuthFlowLoaders(loaders: OAuthFlowLoaders): void bundledLoaders = loaders; } +export const loadAimlapiOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.aimlapi(); + return ((await importOAuthModule("./aimlapi.ts")) as { aimlapiOAuth: OAuthAuth }).aimlapiOAuth; +}; + export const loadAnthropicOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.anthropic(); return ((await importOAuthModule("./anthropic.ts")) as { anthropicOAuth: OAuthAuth }).anthropicOAuth; diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index d1370214df1..2dc677b1360 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -1,3 +1,4 @@ +import { aimlapiOAuth } from "./auth/oauth/aimlapi.ts"; import { anthropicOAuth } from "./auth/oauth/anthropic.ts"; import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts"; import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts"; @@ -10,6 +11,7 @@ import { xaiOAuth } from "./auth/oauth/xai.ts"; /** Register OAuth flows statically embedded in the standalone Bun binary. */ export function registerBunOAuthFlows(): void { registerBundledOAuthFlowLoaders({ + aimlapi: () => aimlapiOAuth, anthropic: () => anthropicOAuth, openaiCodex: () => openaiCodexOAuth, githubCopilot: () => githubCopilotOAuth, diff --git a/packages/ai/src/providers/aimlapi.ts b/packages/ai/src/providers/aimlapi.ts index d283ffac575..ad461cb8e32 100644 --- a/packages/ai/src/providers/aimlapi.ts +++ b/packages/ai/src/providers/aimlapi.ts @@ -1,5 +1,6 @@ import { openAICompletionsApi } from "../api/openai-completions.lazy.ts"; -import { envApiKeyAuth } from "../auth/helpers.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadAimlapiOAuth } from "../auth/oauth/load.ts"; import { createProvider, type Provider } from "../models.ts"; import { AIMLAPI_MODELS } from "./aimlapi.models.ts"; @@ -10,6 +11,11 @@ export function aimlapiProvider(): Provider<"openai-completions"> { baseUrl: "https://api.aimlapi.com/v1", auth: { apiKey: envApiKeyAuth("AI/ML API key", ["AIMLAPI_API_KEY"]), + oauth: lazyOAuth({ + name: "AI/ML API sign-in", + loginLabel: "Sign in with AI/ML API", + load: loadAimlapiOAuth, + }), }, models: Object.values(AIMLAPI_MODELS), api: openAICompletionsApi(), diff --git a/packages/ai/test/aimlapi-oauth.test.ts b/packages/ai/test/aimlapi-oauth.test.ts new file mode 100644 index 00000000000..3bc0eb4e48a --- /dev/null +++ b/packages/ai/test/aimlapi-oauth.test.ts @@ -0,0 +1,173 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { aimlapiOAuth } from "../src/auth/oauth/aimlapi.ts"; +import { aimlapiProvider } from "../src/providers/aimlapi.ts"; + +const ACCOUNT_URL = "https://auth.aimlapi.com/v1/auth/account"; +const SEND_CODE_URL = "https://auth.aimlapi.com/v1/auth/sign-in/code"; +const VERIFY_CODE_URL = "https://auth.aimlapi.com/v1/auth/sign-in/code/verify"; +const PASSWORDLESS_URL = "https://auth.aimlapi.com/v1/auth/account/passwordless"; +const KEYS_URL = "https://app.aimlapi.com/v1/keys"; +const neverAbortedSignal = new AbortController().signal; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function requestBody(init: RequestInit | undefined): Record { + return JSON.parse(String(init?.body)) as Record; +} + +describe.sequential("AI/ML API OAuth", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("is exposed alongside API-key auth", () => { + const provider = aimlapiProvider(); + expect(provider.auth.apiKey).toBeDefined(); + expect(provider.auth.oauth).toBeDefined(); + expect(provider.auth.oauth?.loginLabel).toBe("Sign in with AI/ML API"); + }); + + it("signs in an existing account with an emailed code and mints an API key", async () => { + const calls: string[] = []; + let verifyBody: Record | undefined; + let keyBody: Record | undefined; + let keyAuthHeader: string | null | undefined; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + calls.push(url); + if (url === ACCOUNT_URL) return jsonResponse({ action: "sign-in" }); + if (url === SEND_CODE_URL) return new Response(null, { status: 204 }); + if (url === VERIFY_CODE_URL) { + verifyBody = requestBody(init); + return jsonResponse({ token: "session-token", exp: 9999999999 }); + } + if (url === KEYS_URL) { + keyBody = requestBody(init); + keyAuthHeader = new Headers(init?.headers).get("authorization"); + return jsonResponse({ key: "aiml-test-key", id: "key-1" }); + } + throw new Error(`Unexpected request: ${url}`); + }), + ); + + const prompts = ["user@example.com", "123456"]; + const credential = await aimlapiOAuth.login({ + signal: neverAbortedSignal, + prompt: async () => prompts.shift() ?? "", + notify: () => {}, + }); + + expect(credential).toEqual({ + type: "oauth", + access: "aiml-test-key", + refresh: "", + expires: Number.MAX_SAFE_INTEGER, + }); + expect(calls).toEqual([ACCOUNT_URL, SEND_CODE_URL, VERIFY_CODE_URL, KEYS_URL]); + expect(verifyBody).toEqual({ email: "user@example.com", code: "123456" }); + expect(keyBody).toEqual({ name: "pi CLI" }); + expect(keyAuthHeader).toBe("Bearer session-token"); + }); + + it("creates a new account without requesting a code when the account does not exist", async () => { + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input); + calls.push(url); + if (url === ACCOUNT_URL) return jsonResponse({ action: "sign-up" }); + if (url === PASSWORDLESS_URL) return jsonResponse({ token: "new-session-token", exp: 9999999999 }); + if (url === KEYS_URL) return jsonResponse({ key: "aiml-new-key", id: "key-2" }); + throw new Error(`Unexpected request: ${url}`); + }), + ); + + const credential = await aimlapiOAuth.login({ + signal: neverAbortedSignal, + prompt: async () => "new-user@example.com", + notify: () => {}, + }); + + expect(credential).toMatchObject({ access: "aiml-new-key" }); + expect(calls).toEqual([ACCOUNT_URL, PASSWORDLESS_URL, KEYS_URL]); + }); + + it("rejects an account linked to a third-party sign-in provider", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ action: "sign-in", provider: "google" })), + ); + + await expect( + aimlapiOAuth.login({ + signal: neverAbortedSignal, + prompt: async () => "user@example.com", + notify: () => {}, + }), + ).rejects.toThrow(/signs in via google/); + }); + + it("reports an invalid verification code", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input); + if (url === ACCOUNT_URL) return jsonResponse({ action: "sign-in" }); + if (url === SEND_CODE_URL) return new Response(null, { status: 204 }); + if (url === VERIFY_CODE_URL) return jsonResponse({ message: "invalid code" }, 400); + throw new Error(`Unexpected request: ${url}`); + }), + ); + + const prompts = ["user@example.com", "000000"]; + await expect( + aimlapiOAuth.login({ + signal: neverAbortedSignal, + prompt: async () => prompts.shift() ?? "", + notify: () => {}, + }), + ).rejects.toThrow("AI/ML API request failed (HTTP 400): invalid code"); + }); + + it("rejects a successful key response that carries no key", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input); + if (url === ACCOUNT_URL) return jsonResponse({ action: "sign-in" }); + if (url === SEND_CODE_URL) return new Response(null, { status: 204 }); + if (url === VERIFY_CODE_URL) return jsonResponse({ token: "session-token", exp: 9999999999 }); + if (url === KEYS_URL) return jsonResponse({ id: "key-1" }); + throw new Error(`Unexpected request: ${url}`); + }), + ); + + const prompts = ["user@example.com", "123456"]; + await expect( + aimlapiOAuth.login({ + signal: neverAbortedSignal, + prompt: async () => prompts.shift() ?? "", + notify: () => {}, + }), + ).rejects.toThrow("AI/ML API did not return an API key"); + }); + + it("rejects an empty email without making a request", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + aimlapiOAuth.login({ + signal: neverAbortedSignal, + prompt: async () => " ", + notify: () => {}, + }), + ).rejects.toThrow("Email is required"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +});