diff --git a/changelog.d/features/14267-muse-code-subscription-oauth.md b/changelog.d/features/14267-muse-code-subscription-oauth.md new file mode 100644 index 000000000000..65e0693090c3 --- /dev/null +++ b/changelog.d/features/14267-muse-code-subscription-oauth.md @@ -0,0 +1 @@ +- **feat(oauth):** Muse Code subscription login via device flow — approve in the browser and OmniRoute exchanges the grant for a subscription inference key ([#14267](https://github.com/diegosouzapw/OmniRoute/pull/14267)) — thanks @caniko diff --git a/open-sse/config/providers/registry/muse-code/index.ts b/open-sse/config/providers/registry/muse-code/index.ts index 37db1e988d44..1a7655f343ec 100644 --- a/open-sse/config/providers/registry/muse-code/index.ts +++ b/open-sse/config/providers/registry/muse-code/index.ts @@ -13,10 +13,76 @@ import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; export const muse_codeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ id: "muse-code", alias: "mc", + // Subscription and API-key connections share this entry: inference is the + // Responses API in both cases, authenticated by the connection bearer + // (subscription key for OAuth, META_API_KEY for API-key connections). + baseUrl: "https://api.meta.ai/v1", + urlSuffix: "/responses", + // Live catalog for entitled-model discovery (subscription Spark ids for + // OAuth connections, Llama ids for API-key connections). The generic + // models route derives its fetch config from here. + modelsUrl: "https://api.meta.ai/v1/models", passthroughModels: true, reasoningTransport: "opaque", defaultContextLength: 200000, models: [ + // Subscription Spark family: served only on the Responses API. Declaring + // the wire format (instead of relying on per-request apiFormat) keeps + // Chat Completions callers translated to Responses. Live discovery + // remains authoritative: ids a key is not entitled to are rejected, so + // these entries never route traffic a credential cannot serve. The model + // set and fallback windows mirror the verified subscription contract + // (five known ids; live limits win, 1048576/131072 fallback). + { + id: "muse-spark-1.1", + name: "Muse Spark 1.1", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + supportsXHighEffort: true, + toolCalling: true, + targetFormat: "openai-responses", + }, + { + id: "muse-spark-1.2", + name: "Muse Spark 1.2", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + supportsXHighEffort: true, + toolCalling: true, + targetFormat: "openai-responses", + }, + { + id: "muse-spark-1.2-contributor", + name: "Muse Spark 1.2 Contributor", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + supportsXHighEffort: true, + toolCalling: true, + targetFormat: "openai-responses", + }, + { + id: "muse-spark-1.3", + name: "Muse Spark 1.3", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + supportsXHighEffort: true, + toolCalling: true, + targetFormat: "openai-responses", + }, + { + id: "muse-spark-1.3-contributor", + name: "Muse Spark 1.3 Contributor", + contextLength: 1048576, + maxOutputTokens: 131072, + supportsReasoning: true, + supportsXHighEffort: true, + toolCalling: true, + targetFormat: "openai-responses", + }, { id: "llama-4-maverick", name: "Llama 4 Maverick", diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index af2de6c6c301..eacbce568478 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -162,6 +162,9 @@ const EMBEDDED_DEFAULTS = { ], // GitHub Copilot CLI — github oauth app id (public, device flow) github_copilot_id: [38, 27, 95, 71, 16, 90, 69, 67, 4, 29, 72, 22, 90, 91, 12, 0, 75, 19, 8, 87], + // Muse Code subscription (Meta) — device authorization client id shipped in + // public Muse/CLI bundles (public, device authorization grant). + muse_id: [94, 93, 93, 88, 68, 93, 64, 77, 80, 31, 71, 65, 90, 85, 93, 85], // Grok Build CLI (xAI) — public oauth client id (import-token flow) grok_id: [ 13, 92, 15, 89, 66, 91, 76, 70, 72, 29, 71, 70, 3, 65, 93, 84, 72, 23, 28, 87, 92, 88, 15, 95, diff --git a/src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts b/src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts index fe357918639c..3a12e92cfbb6 100644 --- a/src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts +++ b/src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts @@ -47,6 +47,7 @@ export const SUPPORTED_WIZARD_OAUTH_PROVIDER_IDS = new Set([ "cursor", "kilocode", "cline", + "muse-code", ]); function toProviderOption( diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 8356a1f9e541..4b8ecb48f5d4 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -203,7 +203,12 @@ export function shouldShowProviderSection( type ProviderRecord> = Record; -const OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS = new Set(["kiro", "amazon-q", "kimi-coding"]); +const OAUTH_CARD_API_KEY_CONNECTION_PROVIDER_IDS = new Set([ + "kiro", + "amazon-q", + "kimi-coding", + "muse-code", +]); export function getProviderConnectionsRequestUrl(providerId: string): string { const hasAliases = getProviderConnectionFamilyIds(providerId).length > 1; @@ -500,9 +505,7 @@ export function filterConfiguredProviderEntries( return connections.some( (conn) => connectionBelongsToProviderPage(conn.provider, entry.providerId) && - connectionSearchHaystacks(conn).some((haystack) => - matchesAnyToken(haystack, searchQuery) - ) + connectionSearchHaystacks(conn).some((haystack) => matchesAnyToken(haystack, searchQuery)) ); }); } diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index 549fbad96815..b78092572472 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -246,6 +246,13 @@ export const OAUTH_TEST_CONFIG: Record = { checkExpiry: true, refreshable: true, }, + "muse-code": { + // Muse Code subscription has no refresh-token grant and no stable + // user-info endpoint: the exchanged inference key is validated by + // presence/expiry, and live connectivity is exercised by real + // /v1/responses traffic. Reconnect replaces the stored key. + checkExpiry: 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/connectionPersistence.ts b/src/lib/oauth/connectionPersistence.ts index 5dc15cfe4933..6761d5e6ba18 100644 --- a/src/lib/oauth/connectionPersistence.ts +++ b/src/lib/oauth/connectionPersistence.ts @@ -82,14 +82,31 @@ function isSameClaudeAccount( return true; } +/** + * Does this existing Muse Code connection represent the SAME subscription + * account as the incoming login? The key exchange returns `user_id` and only + * sometimes `user_email`, so an email match cannot be required: agreement on + * the persisted `accountId` decides, mirroring the Codex chatgptUserId rule + * (#7737). Without this, every reconnect of a user_id-only account would fork + * a duplicate connection instead of replacing the stored key. + */ +function isSameMuseAccount( + existingProviderData: Record | null | undefined, + incomingProviderData: Record | null | undefined +): boolean { + const incomingAccountId = incomingProviderData?.accountId; + const existingAccountId = existingProviderData?.accountId; + return Boolean(incomingAccountId) && safeEqual(existingAccountId, incomingAccountId); +} /** * Find the existing OAuth connection (if any) that an incoming token payload * should be merged into, shared by every OAuth-completion call site * (persistOAuthConnection, and the exchange/poll/poll-callback branches in * `src/app/api/oauth/[provider]/[action]/route.ts`). Matches by explicit * connectionId first, then by same email + auth type — with Codex requiring - * workspaceId/chatgptUserId agreement (#7737) and Claude requiring - * organizationUUID agreement, to avoid silently overwriting a different + * workspaceId/chatgptUserId agreement (#7737), Claude requiring + * organizationUUID agreement, and Muse Code requiring accountId agreement, + * to avoid silently overwriting a different * account that merely shares an email. */ export function findExistingOAuthConnectionMatch( @@ -100,6 +117,12 @@ export function findExistingOAuthConnectionMatch( ): Record | undefined { return existing.find((c) => { if (c.id && safeEqual(connectionId, c.id)) return true; + if (provider === "muse-code") { + return ( + c.authType === "oauth" && + isSameMuseAccount(c.providerSpecificData, tokenData.providerSpecificData) + ); + } // Email dedup only when the payload actually carries an email. Without this // guard `safeEqual(undefined, undefined)` is true, so an email-less payload // would false-match the first email-less connection of the provider. @@ -180,8 +203,9 @@ export async function persistOAuthConnection( // / re-auth of a known connection); honor it even when the payload has no // top-level email. Some providers (e.g. GitHub Copilot) keep identity under // providerSpecificData, so gating dedup on tokenData.email alone created a - // duplicate connection on every refresh (#8059). - if (connectionId || tokenData.email) { + // duplicate connection on every refresh (#8059). Muse Code likewise keys + // identity on providerSpecificData.accountId, so it enters dedup too. + if (connectionId || tokenData.email || provider === "muse-code") { const existing = await getProviderConnections({ provider }); const match = findExistingOAuthConnectionMatch(existing, provider, tokenData, connectionId); const matchId = typeof match?.id === "string" ? match.id : null; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 86bfb025e2cd..6cab8bc6bfe3 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -179,6 +179,19 @@ export const KIMI_CODING_CONFIG = { tokenUrl: "https://auth.kimi.com/api/oauth/token", }; +// Muse Code Subscription OAuth Configuration (Device Code Flow + key exchange) +// Device authorization follows the standard device grant; the granted device +// token is then exchanged for a subscription inference key at the key +// endpoint (onboard: true). There is no refresh-token grant: a revoked or +// replaced key requires reconnecting. Inference authenticates with the +// exchanged subscription key, never the device token. +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/", + keyUrl: "https://api.meta.ai/muse-code/key", +}; + // KiloCode OAuth Configuration (Custom Device Auth Flow) export const KILOCODE_CONFIG = { apiBaseUrl: "https://api.kilo.ai", @@ -497,6 +510,7 @@ export const PROVIDERS = { ANTIGRAVITY: "antigravity", AGY: "agy", KIMI_CODING: "kimi-coding", + MUSE_CODE: "muse-code", OPENAI: "openai", GITHUB: "github", GHE_COPILOT: "ghe-copilot", diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index cfd7ced721dc..57660bda9a41 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -16,6 +16,7 @@ import { antigravity } from "./antigravity"; import { agy } from "./agy"; import { qoder } from "./qoder"; import { kimiCoding } from "./kimi-coding"; +import { museCode } from "./muse-code"; import { github } from "./github"; import { gheCopilot } from "./ghe-copilot"; import { gitlabDuo } from "./gitlab-duo"; @@ -39,6 +40,7 @@ export const PROVIDERS = { agy, qoder, "kimi-coding": kimiCoding, + "muse-code": museCode, github, "ghe-copilot": gheCopilot, "gitlab-duo": gitlabDuo, diff --git a/src/lib/oauth/providers/muse-code.ts b/src/lib/oauth/providers/muse-code.ts new file mode 100644 index 000000000000..dedbda83f190 --- /dev/null +++ b/src/lib/oauth/providers/muse-code.ts @@ -0,0 +1,255 @@ +import { MUSE_CODE_CONFIG } from "../constants/oauth"; + +const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; +const AUTH_ORIGIN = "https://auth.meta.com"; +// The device endpoint never issues codes valid longer than a day; refuse +// anything outside (0, 24h] instead of polling a bogus deadline. +const MAX_DEVICE_EXPIRY_SECONDS = 86400; +// Bounded upstream calls: the device endpoints are interactive-paced, so a +// hung socket must fail instead of stalling the connect flow. +const REQUEST_TIMEOUT_MS = 20000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +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; +} + +function verifiedAuthorizationUrl(data: Record): { + url: string; + complete: string; +} { + const complete = requiredText(data.verification_uri_complete, "verification_uri_complete"); + const plain = typeof data.verification_uri === "string" ? data.verification_uri : ""; + for (const candidate of [complete, plain]) { + if (!candidate) continue; + let url: URL; + try { + url = new URL(candidate); + } catch { + throw new Error("Muse Code returned an invalid authorization URL."); + } + if (url.origin !== AUTH_ORIGIN || url.username || url.password) { + throw new Error("Muse Code returned an authorization URL for an unexpected origin."); + } + } + return { url: plain, complete }; +} + +async function postForm(url: string, params: Record): Promise { + return fetch(url, { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams(params).toString(), + }); +} + +export const museCode = { + config: MUSE_CODE_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config: typeof MUSE_CODE_CONFIG) => { + let response: Response; + try { + response = await postForm(config.deviceCodeUrl, { client_id: config.clientId }); + } catch { + throw new Error("Muse Code device authorization request failed."); + } + if (!response.ok) { + throw new Error("Muse Code device authorization request failed."); + } + let data: unknown; + try { + data = await response.json(); + } catch { + throw new Error("Muse Code device authorization response was not JSON."); + } + if (!isRecord(data)) { + throw new Error("Muse Code device authorization response was malformed."); + } + const deviceCode = requiredText(data.device_code, "device_code"); + const userCode = requiredText(data.user_code, "user_code"); + const { url, complete } = verifiedAuthorizationUrl(data); + const expiresIn = + typeof data.expires_in === "number" && Number.isFinite(data.expires_in) + ? data.expires_in + : NaN; + if (!(expiresIn > 0) || expiresIn > MAX_DEVICE_EXPIRY_SECONDS) { + throw new Error("Muse Code returned an invalid device code expiry."); + } + const interval = + typeof data.interval === "number" && Number.isFinite(data.interval) && data.interval > 0 + ? data.interval + : 5; + return { + device_code: deviceCode, + user_code: userCode, + verification_uri: url, + verification_uri_complete: complete, + expires_in: expiresIn, + interval, + }; + }, + pollToken: async ( + config: typeof MUSE_CODE_CONFIG, + deviceCode: string + ): Promise<{ ok: boolean; data: Record }> => { + let response: Response; + try { + response = await postForm(config.tokenUrl, { + client_id: config.clientId, + device_code: deviceCode, + grant_type: DEVICE_GRANT, + }); + } catch { + return { ok: false, data: { error: "network_error" } }; + } + let parsed: unknown; + try { + parsed = await response.json(); + } catch { + return { ok: response.ok, data: { error: "invalid_response" } }; + } + if (!isRecord(parsed)) { + return { ok: response.ok, data: { error: "invalid_response" } }; + } + // Allowlist the fields the shared poll loop consumes. Anything else the + // upstream sends stays out of error paths and persisted state. + if (typeof parsed.access_token === "string" && parsed.access_token.trim()) { + const data: Record = { access_token: parsed.access_token }; + if (typeof parsed.expires_in === "number" && Number.isFinite(parsed.expires_in)) { + data.expires_in = parsed.expires_in; + } + return { ok: response.ok, data }; + } + // Only recognized OAuth error codes pass through, each mapped to a fixed + // message. Upstream descriptions are always discarded: they are free text + // and must never reach UI error paths, where they could carry sensitive + // or credential-like strings. + const POLL_ERROR_MESSAGES: Record = { + authorization_pending: "Authorization pending.", + slow_down: "Authorization pending.", + access_denied: "Authorization denied.", + expired_token: "Device code expired.", + invalid_grant: "Authorization failed.", + }; + const error = + typeof parsed.error === "string" && Object.hasOwn(POLL_ERROR_MESSAGES, parsed.error) + ? parsed.error + : "invalid_response"; + const data: Record = { error }; + if (error !== "invalid_response") { + data.error_description = POLL_ERROR_MESSAGES[error]; + } + return { ok: response.ok, data }; + }, + /** + * Post-exchange hook: trade the granted device token for a subscription + * inference key. Only the inference key and the account identity leave this + * call — the device (account) token authorizes the exchange and is then + * discarded. There is no refresh grant: reconnect replaces the key. + * + * Error bodies are deliberately NOT propagated: a successful body carries + * the inference key itself, so it must never flow into logs or responses. + */ + postExchange: async (tokens: Record) => { + const accountToken = + typeof tokens.access_token === "string" && tokens.access_token.trim() + ? tokens.access_token + : null; + if (!accountToken) { + throw new Error("Muse Code device flow completed without an access token."); + } + let response: Response; + try { + response = await fetch(MUSE_CODE_CONFIG.keyUrl, { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "x-api-version": "1.0.0", + Authorization: `Bearer ${accountToken}`, + }, + body: JSON.stringify({ onboard: true }), + }); + } catch { + throw new Error("Muse Code subscription key request failed."); + } + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new Error("Muse Code subscription key response was not JSON."); + } + if (!response.ok || !isRecord(payload)) { + throw new Error("Muse Code subscription key request failed."); + } + if (payload.is_subs_active === false) { + throw new Error("Muse Code subscription is inactive."); + } + if ( + payload.require_payment === true || + typeof payload.action_url === "string" || + typeof payload.require_payment_action_url === "string" + ) { + throw new Error("Muse Code requires a subscription or billing action."); + } + const apiKey = + typeof payload.api_key === "string" && payload.api_key.trim() ? payload.api_key : null; + const accountId = + typeof payload.user_id === "string" && payload.user_id.trim() + ? payload.user_id + : typeof payload.user_email === "string" && payload.user_email.trim() + ? payload.user_email + : null; + if (!apiKey || !accountId) { + throw new Error("Muse Code subscription key response was incomplete."); + } + return { + apiKey, + accountId, + email: typeof payload.user_email === "string" ? payload.user_email : null, + isSubsActive: payload.is_subs_active === true, + }; + }, + mapTokens: ( + tokens: Record, + extra?: { + apiKey: string; + accountId: string; + email: string | null; + isSubsActive: boolean; + } | null + ) => { + void tokens; + if (!extra || typeof extra.apiKey !== "string" || !extra.apiKey.trim()) { + throw new Error("Muse Code subscription key exchange did not complete."); + } + if (typeof extra.accountId !== "string" || !extra.accountId.trim()) { + throw new Error("Muse Code subscription key exchange returned no account identity."); + } + return { + // Inference authenticates with the exchanged subscription key. The + // default executor prefers accessToken for OAuth connections, so the + // key goes here — never the device (account) token, which is discarded + // after the exchange and never persisted. + accessToken: extra.apiKey, + email: extra.email, + providerSpecificData: { + accountId: extra.accountId, + isSubsActive: extra.isSubsActive, + }, + }; + }, +}; diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 42c3884d65d6..2e442fd16f2a 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -45,6 +45,7 @@ const DEVICE_CODE_PROVIDERS = new Set([ "kiro", "amazon-q", "kimi-coding", + "muse-code", "kilocode", "codebuddy-cn", "ghe-copilot", diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index 647379e59399..ee857b8e8942 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -407,6 +407,7 @@ const LOBE_PROVIDER_ALIASES = { moonshot: "Moonshot", morph: "Morph", "muse-spark-web": "MetaAI", + "muse-code": "MetaAI", nanobanana: "NanoBanana", nebius: "Nebius", "nous-research": "NousResearch", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 654b2baac982..8f99be16c471 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); @@ -340,8 +340,15 @@ function getOrCreateAiProviders(): Record { ensureProvidersValidated(); _aiProviders = {}; for (const section of _PROVIDER_SECTIONS) { + // Skip the OAuth section here: it is applied last so OAuth entries win + // on id overlap, matching getProviderById(), getProviderByAlias(), and + // the static catalog resolution order. Today the only overlap is + // "muse-code" (subscription OAuth + META_API_KEY), whose + // connection/auth metadata must be the subscription variant everywhere. + if (section === OAUTH_PROVIDERS) continue; Object.assign(_aiProviders, section); } + Object.assign(_aiProviders, OAUTH_PROVIDERS); } return _aiProviders; } diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index 3c743c9bbf7f..0d693e5c2cda 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -227,6 +227,28 @@ export const OAUTH_PROVIDERS = { authHint: "Sign in with the same Kimi account used by Kimi Code CLI. OmniRoute uses the CLI OAuth flow and Kimi Coding Plan endpoints.", }, + "muse-code": { + id: "muse-code", + serviceKinds: ["llm"], + alias: "mc", + name: "Muse Code (Subscription)", + icon: "psychology", + color: "#0064E0", + textIcon: "MC", + website: "https://www.meta.ai", + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "Sign in with the Meta account holding your Muse Code subscription via the device flow. OmniRoute exchanges the grant for a subscription inference key; Contributor models may use prompts for training.", + }, + // Canonical-metadata note: "muse-code" intentionally exists in both the + // OAuth catalog (this entry, owning connection/auth metadata) and the + // API-key catalog (existing META_API_KEY entry), because the registry, + // executor, and stored connections all key on the shared id. Static + // catalog resolution and getProviderById() both prefer the OAuth entry, + // so page-level consumers see subscription metadata; API-key connections + // keep working through the dual-auth affordances (DUAL_AUTH_PROVIDER_IDS, + // OAuth-card counting, wizard allowlist). kilocode: { id: "kilocode", serviceKinds: ["llm"], diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 7cc9a9b678cc..fb670fc71af6 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -4092,7 +4092,10 @@ "Content-Type": "application/json" } }, - "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/muse-code-discovery-route.test.ts b/tests/unit/muse-code-discovery-route.test.ts new file mode 100644 index 000000000000..1ca4d6f12cc2 --- /dev/null +++ b/tests/unit/muse-code-discovery-route.test.ts @@ -0,0 +1,105 @@ +/** + * Muse Code subscription discovery through the real models route: an OAuth + * connection's subscription key drives live Meta catalog discovery, and the + * returned catalog reflects the account's models rather than the static + * registry. Upstream Meta traffic is stubbed; SQLite runs isolated per file. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-muse-discovery-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// Import the route (and its fetch chain) at file load, before any test +// stubs globalThis.fetch, so proxy helpers pin the real native fetch. +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +const originalFetch = globalThis.fetch; + +type SeenRequest = { + url: string; + method: string; + authorization: string | null; +}; + +const FAKE_SUBSCRIPTION_KEY = ["test-discovery-subscription-key-", "z".repeat(12)].join(""); + +async function resetStorage(): Promise { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +async function seedOAuthConnection() { + return providersDb.createProviderConnection({ + provider: "muse-code", + authType: "oauth", + name: "muse-discovery-test", + accessToken: FAKE_SUBSCRIPTION_KEY, + isActive: true, + testStatus: "active", + providerSpecificData: { accountId: "user-123", isSubsActive: true }, + }); +} + +test("muse-code OAuth discovery returns the account catalog with the subscription bearer", async () => { + const connection = await seedOAuthConnection(); + const seen: SeenRequest[] = []; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + const requestUrl = String(url); + const headers = new Headers(init?.headers as HeadersInit | undefined); + seen.push({ + url: requestUrl, + method: (init?.method || "GET").toUpperCase(), + authorization: headers.get("authorization"), + }); + if (requestUrl === "https://api.meta.ai/v1/models") { + return Response.json({ + data: [ + { id: "muse-spark-1.3-contributor", object: "model" }, + { id: "muse-spark-1.2", object: "model" }, + ], + }); + } + throw new Error(`Unexpected fetch: ${requestUrl}`); + }) as typeof globalThis.fetch; + + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + try { + assert.equal(response.status, 200); + const body = (await response.json()) as { + source?: string; + models?: Array<{ id: string }>; + }; + assert.equal(body.source, "api"); + const ids = (body.models ?? []).map((model) => model.id); + assert.ok(ids.includes("muse-spark-1.3-contributor"), `live Spark id missing: ${ids}`); + assert.ok(ids.includes("muse-spark-1.2"), `live Spark id missing: ${ids}`); + assert.ok(!ids.includes("llama-4-maverick"), `static-only id leaked into live catalog: ${ids}`); + const discoveryCall = seen.find((call) => call.url === "https://api.meta.ai/v1/models"); + assert.ok(discoveryCall, "expected a live Meta discovery request"); + assert.equal(discoveryCall?.method, "GET"); + assert.equal(discoveryCall?.authorization, `Bearer ${FAKE_SUBSCRIPTION_KEY}`); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/muse-code-oauth-persistence.test.ts b/tests/unit/muse-code-oauth-persistence.test.ts new file mode 100644 index 000000000000..fa6efd41a3cf --- /dev/null +++ b/tests/unit/muse-code-oauth-persistence.test.ts @@ -0,0 +1,178 @@ +/** + * Muse Code subscription OAuth integration: device completion tail → + * persistence → reload → discovery/execution URL and headers → reconnect → + * disconnect, with credential-redaction assertions throughout. + * + * Upstream Meta traffic is stubbed; SQLite runs isolated per test file. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-muse-oauth-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { persistOAuthConnection } = await import("../../src/lib/oauth/connectionPersistence.ts"); +const { museCode } = await import("../../src/lib/oauth/providers/muse-code.ts"); +const { buildProviderHeaders, buildProviderUrl } = + await import("../../open-sse/services/provider.ts"); + +const originalFetch = globalThis.fetch; + +const FAKE_ACCOUNT_TOKEN = ["test-device-account-token-", "x".repeat(12)].join(""); +const FAKE_API_KEY = ["test-subscription-inference-key-", "y".repeat(12)].join(""); + +async function resetStorage(): Promise { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + break; + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +async function completeLogin( + accountId: string, + apiKey: string = FAKE_API_KEY +): Promise> { + globalThis.fetch = (async () => + new Response(JSON.stringify({ api_key: apiKey, user_id: accountId, is_subs_active: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as typeof globalThis.fetch; + const extra = await museCode.postExchange({ access_token: FAKE_ACCOUNT_TOKEN }); + return museCode.mapTokens({ access_token: FAKE_ACCOUNT_TOKEN }, extra) as Record; +} + +test("login tail persists a usable subscription connection without the device token", async () => { + const tokens = await completeLogin("user-123", FAKE_API_KEY); + const created = (await persistOAuthConnection("muse-code", tokens)) as { id: string }; + + const rows = (await providersDb.getProviderConnections({ provider: "muse-code" })) as Array< + Record + >; + assert.equal(rows.length, 1); + const row = rows[0] as Record; + assert.equal(row.id, created.id); + assert.equal(row.accessToken, FAKE_API_KEY); + assert.equal(row.authType, "oauth"); + + // Redaction: the device (account) token must appear nowhere in the row. + const serialized = JSON.stringify(row); + assert.ok(!serialized.includes(FAKE_ACCOUNT_TOKEN), "device token persisted"); + assert.ok(!Object.keys(row).includes("accountToken"), "accountToken field persisted"); +}); + +test("reloaded connection drives the Responses endpoint with the subscription key", async () => { + const tokens = await completeLogin("user-123", FAKE_API_KEY); + await persistOAuthConnection("muse-code", tokens); + const rows = (await providersDb.getProviderConnections({ provider: "muse-code" })) as Array< + Record + >; + const credentials = { + accessToken: rows[0].accessToken, + providerSpecificData: {}, + }; + + assert.equal( + buildProviderUrl("muse-code", "muse-spark-1.3-contributor", true, {}), + "https://api.meta.ai/v1/responses" + ); + const headers = buildProviderHeaders("muse-code", credentials, true) as Record; + assert.equal(headers.Authorization, `Bearer ${FAKE_API_KEY}`); +}); + +test("reconnect replaces the key in place instead of duplicating the connection", async () => { + const first = (await persistOAuthConnection( + "muse-code", + await completeLogin("user-123", FAKE_API_KEY) + )) as { id: string }; + const rotatedKey = `${FAKE_API_KEY}-rotated`; + const second = (await persistOAuthConnection( + "muse-code", + await completeLogin("user-123", rotatedKey) + )) as { id: string }; + + assert.equal(second.id, first.id, "reconnect must update the same row"); + const rows = (await providersDb.getProviderConnections({ provider: "muse-code" })) as Array< + Record + >; + assert.equal(rows.length, 1); + assert.equal(rows[0].accessToken, rotatedKey); + assert.ok(!JSON.stringify(rows[0]).includes(FAKE_ACCOUNT_TOKEN)); +}); + +test("a distinct subscription account gets its own connection", async () => { + const first = (await persistOAuthConnection( + "muse-code", + await completeLogin("user-123", FAKE_API_KEY) + )) as { id: string }; + const second = (await persistOAuthConnection( + "muse-code", + await completeLogin("user-456", `${FAKE_API_KEY}-other`) + )) as { id: string }; + + assert.notEqual(second.id, first.id); + const rows = (await providersDb.getProviderConnections({ provider: "muse-code" })) as Array< + Record + >; + assert.equal(rows.length, 2); +}); + +test("disconnect removes the subscription connection", async () => { + const created = (await persistOAuthConnection( + "muse-code", + await completeLogin("user-123", FAKE_API_KEY) + )) as { id: string }; + const { deleteProviderConnections } = providersDb as unknown as { + deleteProviderConnections: (ids: string[]) => Promise; + }; + await deleteProviderConnections([created.id]); + const rows = await providersDb.getProviderConnections({ provider: "muse-code" }); + assert.equal(rows.length, 0); +}); + +test("credential survives a storage reopen (restart) without the device token", async () => { + const created = (await persistOAuthConnection( + "muse-code", + await completeLogin("user-123", FAKE_API_KEY) + )) as { id: string }; + // Simulate an application restart: drop all handles, reopen the same files. + core.resetDbInstance(); + const rows = (await providersDb.getProviderConnections({ provider: "muse-code" })) as Array< + Record + >; + assert.equal(rows.length, 1); + assert.equal(rows[0].id, created.id); + assert.equal(rows[0].accessToken, FAKE_API_KEY); + assert.ok(!JSON.stringify(rows[0]).includes(FAKE_ACCOUNT_TOKEN)); +}); diff --git a/tests/unit/muse-code-oauth.test.ts b/tests/unit/muse-code-oauth.test.ts new file mode 100644 index 000000000000..cd4a8fffaffd --- /dev/null +++ b/tests/unit/muse-code-oauth.test.ts @@ -0,0 +1,301 @@ +/** + * Muse Code subscription OAuth (device flow + subscription key exchange). + * + * Covers requestDeviceCode validation, pollToken shapes, the key-exchange + * post step (including inactive/billing/malformed cases and credential + * redaction), token mapping for persistence, and provider registration. + * All upstream traffic is stubbed — no network, no real credentials. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { MUSE_CODE_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import PROVIDERS from "../../src/lib/oauth/providers/index.ts"; +import { museCode } from "../../src/lib/oauth/providers/muse-code.ts"; + +const originalFetch = globalThis.fetch; + +type FetchStub = (url: string | URL | Request, init?: RequestInit) => Promise; + +function useFetch(stub: FetchStub): void { + globalThis.fetch = stub as typeof globalThis.fetch; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function calls(): Array<{ url: string; init?: RequestInit }> { + const seen: Array<{ url: string; init?: RequestInit }> = []; + useFetch(async (url: string | URL | Request, init?: RequestInit) => { + seen.push({ url: String(url), init }); + throw new Error("unexpected fetch in this case"); + }); + return seen; +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const DEVICE_OK = { + device_code: "dev-code-1", + user_code: "ABCD-1234", + verification_uri: "https://auth.meta.com/device", + verification_uri_complete: "https://auth.meta.com/device?user_code=ABCD-1234", + expires_in: 1800, + interval: 5, +}; + +// Obviously fake key material — never real credential shapes. +const FAKE_ACCOUNT_TOKEN = ["test-account-token-", "x".repeat(16)].join(""); +const FAKE_API_KEY = ["test-subscription-key-", "y".repeat(16)].join(""); + +test("requestDeviceCode posts the device grant and returns the ticket", async () => { + const seen = calls(); + useFetch(async (url: string | URL | Request, init?: RequestInit) => { + seen.push({ url: String(url), init }); + return jsonResponse(DEVICE_OK); + }); + const ticket = await museCode.requestDeviceCode(MUSE_CODE_CONFIG); + assert.equal(seen.length, 1); + assert.equal(seen[0].url, MUSE_CODE_CONFIG.deviceCodeUrl); + assert.equal(seen[0].init?.method, "POST"); + const body = new URLSearchParams(String(seen[0].init?.body ?? "")); + assert.ok(body.get("client_id")); + assert.equal(ticket.device_code, "dev-code-1"); + assert.equal(ticket.user_code, "ABCD-1234"); + assert.equal(ticket.verification_uri_complete, DEVICE_OK.verification_uri_complete); + assert.equal(ticket.expires_in, 1800); + assert.equal(ticket.interval, 5); +}); + +test("requestDeviceCode rejects non-JSON, missing fields, bad origin, bad expiry", async () => { + const bad: unknown[] = [ + "not-json", + { ...DEVICE_OK, device_code: "" }, + { ...DEVICE_OK, user_code: " " }, + { ...DEVICE_OK, verification_uri_complete: "https://evil.example.com/device" }, + { ...DEVICE_OK, verification_uri_complete: "not a url" }, + { ...DEVICE_OK, expires_in: 0 }, + { ...DEVICE_OK, expires_in: 999999 }, + { ...DEVICE_OK, expires_in: "soon" }, + ["an", "array"], + null, + ]; + for (const payload of bad) { + useFetch(async () => + typeof payload === "string" ? new Response(payload, { status: 200 }) : jsonResponse(payload) + ); + await assert.rejects(museCode.requestDeviceCode(MUSE_CODE_CONFIG)); + } +}); + +test("requestDeviceCode fails closed on HTTP errors without leaking bodies", async () => { + useFetch(async () => jsonResponse({ error: "access_denied", secret: "s3cr3t" }, 403)); + const error = await museCode.requestDeviceCode(MUSE_CODE_CONFIG).then( + () => null, + (e: unknown) => e + ); + assert.ok(error instanceof Error); + assert.ok(!error.message.includes("s3cr3t")); +}); + +test("pollToken returns ok/data shapes for success and pending", async () => { + useFetch(async () => jsonResponse({ access_token: FAKE_ACCOUNT_TOKEN })); + const ok = await museCode.pollToken(MUSE_CODE_CONFIG, "dev-code-1"); + assert.equal(ok.ok, true); + assert.equal(ok.data.access_token, FAKE_ACCOUNT_TOKEN); + + useFetch(async () => jsonResponse({ error: "authorization_pending" })); + const pending = await museCode.pollToken(MUSE_CODE_CONFIG, "dev-code-1"); + assert.equal(pending.ok, true); + assert.equal(pending.data.error, "authorization_pending"); + + useFetch(async () => jsonResponse({ error: "slow_down" })); + const slow = await museCode.pollToken(MUSE_CODE_CONFIG, "dev-code-1"); + assert.equal(slow.ok, true); + assert.equal(slow.data.error, "slow_down"); +}); + +test("pollToken maps unknown errors to a fixed response", async () => { + useFetch(async () => + jsonResponse({ error: "weird_upstream_code", error_description: "secret api_key=abc123" }) + ); + const result = await museCode.pollToken(MUSE_CODE_CONFIG, "dev-code-1"); + assert.equal(result.data.error, "invalid_response"); + assert.ok(!("error_description" in result.data)); +}); + +test("pollToken rejects inherited properties as error codes", async () => { + for (const code of ["constructor", "toString", "hasOwnProperty", "__proto__"]) { + useFetch(async () => jsonResponse({ error: code, error_description: "x" })); + const result = await museCode.pollToken(MUSE_CODE_CONFIG, "dev-code-1"); + assert.equal(result.data.error, "invalid_response", code); + assert.ok(!("error_description" in result.data), code); + } +}); + +test("pollToken replaces known-code descriptions with fixed messages", async () => { + useFetch(async () => + jsonResponse({ error: "access_denied", error_description: "user=alice api_key=abc123" }) + ); + const result = await museCode.pollToken(MUSE_CODE_CONFIG, "dev-code-1"); + assert.equal(result.data.error, "access_denied"); + assert.equal(result.data.error_description, "Authorization denied."); + assert.ok(!String(result.data.error_description).includes("abc123")); +}); + +test("pollToken maps transport failure to network_error", async () => { + useFetch(async () => { + throw new Error("boom"); + }); + const result = await museCode.pollToken(MUSE_CODE_CONFIG, "dev-code-1"); + assert.equal(result.ok, false); + assert.equal(result.data.error, "network_error"); +}); + +test("postExchange trades the device token for a subscription key", async () => { + const seen = calls(); + useFetch(async (url: string | URL | Request, init?: RequestInit) => { + seen.push({ url: String(url), init }); + return jsonResponse({ + api_key: FAKE_API_KEY, + user_id: "user-123", + is_subs_active: true, + }); + }); + const extra = await museCode.postExchange({ access_token: FAKE_ACCOUNT_TOKEN }); + assert.equal(seen.length, 1); + assert.equal(seen[0].url, MUSE_CODE_CONFIG.keyUrl); + assert.equal(seen[0].init?.method, "POST"); + const headers = new Headers(seen[0].init?.headers); + assert.equal(headers.get("Authorization"), `Bearer ${FAKE_ACCOUNT_TOKEN}`); + assert.deepEqual(JSON.parse(String(seen[0].init?.body ?? "{}")), { onboard: true }); + assert.equal(extra.apiKey, FAKE_API_KEY); + assert.equal(extra.accountId, "user-123"); + assert.equal(extra.email, null); + assert.equal(extra.isSubsActive, true); +}); + +test("postExchange accepts user_email identity and surfaces it for dedup", async () => { + useFetch(async () => + jsonResponse({ api_key: FAKE_API_KEY, user_email: "a@example.com", is_subs_active: true }) + ); + const extra = await museCode.postExchange({ access_token: FAKE_ACCOUNT_TOKEN }); + assert.equal(extra.accountId, "a@example.com"); + assert.equal(extra.email, "a@example.com"); +}); + +test("postExchange rejects inactive, billing-action, and incomplete payloads", async () => { + const bad: unknown[] = [ + { api_key: FAKE_API_KEY, user_id: "u", is_subs_active: false }, + { api_key: FAKE_API_KEY, user_id: "u", require_payment: true }, + { api_key: FAKE_API_KEY, user_id: "u", action_url: "https://example.com/pay" }, + { api_key: FAKE_API_KEY, user_id: "u", require_payment_action_url: "https://example.com/p" }, + { user_id: "u", is_subs_active: true }, + { api_key: FAKE_API_KEY, is_subs_active: true }, + { api_key: " ", user_id: "u" }, + ["array"], + ]; + for (const payload of bad) { + useFetch(async () => jsonResponse(payload)); + await assert.rejects(museCode.postExchange({ access_token: FAKE_ACCOUNT_TOKEN })); + } +}); + +test("postExchange never propagates response bodies or transport errors", async () => { + const withBody = async (payload: unknown, status: number) => { + useFetch(async () => jsonResponse(payload, status)); + const error = await museCode.postExchange({ access_token: FAKE_ACCOUNT_TOKEN }).then( + () => null, + (e: unknown) => e + ); + assert.ok(error instanceof Error); + assert.ok(!error.message.includes(FAKE_API_KEY), `leaked key: ${error.message}`); + assert.ok(!error.message.includes(FAKE_ACCOUNT_TOKEN), `leaked token: ${error.message}`); + }; + // A body that itself carries key material must not round-trip into errors. + await withBody({ api_key: FAKE_API_KEY, user_id: "u", is_subs_active: false }, 200); + await withBody({ error: "denied", api_key: FAKE_API_KEY }, 403); + useFetch(async () => new Response("not json", { status: 200 })); + await assert.rejects(museCode.postExchange({ access_token: FAKE_ACCOUNT_TOKEN })); + useFetch(async () => { + throw new Error("socket hang up"); + }); + const transport = await museCode.postExchange({ access_token: FAKE_ACCOUNT_TOKEN }).then( + () => null, + (e: unknown) => e + ); + assert.ok(transport instanceof Error); + assert.ok(!transport.message.includes("socket hang up")); +}); + +test("postExchange requires the device access token", async () => { + await assert.rejects(museCode.postExchange({})); + await assert.rejects(museCode.postExchange({ access_token: " " })); +}); + +test("mapTokens stores the subscription key as the inference bearer", () => { + const mapped = museCode.mapTokens( + { access_token: FAKE_ACCOUNT_TOKEN }, + { apiKey: FAKE_API_KEY, accountId: "user-123", email: null, isSubsActive: true } + ); + assert.equal(mapped.accessToken, FAKE_API_KEY); + assert.ok(!("refreshToken" in mapped), "no refresh grant exists for this flow"); + assert.ok(!("expiresIn" in mapped), "no advertised expiry; reconnect replaces the key"); + const specific = mapped.providerSpecificData as Record; + assert.equal(specific.accountId, "user-123"); + assert.ok(!("accountToken" in specific), "device token is discarded, never persisted"); +}); + +test("mapTokens rejects incomplete exchanges", () => { + assert.throws(() => museCode.mapTokens({ access_token: FAKE_ACCOUNT_TOKEN }, null)); + assert.throws(() => + museCode.mapTokens( + { access_token: FAKE_ACCOUNT_TOKEN }, + { apiKey: "", accountId: "u", email: null, isSubsActive: true } + ) + ); + assert.throws(() => + museCode.mapTokens( + { access_token: FAKE_ACCOUNT_TOKEN }, + { apiKey: FAKE_API_KEY, accountId: " ", email: null, isSubsActive: true } + ) + ); +}); + +test("requestDeviceCode rejects embedded credentials in authorization URLs", async () => { + useFetch(async () => + jsonResponse({ + ...DEVICE_OK, + verification_uri_complete: "https://user:pass@auth.meta.com/device?code=X", + }) + ); + await assert.rejects(museCode.requestDeviceCode(MUSE_CODE_CONFIG)); + useFetch(async () => + jsonResponse({ ...DEVICE_OK, verification_uri: "https://auth.meta.com@evil.example.com/" }) + ); + await assert.rejects(museCode.requestDeviceCode(MUSE_CODE_CONFIG)); +}); + +test("pollToken passes only allowlisted fields to the shared loop", async () => { + useFetch(async () => + jsonResponse({ access_token: FAKE_ACCOUNT_TOKEN, junk: "drop-me", nested: { a: 1 } }) + ); + const ok = await museCode.pollToken(MUSE_CODE_CONFIG, "dev-code-1"); + assert.deepEqual(Object.keys(ok.data).sort(), ["access_token"]); +}); + +test("provider is registered as a device_code flow with masked client id", () => { + assert.equal(PROVIDERS["muse-code"], museCode); + assert.equal(museCode.flowType, "device_code"); + assert.equal(typeof MUSE_CODE_CONFIG.deviceCodeUrl, "string"); + assert.equal(typeof MUSE_CODE_CONFIG.tokenUrl, "string"); + assert.equal(typeof MUSE_CODE_CONFIG.keyUrl, "string"); + // Shape only — the literal client id must never appear in source or tests. + assert.match(MUSE_CODE_CONFIG.clientId, /^\d{16}$/); +}); diff --git a/tests/unit/muse-code-spark-targetformat.test.ts b/tests/unit/muse-code-spark-targetformat.test.ts new file mode 100644 index 000000000000..a89c3dd02ca7 --- /dev/null +++ b/tests/unit/muse-code-spark-targetformat.test.ts @@ -0,0 +1,160 @@ +/** + * Muse Code subscription Spark models must resolve to the Responses wire + * format even though they carry no per-request apiFormat flag: a Chat + * Completions client calling a Spark model through the gateway must get a + * translated Responses body at the /responses endpoint, not a Chat payload. + * Mirrors the opencode-zen/go Spark precedent (#10874, #11046). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/targetFormat.ts"; +import { muse_codeProvider } from "../../open-sse/config/providers/registry/muse-code/index.ts"; +import { deriveConfigFromRegistryModelsUrl } from "../../src/app/api/providers/[id]/models/discoveryConfig.ts"; +import { + getProviderById, + AI_PROVIDERS, + getProviderByAlias, +} from "../../src/shared/constants/providers.ts"; +import { resolveStaticProviderCatalogEntry } from "../../src/lib/providers/catalog.ts"; +import { translateRequest } from "../../open-sse/translator/index.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +const SUBSCRIPTION_SPARK_IDS = [ + "muse-spark-1.1", + "muse-spark-1.2", + "muse-spark-1.2-contributor", + "muse-spark-1.3", + "muse-spark-1.3-contributor", +]; + +test("subscription Spark models resolve to the Responses format on muse-code", () => { + for (const id of SUBSCRIPTION_SPARK_IDS) { + const r = resolveChatCoreTargetFormat({ + provider: "muse-code", + resolvedModel: id, + apiFormat: undefined, + sourceFormat: "openai", + customModelTargetFormat: undefined, + providerSpecificData: null, + }); + assert.equal( + r.targetFormat, + "openai-responses", + `${id} must target the Responses API, not chat/completions` + ); + } +}); + +test("subscription Spark models are statically tagged in the muse-code registry", () => { + for (const id of SUBSCRIPTION_SPARK_IDS) { + const model = muse_codeProvider.models.find((m) => m.id === id); + assert.ok(model, `${id} should be registered in the muse-code provider`); + assert.equal(model?.targetFormat, "openai-responses"); + assert.equal(model?.supportsReasoning, true); + assert.ok( + typeof model?.contextLength === "number" && (model?.contextLength ?? 0) > 0, + `${id} must declare a context window` + ); + } +}); + +test("muse-code registry derives a live discovery config for the Spark catalog", () => { + const config = deriveConfigFromRegistryModelsUrl("muse-code"); + assert.ok(config, "muse-code must expose a discovery config from its registry entry"); + assert.equal(config?.url, "https://api.meta.ai/v1/models"); + assert.equal(config?.method, "GET"); +}); + +test("muse-code keeps opaque reasoning transport for encrypted reasoning", () => { + assert.equal(muse_codeProvider.reasoningTransport, "opaque"); +}); + +test("muse-code resolves to one canonical OAuth-first catalog entry", () => { + // The id exists in both the OAuth and API-key catalogs (the registry and + // executor key on the shared id). Page-level consumers must see the OAuth + // entry: it owns connection/auth metadata, the subscription-risk notice, + // and the subscription auth hint, while API-key connections keep working + // through the dual-auth affordances. + const catalogEntry = resolveStaticProviderCatalogEntry("muse-code"); + assert.ok(catalogEntry, "muse-code must resolve in the static catalog"); + assert.equal(catalogEntry?.category, "oauth"); + assert.equal(catalogEntry?.displayAuthType, "oauth"); + assert.equal(catalogEntry?.subscriptionRisk, true); + const byId = getProviderById("muse-code") as + { id?: string; subscriptionRisk?: boolean } | undefined; + assert.equal(byId?.id, "muse-code"); + assert.equal(byId?.subscriptionRisk, true); + const merged = (AI_PROVIDERS as Record)[ + "muse-code" + ]; + assert.equal(merged?.id, "muse-code"); + assert.equal(merged?.subscriptionRisk, true); + assert.equal(getProviderByAlias("mc")?.id, "muse-code"); +}); + +test("chat callers dispatch a Responses body for Spark models", () => { + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES, + "muse-spark-1.3-contributor", + { + model: "muse-spark-1.3-contributor", + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { name: "get_time", description: "t", parameters: { type: "object" } }, + }, + ], + }, + false, + null, + "muse-code" + ) as Record; + assert.equal(translated.model, "muse-spark-1.3-contributor"); + assert.ok(!("messages" in translated), "no Chat payload may reach the Responses endpoint"); + const input = translated.input as Array>; + assert.ok(Array.isArray(input) && input.length > 0); + assert.equal(input[0].type, "message"); + const tools = translated.tools as Array>; + assert.equal(tools?.[0]?.type, "function"); + assert.equal(tools?.[0]?.name, "get_time"); + assert.equal(translated.store, false); +}); + +test("second-turn tool results keep call ids and outputs for Spark models", () => { + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES, + "muse-spark-1.3", + { + model: "muse-spark-1.3", + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: null, + tool_calls: [ + { id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "noon" }, + ], + include: ["reasoning.encrypted_content"], + }, + false, + null, + "muse-code" + ) as Record; + const input = translated.input as Array>; + const call = input.find((item) => item.type === "function_call") as + Record | undefined; + const output = input.find((item) => item.type === "function_call_output") as + Record | undefined; + assert.equal(call?.call_id, "call_1"); + assert.equal(call?.name, "get_time"); + assert.equal(output?.call_id, "call_1"); + assert.equal(output?.output, "noon"); + assert.deepEqual(translated.include, ["reasoning.encrypted_content"]); +}); diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index fde6dd1b2269..0647e9899816 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -37,6 +37,7 @@ const { GROK_BUILD_OAUTH_CONFIG, KILOCODE_CONFIG, KIMI_CODING_CONFIG, + MUSE_CODE_CONFIG, KIRO_CONFIG, OAUTH_TIMEOUT, PROVIDERS: OAUTH_PROVIDER_IDS, @@ -57,6 +58,7 @@ const EXPECTED_PROVIDER_KEYS = [ "agy", "qoder", "kimi-coding", + "muse-code", "github", "ghe-copilot", "gitlab-duo", @@ -91,6 +93,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { agy: AGY_CONFIG, qoder: QODER_CONFIG, "kimi-coding": KIMI_CODING_CONFIG, + "muse-code": MUSE_CODE_CONFIG, github: GITHUB_CONFIG, "ghe-copilot": GHE_COPILOT_CONFIG, "gitlab-duo": GITLAB_DUO_CONFIG, @@ -128,6 +131,7 @@ const REQUIRED_FIELDS_BY_PROVIDER = { agy: ["authorizeUrl", "tokenUrl", "userInfoUrl", "scopes", "clientId"], qoder: ["extraParams"], "kimi-coding": ["deviceCodeUrl", "tokenUrl", "clientId"], + "muse-code": ["deviceCodeUrl", "tokenUrl", "keyUrl", "clientId"], github: ["deviceCodeUrl", "tokenUrl", "userInfoUrl", "copilotTokenUrl", "clientId"], // GHE Copilot derives its URLs at runtime from the per-connection gheUrl — only static fields. "ghe-copilot": ["clientId", "scopes", "apiVersion", "userAgent"], @@ -265,7 +269,21 @@ test("every registered OAuth provider has a valid config object, flow type and t assert.ok(allowedFlowTypes.has(provider.flowType), `${providerId} has unsupported flowType`); assert.equal(typeof provider.mapTokens, "function", `${providerId} must expose mapTokens`); - const mapped = provider.mapTokens({}); + // muse-code rejects incomplete exchanges (no usable credential exists + // without the key-exchange step), so it gets a completed fixture while + // every other provider keeps the empty-input contract. + const mapped = + providerId === "muse-code" + ? provider.mapTokens( + { access_token: "test-device-token" }, + { + apiKey: "test-subscription-key", + accountId: "test-account", + email: null, + isSubsActive: true, + } + ) + : provider.mapTokens({}); assert.ok( mapped && typeof mapped === "object", `${providerId} mapTokens must return an object` diff --git a/tests/unit/publicCreds.test.ts b/tests/unit/publicCreds.test.ts index 681711b60b63..18456081aae9 100644 --- a/tests/unit/publicCreds.test.ts +++ b/tests/unit/publicCreds.test.ts @@ -60,6 +60,11 @@ test("resolvePublicCred('openference_id') returns the public PKCE client id shap assert.equal(v.length, 9); }); +test("resolvePublicCred('muse_id') returns the Meta device-flow client id shape", () => { + const v = resolvePublicCred("muse_id"); + assert.match(v, /^\d{16}$/); +}); + test("encode/decode roundtrip is stable across arbitrary plaintexts", () => { for (const sample of [ "hello world",