diff --git a/packages/ai/src/providers/index.ts b/packages/ai/src/providers/index.ts index f17beaf11d99..69d8e29cb5ba 100644 --- a/packages/ai/src/providers/index.ts +++ b/packages/ai/src/providers/index.ts @@ -21,6 +21,7 @@ export * as Meta from "./meta.js" export * as MiniMax from "./minimax.js" export * as Mistral from "./mistral.js" export * as Moonshot from "./moonshot.js" +export * as MuseCode from "./muse-code.js" export * as OpenAI from "./openai.js" export * as OpenAICompatible from "./openai-compatible.js" export * as OpenAICompatibleResponses from "./openai-compatible-responses.js" diff --git a/packages/ai/src/providers/muse-code.ts b/packages/ai/src/providers/muse-code.ts new file mode 100644 index 000000000000..9b1ad19d7718 --- /dev/null +++ b/packages/ai/src/providers/muse-code.ts @@ -0,0 +1,422 @@ +// Muse Code subscription provider. +// +// Same https://api.meta.ai/v1 API and shared Meta Responses protocol as the +// Meta API-key provider, but authenticated with a subscription-minted +// inference key from Meta device authorization. It never reads META_API_KEY +// and never falls back to it: without an explicit key every request fails +// closed with MissingCredentialError. +// +// Device authorization, key exchange, and quota parsing below are adapted +// from oh-my-pi PR #10677 (eggpeat/oh-my-pi @ 6785d70d, MIT). +import type { ProviderPackage } from "../provider-package.js" +import { MetaResponses } from "../protocols/meta-responses.js" +import { optional as optionalSecret } from "../route/auth.js" +import { type ProviderAuthOption } from "../route/auth-options.js" +import { Route, type RouteDefaultsInput } from "../route/client.js" +import { Endpoint } from "../route/endpoint.js" +import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js" +import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js" + +export const id = ProviderID.make("muse-code") +const baseURL = "https://api.meta.ai/v1" + +const deviceAuthorizationURL = "https://auth.meta.com/oidc/device/authorization/" +const deviceTokenURL = "https://auth.meta.com/oidc/device/token/" +const subscriptionKeyURL = "https://api.meta.ai/muse-code/key" +const modelDiscoveryURL = "https://api.meta.ai/v1/models" +// Verified Meta client identity for the Muse Code device flow. +const clientID = "1031625952748946" +const headers = { Accept: "application/json", "x-api-version": "1.0.0" } as const + +// Single owner for Muse model policy. Effort order is weakest to strongest; +// `max` is exposed last only on muse-spark-1.3. Display labels are short — +// API IDs are unchanged, and Contributor models keep their suffix because +// their prompts may be used for training. +export const EFFORTS = ["minimal", "low", "medium", "high", "xhigh"] as const +export const NAMES = { + "muse-spark-1.1": "Spark 1.1", + "muse-spark-1.2": "Spark 1.2", + "muse-spark-1.2-contributor": "Spark 1.2 Contributor", + "muse-spark-1.3": "Spark 1.3", + "muse-spark-1.3-contributor": "Spark 1.3 Contributor", +} as const +export const KNOWN_IDS = Object.keys(NAMES) +export const LIMITS = { context: 1_048_576, output: 131_072 } as const + +export const effortsFor = (modelID: string): string[] => + modelID === "muse-spark-1.3" ? [...EFFORTS, "max"] : [...EFFORTS] + +export const variantSettings = (effort: string) => ({ + reasoningEffort: effort, + reasoningSummary: "auto" as const, + include: ["reasoning.encrypted_content"], +}) + +export interface DeviceAuthorization { + readonly deviceCode: string + readonly userCode: string + readonly url: string + readonly instructions: string + readonly expiresIn: number + readonly intervalMs: number +} + +export interface SubscriptionKey { + readonly apiKey?: string + readonly accountID?: string + readonly active: boolean +} + +export interface QuotaWindow { + readonly window: string + readonly usedPercent: number + readonly durationMinutes?: number + readonly resetsAt?: string +} + +export class RateLimitedError extends Error { + readonly retryAfterMs: number + constructor(retryAfterMs: number) { + super("Muse Code is rate limited. Wait before retrying; no alternate account is used.") + this.retryAfterMs = retryAfterMs + } +} + +const retryAfterMs = (value: string | null): number => { + const seconds = Number(value) + if (value && Number.isFinite(seconds)) return Math.max(1000, seconds * 1000) + const when = Date.parse(value ?? "") + return Number.isFinite(when) ? Math.max(1000, when - Date.now()) : 60_000 +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + +const text = (value: unknown, what: string): string => { + if (typeof value !== "string" || value.trim() === "" || /[\r\n\x00]/.test(value)) + throw new Error(`Muse Code returned a missing or invalid ${what}.`) + return value +} + +const allowedURLs = new Set([deviceAuthorizationURL, deviceTokenURL, subscriptionKeyURL, modelDiscoveryURL]) + +async function fetchJSON(fetchFn: typeof fetch, url: string, init: RequestInit, signal?: AbortSignal) { + if (!allowedURLs.has(url)) throw new Error("Muse Code refused an unverified destination.") + let response: Response + try { + response = await fetchFn(url, { ...init, redirect: "error", signal }) + } catch { + if (signal?.aborted) throw new Error("Muse Code request cancelled.") + throw new Error("Muse Code network request failed or timed out. Retry when connectivity is restored.") + } + if (response.status === 429) throw new RateLimitedError(retryAfterMs(response.headers.get("retry-after"))) + if (response.status === 401) + throw new Error("Muse Code authorization expired or was revoked. Reconnect the subscription.") + if (response.status === 402) + throw new Error("Muse Code requires a subscription payment action. Check billing in your Meta account.") + if (response.status === 403) + throw new Error("Muse Code subscription access denied. Check that your subscription is active, then reconnect.") + if (!response.ok) throw new Error(`Muse Code request failed (HTTP ${response.status}).`) + try { + const payload: unknown = await response.json() + if (!isRecord(payload)) throw new Error("malformed") + return payload + } catch { + throw new Error("Muse Code returned malformed data.") + } +} + +export const startDeviceAuthorization = async ( + fetchFn: typeof fetch = fetch, + signal?: AbortSignal, +): Promise => { + const device = await fetchJSON( + fetchFn, + deviceAuthorizationURL, + { + method: "POST", + headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ client_id: clientID }).toString(), + }, + signal, + ) + const url = new URL(text(device["verification_uri_complete"] ?? device["verification_uri"], "authorization URL")) + if (url.origin !== "https://auth.meta.com" || url.username || url.password) + throw new Error("Muse Code returned an unverified authorization URL.") + const expiresIn = Number(device["expires_in"]) + if (!Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 86_400) + throw new Error("Muse Code returned an invalid device expiry.") + const interval = device["interval"] === undefined ? 5 : Number(device["interval"]) + if (!Number.isFinite(interval) || interval <= 0) throw new Error("Muse Code returned an invalid polling interval.") + const userCode = text(device["user_code"], "user code") + return { + deviceCode: text(device["device_code"], "device code"), + userCode, + url: url.toString(), + instructions: `Enter code: ${userCode}.`, + expiresIn, + intervalMs: Math.max(1000, Math.floor(interval * 1000)), + } +} + +// The token endpoint reports poll states as JSON bodies on non-2xx +// responses, so it reads the payload before mapping HTTP states. +async function postTokenPayload(fetchFn: typeof fetch, deviceCode: string, signal?: AbortSignal) { + if (!allowedURLs.has(deviceTokenURL)) throw new Error("Muse Code refused an unverified destination.") + let response: Response + try { + response = await fetchFn(deviceTokenURL, { + method: "POST", + headers: { ...headers, "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: clientID, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }).toString(), + redirect: "error", + signal, + }) + } catch { + if (signal?.aborted) throw new Error("Muse Code login cancelled.") + throw new Error("Muse Code network request failed or timed out. Retry when connectivity is restored.") + } + let payload: unknown + try { + payload = await response.json() + } catch { + throw new Error("Muse Code returned malformed token data.") + } + if (!isRecord(payload)) throw new Error("Muse Code returned malformed token data.") + return { status: response.status, payload, retryAfter: response.headers.get("retry-after") } +} + +export const pollDeviceToken = async ( + deviceCode: string, + fetchFn: typeof fetch = fetch, + opts: { + intervalMs: number + deadline: number + now?: () => number + wait?: (ms: number) => Promise + signal?: AbortSignal + }, +): Promise => { + const now = opts.now ?? Date.now + // The default wait is cancellable so closing the login attempt stops the + // sleep instead of leaving polling running past the device deadline. + const wait = + opts.wait ?? + ((ms: number) => + new Promise((resolve, reject) => { + if (opts.signal?.aborted) return reject(new Error("Muse Code login cancelled.")) + const timer = setTimeout(resolve, ms) + opts.signal?.addEventListener( + "abort", + () => { + clearTimeout(timer) + reject(new Error("Muse Code login cancelled.")) + }, + { once: true }, + ) + })) + let interval = opts.intervalMs + while (now() < opts.deadline) { + if (opts.signal?.aborted) throw new Error("Muse Code login cancelled.") + await wait(Math.min(interval, opts.deadline - now())).catch(() => { + throw new Error("Muse Code login cancelled.") + }) + if (now() >= opts.deadline || opts.signal?.aborted) throw new Error("Muse Code login cancelled.") + const { status, payload, retryAfter } = await postTokenPayload(fetchFn, deviceCode, opts.signal) + if (payload["error"] === "authorization_pending") continue + if (payload["error"] === "slow_down") { + interval += 5000 + continue + } + if (payload["error"] === "access_denied") + throw new Error("Muse Code device authorization denied. Start a new login if this was unintended.") + if (payload["error"] === "expired_token") break + if (status === 429) throw new RateLimitedError(retryAfterMs(retryAfter)) + if (status === 401) throw new Error("Muse Code authorization expired or was revoked. Reconnect the subscription.") + if (status === 402) + throw new Error("Muse Code requires a subscription payment action. Check billing in your Meta account.") + if (status === 403) + throw new Error("Muse Code subscription access denied. Check that your subscription is active, then reconnect.") + if (status !== 200 || payload["error"]) + throw new Error(`Muse Code device authorization failed (HTTP ${status}). Start a new login.`) + return text(payload["access_token"], "account token") + } + throw new Error("Muse Code device code expired. Start a new login.") +} + +async function postSubscriptionKey( + accountToken: string, + onboard: boolean, + fetchFn: typeof fetch, + signal?: AbortSignal, +): Promise> { + const payload = await fetchJSON( + fetchFn, + subscriptionKeyURL, + { + method: "POST", + headers: { ...headers, "Content-Type": "application/json", Authorization: `Bearer ${accountToken}` }, + body: JSON.stringify(onboard ? { onboard: true } : {}), + }, + signal, + ) + if (payload["is_subs_active"] !== undefined && typeof payload["is_subs_active"] !== "boolean") + throw new Error("Muse Code returned malformed subscription status.") + if (payload["is_subs_active"] === false) + throw new Error("Muse Code subscription is inactive. Activate it in your Meta account, then reconnect.") + if (payload["require_payment"] === true || payload["action_url"] || payload["require_payment_action_url"]) + throw new Error("Muse Code requires a subscription or billing action. Check billing in your Meta account.") + return payload +} + +export const exchangeSubscriptionKey = async ( + accountToken: string, + onboard: boolean, + fetchFn: typeof fetch = fetch, + signal?: AbortSignal, +): Promise => { + const payload = await postSubscriptionKey(accountToken, onboard, fetchFn, signal) + // Only the onboard exchange returns an inference key and identity; quota + // lookups must never satisfy authentication. + if (!onboard) return { active: payload["is_subs_active"] !== false } + const apiKey = payload["api_key"] + const accountID = payload["user_id"] ?? payload["user_email"] + return { apiKey: text(apiKey, "subscription key"), accountID: text(accountID, "account identity"), active: true } +} + +// Redacted subscription quota for an account token. The quota lookup never +// returns an inference key, so it cannot satisfy authentication. +export const fetchQuota = async ( + accountToken: string, + fetchFn: typeof fetch = fetch, + signal?: AbortSignal, +): Promise<{ active: boolean; windows: QuotaWindow[] }> => { + const payload = await postSubscriptionKey(accountToken, false, fetchFn, signal) + return { active: payload["is_subs_active"] !== false, windows: quotaOf(payload) } +} + +// Account-entitled model IDs from the subscription key. Callers intersect +// with KNOWN_IDS; unknown revisions are reported, never given guessed +// capabilities. +export const discoverModelIDs = async ( + apiKey: string, + fetchFn: typeof fetch = fetch, + signal?: AbortSignal, +): Promise<{ entitled: string[]; unknown: string[] }> => { + const payload = await fetchJSON( + fetchFn, + modelDiscoveryURL, + { headers: { ...headers, Authorization: `Bearer ${apiKey}` }, signal }, + signal, + ) + if (!Array.isArray(payload["data"])) throw new Error("Muse Code model discovery returned malformed data.") + const entitled: string[] = [] + const unknown: string[] = [] + for (const row of payload["data"]) { + if (!isRecord(row) || typeof row["id"] !== "string") + throw new Error("Muse Code model discovery returned malformed data.") + if (KNOWN_IDS.includes(row["id"])) entitled.push(row["id"]) + else unknown.push(row["id"]) + } + return { entitled, unknown } +} + +export const quotaOf = (payload: Record): QuotaWindow[] => { + const windows: QuotaWindow[] = [] + const usage = payload["subs_usage"] + if (!isRecord(usage)) return windows + for (const name of ["window", "weekly"]) { + const value = usage[name] + if (!isRecord(value) || typeof value["used_percent"] !== "number" || value["used_percent"] < 0) continue + const resets = value["resets_at"] + const millis = + typeof resets === "number" + ? resets * (resets < 1_000_000_000_000 ? 1000 : 1) + : typeof resets === "string" + ? Date.parse(resets) + : Number.NaN + windows.push({ + window: name, + usedPercent: value["used_percent"], + ...(typeof value["window_duration_mins"] === "number" && value["window_duration_mins"] > 0 + ? { durationMinutes: value["window_duration_mins"] } + : {}), + ...(Number.isFinite(millis) && millis > 0 && Number.isFinite(new Date(millis).getTime()) + ? { resetsAt: new Date(millis).toISOString() } + : {}), + }) + } + return windows +} + +export type ProviderOptionsInput = OpenResponsesProviderOptionsInput + +export type LanguageModelOptions = Omit & + ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly providerOptions?: ProviderOptionsInput + } + +export interface Settings extends ProviderPackage.Settings { + readonly apiKey?: string + readonly baseURL?: string + readonly providerOptions?: ProviderOptionsInput +} + +const responsesRoute = Route.make({ + id: "muse-code-responses", + provider: id, + providerMetadataKey: "muse-code", + protocol: MetaResponses.protocol, + endpoint: Endpoint.path("/responses", { baseURL }), + // Meta Responses does not support WebSocket upgrades; always use HTTP/SSE. + transport: MetaResponses.httpTransport, + defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } }, +}) + +export const routes = [responsesRoute] + +const subscriptionAuth = (input: ProviderAuthOption<"optional">) => + "auth" in input && input.auth ? input.auth : optionalSecret(input.apiKey, "muse-code subscription").bearer() + +export const configure = (input: LanguageModelOptions = {}) => { + const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input + // The subscription credential is only ever attached to the verified Meta + // API. Base-URL overrides are rejected instead of redirecting credentials + // to an unverified destination. + if (endpoint !== undefined && endpoint !== baseURL) + throw new Error("Muse Code refused an unverified inference destination.") + const options = { + ...defaults, + endpoint: { baseURL }, + auth: subscriptionAuth(input), + } + const configuredResponses = responsesRoute.with(options) + const responses = (modelID: string | ModelID) => + configuredResponses.model({ id: modelID }) + return { id, model: responses, responses, configure } +} + +export const provider = configure() +export const responses = provider.responses + +export const model: ProviderPackage.Definition["model"] = ( + modelID, + settings, +) => fromSettings(settings).responses(modelID) + +function fromSettings(settings: Settings) { + return configure({ + apiKey: settings.apiKey, + baseURL: settings.baseURL, + headers: settings.headers, + http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + providerOptions: settings.providerOptions, + }) +} + +export * as MuseCode from "./muse-code.js" diff --git a/packages/ai/test/provider/muse-code.test.ts b/packages/ai/test/provider/muse-code.test.ts new file mode 100644 index 000000000000..dec16df554db --- /dev/null +++ b/packages/ai/test/provider/muse-code.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { LLM, LLMClient } from "../../src/index.js" +import { MetaResponses } from "../../src/protocols/meta-responses.js" +import { compileRequest } from "../../src/route/client.js" +import { it } from "../lib/effect.js" +import { dynamicResponse } from "../lib/http.js" +import { sseEvents } from "../lib/sse.js" +import { + EFFORTS, + NAMES, + RateLimitedError, + configure, + discoverModelIDs, + model as museModel, + effortsFor, + exchangeSubscriptionKey, + fetchQuota, + pollDeviceToken, + quotaOf, + startDeviceAuthorization, + variantSettings, +} from "../../src/providers/muse-code.js" + +const device = { + device_code: "fixture-device", + user_code: "TEST-CODE", + verification_uri: "https://auth.meta.com/device", + expires_in: 60, + interval: 1, +} + +describe("muse-code model policy", () => { + test("effort order is weakest to strongest with max last only on 1.3", () => { + expect(EFFORTS).toEqual(["minimal", "low", "medium", "high", "xhigh"]) + expect(effortsFor("muse-spark-1.2")).toEqual(["minimal", "low", "medium", "high", "xhigh"]) + expect(effortsFor("muse-spark-1.3")).toEqual(["minimal", "low", "medium", "high", "xhigh", "max"]) + expect(effortsFor("muse-spark-1.3-contributor")).toEqual(["minimal", "low", "medium", "high", "xhigh"]) + }) + + test("display labels are short and keep the Contributor distinction", () => { + expect(NAMES["muse-spark-1.1"]).toBe("Spark 1.1") + expect(NAMES["muse-spark-1.2"]).toBe("Spark 1.2") + expect(NAMES["muse-spark-1.2-contributor"]).toBe("Spark 1.2 Contributor") + expect(NAMES["muse-spark-1.3"]).toBe("Spark 1.3") + expect(NAMES["muse-spark-1.3-contributor"]).toBe("Spark 1.3 Contributor") + }) + + test("variant settings match the shared Meta Responses handling", () => { + expect(variantSettings("low")).toEqual({ + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }) + }) +}) + +describe("muse-code device authorization", () => { + const fetchDevice = (async () => Response.json(device)) as typeof fetch + + test("device authorization validates the verification destination", async () => { + const authorization = await startDeviceAuthorization(fetchDevice) + expect(authorization.userCode).toBe("TEST-CODE") + expect(authorization.url).toBe("https://auth.meta.com/device") + expect(authorization.intervalMs).toBe(1000) + await expect( + startDeviceAuthorization((async () => + Response.json({ ...device, verification_uri: "https://evil.example" })) as typeof fetch), + ).rejects.toThrow(/unverified/) + }) + + test("polling handles pending, slow_down, denial, and expiry", async () => { + let clock = 0 + let polls = 0 + const waits: number[] = [] + const fetchFn = (async () => { + polls += 1 + if (polls === 1) return Response.json({ error: "authorization_pending" }, { status: 400 }) + if (polls === 2) return Response.json({ error: "slow_down" }, { status: 400 }) + return Response.json({ access_token: "fixture-account" }) + }) as typeof fetch + const token = await pollDeviceToken("fixture-device", fetchFn, { + intervalMs: 1000, + deadline: 60_000, + now: () => clock, + wait: async (ms: number) => { + waits.push(ms) + clock += ms + }, + }) + expect(token).toBe("fixture-account") + expect(waits).toEqual([1000, 1000, 6000]) + + const denied = (async () => Response.json({ error: "access_denied" }, { status: 400 })) as typeof fetch + await expect( + pollDeviceToken("fixture-device", denied, { intervalMs: 0, deadline: 1000, now: () => 0, wait: async () => {} }), + ).rejects.toThrow(/denied/) + const expired = (async () => Response.json({ error: "expired_token" }, { status: 400 })) as typeof fetch + await expect( + pollDeviceToken("fixture-device", expired, { intervalMs: 0, deadline: 1000, now: () => 0, wait: async () => {} }), + ).rejects.toThrow(/expired/) + }) +}) + +describe("muse-code subscription exchange", () => { + test("onboarding requires a key and identity; quota lookups do not", async () => { + const fetchFn = (async (url: unknown, init?: RequestInit) => { + const onboard = JSON.parse(String(init?.body))?.onboard === true + if (onboard) return Response.json({ api_key: "fixture-key", user_id: "fixture-user", is_subs_active: true }) + return Response.json({ is_subs_active: true, subs_usage: { window: { used_percent: 5 } } }) + }) as typeof fetch + const onboarded = await exchangeSubscriptionKey("fixture-account", true, fetchFn) + expect(onboarded).toMatchObject({ apiKey: "fixture-key", accountID: "fixture-user", active: true }) + const quota = await exchangeSubscriptionKey("fixture-account", false, fetchFn) + expect(quota).toEqual({ active: true }) + }) + + test("inactive subscriptions and billing actions fail closed", async () => { + const inactive = (async () => Response.json({ is_subs_active: false })) as typeof fetch + await expect(exchangeSubscriptionKey("fixture-account", true, inactive)).rejects.toThrow(/inactive/) + const billing = (async () => Response.json({ require_payment: true })) as typeof fetch + await expect(exchangeSubscriptionKey("fixture-account", true, billing)).rejects.toThrow(/billing/) + }) + + test("quota windows are allowlisted and redacted", () => { + expect( + quotaOf({ is_subs_active: true, subs_usage: { window: { used_percent: 12, window_duration_mins: 300 } } }), + ).toEqual([{ window: "window", usedPercent: 12, durationMinutes: 300 }]) + expect(quotaOf({})).toEqual([]) + }) + + test("discovery separates entitled models from unknown revisions", async () => { + const fetchFn = (async () => + Response.json({ data: [{ id: "muse-spark-1.3" }, { id: "muse-spark-99" }] })) as typeof fetch + await expect(discoverModelIDs("fixture-key", fetchFn)).resolves.toEqual({ + entitled: ["muse-spark-1.3"], + unknown: ["muse-spark-99"], + }) + }) + + test("quota lookups are redacted and never mint keys", async () => { + const bodies: string[] = [] + const fetchFn = (async (_url: unknown, init?: RequestInit) => { + bodies.push(String(JSON.parse(String(init?.body))?.onboard ?? false)) + return Response.json({ is_subs_active: true, subs_usage: { window: { used_percent: 7 } } }) + }) as typeof fetch + await expect(fetchQuota("fixture-account", fetchFn)).resolves.toEqual({ + active: true, + windows: [{ window: "window", usedPercent: 7 }], + }) + expect(bodies).toEqual(["false"]) + }) + + test("rate limiting carries the Retry-After horizon", async () => { + const limited = (async () => new Response("{}", { status: 429, headers: { "retry-after": "120" } })) as typeof fetch + const failure = await exchangeSubscriptionKey("fixture-account", true, limited).catch((error) => error) + expect(failure).toBeInstanceOf(RateLimitedError) + expect((failure as RateLimitedError).retryAfterMs).toBe(120_000) + }) + + test("the default poll wait is cancellable", async () => { + const controller = new AbortController() + const pending = (async () => { + await new Promise((_resolve, reject) => + controller.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }), + ) + }) as typeof fetch + const polled = pollDeviceToken("fixture-device", pending, { + intervalMs: 60_000, + deadline: Date.now() + 120_000, + signal: controller.signal, + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + controller.abort() + await expect(polled).rejects.toThrow(/cancelled/) + }) + + test("inference rejects unverified base URLs instead of moving credentials", () => { + expect(() => configure({ baseURL: "https://evil.example/v1" })).toThrow(/unverified/) + expect(() => configure({ apiKey: "fixture-key" }).responses("muse-spark-1.3")).not.toThrow() + }) +}) + +describe("muse-code transport", () => { + const tools = [{ name: "read", description: "Read a file", inputSchema: { type: "object" } }] + + it.effect("sends the subscription key only to the verified Responses endpoint", () => + Effect.gen(function* () { + const responses = configure({ apiKey: "fixture-subscription-key" }).responses("muse-spark-1.3") + expect(responses.provider).toBe("muse-code") + expect(responses.route.providerMetadataKey).toBe("muse-code") + expect(responses.route.endpoint.baseURL).toBe("https://api.meta.ai/v1") + expect(responses.route.body).toBe(MetaResponses.protocol.body) + const compiled = yield* compileRequest(LLM.request({ model: responses, prompt: "Hello", tools })) + expect(compiled.protocol).toBe("meta-responses") + expect(compiled.body).toMatchObject({ + model: "muse-spark-1.3", + store: false, + include: ["reasoning.encrypted_content"], + }) + expect(JSON.stringify(compiled.body.tools)).toContain('"read"') + const response = yield* LLMClient.generate(LLM.request({ model: responses, prompt: "Hello", tools })).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.sync(() => { + expect(input.request.method).toBe("POST") + expect(input.request.url).toBe("https://api.meta.ai/v1/responses") + expect(input.request.headers.authorization).toBe("Bearer fixture-subscription-key") + expect(JSON.parse(input.text)).toMatchObject({ model: "muse-spark-1.3", stream: true, store: false }) + return input.respond( + sseEvents( + { type: "response.created", response: { id: "resp_muse" } }, + { + type: "response.output_item.done", + output_index: 0, + item: { + id: "msg_muse", + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "MUSE_SUBSCRIPTION_OK" }], + }, + }, + { type: "response.completed", response: { id: "resp_muse" } }, + ), + { headers: { "content-type": "text/event-stream" } }, + ) + }), + ), + ), + ) + expect(response.text).toBe("MUSE_SUBSCRIPTION_OK") + }), + ) + + it.effect("settings select models without inheriting environment credentials", () => + Effect.gen(function* () { + const selected = museModel("muse-spark-1.2", { apiKey: "explicit-key" }) + expect(selected.route.endpoint.baseURL).toBe("https://api.meta.ai/v1") + const compiled = yield* compileRequest(LLM.request({ model: selected, prompt: "Hello" })) + expect(compiled.body).toMatchObject({ store: false }) + }), + ) +}) diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index a43015790810..e43b3586f857 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -17,6 +17,7 @@ import { LLMGatewayPlugin } from "./provider/llmgateway.js" import { LMStudioPlugin } from "./provider/lmstudio.js" import { MistralPlugin } from "./provider/mistral.js" import { ModalPlugin } from "./provider/modal.js" +import { MuseCodePlugin } from "./provider/muse-code.js" import { NvidiaPlugin } from "./provider/nvidia.js" import { OllamaPlugin } from "./provider/ollama.js" import { OpenAIPlugin } from "./provider/openai.js" @@ -53,6 +54,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ LMStudioPlugin, MistralPlugin, ModalPlugin, + MuseCodePlugin, NvidiaPlugin, OllamaPlugin, OpencodePlugin, diff --git a/packages/core/src/plugin/provider/muse-code.ts b/packages/core/src/plugin/provider/muse-code.ts new file mode 100644 index 000000000000..a45437cc4913 --- /dev/null +++ b/packages/core/src/plugin/provider/muse-code.ts @@ -0,0 +1,203 @@ +import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" +import { define } from "@opencode/plugin/effect/plugin" +import { MuseCode } from "@opencode/ai/providers/muse-code" +import { Clock, Effect, Semaphore, Stream } from "effect" +import { Credential } from "../../credential.js" +import { Bus } from "../../bus.js" +import { Integration } from "../../integration.js" +import { Model } from "../../model.js" +import { Provider } from "../../provider.js" +import type { PluginInternal } from "../internal.js" + +// Verified Muse release dates come from the models.dev snapshot; 1.3 has no +// snapshot entry yet so it sorts last until its date is known. +const RELEASED: Record = { + "muse-spark-1.1": Date.parse("2026-04-08"), + "muse-spark-1.2": Date.parse("2026-08-05"), + "muse-spark-1.2-contributor": Date.parse("2026-08-05"), +} + +const providerID = Provider.ID.make("muse-code") +const integrationID = Integration.ID.make("muse-code") +const deviceMethodID = Integration.MethodID.make("device") +// The account token carries no advertised lifetime, so expiry is a sentinel: +// failures re-authenticate interactively instead of on a schedule. There is +// no refresh-token grant; the stored account token only ever re-exchanges. +const noAdvertisedExpiry = 8_640_000_000_000_000 + +const device = (authorize: IntegrationOAuthMethodRegistration["authorize"]) => + ({ + integrationID, + method: { + id: deviceMethodID, + type: "oauth", + label: "Muse Code subscription (device authorization)", + }, + // Single auth implementation: the ai package owns device validation, + // polling, and key exchange; this layer only forwards cancellation. + authorize, + label: (value) => + typeof value.metadata?.["accountId"] === "string" ? (value.metadata["accountId"] as string) : undefined, + }) satisfies IntegrationOAuthMethodRegistration + +export const MuseCodePlugin = define({ + id: "opencode.provider.muse-code", + effect: Effect.fn(function* (ctx) { + const bus = yield* Bus.Service + const exchangeLock = yield* Semaphore.make(1) + // Account-scoped key-endpoint backoff, mirroring the established + // lifecycle: concurrent exchanges share one flight, and Retry-After is + // honored without touching another account. + const backoff = new Map() + let subscription: Credential.OAuth | undefined + let entitled: string[] = [] + let unsupported: string[] = [] + + // The subscription key exchange is the only place an inference key is + // minted. Quota is informational and redacted; a quota failure never + // fails the login the key exchange already validated. + const exchangeAccount = (accountToken: string) => + exchangeLock.withPermit( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis + const until = backoff.get(accountToken) ?? 0 + if (now < until) return yield* Effect.fail(new MuseCode.RateLimitedError(until - now)) + const subscriptionKey = yield* Effect.tryPromise({ + try: (signal) => MuseCode.exchangeSubscriptionKey(accountToken, true, fetch, signal), + catch: (cause) => cause, + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + if (error instanceof MuseCode.RateLimitedError) { + const at = yield* Clock.currentTimeMillis + backoff.set(accountToken, at + error.retryAfterMs) + } + return yield* Effect.fail(error) + }), + ), + ) + const quota = yield* Effect.tryPromise({ + try: (signal) => MuseCode.fetchQuota(accountToken, fetch, signal), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => ({ active: true, windows: [] as MuseCode.QuotaWindow[] }))) + return Credential.OAuth.make({ + type: "oauth", + methodID: deviceMethodID, + refresh: accountToken, + access: subscriptionKey.apiKey ?? "", + expires: noAdvertisedExpiry, + metadata: { + ...(subscriptionKey.accountID ? { accountId: subscriptionKey.accountID } : {}), + quota: quota.windows, + }, + }) + }), + ) + + const refreshData = Effect.fn("MuseCodePlugin.refresh")(function* () { + const connection = yield* ctx.integration.connection.active(integrationID) + const value = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) + : undefined + subscription = + value?.type === "oauth" && value.methodID === deviceMethodID && value.access !== "" ? value : undefined + entitled = [] + unsupported = [] + const active = subscription + if (!active) return + const discovered = yield* Effect.tryPromise({ + try: (signal) => MuseCode.discoverModelIDs(active.access, fetch, signal), + catch: (cause) => cause, + }).pipe(Effect.orElseSucceed(() => undefined)) + if (!discovered) { + yield* Effect.logWarning("Muse Code model discovery unavailable; registering no models.") + return + } + entitled = discovered.entitled.filter((id) => MuseCode.KNOWN_IDS.includes(id)) + unsupported = discovered.unknown + if (unsupported.length > 0) + yield* Effect.logInfo(`Muse Code returned ${unsupported.length} unsupported model revision(s); skipped.`) + if (entitled.length === 0) + yield* Effect.logInfo("Muse Code reports no entitled models for this account; registering none.") + }) + + const authorize = () => + Effect.gen(function* () { + const authorization = yield* Effect.tryPromise({ + try: (signal) => MuseCode.startDeviceAuthorization(fetch, signal), + catch: (cause) => cause, + }) + const started = yield* Clock.currentTimeMillis + return { + mode: "auto" as const, + url: authorization.url, + instructions: authorization.instructions, + ...(authorization.expiresIn ? { expiresAt: started + authorization.expiresIn * 1000 } : {}), + callback: Effect.gen(function* () { + const accountToken = yield* Effect.tryPromise({ + try: (signal) => + MuseCode.pollDeviceToken(authorization.deviceCode, fetch, { + intervalMs: authorization.intervalMs, + deadline: started + authorization.expiresIn * 1000, + signal, + }), + catch: (cause) => cause, + }) + return yield* exchangeAccount(accountToken) + }), + } + }) + + yield* ctx.integration.transform((editor) => { + editor.update(integrationID, (integration) => { + integration.name = "Muse Code (Subscription)" + }) + // Subscription only: no API-key or environment method, so a Meta PAYG + // key can never authenticate this provider. + editor.method.update(device(authorize)) + }) + yield* refreshData() + yield* ctx.catalog.transform((catalog) => { + catalog.provider.update(providerID, (draft) => { + draft.name = "Muse Code (Subscription)" + draft.activation = "auto" + draft.package = "@opencode/ai/providers/muse-code" + draft.integrationID = integrationID + draft.settings = Provider.mergeOverlay(draft.settings, { baseURL: "https://api.meta.ai/v1" }) + }) + // Entitled models only: the provider row keeps /connect visible before + // login, but no bundled model is ever presented as confirmed access. + for (const apiID of entitled) { + const modelID = Model.ID.make(apiID) + catalog.model.update(providerID, modelID, (draft) => { + Object.assign(draft, { + ...Model.Info.default(providerID, modelID), + id: modelID, + modelID, + providerID, + name: MuseCode.NAMES[apiID as keyof typeof MuseCode.NAMES], + family: Model.Family.make("muse"), + package: "@opencode/ai/providers/muse-code", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: MuseCode.effortsFor(apiID).map((effort) => ({ + id: Model.VariantID.make(effort), + settings: MuseCode.variantSettings(effort), + })), + limit: { context: MuseCode.LIMITS.context, output: MuseCode.LIMITS.output }, + // Subscription quota is not API usage; never render dollar costs. + cost: [], + status: "active" as const, + enabled: true, + time: { released: RELEASED[apiID] ?? 0 }, + }) + }) + } + }) + const reload = () => refreshData().pipe(Effect.andThen(ctx.catalog.reload())) + yield* bus.subscribe(Credential.Event.Switched).pipe( + Stream.filter((event) => event.data.integrationID === integrationID), + Stream.runForEach(reload), + Effect.forkScoped({ startImmediately: true }), + ) + }), +} satisfies PluginInternal.InternalPlugin) diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index c83f8153544a..b56844be137b 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -18,6 +18,7 @@ export const ID = Schema.String.pipe( openrouter: schema.make("openrouter"), mistral: schema.make("mistral"), gitlab: schema.make("gitlab"), + museCode: schema.make("muse-code"), })), ) export type ID = typeof ID.Type