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
33 changes: 19 additions & 14 deletions packages/ai/src/auth/oauth/aimlapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,24 +119,29 @@ async function loginAimlapi(interaction: ProviderAuthInteraction): Promise<OAuth
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") {
// A freshly created passwordless account is inactive until its first top-up
// (AI/ML API mints API keys only for active accounts), so there is no key to
// hand back here yet — create the account and send the user to fund it.
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);
await exchangeForToken("/v1/auth/account/passwordless", { email }, interaction.signal);
throw new Error(
`Account created for ${email}. Add credit at https://aimlapi.com/app, then run /login again to sign in.`,
);
}

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");
const 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);

Expand Down
35 changes: 16 additions & 19 deletions packages/ai/test/aimlapi-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,28 +73,25 @@ describe.sequential("AI/ML API OAuth", () => {
expect(keyAuthHeader).toBe("Bearer session-token");
});

it("creates a new account without requesting a code when the account does not exist", async () => {
it("creates a new account but does not attempt to mint a key — a fresh account is inactive until its first top-up", 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: () => {},
const fetchMock = 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 });
throw new Error(`Unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);

expect(credential).toMatchObject({ access: "aiml-new-key" });
expect(calls).toEqual([ACCOUNT_URL, PASSWORDLESS_URL, KEYS_URL]);
await expect(
aimlapiOAuth.login({
signal: neverAbortedSignal,
prompt: async () => "new-user@example.com",
notify: () => {},
}),
).rejects.toThrow(/Account created for new-user@example.com.*run \/login again/);
expect(calls).toEqual([ACCOUNT_URL, PASSWORDLESS_URL]);
});

it("rejects an account linked to a third-party sign-in provider", async () => {
Expand Down
Loading