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:
Alexey Zaytsev 2026-09-20 06:13:43 -03:00 • committed by GitHub
parent d1230ea200
commit b73412a378
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 481 additions and 0 deletions

View file

@ -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

View file

@ -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` |

View file

@ -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

View file

@ -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;

View 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 };
},
};

View file

@ -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,
});

View file

@ -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",

View file

@ -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,

View file

@ -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(),

View 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);

View 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(),
});
}

View file

@ -64,6 +64,7 @@ export type KnownProvider =
| "opencode"
| "opencode-go"
| "kimi-coding"
| "meta"
| "cloudflare-workers-ai"
| "cloudflare-ai-gateway"
| "qwen-token-plan"

View file

@ -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

View 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" });
});
});

View file

@ -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)",
() => {

View file

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

View file

@ -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

View file

@ -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)

View file

@ -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` |

View file

@ -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)

View file

@ -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",

View file

@ -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"),