diff --git a/.env.example b/.env.example index 34744d642d58..868ac712d76f 100644 --- a/.env.example +++ b/.env.example @@ -1326,6 +1326,11 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann # ── Kimi Coding (Moonshot) ── KIMI_CODING_OAUTH_CLIENT_ID=17e5f671-d194-4dfb-9706-5516cb48c098 +# ── Muse Code (Meta) ── +# Public device-flow client id is baked into open-sse/utils/publicCreds.ts (muse_id). +# Set this only to override the Muse CLI client. Do not put the public id here. +# MUSE_CODE_OAUTH_CLIENT_ID= + # ── GitHub Copilot ── GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 diff --git a/changelog.d/features/14329-muse-code-oauth-cliproxy-parity.md b/changelog.d/features/14329-muse-code-oauth-cliproxy-parity.md new file mode 100644 index 000000000000..7e281a084491 --- /dev/null +++ b/changelog.d/features/14329-muse-code-oauth-cliproxy-parity.md @@ -0,0 +1 @@ +- **feat(oauth):** Muse Code (Meta) device-authorization login with CLIProxyAPI-parity credential lifecycle — RFC 8628 against auth.meta.com, mint the subscription inference key at `/muse-code/key`, persist the durable `dca:` token, remint on 401 / DCA-only records, honor `error.resets_at` quota cooldowns, and import CLIProxyAPI `type: meta` auth files. Dual-auth keeps pasted `META_API_KEY` on the same card. (#14329) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c9ee58231cda..5df4ce53a87e 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -608,6 +608,7 @@ Built-in credentials for **localhost development**. For remote deployments, regi | `GEMINI_OAUTH_CLIENT_ID` | Gemini (Google) | Requires matching `_SECRET`. | | `GEMINI_OAUTH_CLIENT_SECRET` | Gemini (Google) | — | | `KIMI_CODING_OAUTH_CLIENT_ID` | Kimi Coding (Moonshot) | Public client. | +| `MUSE_CODE_OAUTH_CLIENT_ID` | Muse Code (Meta) | Optional override for the public Muse CLI device-flow client id. Leave unset to use the baked-in public client. | | `ANTIGRAVITY_OAUTH_CLIENT_ID` | Antigravity (Google) | Requires matching `_SECRET`. | | `ANTIGRAVITY_OAUTH_CLIENT_SECRET` | Antigravity (Google) | — | | `GITHUB_OAUTH_CLIENT_ID` | GitHub Copilot | Public client. | diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 0eb59fbf33e6..19e611f1661f 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,14 +1,14 @@ --- title: "Provider Reference" version: 3.8.51 -lastUpdated: 2026-09-20 +lastUpdated: 2026-09-22 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-09-20 +> **Last generated:** 2026-09-22 Total providers: **360**. See category breakdown below. @@ -49,7 +49,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `veoaifree-web` | `veo-free` | Veo AI Free | No-auth, video | [link](https://veoaifree.com) | No auth required. Rate limited to 6 requests/hour per IP. | — | | `zcode` | `zc` | ZCode (GLM Coding Plan) | No-auth | [link](https://zcode.z.ai) | No API key stored by OmniRoute. The local ZCode app-server uses the existing builtin:zai-coding-plan login. | — | -## OAuth Providers (24) +## OAuth Providers (25) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -71,6 +71,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `kilocode` | `kc` | Kilo Code | OAuth | — | — | | `kimi-coding` | `kmc` | Kimi Code CLI | OAuth | [link](https://www.kimi.com/code?aff=omniroute) | Sign in with the same Kimi account used by Kimi Code CLI. OmniRoute uses the CLI OAuth flow and Kimi Coding Plan endpoints. | | `kiro` | `kr` | Kiro AI | OAuth | — | Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use. | +| `muse-code` | `mc` | Muse Code (Meta) | OAuth | [link](https://ai.developer.meta.com/docs/muse-code/auth) | Sign in with the Muse Code device flow (same as `muse login` / CLIProxyAPI `-meta-login`) to use a Muse subscription, or paste a META_API_KEY. Device login keeps the durable dca token and mints the inference key; a 401 remints that key. Wire format is OpenAI Responses (POST /responses). | | `openference` | `of` | Openference | OAuth | [link](https://openference.com) | Sign in with your Openference account to route requests through api.openference.com. Includes free-tier access to Qwen3.8 27b and Llama 3.2 3B — see openference.com/pricing for current plan terms. | | `qoder` | `if` | Qoder | OAuth | — | — | | `trae` | `tr` | Trae | OAuth | [link](https://trae.ai) | Trae is an AI-native IDE by ByteDance (SOLO remote agent). Authorize via trae.ai in the popup, or sign in at solo.trae.ai and paste the Cloud-IDE-JWT (sent as 'Authorization: Cloud-IDE-JWT ', ~14-day lifetime) as the access token; web_id/biz_user_id/user_unique_id/scope/tenant/region propagate via providerSpecificData. No headless refresh for pasted tokens — re-paste on expiry. | @@ -117,7 +118,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — | | `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — | -## API Key Providers (paid / paid-with-free-credits) (243) +## API Key Providers (paid / paid-with-free-credits) (242) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -259,7 +260,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | ⚠️ **DEPRECATED.** Monster API shuttered operations on 2026-06-30. Use alternative OpenAI-compatible providers. | | `moonshot` | `moonshot` | Kimi | API key | [link](https://platform.kimi.ai?aff=omniroute) | — | | `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | -| `muse-code` | `mc` | Muse Code (Meta) | API key | [link](https://github.com/meta-llama/llama-stack) | Use your META_API_KEY env var as a Bearer token. Muse Code CLI uses the OpenAI Responses API wire format (POST /responses). | | `naga-ac` | `naga` | Naga.ac | API key, aggregator | [link](https://naga.ac) | Get API key at naga.ac — Google/GitHub/Discord signup available. | | `naga-ai` | `naga-ai` | Naga AI | API key, aggregator | [link](https://naga.ac) | Models marked :free are publicly listed, but no numeric quota is confirmed. Naga's policy warns that free-tier prompts and outputs may be collected or used for training. | | `nanogpt` | `nanogpt` | NanoGPT | API key | [link](https://nano-gpt.com) | — | @@ -448,7 +448,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each - Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts) - Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts) -- Executors: [`open-sse/executors/`](../../open-sse/executors/) (121 implementations) +- Executors: [`open-sse/executors/`](../../open-sse/executors/) (124 implementations) - Translators: [`open-sse/translator/`](../../open-sse/translator/) ## See Also diff --git a/open-sse/config/museCode.ts b/open-sse/config/museCode.ts new file mode 100644 index 000000000000..d38a33a59dfa --- /dev/null +++ b/open-sse/config/museCode.ts @@ -0,0 +1,40 @@ +/** + * Muse Code (Meta) public endpoints and CLI identity. + * + * Parity with CLIProxyAPI's Meta provider (`internal/auth/meta`, + * `internal/runtime/executor/meta_executor*`): RFC 8628 device grant against + * auth.meta.com, then mint of the subscription inference key at + * api.meta.ai/muse-code/key. The Muse CLI User-Agent is required by Meta. + */ + +export const MUSE_CODE_USER_AGENT = "muse-code/1.0.2"; +/** Inference UA used by CLIProxyAPI's MetaExecutor (auth still uses muse-code/1.0.2). */ +export const MUSE_CODE_INFERENCE_USER_AGENT = + "muse-build/1.3.0 (interactive; macos-aarch64; build ac7280f2aca67769d1455a8847bb502b617d50f6)"; +export const MUSE_CODE_AUTH_HOST = "https://auth.meta.com"; +export const MUSE_CODE_DEVICE_CODE_URL = `${MUSE_CODE_AUTH_HOST}/oidc/device/authorization/`; +export const MUSE_CODE_TOKEN_URL = `${MUSE_CODE_AUTH_HOST}/oidc/device/token/`; +export const MUSE_CODE_MINT_URL = "https://api.meta.ai/muse-code/key"; +export const MUSE_CODE_API_BASE_URL = "https://api.meta.ai/v1"; +export const MUSE_CODE_RESPONSES_URL = `${MUSE_CODE_API_BASE_URL}/responses`; +export const MUSE_CODE_DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +export const MUSE_CODE_DEFAULT_POLL_INTERVAL_SEC = 5; +export const MUSE_CODE_MAX_POLL_MS = 15 * 60 * 1000; + +export function museCodeHeaders(extra?: Record): Record { + return { + Accept: "application/json", + "User-Agent": MUSE_CODE_USER_AGENT, + ...extra, + }; +} + +export function isMuseDcaToken(token: string | null | undefined): boolean { + return typeof token === "string" && token.trim().startsWith("dca:"); +} + +export function normalizeMuseBaseUrl(raw: string | null | undefined): string { + const trimmed = typeof raw === "string" ? raw.trim() : ""; + if (!trimmed) return MUSE_CODE_API_BASE_URL; + return trimmed.replace(/\/+$/, ""); +} diff --git a/open-sse/config/providers/registry/muse-code/index.ts b/open-sse/config/providers/registry/muse-code/index.ts index 37db1e988d44..5039d09331c8 100644 --- a/open-sse/config/providers/registry/muse-code/index.ts +++ b/open-sse/config/providers/registry/muse-code/index.ts @@ -1,11 +1,38 @@ +import { + MUSE_CODE_INFERENCE_USER_AGENT, + MUSE_CODE_RESPONSES_URL, +} from "../../../museCode.ts"; import type { RegistryEntry } from "../../shared.ts"; import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; +const MUSE_SPARK_THINKING = ["minimal", "low", "medium", "high", "xhigh", "max"] as const; + +function museSparkModel( + id: string, + name: string, + options: { vision?: boolean; thinking?: readonly string[] } = {} +) { + return { + id, + name, + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsXHighEffort: true, + supportedThinkingEfforts: options.thinking ?? MUSE_SPARK_THINKING, + toolCalling: true, + supportsVision: options.vision !== false, + targetFormat: "openai-responses" as const, + unsupportedParams: ["logprobs", "topLogprobs", "logitBias"], + }; +} + /** * Muse Code CLI — Meta's agentic coding tool. * * Wire format: OpenAI Responses API (POST /responses). - * Auth: Bearer token from META_API_KEY env var. + * Auth: dual — META_API_KEY / pasted Bearer key, or RFC 8628 device OAuth + * (CLIProxyAPI-parity: persist dca token, mint inference key, remint on 401). * Reasoning efforts: xhigh/ultra -> high (handled generically). * * @see https://github.com/joymadhu49/muse-openrouter-shim @@ -13,10 +40,24 @@ import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ id: "muse-code", alias: "mc", + baseUrl: MUSE_CODE_RESPONSES_URL, + extraHeaders: { "User-Agent": MUSE_CODE_INFERENCE_USER_AGENT }, passthroughModels: true, reasoningTransport: "opaque", defaultContextLength: 200000, models: [ + museSparkModel("muse-spark-1.3", "Muse Spark 1.3"), + museSparkModel("muse-spark-1.3-contributor", "Muse Spark 1.3 Contributor"), + museSparkModel("muse-spark-1.2", "Muse Spark 1.2", { + thinking: ["minimal", "low", "medium", "high", "xhigh"], + }), + museSparkModel("muse-spark-1.2-contributor", "Muse Spark 1.2 Contributor", { + thinking: ["minimal", "low", "medium", "high", "xhigh"], + }), + museSparkModel("muse-spark-1.1", "Muse Spark 1.1", { + vision: false, + thinking: ["low", "medium", "high", "xhigh"], + }), { id: "llama-4-maverick", name: "Llama 4 Maverick", diff --git a/open-sse/index.ts b/open-sse/index.ts index 245a9567c95c..178c88c9ddeb 100644 --- a/open-sse/index.ts +++ b/open-sse/index.ts @@ -64,6 +64,7 @@ export { refreshQoderToken, refreshGitHubToken, refreshCopilotToken, + refreshMuseCodeToken, getAccessToken, refreshTokenByProvider, } from "./services/tokenRefresh.ts"; diff --git a/open-sse/services/museCodeAuth.ts b/open-sse/services/museCodeAuth.ts new file mode 100644 index 000000000000..b36e35c87d7c --- /dev/null +++ b/open-sse/services/museCodeAuth.ts @@ -0,0 +1,80 @@ +import { + MUSE_CODE_MINT_URL, + museCodeHeaders, + normalizeMuseBaseUrl, +} from "../config/museCode.ts"; + +export type MuseMintedKey = { + apiKey: string; + baseUrl: string; + email?: string; + name?: string; + subsTierName?: string; + subsTierId?: string; + isSubsActive?: boolean; + hasPaymentMethod?: boolean; + requirePayment?: boolean; + canSubscribe?: boolean; +}; + +/** + * Exchange a Meta device-client access token (`dca:…`) for the subscription + * inference key. Matches CLIProxyAPI MintAPIKey. + */ +export async function mintMuseApiKey( + dcaToken: string, + mintUrl = MUSE_CODE_MINT_URL +): Promise { + const token = dcaToken.trim(); + if (!token) { + throw new Error("Muse Code mint requires a device access token."); + } + const response = await fetch(mintUrl, { + method: "POST", + headers: museCodeHeaders({ + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }), + body: JSON.stringify({ dca_token: token }), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`Muse Code key mint failed (HTTP ${response.status}).`); + } + let data: Record; + try { + data = text ? (JSON.parse(text) as Record) : {}; + } catch { + throw new Error("Muse Code key mint response was not JSON."); + } + const apiKey = + (typeof data.api_key === "string" && data.api_key.trim()) || + (typeof data.apiKey === "string" && data.apiKey.trim()) || + ""; + if (!apiKey) { + throw new Error("Muse Code key mint response missing api_key."); + } + return { + apiKey, + baseUrl: normalizeMuseBaseUrl( + (typeof data.base_url === "string" && data.base_url) || + (typeof data.baseUrl === "string" && data.baseUrl) || + "" + ), + email: + (typeof data.user_email === "string" && data.user_email.trim()) || + (typeof data.email === "string" && data.email.trim()) || + undefined, + name: + (typeof data.user_full_name === "string" && data.user_full_name.trim()) || + (typeof data.name === "string" && data.name.trim()) || + undefined, + subsTierName: typeof data.subs_tier_name === "string" ? data.subs_tier_name : undefined, + subsTierId: typeof data.subs_tier_id === "string" ? data.subs_tier_id : undefined, + isSubsActive: typeof data.is_subs_active === "boolean" ? data.is_subs_active : undefined, + hasPaymentMethod: + typeof data.has_payment_method === "boolean" ? data.has_payment_method : undefined, + requirePayment: typeof data.require_payment === "boolean" ? data.require_payment : undefined, + canSubscribe: typeof data.can_subscribe === "boolean" ? data.can_subscribe : undefined, + }; +} diff --git a/open-sse/services/retryAfterJson.ts b/open-sse/services/retryAfterJson.ts index c0e7cbce6daf..d7f3f3a3ea03 100644 --- a/open-sse/services/retryAfterJson.ts +++ b/open-sse/services/retryAfterJson.ts @@ -25,6 +25,22 @@ function futureTimestampMs(value: unknown, maxMs: number): number | null { return waitMs > 0 ? Math.min(waitMs, maxMs) : null; } +/** + * Meta Muse / CLIProxyAPI subscription quota uses `error.resets_at` as a unix + * epoch (seconds). Accept seconds, milliseconds, numeric strings, and ISO. + */ +function futureResetAtMs(value: unknown, maxMs: number): number | null { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + const ms = value < 1e12 ? value * 1000 : value; + const waitMs = ms - Date.now(); + return waitMs > 0 ? Math.min(waitMs, maxMs) : null; + } + if (typeof value === "string" && /^\d+(?:\.\d+)?$/.test(value.trim())) { + return futureResetAtMs(Number(value), maxMs); + } + return futureTimestampMs(value, maxMs); +} + // RetryInfo.retryDelay / "please retry in Ns" are short per-request throttling // hints (Gemini free-tier RPM/TPM), not long-lived quota resets like Antigravity's // "Resets in 160h" — cap them independently of the caller's maxMs so a malformed or @@ -90,6 +106,12 @@ export function parseDetailedRetryHintFromJsonBody( return { retryAfterMs: retryInfoMs, provenance: "google_rpc_retry_info" }; } + const resetAtHint = futureResetAtMs( + errorObj.resets_at ?? root.resets_at ?? errorObj.resetsAt ?? root.resetsAt, + maxMs + ); + if (resetAtHint !== null) return { retryAfterMs: resetAtHint, provenance: "body" }; + const isoHint = futureTimestampMs(errorObj.retryAfter ?? root.retryAfter, maxMs); if (isoHint !== null) return { retryAfterMs: isoHint, provenance: "body" }; diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index bd63c03dfd19..465bde991043 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -42,6 +42,7 @@ import { import { refreshCodebuddyCnToken } from "./tokenRefresh/providers/codebuddyCn.ts"; import { refreshClineToken } from "./tokenRefresh/providers/cline.ts"; import { refreshKimiCodingToken } from "./tokenRefresh/providers/kimiCoding.ts"; +import { refreshMuseCodeToken } from "./tokenRefresh/providers/museCode.ts"; import { refreshGitLabDuoToken } from "./tokenRefresh/providers/gitlabDuo.ts"; import { refreshClaudeOAuthToken } from "./tokenRefresh/providers/claudeOAuth.ts"; import { refreshGoogleToken } from "./tokenRefresh/providers/google.ts"; @@ -63,6 +64,7 @@ export { refreshCodebuddyCnToken, refreshClineToken, refreshKimiCodingToken, + refreshMuseCodeToken, refreshGitLabDuoToken, refreshClaudeOAuthToken, refreshGoogleToken, @@ -436,6 +438,14 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: proxyConfig ); + case "muse-code": + return await refreshMuseCodeToken( + credentials.refreshToken, + credentials.providerSpecificData, + log, + proxyConfig + ); + case "gitlab-duo": return await refreshGitLabDuoToken( credentials.refreshToken, @@ -470,6 +480,7 @@ export function supportsTokenRefresh(provider) { "amazon-q", "cline", "kimi-coding", + "muse-code", // Devin auth is not refreshable here: devin-desktop accepts an imported API // key (#8228), while devin-cli is local-CLI owned via `devin auth login` // (#8407). Neither connection carries a refresh token, so listing either diff --git a/open-sse/services/tokenRefresh/providers/museCode.ts b/open-sse/services/tokenRefresh/providers/museCode.ts new file mode 100644 index 000000000000..fd4a27729ab0 --- /dev/null +++ b/open-sse/services/tokenRefresh/providers/museCode.ts @@ -0,0 +1,58 @@ +import { MUSE_CODE_MINT_URL, isMuseDcaToken } from "../../../config/museCode.ts"; +import { mintMuseApiKey } from "../../museCodeAuth.ts"; +import { runWithProxyContext } from "../../../utils/proxyFetch.ts"; +import type { RefreshLogger } from "../shared.ts"; + +/** + * Muse Code has no refresh-token grant. CLIProxyAPI remints the inference key + * from the durable `dca:` device token on 401 / missing API key. OmniRoute + * stores that DCA token as `refreshToken`. + */ +export async function refreshMuseCodeToken( + refreshToken: string, + providerSpecificData: Record | null | undefined, + log: RefreshLogger, + proxyConfig: unknown = null +) { + const dcaFromData = + typeof providerSpecificData?.dcaToken === "string" ? providerSpecificData.dcaToken.trim() : ""; + const dcaToken = isMuseDcaToken(dcaFromData) + ? dcaFromData + : isMuseDcaToken(refreshToken) + ? refreshToken.trim() + : ""; + if (!dcaToken) { + log?.warn?.("TOKEN_REFRESH", "Muse Code refresh missing dca token"); + return null; + } + + try { + const minted = await runWithProxyContext(proxyConfig, () => + mintMuseApiKey(dcaToken, MUSE_CODE_MINT_URL) + ); + return { + accessToken: minted.apiKey, + refreshToken: dcaToken, + expiresIn: undefined, + providerSpecificData: { + ...(providerSpecificData && typeof providerSpecificData === "object" + ? providerSpecificData + : {}), + dcaToken, + baseUrl: minted.baseUrl, + email: minted.email, + name: minted.name, + subsTierName: minted.subsTierName, + subsTierId: minted.subsTierId, + isSubsActive: minted.isSubsActive, + hasPaymentMethod: minted.hasPaymentMethod, + requirePayment: minted.requirePayment, + canSubscribe: minted.canSubscribe, + lastRefresh: new Date().toISOString(), + }, + }; + } catch (err) { + log?.warn?.("TOKEN_REFRESH", `Muse Code remint failed: ${(err as Error)?.message || "error"}`); + return null; + } +} diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index af2de6c6c301..0fd3e829e367 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -189,6 +189,8 @@ const EMBEDDED_DEFAULTS = { // Firefly credits balance endpoint public x-api-key (`SunbreakWebUI1`) from // GET firefly.adobe.io/v1/credits/balance browser traffic. adobe_firefly_balance_api_key: [60, 24, 0, 11, 0, 10, 20, 31, 50, 72, 18, 32, 43, 93], + // Muse Code CLI — Meta public OAuth client id (device grant, no secret). + muse_id: [94, 93, 93, 88, 68, 93, 64, 77, 80, 31, 71, 65, 90, 85, 93, 85], } as const; export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS; diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 025a61a6a0b6..5f1b1ab4f912 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -69,6 +69,7 @@ const NO_PKCE_DEVICE_CODE_PROVIDERS = new Set([ "codebuddy-cn", "grok-cli", "ghe-copilot", + "muse-code", ]); /** diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index 549fbad96815..7a1456e47d9d 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -246,6 +246,12 @@ export const OAUTH_TEST_CONFIG: Record = { checkExpiry: true, refreshable: true, }, + "muse-code": { + // Minted Muse inference keys have no advertised expiry. Validate presence; + // remint from the stored dca token is the refresh path (CLIProxyAPI parity). + checkExpiry: true, + refreshable: true, + }, kilocode: { // Kilo OAuth does not expose a stable user-info endpoint in all environments. // Validate using token presence/expiry as a lightweight auth check. diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 86bfb025e2cd..b8aeb2452897 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -172,6 +172,15 @@ export const OPENFERENCE_CONFIG = { callbackHost: "127.0.0.1", }; +// Muse Code (Meta) OAuth — RFC 8628 device grant + subscription key mint. +// Public CLI client id (Muse Code CLI); PKCE is not used on this grant. +export const MUSE_CODE_CONFIG = { + clientId: resolvePublicCred("muse_id", "MUSE_CODE_OAUTH_CLIENT_ID"), + deviceCodeUrl: "https://auth.meta.com/oidc/device/authorization/", + tokenUrl: "https://auth.meta.com/oidc/device/token/", + mintUrl: "https://api.meta.ai/muse-code/key", +}; + // Kimi Coding OAuth Configuration (Device Code Flow) export const KIMI_CODING_CONFIG = { clientId: resolvePublicCred("kimi_id", "KIMI_CODING_OAUTH_CLIENT_ID"), @@ -516,4 +525,5 @@ export const PROVIDERS = { OPENFERENCE: "openference", ZED: "zed", ZED_HOSTED: "zed-hosted", + MUSE_CODE: "muse-code", }; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index cfd7ced721dc..77b5dfde214a 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -31,6 +31,7 @@ import { openference } from "./openference"; import { codebuddyCn } from "./codebuddy-cn"; import { zed } from "./zed"; import { zedHosted } from "./zed-hosted"; +import { museCode } from "./muse-code"; export const PROVIDERS = { claude, @@ -64,6 +65,7 @@ export const PROVIDERS = { // Zed IDE credential bridge — uses keychain import, not standard OAuth zed, "zed-hosted": zedHosted, + "muse-code": museCode, }; export default PROVIDERS; diff --git a/src/lib/oauth/providers/muse-code.ts b/src/lib/oauth/providers/muse-code.ts new file mode 100644 index 000000000000..610fd5da4f0e --- /dev/null +++ b/src/lib/oauth/providers/muse-code.ts @@ -0,0 +1,136 @@ +import { + MUSE_CODE_DEFAULT_POLL_INTERVAL_SEC, + MUSE_CODE_DEVICE_GRANT, + isMuseDcaToken, + museCodeHeaders, +} from "@omniroute/open-sse/config/museCode.ts"; +import { + mintMuseApiKey, + type MuseMintedKey, +} from "@omniroute/open-sse/services/museCodeAuth.ts"; +import { MUSE_CODE_CONFIG } from "../constants/oauth"; + +function requiredText(value: unknown, field: string): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error(`Muse Code device authorization response missing ${field}`); + } + return value.trim(); +} + +async function postForm( + url: string, + params: Record +): Promise<{ ok: boolean; status: number; data: Record }> { + const response = await fetch(url, { + method: "POST", + headers: museCodeHeaders({ "Content-Type": "application/x-www-form-urlencoded" }), + body: new URLSearchParams(params), + }); + const text = await response.text(); + let data: Record = {}; + try { + data = text ? (JSON.parse(text) as Record) : {}; + } catch { + data = { error: "invalid_response", error_description: text }; + } + return { ok: response.ok, status: response.status, data }; +} + +export const museCode = { + config: MUSE_CODE_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const { ok, data } = await postForm(config.deviceCodeUrl, { client_id: config.clientId }); + if (!ok) { + throw new Error("Muse Code device authorization request failed."); + } + const expiresIn = Number(data.expires_in); + if (!Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new Error("Muse Code returned an invalid device code expiry."); + } + const interval = Number(data.interval); + return { + device_code: requiredText(data.device_code, "device_code"), + user_code: requiredText(data.user_code, "user_code"), + verification_uri: typeof data.verification_uri === "string" ? data.verification_uri : "", + verification_uri_complete: + typeof data.verification_uri_complete === "string" + ? data.verification_uri_complete + : "", + expires_in: expiresIn, + interval: + Number.isFinite(interval) && interval > 0 + ? interval + : MUSE_CODE_DEFAULT_POLL_INTERVAL_SEC, + }; + }, + pollToken: async (config, deviceCode: string) => { + const { ok, data } = await postForm(config.tokenUrl, { + client_id: config.clientId, + device_code: deviceCode, + grant_type: MUSE_CODE_DEVICE_GRANT, + }); + return { ok, data }; + }, + /** + * After the device grant, mint the subscription inference key. CLIProxyAPI + * keeps the `dca:` token as the durable credential and remints the API key + * on 401. Mint at login is best-effort: a DCA-only record is still saved and + * reminted on the first request. We persist the DCA token as refreshToken. + */ + postExchange: async (tokens: { access_token?: string }) => { + const dcaToken = typeof tokens.access_token === "string" ? tokens.access_token.trim() : ""; + if (!dcaToken) { + throw new Error("Muse Code device flow completed without an access token."); + } + try { + const minted = await mintMuseApiKey(dcaToken); + return { minted, dcaToken }; + } catch { + return { dcaToken }; + } + }, + mapTokens: ( + tokens: Record, + extra?: { minted?: MuseMintedKey; dcaToken?: string } + ) => { + const dcaToken = + extra?.dcaToken || (typeof tokens.access_token === "string" ? tokens.access_token : ""); + const minted = extra?.minted; + const inferenceKey = minted?.apiKey || (isMuseDcaToken(dcaToken) ? "" : dcaToken); + const hasMintedKey = Boolean(minted?.apiKey); + const dcaExpiresAt = + typeof tokens.expires_in === "number" && Number.isFinite(tokens.expires_in) + ? Date.now() + tokens.expires_in * 1000 + : undefined; + return { + accessToken: inferenceKey || dcaToken, + refreshToken: dcaToken, + // Minted inference keys do not inherit the DCA expiry (CLIProxyAPI leaves + // `expired` empty once an API key exists). Remint is on-demand / 401. + expiresIn: hasMintedKey + ? undefined + : typeof tokens.expires_in === "number" + ? tokens.expires_in + : undefined, + tokenType: typeof tokens.token_type === "string" ? tokens.token_type : "Bearer", + email: minted?.email, + displayName: minted?.name, + providerSpecificData: { + dcaToken, + authKind: "oauth", + baseUrl: minted?.baseUrl, + email: minted?.email, + name: minted?.name, + subsTierName: minted?.subsTierName, + subsTierId: minted?.subsTierId, + isSubsActive: minted?.isSubsActive, + hasPaymentMethod: minted?.hasPaymentMethod, + requirePayment: minted?.requirePayment, + canSubscribe: minted?.canSubscribe, + dcaExpiresAt: dcaExpiresAt ? new Date(dcaExpiresAt).toISOString() : undefined, + lastRefresh: new Date().toISOString(), + }, + }; + }, +}; diff --git a/src/lib/oauth/utils/cliProxyAuthImport.ts b/src/lib/oauth/utils/cliProxyAuthImport.ts index 9d4169249815..8121c9d10046 100644 --- a/src/lib/oauth/utils/cliProxyAuthImport.ts +++ b/src/lib/oauth/utils/cliProxyAuthImport.ts @@ -25,6 +25,7 @@ export const CLIPROXY_TYPE_TO_PROVIDER: Record = { codex: "codex", antigravity: "antigravity", kimi: "kimi", + meta: "muse-code", }; export interface ParsedCliProxyAuth { @@ -35,6 +36,7 @@ export interface ParsedCliProxyAuth { refreshToken: string | null; expiresAt: string | null; projectId: string | null; + providerSpecificData?: Record; } function asString(value: unknown): string | null { @@ -76,6 +78,38 @@ export function parseCliProxyAuthRecord(raw: unknown, now: number = 0): ParsedCl const provider = CLIPROXY_TYPE_TO_PROVIDER[type]; if (!provider) return null; const accessToken = asString(record.access_token); + if (provider === "muse-code") { + const dcaToken = + asString(record.dca_token) || + (accessToken && accessToken.startsWith("dca:") ? accessToken : null); + const apiKey = asString(record.api_key); + const inferenceKey = + apiKey && !apiKey.startsWith("dca:") + ? apiKey + : accessToken && !accessToken.startsWith("dca:") + ? accessToken + : ""; + if (!inferenceKey && !dcaToken) return null; + const minted = Boolean(inferenceKey); + return { + provider, + type, + email: asString(record.email), + accessToken: inferenceKey || dcaToken || "", + refreshToken: dcaToken, + // Minted Muse keys do not inherit the DCA expiry clock. + expiresAt: minted ? null : resolveCliProxyExpiry(record, now), + projectId: null, + providerSpecificData: { + dcaToken: dcaToken ?? undefined, + baseUrl: asString(record.base_url) ?? undefined, + email: asString(record.email) ?? undefined, + name: asString(record.name) ?? undefined, + authKind: asString(record.auth_kind) || "oauth", + importedFrom: "cliproxyapi", + }, + }; + } if (!accessToken) return null; return { provider, @@ -103,6 +137,7 @@ export function toConnectionPayload(parsed: ParsedCliProxyAuth): JsonRecord { testStatus: "active", providerSpecificData: { ...(parsed.projectId ? { projectId: parsed.projectId } : {}), + ...(parsed.providerSpecificData || {}), importedFrom: "cliproxyapi", importedAt: new Date().toISOString(), }, diff --git a/src/lib/quota/planRegistry.ts b/src/lib/quota/planRegistry.ts index af618d8bd6be..e7f691d6fcf4 100644 --- a/src/lib/quota/planRegistry.ts +++ b/src/lib/quota/planRegistry.ts @@ -72,6 +72,12 @@ const KNOWN_PLANS: Record = { { unit: "tokens", window: "weekly", limit: Number.EPSILON }, ], }, + // Muse Code subscriptions (Everyday / High / Power / contributor) have no + // OmniRoute-side balance API. Upstream 429 bodies carry `error.resets_at`. + "muse-code": { + provider: "muse-code", + dimensions: [{ unit: "tokens", window: "weekly", limit: Number.EPSILON }], + }, // Xiaomi MiMo token plan (platform.xiaomimimo.com/token-plan) is a MONTHLY // allowance with no balance API. Default seeds the "lite" plan's 4.1B-token // monthly cap so the Wizard pre-fills a usable fair-share limit; adjust in the diff --git a/src/lib/usage/flatRateProviders.ts b/src/lib/usage/flatRateProviders.ts index 117dc010eb8c..efb41a02f591 100644 --- a/src/lib/usage/flatRateProviders.ts +++ b/src/lib/usage/flatRateProviders.ts @@ -39,6 +39,7 @@ const FLAT_RATE_SUBSCRIPTION_PROVIDER_IDS: ReadonlySet = new Set([ "minimax", // "Minimax Coding" plan "kimi-coding", // Kimi Coding plan (OAuth) "kimi-coding-apikey", // Kimi Coding plan (API-key auth, still flat-rate) + "muse-code", // Muse Code subscription (device OAuth minted key or META_API_KEY) "xiaomi-mimo", // Xiaomi MiMo plan (issue: "MiMo Token Plan") "bailian-coding-plan", // Alibaba Token Plan (legacy provider ID) "qwen-cloud-token-plan", // Qwen Cloud Token Plan diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 42c3884d65d6..c1c4080d6f5e 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -49,6 +49,7 @@ const DEVICE_CODE_PROVIDERS = new Set([ "codebuddy-cn", "ghe-copilot", "grok-cli", + "muse-code", ]); const TOKEN_PASTE_PROVIDERS = new Set(["devin-desktop", "devin-cli", "grok-cli"]); diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index 647379e59399..d29e28fde7e2 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -406,6 +406,7 @@ const LOBE_PROVIDER_ALIASES = { mistralai: "Mistral", moonshot: "Moonshot", morph: "Morph", + "muse-code": "MetaAI", "muse-spark-web": "MetaAI", nanobanana: "NanoBanana", nebius: "Nebius", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index b0ea495571b0..c8000bfb85ec 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -43,7 +43,7 @@ export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean { // Providers presented as one dashboard card with OAuth as the primary action // and a direct API-key alternative. Keep these out of FREE_APIKEY_PROVIDER_IDS. -const DUAL_AUTH_PROVIDER_IDS = new Set(["clinepass", "codebuddy-cn", "xai"]); +const DUAL_AUTH_PROVIDER_IDS = new Set(["clinepass", "codebuddy-cn", "xai", "muse-code"]); export function supportsDualAuthProvider(providerId: unknown): boolean { return typeof providerId === "string" && DUAL_AUTH_PROVIDER_IDS.has(providerId); diff --git a/src/shared/constants/providers/apikey/frontier-labs.ts b/src/shared/constants/providers/apikey/frontier-labs.ts index e22a0e43306e..a793af2980fd 100644 --- a/src/shared/constants/providers/apikey/frontier-labs.ts +++ b/src/shared/constants/providers/apikey/frontier-labs.ts @@ -341,20 +341,4 @@ export const APIKEY_PROVIDERS_FRONTIER = { "Writer Palmyra is OpenAI-compatible at https://api.writer.com/v1. palmyra-x5 offers a 1M-token context window.", hasFree: false, }, - "muse-code": { - id: "muse-code", - serviceKinds: ["llm"], - alias: "mc", - name: "Muse Code (Meta)", - icon: "auto_awesome", - color: "#0866FF", - textIcon: "MC", - website: "https://github.com/meta-llama/llama-stack", - authHint: - "Use your META_API_KEY env var as a Bearer token. Muse Code CLI uses the OpenAI Responses API wire format (POST /responses).", - apiHint: - "Muse Code is OpenAI-compatible. OmniRoute routes chat traffic through the Responses API and exposes the proprietary model catalog at /v1/muse-code/models.", - passthroughModels: true, - hasFree: false, - }, }; diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index 3c743c9bbf7f..1e92b7fb78a4 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -307,4 +307,19 @@ export const OAUTH_PROVIDERS = { authHint: "Tencent CodeBuddy CN (copilot.tencent.com). Sign in via the official CLI device-code flow, or paste a direct API key (sent as Authorization: Bearer). Catalog: GLM / Kimi / MiniMax / DeepSeek / Hunyuan.", }, + "muse-code": { + id: "muse-code", + serviceKinds: ["llm"], + alias: "mc", + name: "Muse Code (Meta)", + icon: "auto_awesome", + color: "#0866FF", + textIcon: "MC", + website: "https://ai.developer.meta.com/docs/muse-code/auth", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + passthroughModels: true, + authHint: + "Sign in with the Muse Code device flow (same as `muse login` / CLIProxyAPI `-meta-login`) to use a Muse subscription, or paste a META_API_KEY. Device login keeps the durable dca token and mints the inference key; a 401 remints that key. Wire format is OpenAI Responses (POST /responses).", + }, }; diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 217ee8166f5b..90cb7786e052 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -4103,19 +4103,25 @@ "apiKey": { "Accept": "text/event-stream", "Authorization": "Bearer ", - "Content-Type": "application/json" + "Content-Type": "application/json", + "User-Agent": "muse-build/1.3.0 (interactive; macos-aarch64; build ac7280f2aca67769d1455a8847bb502b617d50f6)" }, "nonStream": { "Authorization": "Bearer ", - "Content-Type": "application/json" + "Content-Type": "application/json", + "User-Agent": "muse-build/1.3.0 (interactive; macos-aarch64; build ac7280f2aca67769d1455a8847bb502b617d50f6)" }, "oauth": { "Accept": "text/event-stream", "Authorization": "Bearer ", - "Content-Type": "application/json" + "Content-Type": "application/json", + "User-Agent": "muse-build/1.3.0 (interactive; macos-aarch64; build ac7280f2aca67769d1455a8847bb502b617d50f6)" } }, - "url": {} + "url": { + "nonStream": "https://api.meta.ai/v1/responses", + "stream": "https://api.meta.ai/v1/responses" + } }, "muse-spark-web": { "format": "openai", diff --git a/tests/unit/cliproxy-auth-import-1934.test.ts b/tests/unit/cliproxy-auth-import-1934.test.ts index 8c5a01809de5..f3f5314271db 100644 --- a/tests/unit/cliproxy-auth-import-1934.test.ts +++ b/tests/unit/cliproxy-auth-import-1934.test.ts @@ -55,15 +55,38 @@ test("parseCliProxyAuthRecord returns null for unknown type or missing access to }); test("every CLIPROXY_TYPE_TO_PROVIDER target is a real OAuth provider id", () => { - // codex/antigravity/claude/kimi are all OmniRoute providers + // codex/antigravity/claude/kimi/muse-code are all OmniRoute providers for (const provider of Object.values(CLIPROXY_TYPE_TO_PROVIDER)) { assert.ok( - ["claude", "codex", "antigravity", "kimi"].includes(provider), + ["claude", "codex", "antigravity", "kimi", "muse-code"].includes(provider), `unexpected provider mapping: ${provider}` ); } }); +test("parseCliProxyAuthRecord maps a Meta Muse OAuth file keeping dca + minted key", () => { + const parsed = parseCliProxyAuthRecord( + { + type: "meta", + auth_kind: "oauth", + access_token: "LLM|minted", + api_key: "LLM|minted", + dca_token: "dca:durable", + base_url: "https://api.meta.ai/v1", + email: "muse@example.com", + name: "Muse User", + expired: "2030-01-01T00:00:00Z", + }, + T0 + ); + assert.equal(parsed?.provider, "muse-code"); + assert.equal(parsed?.accessToken, "LLM|minted"); + assert.equal(parsed?.refreshToken, "dca:durable"); + assert.equal(parsed?.expiresAt, null); + assert.equal(parsed?.providerSpecificData?.dcaToken, "dca:durable"); + assert.equal(parsed?.providerSpecificData?.baseUrl, "https://api.meta.ai/v1"); +}); + test("resolveCliProxyExpiry handles absolute `expired` (string + unix) and relative `expires_in`", () => { assert.equal( resolveCliProxyExpiry({ expired: "2030-01-01T00:00:00Z" }, T0), diff --git a/tests/unit/muse-code-models.test.ts b/tests/unit/muse-code-models.test.ts index 6a12789e9b16..9ef99028207c 100644 --- a/tests/unit/muse-code-models.test.ts +++ b/tests/unit/muse-code-models.test.ts @@ -26,6 +26,12 @@ test("muse-code models include llama-4-maverick", () => { assert.ok(ids.includes("llama-4-maverick"), "must include llama-4-maverick"); }); +test("muse-code models include muse-spark subscription ids", () => { + const ids = muse_codeProvider.models.map((m) => m.id); + assert.ok(ids.includes("muse-spark-1.3"), "must include muse-spark-1.3"); + assert.ok(ids.includes("muse-spark-1.3-contributor"), "must include muse-spark-1.3-contributor"); +}); + test("muse-code models include llama-4-scout", () => { const ids = muse_codeProvider.models.map((m) => m.id); assert.ok(ids.includes("llama-4-scout"), "must include llama-4-scout"); diff --git a/tests/unit/muse-code-oauth.test.ts b/tests/unit/muse-code-oauth.test.ts new file mode 100644 index 000000000000..86c5aa6e3742 --- /dev/null +++ b/tests/unit/muse-code-oauth.test.ts @@ -0,0 +1,169 @@ +/** + * Muse Code (Meta) device OAuth — CLIProxyAPI parity. + * + * Device grant at auth.meta.com, mint at api.meta.ai/muse-code/key, + * persist dca + minted key, remint on 401 / DCA-only records. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { OAUTH_PROVIDERS, supportsDualAuthProvider } from "../../src/shared/constants/providers.ts"; +import { MUSE_CODE_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import { museCode } from "../../src/lib/oauth/providers/muse-code.ts"; +import { + MUSE_CODE_DEVICE_GRANT, + MUSE_CODE_MINT_URL, + MUSE_CODE_USER_AGENT, + isMuseDcaToken, + normalizeMuseBaseUrl, +} from "../../open-sse/config/museCode.ts"; +import { mintMuseApiKey } from "../../open-sse/services/museCodeAuth.ts"; +import { refreshMuseCodeToken } from "../../open-sse/services/tokenRefresh/providers/museCode.ts"; +import { supportsTokenRefresh } from "../../open-sse/services/tokenRefresh.ts"; +import { parseDetailedRetryHintFromJsonBody } from "../../open-sse/services/retryAfterJson.ts"; +import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts"; +import { CLIPROXY_TYPE_TO_PROVIDER } from "../../src/lib/oauth/utils/cliProxyAuthImport.ts"; +import { muse_codeProvider } from "../../open-sse/config/providers/registry/muse-code/index.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("muse-code is a dual-auth OAuth catalog provider", () => { + assert.equal(supportsDualAuthProvider("muse-code"), true); + assert.equal(OAUTH_PROVIDERS["muse-code"]?.id, "muse-code"); + assert.equal(OAUTH_PROVIDERS["muse-code"]?.alias, "mc"); + assert.equal(museCode.flowType, "device_code"); + assert.equal(CLIPROXY_TYPE_TO_PROVIDER.meta, "muse-code"); +}); + +test("public Muse CLI client id is resolved through resolvePublicCred", () => { + const clientId = resolvePublicCred("muse_id", "MUSE_CODE_OAUTH_CLIENT_ID"); + assert.equal(clientId, MUSE_CODE_CONFIG.clientId); + assert.match(clientId, /^\d+$/); + assert.equal(clientId.length > 10, true); +}); + +test("isMuseDcaToken and normalizeMuseBaseUrl match CLIProxyAPI helpers", () => { + assert.equal(isMuseDcaToken("dca:abc"), true); + assert.equal(isMuseDcaToken(" dca:abc "), true); + assert.equal(isMuseDcaToken("LLM|key"), false); + assert.equal(normalizeMuseBaseUrl(""), "https://api.meta.ai/v1"); + assert.equal(normalizeMuseBaseUrl("https://api.meta.ai/v1/"), "https://api.meta.ai/v1"); +}); + +test("mintMuseApiKey posts dca_token with Bearer + muse-code UA", async () => { + globalThis.fetch = async (url, init = {}) => { + assert.equal(String(url), MUSE_CODE_MINT_URL); + const headers = init.headers as Record; + assert.equal(headers.Authorization, "Bearer dca:login"); + assert.equal(headers["User-Agent"], MUSE_CODE_USER_AGENT); + assert.deepEqual(JSON.parse(String(init.body)), { dca_token: "dca:login" }); + return new Response( + JSON.stringify({ + api_key: "LLM|k", + base_url: "https://api.meta.ai/v1/", + user_email: "a@b.com", + has_payment_method: true, + require_payment: false, + can_subscribe: true, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }; + + const minted = await mintMuseApiKey("dca:login"); + assert.equal(minted.apiKey, "LLM|k"); + assert.equal(minted.baseUrl, "https://api.meta.ai/v1"); + assert.equal(minted.hasPaymentMethod, true); + assert.equal(minted.requirePayment, false); + assert.equal(minted.canSubscribe, true); +}); + +test("postExchange keeps the dca token when mint fails (CLIProxyAPI best-effort)", async () => { + globalThis.fetch = async () => + new Response("nope", { status: 500, headers: { "Content-Type": "text/plain" } }); + + const extra = await museCode.postExchange({ access_token: "dca:still-valid" }); + assert.equal(extra.dcaToken, "dca:still-valid"); + assert.equal(extra.minted, undefined); + + const mapped = museCode.mapTokens({ access_token: "dca:still-valid", expires_in: 60 }, extra); + assert.equal(mapped.accessToken, "dca:still-valid"); + assert.equal(mapped.refreshToken, "dca:still-valid"); + assert.equal(mapped.expiresIn, 60); +}); + +test("mapTokens does not copy DCA expiry onto a minted inference key", () => { + const mapped = museCode.mapTokens( + { access_token: "dca:login", expires_in: 1800, token_type: "Bearer" }, + { + dcaToken: "dca:login", + minted: { + apiKey: "LLM|k", + baseUrl: "https://api.meta.ai/v1", + email: "a@b.com", + name: "A", + isSubsActive: true, + }, + } + ); + assert.equal(mapped.accessToken, "LLM|k"); + assert.equal(mapped.refreshToken, "dca:login"); + assert.equal(mapped.expiresIn, undefined); + assert.equal(mapped.providerSpecificData.dcaToken, "dca:login"); +}); + +test("supportsTokenRefresh includes the muse-code remint path", () => { + assert.equal(supportsTokenRefresh("muse-code"), true); +}); + +test("refreshMuseCodeToken remints from providerSpecificData.dcaToken", async () => { + globalThis.fetch = async (_url, init = {}) => { + assert.equal((init.headers as Record).Authorization, "Bearer dca:from-data"); + return new Response(JSON.stringify({ api_key: "LLM|new", base_url: "https://api.meta.ai/v1" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await refreshMuseCodeToken("not-a-dca", { dcaToken: "dca:from-data" }, null); + assert.equal(result?.accessToken, "LLM|new"); + assert.equal(result?.refreshToken, "dca:from-data"); +}); + +test("429 subscription quota error.resets_at is parsed as a cooldown", () => { + const resetEpoch = Math.floor(Date.now() / 1000) + 3600; + const hint = parseDetailedRetryHintFromJsonBody( + JSON.stringify({ + error: { + code: "rate_limit_exceeded", + message: "Subscription quota exhausted. Please try again later.", + resets_at: resetEpoch, + type: "rate_limit_error", + }, + }), + 30 * 24 * 60 * 60 * 1000 + ); + assert.ok(hint); + assert.ok(hint.retryAfterMs > 3_000_000); + assert.ok(hint.retryAfterMs <= 3600_000); +}); + +test("curated catalog includes Muse Spark subscription models", () => { + const ids = muse_codeProvider.models.map((model) => model.id); + for (const id of [ + "muse-spark-1.3", + "muse-spark-1.3-contributor", + "muse-spark-1.2", + "muse-spark-1.2-contributor", + "muse-spark-1.1", + "llama-4-maverick", + ]) { + assert.ok(ids.includes(id), `missing ${id}`); + } + assert.equal(museCode.config.mintUrl, MUSE_CODE_MINT_URL); + assert.equal(MUSE_CODE_DEVICE_GRANT, "urn:ietf:params:oauth:grant-type:device_code"); +}); diff --git a/tests/unit/muse-code-provider.test.ts b/tests/unit/muse-code-provider.test.ts index 42c582aabe32..7e7ed20cd2b1 100644 --- a/tests/unit/muse-code-provider.test.ts +++ b/tests/unit/muse-code-provider.test.ts @@ -9,6 +9,7 @@ import assert from "node:assert/strict"; import { muse_codeProvider } from "../../open-sse/config/providers/registry/muse-code/index.ts"; import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts"; +import { OAUTH_PROVIDERS, supportsDualAuthProvider } from "../../src/shared/constants/providers.ts"; // ── Registry entry structure ──────────────────────────────────────────────── @@ -89,3 +90,9 @@ test("muse-code is discoverable via alias", () => { assert.ok(entry, "getRegistryEntry must find muse-code by alias mc"); assert.equal(entry.id, "muse-code"); }); + +test("muse-code is dual-auth with an OAuth catalog card", () => { + assert.equal(supportsDualAuthProvider("muse-code"), true); + assert.equal(OAUTH_PROVIDERS["muse-code"]?.id, "muse-code"); + assert.equal(muse_codeProvider.baseUrl, "https://api.meta.ai/v1/responses"); +}); diff --git a/tests/unit/oauth-polltoken-nonjson-body.test.ts b/tests/unit/oauth-polltoken-nonjson-body.test.ts index b256734749f3..e4836cecbeff 100644 --- a/tests/unit/oauth-polltoken-nonjson-body.test.ts +++ b/tests/unit/oauth-polltoken-nonjson-body.test.ts @@ -2,7 +2,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import PROVIDERS_MAP from "../../src/lib/oauth/providers/index.ts"; -import { GITHUB_CONFIG, KIMI_CODING_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import { + GITHUB_CONFIG, + KIMI_CODING_CONFIG, + MUSE_CODE_CONFIG, +} from "../../src/lib/oauth/constants/oauth.ts"; // Regression guard for the OAuth device-flow pollToken double body read. // @@ -28,6 +32,7 @@ function stubFetch(body: string, init?: ResponseInit) { const providers = [ { id: "kimi-coding", config: KIMI_CODING_CONFIG }, { id: "github", config: GITHUB_CONFIG }, + { id: "muse-code", config: MUSE_CODE_CONFIG }, ] as const; for (const { id, config } of providers) { diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index fde6dd1b2269..526088d9dbd5 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -45,6 +45,7 @@ const { XAI_OAUTH_CONFIG, OPENFERENCE_CONFIG, ZED_HOSTED_CONFIG, + MUSE_CODE_CONFIG, } = oauthModule; const { getAntigravityLoadCodeAssistMetadata } = antigravityHeadersModule; @@ -75,6 +76,7 @@ const EXPECTED_PROVIDER_KEYS = [ "codebuddy-cn", "zed", "zed-hosted", + "muse-code", ]; const browserUrl = "http://localhost:20128/callback"; @@ -109,6 +111,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { "codebuddy-cn": CODEBUDDY_CN_CONFIG, zed: ZED_CONFIG, "zed-hosted": ZED_HOSTED_CONFIG, + "muse-code": MUSE_CODE_CONFIG, }; const KIRO_REQUIRED_FIELDS = [ @@ -158,6 +161,7 @@ const REQUIRED_FIELDS_BY_PROVIDER = { "grok-cli": ["authorizeUrl", "tokenUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"], // prettier-ignore "zed-hosted": ["webBaseUrl", "cloudBaseUrl", "llmBaseUrl", "userInfoUrl", "llmTokenUrl", "modelsUrl"], + "muse-code": ["deviceCodeUrl", "tokenUrl", "clientId", "mintUrl"], }; function getByPath(object, path) { @@ -447,7 +451,7 @@ test("Google OAuth callbacks stay on localhost when no custom credentials are co }); test("device and import-token providers expose the flow-specific fields expected by their configs", () => { - const deviceProviders = ["kimi-coding", "github", "kiro", "amazon-q", "kilocode"]; + const deviceProviders = ["kimi-coding", "github", "kiro", "amazon-q", "kilocode", "muse-code"]; for (const providerId of deviceProviders) { const provider = PROVIDERS[providerId]; @@ -729,6 +733,66 @@ test("Kimi Coding executes mocked device-code flow and token mapping", async () ); }); +test("Muse Code executes mocked device-code, mint, and dca-preserving mapTokens", async () => { + useFetchSequence([ + (url, init) => { + const params = init.body; + assert.equal(String(url), MUSE_CODE_CONFIG.deviceCodeUrl); + assert.equal(params.get("client_id"), MUSE_CODE_CONFIG.clientId); + assert.equal(init.headers["User-Agent"], "muse-code/1.0.2"); + assert.equal(init.headers["Content-Type"], "application/x-www-form-urlencoded"); + return jsonResponse({ + device_code: "muse-device", + user_code: "ABCD-EFGH", + verification_uri: "https://auth.meta.com/oauth/device/", + verification_uri_complete: "https://auth.meta.com/oauth/device/?code=ABCD-EFGH", + expires_in: 1800, + interval: 5, + }); + }, + (url, init) => { + const params = init.body; + assert.equal(String(url), MUSE_CODE_CONFIG.tokenUrl); + assert.equal(params.get("client_id"), MUSE_CODE_CONFIG.clientId); + assert.equal(params.get("device_code"), "muse-device"); + assert.equal(params.get("grant_type"), "urn:ietf:params:oauth:grant-type:device_code"); + assert.equal(init.headers["User-Agent"], "muse-code/1.0.2"); + return jsonResponse({ + access_token: "dca:device-access", + token_type: "Bearer", + expires_in: 3600, + }); + }, + (url, init) => { + assert.equal(String(url), MUSE_CODE_CONFIG.mintUrl); + assert.equal(init.headers.Authorization, "Bearer dca:device-access"); + assert.equal(init.headers["User-Agent"], "muse-code/1.0.2"); + assert.deepEqual(JSON.parse(init.body), { dca_token: "dca:device-access" }); + return jsonResponse({ + api_key: "LLM|minted-key", + base_url: "https://api.meta.ai/v1", + user_email: "muse@example.com", + user_full_name: "Muse User", + subs_tier_name: "Power", + is_subs_active: true, + }); + }, + ]); + + const device = await PROVIDERS["muse-code"].requestDeviceCode(MUSE_CODE_CONFIG); + const poll = await PROVIDERS["muse-code"].pollToken(MUSE_CODE_CONFIG, device.device_code); + const extra = await PROVIDERS["muse-code"].postExchange(poll.data); + const mapped = PROVIDERS["muse-code"].mapTokens(poll.data, extra); + + assert.equal(device.user_code, "ABCD-EFGH"); + assert.equal(mapped.accessToken, "LLM|minted-key"); + assert.equal(mapped.refreshToken, "dca:device-access"); + assert.equal(mapped.expiresIn, undefined); + assert.equal(mapped.email, "muse@example.com"); + assert.equal(mapped.providerSpecificData.dcaToken, "dca:device-access"); + assert.equal(mapped.providerSpecificData.subsTierName, "Power"); +}); + test("GitHub executes mocked device-code and profile enrichment flows", async () => { useFetchSequence([ jsonResponse({ diff --git a/tests/unit/oauth-providers-error-handling.test.ts b/tests/unit/oauth-providers-error-handling.test.ts index 9840cab31534..0195d301c77b 100644 --- a/tests/unit/oauth-providers-error-handling.test.ts +++ b/tests/unit/oauth-providers-error-handling.test.ts @@ -102,6 +102,15 @@ test("P1: _getAccessTokenInternal passes providerSpecificData to refreshKimiCodi ); }); +test("P1: _getAccessTokenInternal passes providerSpecificData to refreshMuseCodeToken", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + assert.match( + src, + /case\s+["']muse-code["']:[\s\S]{1,300}providerSpecificData/, + "muse-code case must pass providerSpecificData" + ); +}); + // ─── P1: GitHub Copilot sub-token health check ──────────────────────────────── test("P1: GitHub Copilot sub-token is refreshed by tokenHealthCheck", async () => { diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index 11696de7c097..5b8fcb70b8a1 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -38,12 +38,14 @@ // Agnes AI China (#13399, cdcde97c7) adds one apikey/regional entry — 241. // xKiro (#12648, 83fa4328f) adds one apikey entry — 242. // Lyceum (pay-per-use OpenAI-compatible gateway, 2026-09-20) adds one apikey entry — 243. +// Muse Code device OAuth dual-auth moves `muse-code` from frontier-labs into +// OAUTH_PROVIDERS (same pattern as clinepass/codebuddy-cn) — 242. import { test } from "node:test"; import assert from "node:assert/strict"; const P = await import("../../src/shared/constants/providers.ts"); -const APIKEY_PROVIDER_COUNT = 243; +const APIKEY_PROVIDER_COUNT = 242; test("barrel still exports every catalog + key helpers", () => { for (const name of [ diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 9e2081539a15..1331f67cbf15 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -1078,6 +1078,14 @@ test("connectionMatchesProviderCard counts a dual-auth provider's PAT (apikey) c connectionMatchesProviderCard({ provider: "qoder", authType: "oauth" }, "qoder", "oauth"), true ); + assert.equal( + connectionMatchesProviderCard( + { provider: "muse-code", authType: "apikey" }, + "muse-code", + "oauth" + ), + true + ); // A normal OAuth-only provider must NOT count an apikey connection on its OAuth card. assert.equal( connectionMatchesProviderCard({ provider: "claude", authType: "apikey" }, "claude", "oauth"), diff --git a/tests/unit/publicCreds.test.ts b/tests/unit/publicCreds.test.ts index 681711b60b63..8745a3380516 100644 --- a/tests/unit/publicCreds.test.ts +++ b/tests/unit/publicCreds.test.ts @@ -60,6 +60,12 @@ test("resolvePublicCred('openference_id') returns the public PKCE client id shap assert.equal(v.length, 9); }); +test("resolvePublicCred('muse_id') returns the public Meta Muse CLI numeric client id", () => { + const v = resolvePublicCred("muse_id"); + assert.match(v, /^\d+$/); + assert.ok(v.length >= 12); +}); + test("encode/decode roundtrip is stable across arbitrary plaintexts", () => { for (const sample of [ "hello world",