Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions packages/ai/src/auth/oauth/aimlapi.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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<JsonObject | undefined> {
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<void> {
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<string> {
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<string> {
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<OAuthCredential> {
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 };
},
};
6 changes: 6 additions & 0 deletions packages/ai/src/auth/oauth/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const importOAuthModule = (specifier: string): Promise<unknown> => {
};

type OAuthFlowLoaders = {
aimlapi: () => OAuthAuth | Promise<OAuthAuth>;
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
Expand All @@ -28,6 +29,11 @@ export function registerBundledOAuthFlowLoaders(loaders: OAuthFlowLoaders): void
bundledLoaders = loaders;
}

export const loadAimlapiOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.aimlapi();
return ((await importOAuthModule("./aimlapi.ts")) as { aimlapiOAuth: OAuthAuth }).aimlapiOAuth;
};

export const loadAnthropicOAuth = async (): Promise<OAuthAuth> => {
if (bundledLoaders) return bundledLoaders.anthropic();
return ((await importOAuthModule("./anthropic.ts")) as { anthropicOAuth: OAuthAuth }).anthropicOAuth;
Expand Down
2 changes: 2 additions & 0 deletions packages/ai/src/bun-oauth.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion packages/ai/src/providers/aimlapi.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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(),
Expand Down
173 changes: 173 additions & 0 deletions packages/ai/test/aimlapi-oauth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return JSON.parse(String(init?.body)) as Record<string, unknown>;
}

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<string, unknown> | undefined;
let keyBody: Record<string, unknown> | 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();
});
});
Loading