feat(muse): add Meta Muse provider with OAuth login and model catalog

This commit is contained in:
decolua committed 2026-10-01 09:56:27 +07:00
1 parent 2310ad4b14
commit 2880980709
15 files changed
+318 -13

No files matched your search

+3
View File
@@ -41,6 +41,9 @@ function applyAuth(headers, desc, credentials) {
const HEADER_HOOKS = { const HEADER_HOOKS = {
// Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID) // Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID)
kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)), kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)),
// Muse: x-api-version only on subscription (minted key) requests — plain
// Model API keys already work without it
museHeaders: (h, c) => { if (c?.accessToken && !c?.apiKey) h["x-api-version"] = "1.0.0"; },
clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)), clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)),
kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; }, kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; },
}; };
+9
View File
@@ -156,6 +156,15 @@ export const MODEL_PRICING = {
// === Grok === // === Grok ===
"grok-code-fast-1": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, "grok-code-fast-1": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 },
// === Muse (Meta Model API) ===
// Rates from https://dev.meta.ai/docs/pricing-rate-limits (contributor tier:
// cheaper, Meta may train on the data).
"muse-spark-1.3": { input: 1.25, output: 4.25, cached: 0.15, reasoning: 4.25, cache_creation: 0 },
"muse-spark-1.2": { input: 1.25, output: 4.25, cached: 0.15, reasoning: 4.25, cache_creation: 0 },
"muse-spark-1.1": { input: 1.25, output: 4.25, cached: 0.15, reasoning: 4.25, cache_creation: 0 },
"muse-spark-1.3-contributor": { input: 0.10, output: 0.20, cached: 0.002, reasoning: 0.20, cache_creation: 0 },
"muse-spark-1.2-contributor": { input: 0.10, output: 0.20, cached: 0.002, reasoning: 0.20, cache_creation: 0 },
// === OpenRouter fallback === // === OpenRouter fallback ===
"auto": { input: 2.00, output: 8.00, cached: 1.00, reasoning: 12.00, cache_creation: 2.00 }, "auto": { input: 2.00, output: 8.00, cached: 1.00, reasoning: 12.00, cache_creation: 2.00 },
+2
View File
@@ -132,6 +132,7 @@ import p129 from "./agnes.js";
import p130 from "./bai.js"; import p130 from "./bai.js";
import p131 from "./tinyfish.js"; import p131 from "./tinyfish.js";
import p132 from "./v1m.js"; import p132 from "./v1m.js";
import p133 from "./muse.js";
export default [ export default [
p0, p0,
p1, p1,
@@ -264,4 +265,5 @@ export default [
p130, p130,
p131, p131,
p132, p132,
p133,
]; ];
+67
View File
@@ -0,0 +1,67 @@
// Muse (Meta Model API) — dual auth (same pattern as kimi):
// oauth = Muse Code subscription (Meta account device code, mints an LLM|… key)
// apikey = pay-as-you-go Model API key from dev.meta.ai
// Transport is shared; oauth accounts get x-api-version via the museHeaders hook.
// Meta issues no refresh token for the subscription flow → re-login on 401.
export default {
id: "muse",
priority: 120,
alias: "muse",
aliases: [
"muse-ai",
"meta-model-api",
"muse-code",
"muse-subscription",
],
uiAlias: "muse",
display: {
name: "Muse (Meta Model API)",
icon: "auto_awesome",
color: "#0866FF",
textIcon: "MU",
website: "https://muse.ai",
notice: {
text: "Sign in with your Meta account (Muse Code subscription) or paste a Model API key from dev.meta.ai. Subscription keys are minted per account; Meta may train on contributor-tier data.",
apiKeyUrl: "https://dev.meta.ai",
signupUrl: "https://muse.ai",
},
},
category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
transport: {
baseUrl: "https://api.meta.ai/v1/chat/completions",
validateUrl: "https://api.meta.ai/v1/models",
modelsUrl: "https://api.meta.ai/v1/models",
auth: { combined: true, header: "Authorization", scheme: "bearer", hooks: ["museHeaders"] },
},
// Multi-endpoint: Meta accepts Chat Completions and Responses wire formats on
// the same key (https://dev.meta.ai/docs/protocols). Muse Spark reasoning
// (incl. encrypted_content replay) only round-trips on Responses, so models
// pin targetFormat there.
transports: [
{
format: "openai",
baseUrl: "https://api.meta.ai/v1/chat/completions",
auth: { combined: true, header: "Authorization", scheme: "bearer", hooks: ["museHeaders"] },
},
{
format: "openai-responses",
baseUrl: "https://api.meta.ai/v1/responses",
auth: { combined: true, header: "Authorization", scheme: "bearer", hooks: ["museHeaders"] },
},
],
models: [
{ id: "muse-spark-1.3", name: "Muse Spark 1.3", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "muse-spark-1.2", name: "Muse Spark 1.2", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "muse-spark-1.1", name: "Muse Spark 1.1", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
{ id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] },
],
passthroughModels: true,
oauth: {
clientId: "1031625952748946",
deviceCodeUrl: "https://auth.meta.com/oidc/device/authorization/",
tokenUrl: "https://auth.meta.com/oidc/device/token/",
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

@@ -265,6 +265,7 @@ export async function GET(request, { params }) {
"qoder", "qoder",
"qoder-cn", "qoder-cn",
"grok-cli", "grok-cli",
"muse",
]; ];
let deviceData; let deviceData;
if (noPkceDeviceProviders.includes(provider)) { if (noPkceDeviceProviders.includes(provider)) {
@@ -552,6 +553,8 @@ export async function POST(request, { params }) {
error: result.error, error: result.error,
errorDescription: result.errorDescription, errorDescription: result.errorDescription,
pending: isPending, pending: isPending,
// fatal: unrecoverable (e.g. post-exchange failure) — client must stop polling and show it
...(result.fatal ? { fatal: true } : {}),
}); });
} }
@@ -169,6 +169,14 @@ function buildQoderModelsResolver(providerId) {
// Provider models endpoints configuration // Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG = { const PROVIDER_MODELS_CONFIG = {
"muse": {
url: "https://api.meta.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json", "x-api-version": "1.0.0" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
claude: { claude: {
url: "https://api.anthropic.com/v1/models", url: "https://api.anthropic.com/v1/models",
method: "GET", method: "GET",
+11 -1
View File
@@ -137,6 +137,15 @@ const OAUTH_TEST_CONFIG = {
402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.", 402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.",
}, },
}, },
// Muse Code subscription — probe /v1/models with the minted LLM|… key
"muse": {
url: "https://api.meta.ai/v1/models",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: { "x-api-version": "1.0.0" },
refreshable: false,
},
}; };
/** /**
@@ -703,7 +712,8 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
case "dahl": case "dahl":
case "atria": case "atria":
case "agnes": case "agnes":
case "bai": { case "bai":
case "muse": {
const cfg = PROVIDERS[connection.provider]; const cfg = PROVIDERS[connection.provider];
const res = await fetchWithConnectionProxy(cfg.validateUrl, { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); const res = await fetchWithConnectionProxy(cfg.validateUrl, { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+4
View File
@@ -127,6 +127,10 @@ export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] };
// Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes // Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes
export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] }; export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] };
// Muse — subscription device code flow to auth.meta.com, no refresh
// (Meta rejects refresh_token grants; the minted Model API key never expires).
export const MUSE_CONFIG = { ...PROVIDER_OAUTH["muse"] };
// Trae (ByteDance marscode) OAuth — authorization_code flow with local callback. // Trae (ByteDance marscode) OAuth — authorization_code flow with local callback.
// 1) POST GetLoginGuidance {loginTraceID} → {Result.LoginHost} // 1) POST GetLoginGuidance {loginTraceID} → {Result.LoginHost}
// 2) Browser opens ${loginHost}/authorization?client_id=...&login_trace_id=...&auth_callback_url=${cb} // 2) Browser opens ${loginHost}/authorization?client_id=...&login_trace_id=...&auth_callback_url=${cb}
+11 -1
View File
@@ -8,6 +8,7 @@ import claude from "./claude.js";
import codex from "./codex.js"; import codex from "./codex.js";
import xai from "./xai.js"; import xai from "./xai.js";
import grokCli from "./grok-cli.js"; import grokCli from "./grok-cli.js";
import muse from "./muse.js";
import geminiCli from "./gemini-cli.js"; import geminiCli from "./gemini-cli.js";
import antigravity from "./antigravity.js"; import antigravity from "./antigravity.js";
import iflow from "./iflow.js"; import iflow from "./iflow.js";
@@ -34,6 +35,7 @@ const PROVIDERS = {
codex, codex,
xai, xai,
"grok-cli": grokCli, "grok-cli": grokCli,
muse,
"gemini-cli": geminiCli, "gemini-cli": geminiCli,
antigravity, antigravity,
iflow, iflow,
@@ -174,7 +176,15 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra
// Call postExchange to get additional data (copilotToken, userInfo, etc.) // Call postExchange to get additional data (copilotToken, userInfo, etc.)
let extra = null; let extra = null;
if (provider.postExchange) { if (provider.postExchange) {
extra = await provider.postExchange(result.data); try {
extra = await provider.postExchange(result.data);
} catch (err) {
// The grant succeeded but post-login exchange failed (e.g. Muse key
// mint). The device code is one-shot, so re-polling can never
// recover — surface as fatal so the client stops and shows the error.
console.warn(`[oauth] ${providerName} postExchange failed:`, err?.message || err);
return { success: false, error: "exchange_failed", errorDescription: err.message, fatal: true };
}
} }
const tokens = provider.mapTokens(result.data, extra); const tokens = provider.mapTokens(result.data, extra);
// Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403 // Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403
+132
View File
@@ -0,0 +1,132 @@
import { MUSE_CONFIG } from "../constants/oauth.js";
// Muse Code subscription — Meta account device code flow to auth.meta.com,
// then mint the Model API key (LLM|…) the chat transport actually uses.
const MUSE_KEY_URL = "https://api.meta.ai/muse-code/key";
const API_VERSION = "1.0.0";
const muse = {
config: MUSE_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"x-api-version": API_VERSION,
},
body: new URLSearchParams({ client_id: config.clientId }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Muse Code device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"x-api-version": API_VERSION,
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: config.clientId,
}),
});
let data;
try {
data = await response.json();
} catch {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
const pending =
data?.error === "authorization_pending" ||
data?.error === "slow_down";
return { ok: response.ok || pending, data };
},
postExchange: async (tokens) => {
// Mint the subscription API key; onboard:true enrolls the account on first
// login. The endpoint is aggressively rate-limited (429) and the device code
// is one-shot, so retry transient failures here instead of failing the login.
let response;
for (let attempt = 0; ; attempt++) {
response = await fetch(MUSE_KEY_URL, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
"x-api-version": API_VERSION,
},
body: JSON.stringify({ onboard: true }),
redirect: "error",
});
const transient = response.status === 429 || response.status >= 500;
if (!transient || attempt >= 2) break;
await new Promise((r) => setTimeout(r, 5000 * (attempt + 1)));
}
const text = await response.text();
if (!response.ok) {
// Meta's error envelope (e.g. 403 code 4705002) carries the fix-it URL
let msg = `${response.status} ${text.slice(0, 200)}`;
try {
const err = JSON.parse(text);
if (err?.title || err?.detail) {
msg = [err.title, err.detail].filter(Boolean).join(": ");
if (err.action_url) msg += ` — ${err.action_url}`;
}
} catch { /* non-JSON error body */ }
throw new Error(`Muse Code key mint failed: ${msg}`);
}
let payload;
try {
payload = JSON.parse(text);
} catch {
throw new Error("Muse Code key mint returned invalid JSON");
}
if (payload.is_subs_active === false) {
throw new Error("Muse Code subscription is inactive — activate it on muse.ai first");
}
const actionUrl = payload.action_url || payload.require_payment_action_url;
if (!payload.api_key && (payload.require_payment || actionUrl)) {
throw new Error(`Muse Code subscription required${actionUrl ? `: ${actionUrl}` : ""}`);
}
if (!payload.api_key) {
throw new Error("Muse Code key response is missing api_key");
}
return { key: payload };
},
mapTokens: (tokens, extra) => {
const payload = extra?.key || {};
// Chat requests carry the minted Model API key, not the Meta account token.
// No expiry/refresh from Meta — a dead key means re-login.
return {
accessToken: payload.api_key,
refreshToken: null,
expiresIn: null,
email: payload.user_email?.trim().toLowerCase() || undefined,
providerSpecificData: {
authMethod: "device_code",
// Kept so a future re-mint can run without another device login
oauthAccessToken: tokens.access_token,
subscriptionTier: payload.subs_tier_name || null,
},
};
},
};
export default muse;
+2 -1
View File
@@ -175,7 +175,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
return; return;
} }
if (data.error === "expired_token" || data.error === "access_denied") { if (data.error === "expired_token" || data.error === "access_denied" || data.fatal) {
throw new Error(data.errorDescription || data.error); throw new Error(data.errorDescription || data.error);
} }
@@ -291,6 +291,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
"qoder", "qoder",
"qoder-cn", "qoder-cn",
"grok-cli", "grok-cli",
"muse",
]; ];
if (deviceCodeProviders.includes(provider)) { if (deviceCodeProviders.includes(provider)) {
setIsDeviceCode(true); setIsDeviceCode(true);
+10 -1
View File
@@ -116,7 +116,12 @@
"devin": "devin", "devin": "devin",
"devin-cli": "devin-cli", "devin-cli": "devin-cli",
"morph": "morph", "morph": "morph",
"morphllm": "morph" "morphllm": "morph",
"muse": "muse",
"muse-ai": "muse",
"meta-model-api": "muse",
"muse-code": "muse",
"muse-subscription": "muse"
}, },
"idToAlias": { "idToAlias": {
"agnes": "agnes", "agnes": "agnes",
@@ -176,6 +181,7 @@
"mistral": "mistral", "mistral": "mistral",
"mmf": "mmf", "mmf": "mmf",
"morph": "morph", "morph": "morph",
"muse": "muse",
"nanobanana": "nanobanana", "nanobanana": "nanobanana",
"nebius": "nebius", "nebius": "nebius",
"nvidia": "nvidia", "nvidia": "nvidia",
@@ -211,6 +217,7 @@
"modelKeys": [ "modelKeys": [
"af", "af",
"ag", "ag",
"agnes",
"alicode", "alicode",
"alicode-intl", "alicode-intl",
"alims-intl", "alims-intl",
@@ -270,6 +277,7 @@
"mistral", "mistral",
"mmf", "mmf",
"morph", "morph",
"muse",
"nanobanana", "nanobanana",
"nebius", "nebius",
"nvidia", "nvidia",
@@ -302,6 +310,7 @@
"together", "together",
"tokenharbor", "tokenharbor",
"tokenrouter", "tokenrouter",
"v1m",
"venice", "venice",
"vertex", "vertex",
"vertex-partner", "vertex-partner",
+54 -9
View File
@@ -121,7 +121,9 @@
"usage": { "usage": {
"oauthUrl": "https://api.anthropic.com/api/oauth/usage", "oauthUrl": "https://api.anthropic.com/api/oauth/usage",
"orgUrl": "https://api.anthropic.com/v1/organizations/{org_id}/usage", "orgUrl": "https://api.anthropic.com/v1/organizations/{org_id}/usage",
"settingsUrl": "https://api.anthropic.com/v1/settings" "settingsUrl": "https://api.anthropic.com/v1/settings",
"profileUrl": "https://api.anthropic.com/api/oauth/profile",
"resetUrl": "https://api.anthropic.com/api/organizations/{org_id}/reset_rate_limits"
}, },
"clientId": "9d1c250a-e61b-44d9-88ed-5944d1962f5e", "clientId": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
"tokenUrl": "https://api.anthropic.com/v1/oauth/token" "tokenUrl": "https://api.anthropic.com/v1/oauth/token"
@@ -199,10 +201,11 @@
"baseUrl": "https://chatgpt.com/backend-api/codex/responses", "baseUrl": "https://chatgpt.com/backend-api/codex/responses",
"format": "openai-responses", "format": "openai-responses",
"forceStream": true, "forceStream": true,
"cliVersion": "0.154.0", "cliVersion": "0.155.0",
"headers": { "headers": {
"originator": "codex_cli_rs", "originator": "codex_cli_rs",
"User-Agent": "codex_cli_rs/0.154.0" "User-Agent": "codex_cli_rs/0.155.0",
"version": "0.155.0"
}, },
"usage": { "usage": {
"url": "https://chatgpt.com/backend-api/wham/usage", "url": "https://chatgpt.com/backend-api/wham/usage",
@@ -1086,6 +1089,14 @@
}, },
"format": "openai" "format": "openai"
}, },
"tokenharbor": {
"baseUrl": "https://tokenharbor.ai/v1/chat/completions",
"validateUrl": "https://tokenharbor.ai/v1/models",
"retry": {
"429": 2
},
"format": "openai"
},
"dahl": { "dahl": {
"baseUrl": "https://inference.dahl.global/v1/chat/completions", "baseUrl": "https://inference.dahl.global/v1/chat/completions",
"validateUrl": "https://inference.dahl.global/v1/models", "validateUrl": "https://inference.dahl.global/v1/models",
@@ -1106,12 +1117,46 @@
"validateUrl": "https://api.b.ai/v1/models", "validateUrl": "https://api.b.ai/v1/models",
"format": "openai" "format": "openai"
}, },
"tokenharbor": { "muse": {
"baseUrl": "https://tokenharbor.ai/v1/chat/completions", "baseUrl": "https://api.meta.ai/v1/chat/completions",
"validateUrl": "https://tokenharbor.ai/v1/models", "validateUrl": "https://api.meta.ai/v1/models",
"retry": { "modelsUrl": "https://api.meta.ai/v1/models",
"429": 2 "auth": {
"combined": true,
"header": "Authorization",
"scheme": "bearer",
"hooks": [
"museHeaders"
]
}, },
"format": "openai" "format": "openai",
"clientId": "1031625952748946",
"tokenUrl": "https://auth.meta.com/oidc/device/token/",
"transports": [
{
"format": "openai",
"baseUrl": "https://api.meta.ai/v1/chat/completions",
"auth": {
"combined": true,
"header": "Authorization",
"scheme": "bearer",
"hooks": [
"museHeaders"
]
}
},
{
"format": "openai-responses",
"baseUrl": "https://api.meta.ai/v1/responses",
"auth": {
"combined": true,
"header": "Authorization",
"scheme": "bearer",
"hooks": [
"museHeaders"
]
}
}
]
} }
} }
+2
View File
@@ -23,6 +23,8 @@ const ALIAS_TOKENS = [
"af","airforce","api-airforce","llm7","llm-7","samba","sambanova","bm","bluesminds", "af","airforce","api-airforce","llm7","llm-7","samba","sambanova","bm","bluesminds",
"bzl","bazaarlink","kgw","kilo-gateway","hunyuan","tencent","qianfan","baidu","ernie", "bzl","bazaarlink","kgw","kilo-gateway","hunyuan","tencent","qianfan","baidu","ernie",
"dv","devin","devin-cli","morph","morphllm", "dv","devin","devin-cli","morph","morphllm",
"muse","muse-ai","meta-model-api",
"muse-code","muse-subscription",
]; ];
// Sort idToAlias by key — runtime accesses by key, order is irrelevant (content-based) // Sort idToAlias by key — runtime accesses by key, order is irrelevant (content-based)