From 288098070906f02d3ce808855514e96c9697a447 Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 1 Oct 2026 09:56:27 +0700 Subject: [PATCH] feat(muse): add Meta Muse provider with OAuth login and model catalog --- open-sse/executors/default.js | 3 + open-sse/providers/pricing.js | 9 ++ open-sse/providers/registry/index.js | 2 + open-sse/providers/registry/muse.js | 67 +++++++++ public/providers/muse.png | Bin 0 -> 5928 bytes .../api/oauth/[provider]/[action]/route.js | 5 +- src/app/api/providers/[id]/models/route.js | 8 ++ src/app/api/providers/[id]/test/testUtils.js | 12 +- src/lib/oauth/constants/oauth.js | 4 + src/lib/oauth/providers/index.js | 12 +- src/lib/oauth/providers/muse.js | 132 ++++++++++++++++++ src/shared/components/OAuthModal.js | 3 +- tests/__baseline__/alias-baseline.json | 11 +- tests/__baseline__/providers-baseline.json | 63 +++++++-- tests/__baseline__/verify-alias.mjs | 2 + 15 files changed, 319 insertions(+), 14 deletions(-) create mode 100644 open-sse/providers/registry/muse.js create mode 100644 public/providers/muse.png create mode 100644 src/lib/oauth/providers/muse.js diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index e5c59741..dbd42cfc 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -41,6 +41,9 @@ function applyAuth(headers, desc, credentials) { const HEADER_HOOKS = { // Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.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)), kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; }, }; diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index 09d896a5..a57e6bda 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -156,6 +156,15 @@ export const MODEL_PRICING = { // === Grok === "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 === "auto": { input: 2.00, output: 8.00, cached: 1.00, reasoning: 12.00, cache_creation: 2.00 }, diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 17d1fea9..6c317ee8 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -132,6 +132,7 @@ import p129 from "./agnes.js"; import p130 from "./bai.js"; import p131 from "./tinyfish.js"; import p132 from "./v1m.js"; +import p133 from "./muse.js"; export default [ p0, p1, @@ -264,4 +265,5 @@ export default [ p130, p131, p132, + p133, ]; diff --git a/open-sse/providers/registry/muse.js b/open-sse/providers/registry/muse.js new file mode 100644 index 00000000..4903980e --- /dev/null +++ b/open-sse/providers/registry/muse.js @@ -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/", + }, +}; diff --git a/public/providers/muse.png b/public/providers/muse.png new file mode 100644 index 0000000000000000000000000000000000000000..560e064eb21876f0298a8b122af2d79169158626 GIT binary patch literal 5928 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 literal 0 HcmV?d00001 diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index a40dd49f..b31a0d16 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -265,6 +265,7 @@ export async function GET(request, { params }) { "qoder", "qoder-cn", "grok-cli", + "muse", ]; let deviceData; if (noPkceDeviceProviders.includes(provider)) { @@ -546,12 +547,14 @@ export async function POST(request, { params }) { // Still pending or error - don't create connection for pending states const isPending = result.pending || result.error === "authorization_pending" || result.error === "slow_down"; - + return NextResponse.json({ success: false, error: result.error, errorDescription: result.errorDescription, pending: isPending, + // fatal: unrecoverable (e.g. post-exchange failure) — client must stop polling and show it + ...(result.fatal ? { fatal: true } : {}), }); } diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 4e990bca..37ebce4d 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -169,6 +169,14 @@ function buildQoderModelsResolver(providerId) { // Provider models endpoints configuration 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: { url: "https://api.anthropic.com/v1/models", method: "GET", diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index ecdac3e9..7f9eedeb 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -137,6 +137,15 @@ const OAUTH_TEST_CONFIG = { 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 "atria": case "agnes": - case "bai": { + case "bai": + case "muse": { const cfg = PROVIDERS[connection.provider]; const res = await fetchWithConnectionProxy(cfg.validateUrl, { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index cd35d331..9d9d2a1f 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -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 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. // 1) POST GetLoginGuidance {loginTraceID} → {Result.LoginHost} // 2) Browser opens ${loginHost}/authorization?client_id=...&login_trace_id=...&auth_callback_url=${cb} diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js index 86ed5338..80063950 100644 --- a/src/lib/oauth/providers/index.js +++ b/src/lib/oauth/providers/index.js @@ -8,6 +8,7 @@ import claude from "./claude.js"; import codex from "./codex.js"; import xai from "./xai.js"; import grokCli from "./grok-cli.js"; +import muse from "./muse.js"; import geminiCli from "./gemini-cli.js"; import antigravity from "./antigravity.js"; import iflow from "./iflow.js"; @@ -34,6 +35,7 @@ const PROVIDERS = { codex, xai, "grok-cli": grokCli, + muse, "gemini-cli": geminiCli, antigravity, iflow, @@ -174,7 +176,15 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra // Call postExchange to get additional data (copilotToken, userInfo, etc.) let extra = null; 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); // Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403 diff --git a/src/lib/oauth/providers/muse.js b/src/lib/oauth/providers/muse.js new file mode 100644 index 00000000..2d7f2c40 --- /dev/null +++ b/src/lib/oauth/providers/muse.js @@ -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; diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index 7a76a020..6b73bc8b 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -175,7 +175,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, 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); } @@ -291,6 +291,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, "qoder", "qoder-cn", "grok-cli", + "muse", ]; if (deviceCodeProviders.includes(provider)) { setIsDeviceCode(true); diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index 80d3af8a..65062bb8 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -116,7 +116,12 @@ "devin": "devin", "devin-cli": "devin-cli", "morph": "morph", - "morphllm": "morph" + "morphllm": "morph", + "muse": "muse", + "muse-ai": "muse", + "meta-model-api": "muse", + "muse-code": "muse", + "muse-subscription": "muse" }, "idToAlias": { "agnes": "agnes", @@ -176,6 +181,7 @@ "mistral": "mistral", "mmf": "mmf", "morph": "morph", + "muse": "muse", "nanobanana": "nanobanana", "nebius": "nebius", "nvidia": "nvidia", @@ -211,6 +217,7 @@ "modelKeys": [ "af", "ag", + "agnes", "alicode", "alicode-intl", "alims-intl", @@ -270,6 +277,7 @@ "mistral", "mmf", "morph", + "muse", "nanobanana", "nebius", "nvidia", @@ -302,6 +310,7 @@ "together", "tokenharbor", "tokenrouter", + "v1m", "venice", "vertex", "vertex-partner", diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 8c12ef73..d8fb0e92 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -121,7 +121,9 @@ "usage": { "oauthUrl": "https://api.anthropic.com/api/oauth/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", "tokenUrl": "https://api.anthropic.com/v1/oauth/token" @@ -199,10 +201,11 @@ "baseUrl": "https://chatgpt.com/backend-api/codex/responses", "format": "openai-responses", "forceStream": true, - "cliVersion": "0.154.0", + "cliVersion": "0.155.0", "headers": { "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": { "url": "https://chatgpt.com/backend-api/wham/usage", @@ -1086,6 +1089,14 @@ }, "format": "openai" }, + "tokenharbor": { + "baseUrl": "https://tokenharbor.ai/v1/chat/completions", + "validateUrl": "https://tokenharbor.ai/v1/models", + "retry": { + "429": 2 + }, + "format": "openai" + }, "dahl": { "baseUrl": "https://inference.dahl.global/v1/chat/completions", "validateUrl": "https://inference.dahl.global/v1/models", @@ -1106,12 +1117,46 @@ "validateUrl": "https://api.b.ai/v1/models", "format": "openai" }, - "tokenharbor": { - "baseUrl": "https://tokenharbor.ai/v1/chat/completions", - "validateUrl": "https://tokenharbor.ai/v1/models", - "retry": { - "429": 2 + "muse": { + "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" + ] }, - "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" + ] + } + } + ] } } \ No newline at end of file diff --git a/tests/__baseline__/verify-alias.mjs b/tests/__baseline__/verify-alias.mjs index a3fc1f91..b8c44cba 100644 --- a/tests/__baseline__/verify-alias.mjs +++ b/tests/__baseline__/verify-alias.mjs @@ -23,6 +23,8 @@ const ALIAS_TOKENS = [ "af","airforce","api-airforce","llm7","llm-7","samba","sambanova","bm","bluesminds", "bzl","bazaarlink","kgw","kilo-gateway","hunyuan","tencent","qianfan","baidu","ernie", "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)