From b73412a3787abbc9427fd6f335b423c66e0e3fa8 Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Sun, 20 Sep 2026 06:13:43 -0300 Subject: [PATCH] feat(ai,coding-agent): add Meta provider with Muse subscription OAuth (#9096) Device authorization flow against auth.meta.com mints a Model API key via the Muse Code key-mint endpoint. Identity token stored as refresh, minted key as access; daily re-mint via the standard OAuth scheduler. Identity is non-renewable (no refresh grant), so mint 401/403 directs to /login meta. Models sourced from models.dev; default muse-spark-1.3. --- packages/ai/CHANGELOG.md | 1 + packages/ai/README.md | 2 + packages/ai/scripts/generate-models.ts | 27 +++ packages/ai/src/auth/oauth/load.ts | 6 + packages/ai/src/auth/oauth/meta.ts | 208 ++++++++++++++++++ packages/ai/src/bun-oauth.ts | 2 + packages/ai/src/env-api-keys.ts | 1 + packages/ai/src/models.generated.ts | 3 + packages/ai/src/providers/all.ts | 2 + packages/ai/src/providers/meta.models.ts | 8 + packages/ai/src/providers/meta.ts | 24 ++ packages/ai/src/types.ts | 1 + .../ai/test/cross-provider-handoff.test.ts | 2 + packages/ai/test/meta-oauth.test.ts | 149 +++++++++++++ packages/ai/test/stream.test.ts | 24 ++ packages/ai/test/tokens.test.ts | 8 + packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 1 + packages/coding-agent/docs/providers.md | 8 + packages/coding-agent/src/cli/args.ts | 1 + .../coding-agent/src/core/model-resolver.ts | 1 + scripts/build-coding-agent-bundle.mjs | 1 + 22 files changed, 481 insertions(+) create mode 100644 packages/ai/src/auth/oauth/meta.ts create mode 100644 packages/ai/src/providers/meta.models.ts create mode 100644 packages/ai/src/providers/meta.ts create mode 100644 packages/ai/test/meta-oauth.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2ec8e2ab1..272ace607 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -67,6 +67,7 @@ - Added an optional timestamp argument to `uuidv7()` for follower IDs. - Added narrow `api`, `providers`, and `utils` subpath exports for direct imports without loading the package barrel. - Added Anthropic per-turn effort persistence, deterministic historical effort markers, and signed-thinking mismatch recovery for supported Claude models across Anthropic Messages transports, including OpenRouter. +- Added Meta provider (Model API key and Muse subscription OAuth) with Muse Spark models. ### Fixed diff --git a/packages/ai/README.md b/packages/ai/README.md index 52bcb1138..3eb24c1c6 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -88,6 +88,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and - **OpenCode Go** - **Fireworks** (uses OpenAI- and Anthropic-compatible APIs) - **Kimi For Coding** (Moonshot AI subscription endpoint, uses Anthropic-compatible API) +- **Meta** (Model API, uses OpenAI Responses-compatible API) - **Qwen Token Plan** (separate Individual and existing catalogs, with a separate China provider) - **Xiaomi MiMo** (defaults to API billing endpoint, with separate Token Plan providers for `cn`/`ams`/`sgp` regions) - **Any OpenAI-compatible API**: Ollama, vLLM, LM Studio, etc. @@ -444,6 +445,7 @@ Built-in providers resolve these env vars (Node.js; in browsers pass `apiKey` ex | Hugging Face | `HF_TOKEN` | | OpenCode Zen / OpenCode Go | `OPENCODE_API_KEY` | | Kimi For Coding | `KIMI_API_KEY` | +| Meta | `META_API_KEY` | | Qwen Token Plan (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` | | Qwen Token Plan (Individual) | `QWEN_TOKEN_PLAN_API_KEY` | | Qwen Token Plan (China) | `QWEN_TOKEN_PLAN_CN_API_KEY` | diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 238a21097..0eff20893 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -2004,6 +2004,33 @@ async function loadModelsDevData(): Promise[]> { } } + // Process Meta models + if (data.meta?.models) { + for (const [modelId, model] of Object.entries(data.meta.models)) { + const m = model as ModelsDevModel; + if (m.tool_call !== true) continue; + + models.push({ + id: modelId, + name: m.name || modelId, + api: "openai-responses", + provider: "meta", + baseUrl: "https://api.meta.ai/v1", + reasoning: m.reasoning === true, + input: m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"], + cost: { + input: m.cost?.input || 0, + output: m.cost?.output || 0, + cacheRead: m.cost?.cache_read || 0, + cacheWrite: m.cost?.cache_write || 0, + }, + contextWindow: m.limit?.context || 4096, + maxTokens: m.limit?.output || 4096, + }); + recordModelsDevReasoningOptions("meta", modelId, m); + } + } + models.push(...processZaiModels(data)); // Process Mistral models diff --git a/packages/ai/src/auth/oauth/load.ts b/packages/ai/src/auth/oauth/load.ts index e9c72d87d..29461ec9d 100644 --- a/packages/ai/src/auth/oauth/load.ts +++ b/packages/ai/src/auth/oauth/load.ts @@ -17,6 +17,7 @@ type OAuthFlowLoaders = { githubCopilot: () => OAuthAuth | Promise; openrouter: () => OAuthAuth | Promise; kimiCoding: () => OAuthAuth | Promise; + meta: () => OAuthAuth | Promise; xai: () => OAuthAuth | Promise; radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise; }; @@ -53,6 +54,11 @@ export const loadKimiCodingOAuth = async (): Promise => { return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth; }; +export const loadMetaOAuth = async (): Promise => { + if (bundledLoaders) return bundledLoaders.meta(); + return ((await importOAuthModule("./meta.ts")) as { metaOAuth: OAuthAuth }).metaOAuth; +}; + export const loadXaiOAuth = async (): Promise => { if (bundledLoaders) return bundledLoaders.xai(); return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth; diff --git a/packages/ai/src/auth/oauth/meta.ts b/packages/ai/src/auth/oauth/meta.ts new file mode 100644 index 000000000..b9f331cc8 --- /dev/null +++ b/packages/ai/src/auth/oauth/meta.ts @@ -0,0 +1,208 @@ +/** + * Meta Model API OAuth flow + * + * RFC 8628 device authorization grant against https://auth.meta.com (JSON + * responses). Meta splits identity from API access: the resulting identity + * token is not accepted for inference, so it is exchanged for a Model API + * key via the Muse Code key-mint endpoint (minted keys live about a day). + * The identity token is stored as `refresh` and the minted key as `access`, + * so the standard OAuth scheduler re-mints the key when it expires with no + * bespoke renewal machinery. The identity token itself is not renewable + * (auth.meta.com answers grant_type=refresh_token with 404 and issues no + * refresh_token), so a 401/403 from mint means the session is dead and the + * user must sign in again. + */ + +import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts"; +import { pollOAuthDeviceCodeFlow } from "./device-code.ts"; + +// Muse Code CLI client id. +const CLIENT_ID = "1031625952748946"; +const AUTH_HOST = "https://auth.meta.com"; +const DEVICE_AUTHORIZATION_URL = `${AUTH_HOST}/oidc/device/authorization/`; +const DEVICE_TOKEN_URL = `${AUTH_HOST}/oidc/device/token/`; +const API_KEY_MINT_URL = "https://api.meta.ai/muse-code/key"; +const API_KEY_LIFETIME_MS = 24 * 60 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 30 * 1000; + +type DeviceAuthorization = { + deviceCode: string; + userCode: string; + verificationUri: string; + intervalSeconds?: number; + expiresInSeconds?: number; +}; + +function requestSignal(signal: AbortSignal): AbortSignal { + return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]); +} + +async function readJson(response: Response): Promise | null> { + try { + const json = await response.json(); + return json && typeof json === "object" ? (json as Record) : null; + } catch { + return null; + } +} + +function errorDetail(json: Record | null): string { + for (const key of ["error_description", "detail", "message", "error"]) { + const value = json?.[key]; + if (typeof value === "string" && value.trim()) return `: ${value.trim()}`; + } + return ""; +} + +/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */ +function trustedHttpUrl(value: unknown): string | null { + if (typeof value !== "string" || !value) return null; + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + return url.href; + } catch { + return null; + } +} + +function positiveNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +async function startDeviceAuthorization(signal: AbortSignal): Promise { + const response = await fetch(DEVICE_AUTHORIZATION_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ client_id: CLIENT_ID }).toString(), + signal: requestSignal(signal), + }); + const json = await readJson(response); + if (!response.ok) { + throw new Error(`Meta device authorization failed with status ${response.status}${errorDetail(json)}`); + } + const deviceCode = json?.device_code; + const userCode = json?.user_code; + const verificationUri = trustedHttpUrl(json?.verification_uri_complete) ?? trustedHttpUrl(json?.verification_uri); + if (typeof deviceCode !== "string" || !deviceCode || typeof userCode !== "string" || !userCode || !verificationUri) { + throw new Error(`Invalid Meta device authorization response: ${JSON.stringify(json)}`); + } + return { + deviceCode, + userCode, + verificationUri, + intervalSeconds: positiveNumber(json?.interval), + expiresInSeconds: positiveNumber(json?.expires_in), + }; +} + +async function pollForIdentityToken(device: DeviceAuthorization, signal: AbortSignal): Promise { + return pollOAuthDeviceCodeFlow({ + intervalSeconds: device.intervalSeconds, + expiresInSeconds: device.expiresInSeconds, + waitBeforeFirstPoll: true, + signal, + poll: async () => { + const response = await fetch(DEVICE_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: device.deviceCode, + client_id: CLIENT_ID, + }).toString(), + signal: requestSignal(signal), + }); + const json = await readJson(response); + if (response.ok && typeof json?.access_token === "string" && json.access_token) { + return { status: "complete", value: json.access_token }; + } + switch (json?.error) { + case "authorization_pending": + return { status: "pending" }; + case "slow_down": + return { status: "slow_down", intervalSeconds: positiveNumber(json?.interval) }; + case "access_denied": + return { status: "failed", message: "Meta login was denied." }; + case "expired_token": + return { status: "failed", message: "Meta device authorization expired. Please restart login." }; + default: + return { + status: "failed", + message: `Meta device token request failed with status ${response.status}${errorDetail(json)}`, + }; + } + }, + }); +} + +/** Exchange an identity token for a Model API key. Keys are valid for about a day. */ +async function mintApiKey(identityToken: string, signal: AbortSignal): Promise { + const response = await fetch(API_KEY_MINT_URL, { + method: "POST", + headers: { + Accept: "application/json", + Authorization: `Bearer ${identityToken}`, + "Content-Type": "application/json", + "x-api-version": "1.0.0", + }, + body: "{}", + signal: requestSignal(signal), + }); + const json = await readJson(response); + if (response.status === 401 || response.status === 403) { + // Identity token is not renewable (see file header); only a fresh device flow helps. + throw new Error( + `Meta session expired (status ${response.status}). Run \`/login meta\` to sign in again.${errorDetail(json)}`, + ); + } + if (!response.ok) { + throw new Error(`Meta API key mint failed with status ${response.status}${errorDetail(json)}`); + } + const apiKey = json?.api_key; + if (typeof apiKey !== "string" || !apiKey) { + const actionUrl = trustedHttpUrl(json?.action_url); + throw new Error(`Meta did not issue an API key.${actionUrl ? ` Complete setup at ${actionUrl}` : ""}`); + } + return { type: "oauth", refresh: identityToken, access: apiKey, expires: Date.now() + API_KEY_LIFETIME_MS }; +} + +async function loginMeta(interaction: ProviderAuthInteraction): Promise { + try { + const device = await startDeviceAuthorization(interaction.signal); + interaction.notify({ + type: "device_code", + userCode: device.userCode, + verificationUri: device.verificationUri, + intervalSeconds: device.intervalSeconds, + expiresInSeconds: device.expiresInSeconds, + }); + const identityToken = await pollForIdentityToken(device, interaction.signal); + interaction.notify({ type: "progress", message: "Enabling Meta Model API access..." }); + return await mintApiKey(identityToken, interaction.signal); + } catch (e) { + // An in-flight fetch rejects with a DOMException on abort; the login UI matches on this message. + if (interaction.signal.aborted) throw new Error("Login cancelled"); + throw e; + } +} + +export const metaOAuth: OAuthAuth = { + name: "Meta (Muse subscription)", + isSubscription: true, + loginLabel: "Sign in with Meta", + + login: loginMeta, + + refresh: (credential, signal) => mintApiKey(credential.refresh, signal), + + async toAuth(credential) { + return { apiKey: credential.access }; + }, +}; diff --git a/packages/ai/src/bun-oauth.ts b/packages/ai/src/bun-oauth.ts index d1370214d..a969038ac 100644 --- a/packages/ai/src/bun-oauth.ts +++ b/packages/ai/src/bun-oauth.ts @@ -2,6 +2,7 @@ import { anthropicOAuth } from "./auth/oauth/anthropic.ts"; import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts"; import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts"; import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts"; +import { metaOAuth } from "./auth/oauth/meta.ts"; import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts"; import { openRouterOAuth } from "./auth/oauth/openrouter.ts"; import { createRadiusOAuth } from "./auth/oauth/radius.ts"; @@ -15,6 +16,7 @@ export function registerBunOAuthFlows(): void { githubCopilot: () => githubCopilotOAuth, openrouter: () => openRouterOAuth, kimiCoding: () => kimiCodingOAuth, + meta: () => metaOAuth, xai: () => xaiOAuth, radius: createRadiusOAuth, }); diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index 5b952b7b5..20e319ea6 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -107,6 +107,7 @@ function getApiKeyEnvVars(provider: string): readonly string[] | undefined { opencode: "OPENCODE_API_KEY", "opencode-go": "OPENCODE_API_KEY", "kimi-coding": "KIMI_API_KEY", + meta: "META_API_KEY", "cloudflare-workers-ai": "CLOUDFLARE_API_KEY", "cloudflare-ai-gateway": "CLOUDFLARE_API_KEY", xiaomi: "XIAOMI_API_KEY", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 5d051c119..8dd4d291f 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -17,6 +17,7 @@ import { GOOGLE_VERTEX_MODELS } from "./providers/google-vertex.models.ts"; import { GROQ_MODELS } from "./providers/groq.models.ts"; import { HUGGINGFACE_MODELS } from "./providers/huggingface.models.ts"; import { KIMI_CODING_MODELS } from "./providers/kimi-coding.models.ts"; +import { META_MODELS } from "./providers/meta.models.ts"; import { MINIMAX_MODELS } from "./providers/minimax.models.ts"; import { MINIMAX_CN_MODELS } from "./providers/minimax-cn.models.ts"; import { MISTRAL_MODELS } from "./providers/mistral.models.ts"; @@ -59,6 +60,7 @@ export const MODELS: { readonly "groq": typeof GROQ_MODELS; readonly "huggingface": typeof HUGGINGFACE_MODELS; readonly "kimi-coding": typeof KIMI_CODING_MODELS; + readonly "meta": typeof META_MODELS; readonly "minimax": typeof MINIMAX_MODELS; readonly "minimax-cn": typeof MINIMAX_CN_MODELS; readonly "mistral": typeof MISTRAL_MODELS; @@ -100,6 +102,7 @@ export const MODELS: { "groq": GROQ_MODELS, "huggingface": HUGGINGFACE_MODELS, "kimi-coding": KIMI_CODING_MODELS, + "meta": META_MODELS, "minimax": MINIMAX_MODELS, "minimax-cn": MINIMAX_CN_MODELS, "mistral": MISTRAL_MODELS, diff --git a/packages/ai/src/providers/all.ts b/packages/ai/src/providers/all.ts index 8785e94ae..1eedeb789 100644 --- a/packages/ai/src/providers/all.ts +++ b/packages/ai/src/providers/all.ts @@ -19,6 +19,7 @@ import { googleVertexProvider } from "./google-vertex.ts"; import { groqProvider } from "./groq.ts"; import { huggingfaceProvider } from "./huggingface.ts"; import { kimiCodingProvider } from "./kimi-coding.ts"; +import { metaProvider } from "./meta.ts"; import { minimaxProvider } from "./minimax.ts"; import { minimaxCnProvider } from "./minimax-cn.ts"; import { mistralProvider } from "./mistral.ts"; @@ -104,6 +105,7 @@ export function builtinProviders(): Provider[] { groqProvider(), huggingfaceProvider(), kimiCodingProvider(), + metaProvider(), minimaxProvider(), minimaxCnProvider(), mistralProvider(), diff --git a/packages/ai/src/providers/meta.models.ts b/packages/ai/src/providers/meta.models.ts new file mode 100644 index 000000000..40d274d47 --- /dev/null +++ b/packages/ai/src/providers/meta.models.ts @@ -0,0 +1,8 @@ +// This file is auto-generated by scripts/generate-models.ts +// Do not edit manually - run 'npm run generate-models' to update + +import values from "./data/meta.json" with { type: "json" }; +import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts"; + +export const META_MODELS: ModelCatalog = + flattenModelCatalog("meta", values); diff --git a/packages/ai/src/providers/meta.ts b/packages/ai/src/providers/meta.ts new file mode 100644 index 000000000..228c6cde4 --- /dev/null +++ b/packages/ai/src/providers/meta.ts @@ -0,0 +1,24 @@ +import { openAIResponsesApi } from "../api/openai-responses.lazy.ts"; +import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts"; +import { loadMetaOAuth } from "../auth/oauth/load.ts"; +import { createProvider, type Provider } from "../models.ts"; +import { META_MODELS } from "./meta.models.ts"; + +export function metaProvider(): Provider<"openai-responses"> { + return createProvider({ + id: "meta", + name: "Meta", + baseUrl: "https://api.meta.ai/v1", + auth: { + apiKey: envApiKeyAuth("Meta Model API key", ["META_API_KEY"]), + oauth: lazyOAuth({ + name: "Meta (Muse subscription)", + isSubscription: true, + loginLabel: "Sign in with Meta", + load: loadMetaOAuth, + }), + }, + models: Object.values(META_MODELS), + api: openAIResponsesApi(), + }); +} diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 72720808f..2f28cc893 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -64,6 +64,7 @@ export type KnownProvider = | "opencode" | "opencode-go" | "kimi-coding" + | "meta" | "cloudflare-workers-ai" | "cloudflare-ai-gateway" | "qwen-token-plan" diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index ada2b536f..ab02b8248 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -112,6 +112,8 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ { provider: "baseten", model: "zai-org/GLM-5.2", label: "baseten-glm-5.2" }, // Kimi For Coding { provider: "kimi-coding", model: "kimi-for-coding", label: "kimi-for-coding" }, + // Meta + { provider: "meta", model: "muse-spark-1.3", label: "meta-muse-spark-1.3" }, // Mistral { provider: "mistral", model: "devstral-medium-latest", label: "mistral-devstral-medium" }, // MiniMax diff --git a/packages/ai/test/meta-oauth.test.ts b/packages/ai/test/meta-oauth.test.ts new file mode 100644 index 000000000..3e2ad3268 --- /dev/null +++ b/packages/ai/test/meta-oauth.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { metaOAuth } from "../src/auth/oauth/meta.ts"; +import type { ProviderAuthInteraction } from "../src/auth/types.ts"; + +const CLIENT_ID = "1031625952748946"; +const DEVICE_AUTHORIZATION_URL = "https://auth.meta.com/oidc/device/authorization/"; +const DEVICE_TOKEN_URL = "https://auth.meta.com/oidc/device/token/"; +const MINT_URL = "https://api.meta.ai/muse-code/key"; +const DAY_MS = 24 * 60 * 60 * 1000; + +function jsonResponse(body: unknown, status: number = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +function getUrl(input: unknown): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.toString(); + if (input instanceof Request) return input.url; + throw new Error(`Unsupported fetch input: ${String(input)}`); +} + +function createInteraction(events: Array>): ProviderAuthInteraction { + return { + signal: new AbortController().signal, + prompt: async () => { + throw new Error("Meta login should not prompt"); + }, + notify: (event) => events.push(event), + }; +} + +describe("Meta OAuth", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it("logs in with the device flow and mints a Model API key", async () => { + vi.useFakeTimers(); + const startTime = new Date("2026-09-03T00:00:00Z"); + vi.setSystemTime(startTime); + + const events: Array> = []; + const pollResponses = [ + jsonResponse({ error: "authorization_pending" }, 400), + jsonResponse({ access_token: "identity-token", token_type: "Bearer" }), + ]; + + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = getUrl(input); + if (url === DEVICE_AUTHORIZATION_URL) { + expect(init?.method).toBe("POST"); + expect(new URLSearchParams(String(init?.body)).get("client_id")).toBe(CLIENT_ID); + return jsonResponse({ + device_code: "device-code-123", + user_code: "ABCD-1234", + verification_uri: "https://auth.meta.com/oauth/device/", + verification_uri_complete: "https://auth.meta.com/oauth/device/?code=ABCD-1234", + interval: 5, + expires_in: 600, + }); + } + if (url === DEVICE_TOKEN_URL) { + const params = new URLSearchParams(String(init?.body)); + expect(params.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code"); + expect(params.get("client_id")).toBe(CLIENT_ID); + expect(params.get("device_code")).toBe("device-code-123"); + const response = pollResponses.shift(); + if (!response) throw new Error("Unexpected extra token poll"); + return response; + } + if (url === MINT_URL) { + expect(init?.method).toBe("POST"); + expect(init?.headers).toMatchObject({ Authorization: "Bearer identity-token" }); + return jsonResponse({ api_key: "LLM|minted-key" }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }), + ); + + const credentialPromise = metaOAuth.login(createInteraction(events)); + for (let i = 0; i < 5 && events.length === 0; i++) { + await vi.advanceTimersByTimeAsync(0); + } + expect(events[0]).toEqual({ + type: "device_code", + userCode: "ABCD-1234", + verificationUri: "https://auth.meta.com/oauth/device/?code=ABCD-1234", + intervalSeconds: 5, + expiresInSeconds: 600, + }); + + await vi.advanceTimersByTimeAsync(10000); + await expect(credentialPromise).resolves.toEqual({ + type: "oauth", + refresh: "identity-token", + access: "LLM|minted-key", + expires: startTime.getTime() + 10000 + DAY_MS, + }); + }); + + it("re-mints the API key from the stored identity token on refresh", async () => { + vi.useFakeTimers(); + const now = new Date("2026-09-03T12:00:00Z"); + vi.setSystemTime(now); + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + expect(getUrl(input)).toBe(MINT_URL); + expect(init?.headers).toMatchObject({ Authorization: "Bearer identity-token" }); + return jsonResponse({ api_key: "LLM|fresh-key" }); + }), + ); + + await expect( + metaOAuth.refresh( + { type: "oauth", refresh: "identity-token", access: "LLM|old-key", expires: 1 }, + new AbortController().signal, + ), + ).resolves.toEqual({ + type: "oauth", + refresh: "identity-token", + access: "LLM|fresh-key", + expires: now.getTime() + DAY_MS, + }); + }); + + it("reports the setup URL when Meta issues no key", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse({ require_payment: true, action_url: "https://dev.meta.ai/billing" })), + ); + await expect( + metaOAuth.refresh( + { type: "oauth", refresh: "identity-token", access: "", expires: 1 }, + new AbortController().signal, + ), + ).rejects.toThrow("Complete setup at https://dev.meta.ai/billing"); + }); + + it("uses the minted key as the request api key", async () => { + await expect( + metaOAuth.toAuth({ type: "oauth", refresh: "identity-token", access: "LLM|key", expires: 1 }), + ).resolves.toEqual({ apiKey: "LLM|key" }); + }); +}); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 6614b1164..362f30143 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -1067,6 +1067,30 @@ describe("Generate E2E Tests", () => { }, ); + describe.skipIf(!process.env.META_API_KEY)("Meta Provider (muse-spark-1.3 via OpenAI Responses)", () => { + const llm = getModel("meta", "muse-spark-1.3"); + + it("should complete basic text generation", { retry: 3 }, async () => { + await basicTextGeneration(llm); + }); + + it("should handle tool calling", { retry: 3 }, async () => { + await handleToolCall(llm); + }); + + it("should handle streaming", { retry: 3 }, async () => { + await handleStreaming(llm); + }); + + it("should handle thinking mode", { retry: 3 }, async () => { + await handleThinking(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); + }); + + it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { + await multiTurn(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 }); + }); + }); + describe.skipIf(!process.env.XIAOMI_API_KEY)( "Xiaomi MiMo (API billing) Provider (Xiaomi MiMo-V2.5-Pro via Anthropic Messages)", () => { diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 95a64c3d1..f85535e9f 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -233,6 +233,14 @@ describe("Token Statistics on Abort", () => { }); }); + describe.skipIf(!process.env.META_API_KEY)("Meta Provider", () => { + const llm = getModel("meta", "muse-spark-1.3"); + + it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { + await testTokensOnAbort(llm); + }); + }); + describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider", () => { const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash"); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 46c6bb283..b33d5cf0b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -124,6 +124,7 @@ - Added inherited OpenAI-compatible `vllmPriority` and `supportsMaxOutputTokens` model settings for vLLM scheduler priority and OpenAI Responses output-token limits ([#9004](https://github.com/earendil-works/pi/pull/9004) by [@AppleDannyClegg](https://github.com/AppleDannyClegg), [#8941](https://github.com/earendil-works/pi/pull/8941) by [@scturtle](https://github.com/scturtle)). - Added inherited LaTeX rendering for relational algebra join symbols ([#9050](https://github.com/earendil-works/pi/pull/9050) by [@haoqixu](https://github.com/haoqixu)). - Added a clickable "Jump to latest message" label with the `tui.altScreen.bottom` shortcut to the fullscreen transcript while it is scrolled up ([#9080](https://github.com/earendil-works/pi/pull/9080) by [@rwachtler](https://github.com/rwachtler)). +- Added Meta (Muse subscription) login via `/login meta` with automatic Model API key refresh, plus `META_API_KEY` support. ### Changed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index d7b168530..ae0f18175 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -130,6 +130,7 @@ For each built-in provider, pi maintains a list of tool-capable models. Configur - Together AI - Baseten - Kimi For Coding +- Meta - MiniMax - Xiaomi MiMo - Xiaomi MiMo Token Plan (China) diff --git a/packages/coding-agent/docs/providers.md b/packages/coding-agent/docs/providers.md index bae375e08..ab23d2f35 100644 --- a/packages/coding-agent/docs/providers.md +++ b/packages/coding-agent/docs/providers.md @@ -20,6 +20,7 @@ Use `/login` in interactive mode, then select a provider: - Claude Pro/Max - GitHub Copilot - xAI (Grok/X subscription) +- Meta (Muse subscription) - OpenRouter (OAuth-minted API key billed from OpenRouter credits) - Radius @@ -44,6 +45,12 @@ Anthropic subscription auth is active for Claude Pro/Max accounts. Third-party h - Run `/login xai`, then select **Use a subscription** - `XAI_API_KEY` remains available through **Use an API key** +### Meta (Muse subscription) + +- Run `/login meta`, then select **Sign in with Meta** to open the device authorization flow +- The login mints a Model API key that is re-minted automatically about once a day +- `META_API_KEY` remains available through **Use an API key** + ### OpenRouter - Run `/login openrouter`, then select **Sign in with OpenRouter** to open the OpenRouter PKCE authorization flow @@ -94,6 +101,7 @@ pi | Together AI | `TOGETHER_API_KEY` | `together` | | Baseten | `BASETEN_API_KEY` | `baseten` | | Kimi For Coding | `KIMI_API_KEY` | `kimi-coding` | +| Meta | `META_API_KEY` | `meta` | | MiniMax | `MINIMAX_API_KEY` | `minimax` | | MiniMax (China) | `MINIMAX_CN_API_KEY` | `minimax-cn` | | Qwen Token Plan (existing catalog) | `QWEN_TOKEN_PLAN_API_KEY` | `qwen-token-plan` | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 8ad5da63e..284888d13 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -413,6 +413,7 @@ ${chalk.bold("Environment Variables:")} MOONSHOT_API_KEY - Moonshot AI API key OPENCODE_API_KEY - OpenCode Zen/OpenCode Go API key KIMI_API_KEY - Kimi For Coding API key + META_API_KEY - Meta Model API key CLOUDFLARE_API_KEY - Cloudflare API token (Workers AI and AI Gateway) CLOUDFLARE_ACCOUNT_ID - Cloudflare account id (required for both) CLOUDFLARE_GATEWAY_ID - Cloudflare AI Gateway slug (required for AI Gateway) diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 156476c05..64aa93608 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -49,6 +49,7 @@ export const defaultModelPerProvider: Record = { opencode: "kimi-k2.6", "opencode-go": "kimi-k2.6", "kimi-coding": "kimi-for-coding", + meta: "muse-spark-1.3", "cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6", "cloudflare-ai-gateway": "workers-ai/@cf/moonshotai/kimi-k2.6", "qwen-token-plan": "qwen3.7-max", diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index 129b52c4c..aac3dd6e5 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -190,6 +190,7 @@ const lazyResult = await build({ "github-copilot": join(aiDistDir, "auth", "oauth", "github-copilot.js"), "image-resize-worker": join(codingAgentDistDir, "utils", "image-resize-worker.js"), "kimi-coding": join(aiDistDir, "auth", "oauth", "kimi-coding.js"), + meta: join(aiDistDir, "auth", "oauth", "meta.js"), "openai-codex": join(aiDistDir, "auth", "oauth", "openai-codex.js"), openrouter: join(aiDistDir, "auth", "oauth", "openrouter.js"), radius: join(aiDistDir, "auth", "oauth", "radius.js"),