mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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.
This commit is contained in:
parent
d1230ea200
commit
b73412a378
22 changed files with 481 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -2004,6 +2004,33 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ type OAuthFlowLoaders = {
|
|||
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
meta: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
xai: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
|
||||
};
|
||||
|
|
@ -53,6 +54,11 @@ export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
|
|||
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
|
||||
};
|
||||
|
||||
export const loadMetaOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.meta();
|
||||
return ((await importOAuthModule("./meta.ts")) as { metaOAuth: OAuthAuth }).metaOAuth;
|
||||
};
|
||||
|
||||
export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.xai();
|
||||
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
|
||||
|
|
|
|||
208
packages/ai/src/auth/oauth/meta.ts
Normal file
208
packages/ai/src/auth/oauth/meta.ts
Normal file
|
|
@ -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<Record<string, unknown> | null> {
|
||||
try {
|
||||
const json = await response.json();
|
||||
return json && typeof json === "object" ? (json as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorDetail(json: Record<string, unknown> | 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<DeviceAuthorization> {
|
||||
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<string> {
|
||||
return pollOAuthDeviceCodeFlow<string>({
|
||||
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<OAuthCredential> {
|
||||
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<OAuthCredential> {
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
8
packages/ai/src/providers/meta.models.ts
Normal file
8
packages/ai/src/providers/meta.models.ts
Normal file
|
|
@ -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<typeof values, "meta"> =
|
||||
flattenModelCatalog("meta", values);
|
||||
24
packages/ai/src/providers/meta.ts
Normal file
24
packages/ai/src/providers/meta.ts
Normal file
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ export type KnownProvider =
|
|||
| "opencode"
|
||||
| "opencode-go"
|
||||
| "kimi-coding"
|
||||
| "meta"
|
||||
| "cloudflare-workers-ai"
|
||||
| "cloudflare-ai-gateway"
|
||||
| "qwen-token-plan"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
149
packages/ai/test/meta-oauth.test.ts
Normal file
149
packages/ai/test/meta-oauth.test.ts
Normal file
|
|
@ -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<Record<string, unknown>>): 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<Record<string, unknown>> = [];
|
||||
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<Response> => {
|
||||
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<Response> => {
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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)",
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue