From 6b47a80388b8c429b3863aadcba73cc90af92104 Mon Sep 17 00:00:00 2001 From: "Can H. Tartanoglu" Date: Thu, 10 Sep 2026 21:45:29 +0200 Subject: [PATCH 1/2] feat(ai): add Muse Code subscription authentication Implements subscription-backed Muse Code access as a distinct muse-code provider, separate from the Meta API-key provider: - packages/ai providers/muse-code: Responses route on the shared MetaResponses protocol with explicit subscription-bearer auth (fails closed, never reads META_API_KEY). Single owner for model policy (short labels, minimal..xhigh efforts with max last only on muse-spark-1.3, token limits) plus device authorization, subscription-key exchange, entitled-model discovery, and quota parsing adapted from oh-my-pi PR #10677. - packages/core provider/muse-code: device-code OAuth method, subscription-gated catalog models (provider visible before login, no bundled model presented as entitled access), and account-token refresh via key re-exchange (no refresh-token grant exists). - References anomalyco/opencode#41551. --- packages/ai/src/providers/index.ts | 1 + packages/ai/src/providers/muse-code.ts | 362 ++++++++++++++++++ packages/ai/test/provider/muse-code.test.ts | 130 +++++++ packages/core/src/plugin/provider.ts | 2 + .../core/src/plugin/provider/muse-code.ts | 183 +++++++++ packages/schema/src/provider.ts | 1 + 6 files changed, 679 insertions(+) create mode 100644 packages/ai/src/providers/muse-code.ts create mode 100644 packages/ai/test/provider/muse-code.test.ts create mode 100644 packages/core/src/plugin/provider/muse-code.ts 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..20d6f6595287 --- /dev/null +++ b/packages/ai/src/providers/muse-code.ts @@ -0,0 +1,362 @@ +// 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 +} + +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 Error("Muse Code is rate limited. Wait before retrying.") + 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 } +} + +export const pollDeviceToken = async ( + deviceCode: string, + fetchFn: typeof fetch = fetch, + opts: { intervalMs: number; deadline: number; now?: () => number; wait?: (ms: number) => Promise; signal?: AbortSignal } = { + intervalMs: 5000, + deadline: Number.POSITIVE_INFINITY, + }, +): Promise => { + const now = opts.now ?? Date.now + const wait = opts.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + 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 } = 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 Error("Muse Code is rate limited. Wait before retrying.") + 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.") +} + +export const exchangeSubscriptionKey = async ( + accountToken: string, + onboard: boolean, + fetchFn: typeof fetch = 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.") + // 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 } +} + +// 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 + const options = { + ...defaults, + endpoint: { baseURL: 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..32bc200d5adf --- /dev/null +++ b/packages/ai/test/provider/muse-code.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test" +import { + EFFORTS, + NAMES, + discoverModelIDs, + effortsFor, + exchangeSubscriptionKey, + 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"], + }) + }) +}) 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..cf607e48cd44 --- /dev/null +++ b/packages/core/src/plugin/provider/muse-code.ts @@ -0,0 +1,183 @@ +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, Schema, Stream } from "effect" +import { App } from "../../app.js" +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") +const pollingSafetyMargin = 3000 +// Account tokens are long-lived; the expiry only schedules a cheap, +// deduplicated key re-exchange through refresh below. +const credentialLifetime = 90 * 24 * 60 * 60 * 1000 + +const Device = Schema.Struct({ + device_code: Schema.String, + user_code: Schema.String, + verification_uri: Schema.String, + verification_uri_complete: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + interval: Schema.optional(Schema.Number), +}) + +const device = (app: App.Info) => + ({ + integrationID, + method: { + id: deviceMethodID, + type: "oauth", + label: "Muse Code subscription (device authorization)", + }, + authorize: () => + Effect.gen(function* () { + const fetched: unknown = yield* Effect.promise(() => + fetch("https://auth.meta.com/oidc/device/authorization/", { + method: "POST", + headers: headers(app), + body: new URLSearchParams({ client_id: "1031625952748946" }).toString(), + redirect: "error", + }).then((response) => response.json()), + ) + const started = yield* Clock.currentTimeMillis + const value = yield* Effect.try(() => Schema.decodeUnknownSync(Device)(fetched)) + const url = value.verification_uri_complete ?? value.verification_uri + if (!url.startsWith("https://auth.meta.com/")) + return yield* Effect.fail(new Error("Muse Code returned an unverified authorization URL.")) + const lifetime = positiveSeconds(value.expires_in, 0) + return { + mode: "auto" as const, + url, + instructions: `Enter code: ${value.user_code}.`, + ...(lifetime ? { expiresAt: started + lifetime * 1000 } : {}), + callback: Effect.gen(function* () { + const accountToken = yield* Effect.promise(() => + MuseCode.pollDeviceToken(value.device_code, fetch, { + intervalMs: Math.max(positiveSeconds(value.interval, 5) * 1000, 1000), + deadline: started + positiveSeconds(value.expires_in, 300) * 1000, + }), + ) + return yield* credential(deviceMethodID, accountToken) + }), + } + }), + refresh: (value) => credential(deviceMethodID, value.refresh), + label: (value) => + typeof value.metadata?.["accountId"] === "string" ? (value.metadata["accountId"] as string) : undefined, + }) satisfies IntegrationOAuthMethodRegistration + +// The subscription key exchange is the only place an inference key is +// minted. There is no refresh-token grant: refresh re-exchanges the stored +// account token, which never leaves the credential layer for inference or +// discovery except as its own bearer to the key endpoint. +function credential(methodID: Integration.MethodID, accountToken: string) { + return Effect.promise(() => MuseCode.exchangeSubscriptionKey(accountToken, true)).pipe( + Effect.map((subscription) => + Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: accountToken, + access: subscription.apiKey ?? "", + expires: Date.now() + credentialLifetime, + metadata: subscription.accountID ? { accountId: subscription.accountID } : undefined, + }), + ), + ) +} + +export const MuseCodePlugin = define({ + id: "opencode.provider.muse-code", + effect: Effect.fn(function* (ctx) { + const bus = yield* Bus.Service + let subscription: Credential.OAuth | undefined + + const load = Effect.fn("MuseCodePlugin.load")(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 + }) + + 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(ctx.app)) + }) + yield* load() + 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 (and therefore /connect stays + // visible before login, but no bundled model is presented as access. + if (!subscription) return + for (const apiID of MuseCode.KNOWN_IDS) { + 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 = () => load().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) + +function headers(app: App.Info) { + return { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": App.useragent(app), + } +} + +function positiveSeconds(value: unknown, fallback: number) { + const seconds = Number(value) + return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback +} 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 From ef2a7b11a3e88fa583cfd942806ab3204759fe93 Mon Sep 17 00:00:00 2001 From: "Can H. Tartanoglu" Date: Thu, 10 Sep 2026 21:45:32 +0200 Subject: [PATCH 2/2] fix(muse-code): enforce destinations, wire discovery, propagate cancellation - ai: reject unverified inference base URLs in configure() instead of attaching subscription credentials to them. - ai: RateLimitedError carries Retry-After; cancellable default poll wait; poll options require an explicit deadline. - ai: fetchQuota helper for redacted quota without minting keys. - ai: transport round-trip test (verified endpoint, bearer key, store/include, function tools). - core: single auth implementation via the ai helpers; cancellation forwarded through Effect signals. - core: catalog registers only entitled models from live discovery; unavailable and empty entitlement stay distinct and logged. - core: drop the invented expiry/refresh schedule; single-flight key exchange with account-scoped Retry-After backoff; redacted quota in credential metadata. --- packages/ai/src/providers/muse-code.ts | 90 ++++++-- packages/ai/test/provider/muse-code.test.ts | 119 ++++++++++- .../core/src/plugin/provider/muse-code.ts | 198 ++++++++++-------- 3 files changed, 300 insertions(+), 107 deletions(-) diff --git a/packages/ai/src/providers/muse-code.ts b/packages/ai/src/providers/muse-code.ts index 20d6f6595287..9b1ad19d7718 100644 --- a/packages/ai/src/providers/muse-code.ts +++ b/packages/ai/src/providers/muse-code.ts @@ -74,6 +74,21 @@ export interface QuotaWindow { 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) @@ -94,7 +109,7 @@ async function fetchJSON(fetchFn: typeof fetch, url: string, init: RequestInit, 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 Error("Muse Code is rate limited. Wait before retrying.") + 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) @@ -172,19 +187,38 @@ async function postTokenPayload(fetchFn: typeof fetch, deviceCode: string, signa 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 } + 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 } = { - intervalMs: 5000, - deadline: Number.POSITIVE_INFINITY, + opts: { + intervalMs: number + deadline: number + now?: () => number + wait?: (ms: number) => Promise + signal?: AbortSignal }, ): Promise => { const now = opts.now ?? Date.now - const wait = opts.wait ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + // 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.") @@ -192,7 +226,7 @@ export const pollDeviceToken = async ( throw new Error("Muse Code login cancelled.") }) if (now() >= opts.deadline || opts.signal?.aborted) throw new Error("Muse Code login cancelled.") - const { status, payload } = await postTokenPayload(fetchFn, deviceCode, opts.signal) + const { status, payload, retryAfter } = await postTokenPayload(fetchFn, deviceCode, opts.signal) if (payload["error"] === "authorization_pending") continue if (payload["error"] === "slow_down") { interval += 5000 @@ -201,9 +235,8 @@ export const pollDeviceToken = async ( 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 Error("Muse Code is rate limited. Wait before retrying.") - if (status === 401) - throw new Error("Muse Code authorization expired or was revoked. Reconnect the subscription.") + 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) @@ -215,12 +248,12 @@ export const pollDeviceToken = async ( throw new Error("Muse Code device code expired. Start a new login.") } -export const exchangeSubscriptionKey = async ( +async function postSubscriptionKey( accountToken: string, onboard: boolean, - fetchFn: typeof fetch = fetch, + fetchFn: typeof fetch, signal?: AbortSignal, -): Promise => { +): Promise> { const payload = await fetchJSON( fetchFn, subscriptionKeyURL, @@ -237,6 +270,16 @@ export const exchangeSubscriptionKey = async ( 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 } @@ -245,6 +288,17 @@ export const exchangeSubscriptionKey = async ( 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. @@ -263,7 +317,8 @@ export const discoverModelIDs = async ( 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 (!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"]) } @@ -330,9 +385,14 @@ const subscriptionAuth = (input: ProviderAuthOption<"optional">) => 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: endpoint ?? baseURL }, + endpoint: { baseURL }, auth: subscriptionAuth(input), } const configuredResponses = responsesRoute.with(options) diff --git a/packages/ai/test/provider/muse-code.test.ts b/packages/ai/test/provider/muse-code.test.ts index 32bc200d5adf..dec16df554db 100644 --- a/packages/ai/test/provider/muse-code.test.ts +++ b/packages/ai/test/provider/muse-code.test.ts @@ -1,10 +1,21 @@ 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, @@ -53,9 +64,8 @@ describe("muse-code device authorization", () => { 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, - ), + startDeviceAuthorization((async () => + Response.json({ ...device, verification_uri: "https://evil.example" })) as typeof fetch), ).rejects.toThrow(/unverified/) }) @@ -127,4 +137,107 @@ describe("muse-code subscription exchange", () => { 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/muse-code.ts b/packages/core/src/plugin/provider/muse-code.ts index cf607e48cd44..a45437cc4913 100644 --- a/packages/core/src/plugin/provider/muse-code.ts +++ b/packages/core/src/plugin/provider/muse-code.ts @@ -1,8 +1,7 @@ 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, Schema, Stream } from "effect" -import { App } from "../../app.js" +import { Clock, Effect, Semaphore, Stream } from "effect" import { Credential } from "../../credential.js" import { Bus } from "../../bus.js" import { Integration } from "../../integration.js" @@ -21,21 +20,12 @@ const RELEASED: Record = { const providerID = Provider.ID.make("muse-code") const integrationID = Integration.ID.make("muse-code") const deviceMethodID = Integration.MethodID.make("device") -const pollingSafetyMargin = 3000 -// Account tokens are long-lived; the expiry only schedules a cheap, -// deduplicated key re-exchange through refresh below. -const credentialLifetime = 90 * 24 * 60 * 60 * 1000 +// 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 = Schema.Struct({ - device_code: Schema.String, - user_code: Schema.String, - verification_uri: Schema.String, - verification_uri_complete: Schema.optional(Schema.String), - expires_in: Schema.optional(Schema.Number), - interval: Schema.optional(Schema.Number), -}) - -const device = (app: App.Info) => +const device = (authorize: IntegrationOAuthMethodRegistration["authorize"]) => ({ integrationID, method: { @@ -43,86 +33,130 @@ const device = (app: App.Info) => type: "oauth", label: "Muse Code subscription (device authorization)", }, - authorize: () => - Effect.gen(function* () { - const fetched: unknown = yield* Effect.promise(() => - fetch("https://auth.meta.com/oidc/device/authorization/", { - method: "POST", - headers: headers(app), - body: new URLSearchParams({ client_id: "1031625952748946" }).toString(), - redirect: "error", - }).then((response) => response.json()), - ) - const started = yield* Clock.currentTimeMillis - const value = yield* Effect.try(() => Schema.decodeUnknownSync(Device)(fetched)) - const url = value.verification_uri_complete ?? value.verification_uri - if (!url.startsWith("https://auth.meta.com/")) - return yield* Effect.fail(new Error("Muse Code returned an unverified authorization URL.")) - const lifetime = positiveSeconds(value.expires_in, 0) - return { - mode: "auto" as const, - url, - instructions: `Enter code: ${value.user_code}.`, - ...(lifetime ? { expiresAt: started + lifetime * 1000 } : {}), - callback: Effect.gen(function* () { - const accountToken = yield* Effect.promise(() => - MuseCode.pollDeviceToken(value.device_code, fetch, { - intervalMs: Math.max(positiveSeconds(value.interval, 5) * 1000, 1000), - deadline: started + positiveSeconds(value.expires_in, 300) * 1000, - }), - ) - return yield* credential(deviceMethodID, accountToken) - }), - } - }), - refresh: (value) => credential(deviceMethodID, value.refresh), + // 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 -// The subscription key exchange is the only place an inference key is -// minted. There is no refresh-token grant: refresh re-exchanges the stored -// account token, which never leaves the credential layer for inference or -// discovery except as its own bearer to the key endpoint. -function credential(methodID: Integration.MethodID, accountToken: string) { - return Effect.promise(() => MuseCode.exchangeSubscriptionKey(accountToken, true)).pipe( - Effect.map((subscription) => - Credential.OAuth.make({ - type: "oauth", - methodID, - refresh: accountToken, - access: subscription.apiKey ?? "", - expires: Date.now() + credentialLifetime, - metadata: subscription.accountID ? { accountId: subscription.accountID } : undefined, - }), - ), - ) -} - 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[] = [] - const load = Effect.fn("MuseCodePlugin.load")(function* () { + // 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(ctx.app)) + editor.method.update(device(authorize)) }) - yield* load() + yield* refreshData() yield* ctx.catalog.transform((catalog) => { catalog.provider.update(providerID, (draft) => { draft.name = "Muse Code (Subscription)" @@ -131,10 +165,9 @@ export const MuseCodePlugin = define({ draft.integrationID = integrationID draft.settings = Provider.mergeOverlay(draft.settings, { baseURL: "https://api.meta.ai/v1" }) }) - // Entitled models only: the provider row (and therefore /connect stays - // visible before login, but no bundled model is presented as access. - if (!subscription) return - for (const apiID of MuseCode.KNOWN_IDS) { + // 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, { @@ -160,7 +193,7 @@ export const MuseCodePlugin = define({ }) } }) - const reload = () => load().pipe(Effect.andThen(ctx.catalog.reload())) + 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), @@ -168,16 +201,3 @@ export const MuseCodePlugin = define({ ) }), } satisfies PluginInternal.InternalPlugin) - -function headers(app: App.Info) { - return { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": App.useragent(app), - } -} - -function positiveSeconds(value: unknown, fallback: number) { - const seconds = Number(value) - return Number.isFinite(seconds) && seconds > 0 ? seconds : fallback -}