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 00000000..560e064e Binary files /dev/null and b/public/providers/muse.png differ 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)