From 7fdd4895ccc7192ba91f4005f0ae51dfc3f95918 Mon Sep 17 00:00:00 2001 From: Michael Gasperini Date: Mon, 26 Oct 2026 11:47:29 +0100 Subject: [PATCH] feat(login): sync the live catalog and report the sign-in status - read GET https://api.meta.ai/v1/models with the stored subscription key and declare the chat catalog on the `meta` route, so Muse Wonder and future chat models appear in the picker; non-chat families are filtered and a model list edited by the user is never overwritten - add GET /muse-code/status (pending/authorized/failed/idle) and rebuild the Settings > Models card: coloured status dot, monospace code badge, primary and secondary actions, and live polling that flips to "Signed in" - /muse-login now waits for Meta's confirmation and answers with the final state instead of leaving the chat on "sign-in started" - unref the status poll timer so the chain cannot pin a Node process open --- CHANGELOG.md | 21 ++ CITATION.cff | 2 +- README.md | 91 +++++--- client/client.js | 415 +++++++++++++++++++++++++++------ lib/index.d.ts | 101 +++++++- lib/index.js | 472 ++++++++++++++++++++++++++++++------- package-lock.json | 4 +- package.json | 2 +- src/index.ts | 538 +++++++++++++++++++++++++++++++++++-------- test/client.test.mjs | 230 +++++++++++------- test/plugin.test.mjs | 296 +++++++++++++++--------- 11 files changed, 1692 insertions(+), 480 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53592a8..22b2758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.1] - 2026-10-26 + +### Added + +- Live model catalog sync: the plugin reads the account's chat catalog + (`GET https://api.meta.ai/v1/models`) with the stored subscription key and declares it on the + route, so Muse Wonder and future chat models appear in the picker. Non-chat families + (`muse-image-*`, `muse-voice-*`, `sam-*`) are filtered; the Muse Spark rows keep the shipped + catalog fidelity; a model list edited by the user is never overwritten. +- Live sign-in status: an exact `GET /muse-code/status` route reports `pending`, `authorized`, + `failed`, or `idle`. +- The Settings > Models card was rebuilt as a proper panel: title, coloured status dot + (**Signed in**, **Waiting for Meta…**, **Sign-in failed**), the device code as a monospace + badge, and separate primary/secondary actions. The card polls the status route and flips to + **Signed in** without a reload. + +### Fixed + +- `/muse-login` no longer leaves the chat on "sign-in started": after opening the browser it waits + for Meta's confirmation (bounded) and answers with the final state or the failure reason. + ## [0.2.0] - 2026-10-26 ### Changed diff --git a/CITATION.cff b/CITATION.cff index 17d1759..f0d7b39 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,7 +1,7 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below." title: "dsh-muse-code" -version: 0.2.0 +version: 0.2.1 date-released: "2026-10-26" url: "https://github.com/TheStreamCode/dsh-muse-code" repository-code: "https://github.com/TheStreamCode/dsh-muse-code" diff --git a/README.md b/README.md index 9fc39c5..82ec59b 100644 --- a/README.md +++ b/README.md @@ -47,43 +47,52 @@ bundle. ## Quick start 1. Install the bundle and restart DSH. -2. Open **Settings → Models** and find **Muse Code (Meta subscription)**. Its models are the - ones DSH's Meta catalog advertises: Muse Spark 1.1, 1.2, and 1.3, plus their `-contributor` - variants. -3. Press **Sign in** in the Muse Code area of that page. The Host starts DSH's Meta device-code - flow, opens the verification page in your default browser, and shows the code to confirm. - Without the page (CLI or a page that cannot reach the Host), run `/muse-login` in any chat and - follow the printed URL and code instead. -4. Confirm the code on Meta's page and approve the login. DSH stores the credential and keeps it - fresh; the Muse Code models are selectable as soon as Meta confirms. +2. Open **Settings → Models**: the **Muse Code (Meta subscription)** card shows the subscription + status and the models the account catalog serves — Muse Spark 1.1/1.2/1.3 (+ `-contributor`) + and the Muse Wonder family, plus any future chat model the account lists. +3. Press **Sign in** on that card. The Host starts DSH's Meta device-code flow, opens the + verification page in your default browser, and shows the code. The card follows the attempt + and switches to **Signed in** when Meta confirms. Without the page (CLI, or a page that cannot + reach the Host), run `/muse-login` in a chat instead. +4. Confirm the code on Meta's page and approve. DSH stores the credential and keeps it fresh; + `/muse-login` answers with the final state ("Muse Code is signed in") once Meta confirms. 5. Pick a Muse model in the model picker, for example `Muse Spark 1.3`. The credential lives in the DSH credential store as the `llm-pi-ai/meta` record. It is never -written to `cordis.patch.yml`, which carries only the route's display name. +written to `cordis.patch.yml`, which carries only the route's display name and its model list. ## Where the sign-in lives DSH 0.2.1 exposes no generic Remote method for starting a provider OAuth flow, so the Settings -page cannot call the authorization seam directly. The bundle therefore registers an exact local -Web route (`POST /muse-code/login`) on the Host's own HTTP server; the **Sign in** control under -Settings → Models calls it, and the Host both opens the verification page and answers with the -code and the instructions. The same flow is available as the `/muse-login` command for the -composer, CLI surfaces, or a page whose origin cannot reach the Host. +page cannot call the authorization seam directly. The bundle therefore registers exact local Web +routes on the Host's own HTTP server: + +- `POST /muse-code/login` starts the sign-in and answers with the code and the Meta URL; +- `GET /muse-code/status` reports `pending`, `authorized`, `failed`, or `idle`, which is what lets + the card flip to **Signed in** without a reload. + +The `/muse-login` command runs the same flow and waits for Meta's confirmation before answering, +so the chat shows the final state instead of leaving the prompt waiting. A page whose origin +cannot reach the Host falls back to that command. ## Features - Declares the shipped `meta` provider route in the `llm-pi-ai` settings section, labeled **Muse Code (Meta subscription)**, so Settings → Models and the model picker show it. An - existing `meta` profile is left untouched. -- Adds a **Sign in** control under Settings → Models, backed by the local - `POST /muse-code/login` route, and the `/muse-login` chat command. Both start DSH's own - device-code flow (`llm-pi-ai/meta`), open the verification page in the default browser, and - return the code so the surface can show it. + existing `meta` profile without a model list is left untouched apart from that list. +- Syncs the account's live chat catalog (`GET https://api.meta.ai/v1/models`, authenticated with + the stored subscription key) into the route, so the Muse Wonder family and future chat models + are selectable without waiting for a DSH catalog update. Non-chat families (`muse-image-*`, + `muse-voice-*`, `sam-*`) are filtered out; the five Muse Spark rows keep the shipped catalog's + names, context window, output cap, and reasoning levels. A model list you edited yourself is + never overwritten. +- Adds a **Sign in** card under Settings → Models with the live status, the device code, and a + direct Meta page link, backed by the routes above, plus the `/muse-login` chat command. Both + start DSH's own device-code flow (`llm-pi-ai/meta`) and open the verification page in the + default browser. - Leaves credential handling to DSH: the shipped flow stores the sign-in as an OAuth credential record and re-mints Meta's short-lived inference key automatically, so the route keeps working after the key rotates. -- Serves the models DSH advertises for the Meta provider, with text and image input, a - 1,048,576-token context window, and the reasoning levels each model supports. ## Requirements @@ -98,22 +107,25 @@ composer, CLI surfaces, or a page whose origin cannot reach the Host. | Setting | Purpose | Default | | --- | --- | --- | | `meta` provider route | Declared by the bundle in the `llm-pi-ai` settings section; shown under Settings → Models and in the model picker. | declared on first boot | -| **Sign in** control | Starts the shipped Meta device-code flow from Settings → Models, opens the browser, and shows the code. Backed by `POST /muse-code/login` on the local Host. | available after install | -| `/muse-login` | The same sign-in from a chat or CLI surface, printing the URL and code. | available after install | +| **Sign in** card | Starts the shipped Meta device-code flow, opens the browser, shows the code, and follows the live status until **Signed in**. Backed by `POST /muse-code/login` and `GET /muse-code/status` on the local Host. | available after install | +| `/muse-login` | The same sign-in from a chat or CLI surface; waits for Meta's confirmation and reports the final state. | available after install | +| Live model catalog | Chat models the account serves (`GET /v1/models`), written into the declared route when it has no model list of its own. | synced after sign-in | | `META_API_KEY` | Optional Meta Model API key that DSH's Meta provider accepts instead of the subscription sign-in. | unset | -| Model | Any model the Meta provider advertises; availability depends on the subscription plan. | `muse-spark-1.3` | +| Model | Any model the route serves; availability depends on the subscription plan. | `muse-spark-1.3` | The `MUSE_CODE_SUB_TOKEN` reference from the 0.1.x releases is no longer used by this bundle; see the upgrade note under Troubleshooting. ## Privacy and security -- The bundle itself calls no endpoint and stores no secret. The sign-in, the credential record, - and the inference requests belong to DSH: requests go to `https://api.meta.ai/v1`, and the - verification page is opened on `https://auth.meta.com`. -- The local `POST /muse-code/login` route only starts that flow and answers with the verification - URL and the one-time code; it transmits no credential and is reachable only on the Host's own - loopback listener. +- The bundle talks to one endpoint: `GET https://api.meta.ai/v1/models`, authenticated with the + stored subscription key, to sync the account's chat catalog into the route. It stores no + secret, and the sign-in, the credential record, and the inference requests belong to DSH: + requests go to `https://api.meta.ai/v1`, and the verification page is opened on + `https://auth.meta.com`. +- The local `POST /muse-code/login`, `GET /muse-code/login`, and `GET /muse-code/status` routes + only start the flow and report its state; they transmit no credential and are reachable only + on the Host's own loopback listener. - The stored credential is the `llm-pi-ai/meta` record in the DSH credential store. It never appears in configuration or logs. - To remove access, remove the bundle, remove the declared provider from **Settings → Models** @@ -153,8 +165,21 @@ flow. ### The browser did not open -The Host opens it best-effort through the platform's URI handler. Both the control and the -command print the verification URL and the code, so open the URL manually and enter the code. +The Host opens it best-effort through the platform's URI handler. The card and the command show +the code and the sign-in page link, so open the page manually and enter the code. + +### The Muse Code card stays on "Waiting for Meta…" + +Meta has not confirmed the code yet, or the page could not reach the Host's status route. Approve +the code on Meta's page; if the card never changes, use `/muse-login` in a chat, which waits for +the confirmation itself. + +### Muse Wonder (or another catalog model) is missing + +The live catalog sync only fills a route that has no model list of its own. If you edited the +provider's models under **Settings → Models → Edit**, remove that list (or reset it) and restart +DSH so the sync can refill it from the account catalog. The sync also needs the stored credential: +sign in again first if the card is not **Signed in**. ### Meta denies the login, or the subscription is inactive diff --git a/client/client.js b/client/client.js index d43cdf1..5da4acf 100644 --- a/client/client.js +++ b/client/client.js @@ -1,10 +1,9 @@ // Web client half of the Muse Code integration. // -// The Settings > Models page renders the plugins footer seat after the provider -// rows. This module adds the Sign in control there: it calls the Host's exact -// `/muse-code/login` route, which starts the shipped Meta device-code flow, -// opens the verification page in the default browser, and answers with the code -// and the instructions to show next to the button. +// Renders one card in the Settings > Models footer seat: the subscription +// status, the Sign in control, the device code, and a live status that flips +// from "Waiting for Meta…" to "Signed in" without a page reload. The card calls +// the Host's exact routes (`POST /muse-code/login`, `GET /muse-code/status`). // // This file is a browser CLASSIC SCRIPT, not an ES module: DSH concatenates // client artifacts into plain script bundles, so an `export` statement here @@ -14,12 +13,41 @@ // name; React comes from the browser module table. const PACKAGE_ID = "@thestreamcode/dsh-muse-code" -const HINT_ID = "dsh-muse-code-sign-in" +const CARD_ID = "dsh-muse-code-sign-in" const LOGIN_ROUTE = "/muse-code/login" +const STATUS_ROUTE = "/muse-code/status" const REQUEST_TIMEOUT_MS = 25_000 +const POLL_INTERVAL_MS = 2_000 +const POLL_ATTEMPTS = 150 + +const COLOR = { + labelPrimary: "var(--dsw-alias-label-primary)", + labelSecondary: "var(--dsw-alias-label-secondary)", + labelTertiary: "var(--dsw-alias-label-tertiary)", + labelError: "var(--dsw-alias-state-error-primary)", + labelSuccess: "var(--dsw-alias-state-success-primary)", + labelWarn: "var(--dsw-alias-state-warn-primary)", + border: "var(--dsw-alias-border-l2)", + primaryFill: "var(--dsw-alias-button-primary-fill)", + primaryForeground: "var(--dsw-alias-label-primary-foreground)", + layer: "var(--dsw-alias-bg-layer-1)", + inlineCode: "var(--dsw-alias-markdown-inline-code)", +} + +const MODULE_FONT = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" const loader = globalThis.__ModuleLoader__ +/** One module-level poll chain: the footer seat renders one card. */ +const pollState = { timer: undefined, tries: 0 } + +function stopPolling() { + if (pollState.timer !== undefined) { + clearTimeout(pollState.timer) + pollState.timer = undefined + } +} + /** Loopback origin of the Host that owns this page, when it is discoverable. */ function hostOrigin() { const transport = globalThis.__DSH_TRANSPORT__ @@ -30,106 +58,359 @@ function hostOrigin() { return "" } +async function getJson(url, init) { + const controller = typeof AbortController === "function" ? new AbortController() : undefined + const timer = controller === undefined ? undefined : setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + try { + const response = await fetch(url, { + ...(init || {}), + headers: { accept: "application/json" }, + ...(controller === undefined ? {} : { signal: controller.signal }), + }) + const payload = await response.json().catch(() => null) + return { ok: response.ok, status: response.status, payload } + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + loader?.load({ id: PACKAGE_ID, factory(require) { const React = require("react") const h = React.createElement + function statusView(phase) { + if (phase === "authorized") return { label: "Signed in", color: COLOR.labelSuccess } + if (phase === "pending" || phase === "starting") return { label: "Waiting for Meta…", color: COLOR.labelWarn } + if (phase === "failed") return { label: "Sign-in failed", color: COLOR.labelError } + if (phase === "checking") return { label: "Checking…", color: COLOR.labelTertiary } + return { label: "Not signed in", color: COLOR.labelTertiary } + } + function MuseCodeSignIn() { - const [state, setState] = React.useState({ status: "idle", text: "" }) + const [state, setState] = React.useState({ + phase: "checking", + text: "", + code: "", + metaUrl: "", + }) const origin = hostOrigin() - async function signIn() { - setState({ status: "running", text: "" }) - const controller = typeof AbortController === "function" ? new AbortController() : undefined - const timer = controller === undefined ? undefined : setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) - try { - const response = await fetch(`${origin}${LOGIN_ROUTE}`, { - method: "POST", - headers: { accept: "application/json" }, - ...(controller === undefined ? {} : { signal: controller.signal }), + function applyStatus(payload) { + if (!payload || typeof payload.state !== "string") return + if (payload.state === "authorized") { + stopPolling() + setState(function (previous) { + return { + ...previous, + phase: "authorized", + text: payload.text || "Muse Code is signed in.", + code: "", + } + }) + return + } + if (payload.state === "pending") { + setState(function (previous) { + return { + ...previous, + phase: "pending", + code: typeof payload.code === "string" ? payload.code : previous.code, + } + }) + pollStatus() + return + } + if (payload.state === "failed" || payload.state === "cancelled") { + stopPolling() + setState(function (previous) { + return { + ...previous, + phase: "failed", + text: payload.text || "Sign-in did not complete.", + } }) - const payload = await response.json().catch(() => null) - if (payload && typeof payload.text === "string" && payload.text.length > 0) { - setState({ status: payload.ok === true ? "success" : "error", text: payload.text }) + return + } + setState(function (previous) { + return previous.phase === "checking" ? { ...previous, phase: "idle" } : previous + }) + } + + function pollStatus() { + if (origin.length === 0) return + if (pollState.timer !== undefined) return + if (pollState.tries >= POLL_ATTEMPTS) return + pollState.tries += 1 + pollState.timer = setTimeout(function () { + pollState.timer = undefined + getJson(origin + STATUS_ROUTE).then( + function (response) { + applyStatus(response.payload) + }, + function () { + pollStatus() + }, + ) + }, POLL_INTERVAL_MS) + // Browser timers are numbers; Node's test runner sees the object. The + // optional call keeps a pending poll from pinning the process open. + pollState.timer.unref?.() + } + + function refreshStatus() { + if (origin.length === 0) return + getJson(origin + STATUS_ROUTE).then( + function (response) { + applyStatus(response.payload) + }, + function () { + setState(function (previous) { + return previous.phase === "checking" ? { ...previous, phase: "idle" } : previous + }) + }, + ) + } + + React.useEffect( + function () { + refreshStatus() + return function () { + stopPolling() + } + }, + [origin], + ) + + async function startSignIn() { + stopPolling() + pollState.tries = 0 + setState(function (previous) { + return { ...previous, phase: "starting", text: "Starting the sign-in…", code: "" } + }) + try { + const response = await getJson(origin + LOGIN_ROUTE, { method: "POST" }) + const payload = response.payload + if (payload && payload.ok === true) { + setState(function (previous) { + return { + ...previous, + phase: "pending", + text: payload.text || "Confirm the code on Meta's page.", + code: typeof payload.code === "string" ? payload.code : "", + metaUrl: typeof payload.url === "string" ? payload.url : "", + } + }) + pollStatus() return } - setState({ - status: "error", - text: `Sign-in failed (HTTP ${response.status}). Run /muse-login in a chat instead.`, + setState(function (previous) { + return { + ...previous, + phase: "failed", + text: + (payload && typeof payload.text === "string" && payload.text) || + "Could not start the sign-in. Run /muse-login in a chat instead.", + } }) } catch { - setState({ - status: "error", - text: "Could not reach the Host. Run /muse-login in a chat instead.", + setState(function (previous) { + return { + ...previous, + phase: "failed", + text: "Could not reach the Host. Run /muse-login in a chat instead.", + } }) - } finally { - if (timer !== undefined) clearTimeout(timer) } } - const style = { - display: "flex", - flexDirection: "column", - gap: "0.5rem", - margin: 0, - fontSize: "0.875rem", - lineHeight: 1.5, - color: "var(--dsw-alias-text-secondary)", - } + const view = statusView(state.phase) + const busy = state.phase === "starting" || state.phase === "pending" + const description = + state.phase === "authorized" + ? "Muse Code models are available in the model picker." + : "Sign in once with the Meta account that holds the Muse Code subscription." + const messageColor = + state.phase === "failed" + ? COLOR.labelError + : state.phase === "authorized" + ? COLOR.labelSuccess + : COLOR.labelSecondary + const secondaryHref = + state.metaUrl.length > 0 ? state.metaUrl : origin.length > 0 ? origin + LOGIN_ROUTE : "" + const children = [ + h( + "div", + { + key: "head", + style: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "0.75rem", + flexWrap: "wrap", + }, + }, + h( + "span", + { style: { fontSize: "0.9375rem", fontWeight: 500, color: COLOR.labelPrimary } }, + "Muse Code (Meta subscription)", + ), + h( + "span", + { + style: { + display: "inline-flex", + alignItems: "center", + gap: "0.375rem", + fontSize: "0.75rem", + color: COLOR.labelSecondary, + }, + }, + h("span", { + style: { width: "0.5rem", height: "0.5rem", borderRadius: "999px", background: view.color, flex: "0 0 auto" }, + "aria-hidden": true, + }), + view.label, + ), + ), h( "p", - { key: "title", style: { margin: 0 } }, - "Muse Code (Meta subscription): ", - origin.length > 0 - ? "sign in here once; the provider then appears in the model picker." - : "run /muse-login in any chat to sign in.", + { + key: "description", + style: { margin: 0, fontSize: "0.8125rem", lineHeight: 1.5, color: COLOR.labelSecondary }, + }, + description, ), ] + + if (state.code.length > 0 && state.phase !== "authorized") { + children.push( + h( + "div", + { key: "code", style: { display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" } }, + h("span", { style: { fontSize: "0.75rem", color: COLOR.labelTertiary } }, "Code"), + h( + "code", + { + style: { + fontFamily: MODULE_FONT, + fontSize: "1rem", + letterSpacing: "0.1em", + padding: "0.125rem 0.5rem", + borderRadius: "0.375rem", + background: COLOR.inlineCode, + color: COLOR.labelPrimary, + }, + }, + state.code, + ), + ), + ) + } + + if (state.text.length > 0) { + children.push( + h( + "p", + { + key: "message", + role: "status", + style: { + margin: 0, + fontSize: "0.8125rem", + lineHeight: 1.5, + color: messageColor, + overflowWrap: "anywhere", + }, + }, + state.text, + ), + ) + } + if (origin.length > 0) { children.push( h( "div", - { key: "actions", style: { display: "flex", alignItems: "center", gap: "0.75rem" } }, + { + key: "actions", + style: { display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap", marginTop: "0.25rem" }, + }, h( "button", { type: "button", - onClick: signIn, - disabled: state.status === "running", + disabled: busy, + onClick: startSignIn, style: { padding: "0.375rem 0.875rem", borderRadius: "0.5rem", - border: "1px solid var(--dsw-alias-border-secondary)", - background: "var(--dsw-alias-bg-interactive)", - color: "var(--dsw-alias-text-primary)", - cursor: state.status === "running" ? "progress" : "pointer", - fontSize: "0.875rem", + border: "none", + background: busy ? COLOR.border : COLOR.primaryFill, + color: busy ? COLOR.labelTertiary : COLOR.primaryForeground, + fontSize: "0.8125rem", + fontWeight: 500, + cursor: busy ? "progress" : "pointer", }, }, - state.status === "running" ? "Starting sign-in…" : "Sign in", - ), - h( - "span", - { "aria-live": "polite" }, - state.status === "idle" ? "" : state.text, - ), - h( - "a", - { - href: `${origin}${LOGIN_ROUTE}`, - target: "_blank", - rel: "noreferrer", - style: { fontSize: "0.8rem" }, - }, - "Open the sign-in page", + busy ? "Signing in…" : state.phase === "authorized" ? "Sign in again" : "Sign in", ), + secondaryHref.length > 0 + ? h( + "a", + { + href: secondaryHref, + target: "_blank", + rel: "noreferrer", + style: { + padding: "0.375rem 0.875rem", + borderRadius: "0.5rem", + border: `1px solid ${COLOR.border}`, + color: COLOR.labelPrimary, + fontSize: "0.8125rem", + textDecoration: "none", + whiteSpace: "nowrap", + }, + }, + state.phase === "pending" ? "Open Meta page" : "Open sign-in page", + ) + : null, + ), + ) + } else { + children.push( + h( + "p", + { key: "fallback", style: { margin: 0, fontSize: "0.8125rem", color: COLOR.labelSecondary } }, + "This page cannot reach the local Host; run ", + h("code", null, "/muse-login"), + " in a chat to sign in.", ), ) } - return h("div", { style }, children) + + return h( + "div", + { + style: { + marginTop: "1rem", + padding: "0.875rem 1rem", + border: `1px solid ${COLOR.border}`, + borderRadius: "0.75rem", + background: COLOR.layer, + display: "flex", + flexDirection: "column", + gap: "0.5rem", + maxWidth: "42rem", + textAlign: "left", + boxSizing: "border-box", + }, + }, + children, + ) } return { @@ -139,7 +420,7 @@ loader?.load({ ctx.slots.register( { name: "settings.models.footer", - id: HINT_ID, + id: CARD_ID, order: 10, }, MuseCodeSignIn, diff --git a/lib/index.d.ts b/lib/index.d.ts index b398b5b..ec57e93 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -3,6 +3,8 @@ export declare const name = "dsh-muse-code"; export declare const inject: string[]; /** Web route the Settings control calls to start the sign-in. */ export declare const LOGIN_ROUTE = "/muse-code/login"; +/** Web route that reports whether the sign-in completed. */ +export declare const STATUS_ROUTE = "/muse-code/status"; type Logger = { info?(message: string): void; warn?(message: string, ...rest: unknown[]): void; @@ -73,6 +75,16 @@ type SettingsFace = { describe(): SettingsRow[]; update(ns: string, patch: Record, expectedRevision: number): Promise; }; +type CredentialRecord = { + kind?: string; + payload?: Record; +}; +type CredentialsFace = { + readRecord(key: string): Promise; + describeRecord(key: string): Promise<{ + configured: boolean; + }>; +}; type WebRoute = { kind: "exact" | "prefix"; path: string; @@ -84,6 +96,7 @@ type WebServerFace = { type ChildContext = HostContext & { settings?: SettingsFace; webServer?: WebServerFace; + credentials?: CredentialsFace; }; type HostContext = { authorization: AuthorizationFace; @@ -91,11 +104,36 @@ type HostContext = { logger?: Logger; effect(callback: () => () => void, label?: string): () => void; inject?(dependencies: string[], callback: (child: ChildContext) => void): void; + on?(event: string, listener: (...args: never[]) => void): () => void; +}; +/** One sign-in lifecycle fact the surfaces can read. */ +export type SignInSnapshot = { + state: "idle" | "pending" | "authorized" | "failed" | "cancelled"; + text?: string; + code?: string; + at: number; }; +/** Mutable snapshot shared by the command and the two Web routes. */ +export type SignInState = { + get(): SignInSnapshot; + set(snapshot: SignInSnapshot): void; +}; +/** Create one empty sign-in state. */ +export declare function createSignInState(): SignInState; /** Injected side effects, overridable in tests. */ export type SignInOptions = { openBrowser?: (url: string) => boolean; timeoutMs?: number; + /** How long the command waits for Meta's confirmation after opening the page. */ + waitForResultMs?: number; + state?: SignInState; + credentials?: () => CredentialsFace | undefined; + /** Capture the background attempt so the command can wait for its result. */ + onAttempt?: (attempt: Promise<{ + status: string; + }>) => void; + /** Override the live catalog fetch, for tests. */ + fetchModelIds?: (accessKey: string) => Promise; }; /** Actionable instructions extracted from one authorization notice. */ export type SignInInstruction = { @@ -108,6 +146,7 @@ export type SignInInstruction = { }; type SignInOutcome = { ok: true; + opened: boolean; text: string; url: string; code?: string; @@ -117,8 +156,8 @@ type SignInOutcome = { text: string; }; /** - * Register the sign-in command, the Settings > Models sign-in route, and the - * settings write that makes the Muse Code provider visible. + * Register the sign-in command, the Settings > Models routes, the live catalog + * sync, and the settings write that makes the Muse Code provider visible. * @param ctx - Host plugin context. */ export declare function apply(ctx: HostContext): void; @@ -126,13 +165,21 @@ export declare function apply(ctx: HostContext): void; export declare function museLoginCommand(ctx: HostContext, options?: SignInOptions): Parameters[0]; /** * Build the exact Web route that starts the same sign-in from the Settings - * page. The route answers JSON so the Client control can render the code and - * the failure reason next to its button. + * page. POST answers JSON for the Settings control; GET answers a small page + * that shows the code and polls the status route until Meta confirms. * @param ctx - Host plugin context. * @param options - injected side effects, overridable in tests. * @returns an HTTP handler for {@link LOGIN_ROUTE}. */ export declare function createLoginRouteHandler(ctx: HostContext, options?: SignInOptions): (request: IncomingMessage, response: ServerResponse) => Promise; +/** + * Build the exact Web route that reports the sign-in state, so the Settings + * control can flip to "Signed in" after Meta confirms. + * @param ctx - Host plugin context. + * @param options - injected side effects, overridable in tests. + * @returns an HTTP handler for {@link STATUS_ROUTE}. + */ +export declare function createStatusRouteHandler(ctx: HostContext, options?: SignInOptions): (request: IncomingMessage, response: ServerResponse) => Promise; /** * Start one sign-in attempt and wait for the actionable device-code notice. * The attempt itself keeps running after the notice, so Meta's approval is @@ -142,6 +189,17 @@ export declare function createLoginRouteHandler(ctx: HostContext, options?: Sign * @returns the user-facing outcome. */ export declare function startSignIn(ctx: HostContext, options?: SignInOptions): Promise; +/** + * Wait for one sign-in attempt to settle, bounded by the caller's deadline. + * @param attempt - the background attempt promise. + * @param waitMs - upper bound in milliseconds. + * @returns the settled outcome, or undefined on timeout/failure to settle. + */ +export declare function waitForSettlement(attempt: Promise<{ + status: string; +}>, waitMs: number): Promise<{ + status: string; +} | undefined>; /** * Read the actionable instruction out of one authorization notice. * @param notice - a seam notice (`message`/`url`/`code`) or a raw adapter one. @@ -173,4 +231,39 @@ export declare function ensureMuseRoute(settings: SettingsFace, logger?: Logger, attempts?: number; delayMs?: number; }): Promise; +/** + * Sync the account's live chat catalog into the declared route. + * + * Meta lists the subscription catalog behind the minted key and publishes no + * static metadata, so the ids are turned into conservative DSH model entries: + * the five Muse Spark rows keep the shipped catalog's fidelity, every other + * chat id (Muse Wonder included) takes the family defaults. A route that + * already declares its own `models` list is left untouched. + * + * @param settings - the Settings form service. + * @param logger - optional plugin logger. + * @param options - credentials access and fetch override, for tests. + * @returns whether this call wrote a model list. + */ +export declare function syncMuseModels(settings: SettingsFace, logger?: Logger, options?: Pick): Promise; +/** Map one live model id to a conservative DSH pi-ai model entry. */ +export declare function museModelEntry(id: string): Record; +/** Whether one catalog id is a chat model this route can serve. */ +export declare function isChatModelId(id: string): boolean; +/** Turn a live model id into a display name ("muse-wonder-full-1.0"). */ +export declare function prettifyModelName(id: string): string; +/** + * Fetch the account's live chat catalog. + * @param accessKey - minted subscription key stored in the OAuth record. + * @param timeoutMs - request bound. + * @returns the chat model ids, or null on any failure. + */ +export declare function fetchLiveModelIds(accessKey: string, timeoutMs?: number): Promise; +/** + * The page a plain navigation to {@link LOGIN_ROUTE} receives: the code, a link + * to Meta, and a poll of {@link STATUS_ROUTE} until the sign-in completes. + * @param outcome - result of the sign-in attempt. + * @returns a self-contained HTML document. + */ +export declare function signInPage(outcome: SignInOutcome): string; export {}; diff --git a/lib/index.js b/lib/index.js index 44e68ac..e470f3a 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,19 +3,20 @@ // DeepSeek Harness already serves this subscription: the shipped pi-ai catalog // exposes the `meta` route (api.meta.ai/v1) and registers the `llm-pi-ai/meta` // authorization flow, which runs Meta's device-code login and stores the -// renewable credential record. The Web/Desktop Settings surface has no generic -// button for provider OAuth flows, so this bundle adds the pieces a user needs: +// renewable credential record. This bundle adds what the harness does not: // // - it declares the `meta` route in the `llm-pi-ai` settings section, so the // provider appears under Settings > Models and its models in the model picker; -// - it exposes the shipped sign-in as the `/muse-login` command; -// - it registers an exact Web route (`/muse-code/login`) and a Sign in control -// under Settings > Models that calls it, because the Settings page cannot -// reach the Host authorization seam through the Remote API. -// -// The Host opens the verification page in the user's default browser itself; the -// route and the command also return the code and the URL, so a blocked browser -// never strands the sign-in. +// - it syncs the account's live chat catalog (`GET /v1/models`) into that +// route, so models the shipped pi-ai catalog does not list (Muse Wonder and +// future ids) are selectable without a DSH update; +// - it exposes the shipped sign-in as `/muse-login` and as an exact local Web +// route (`POST /muse-code/login`) that the Settings control calls, because +// the Settings page cannot reach the authorization seam through the Remote +// API; +// - it reports the sign-in state (`GET /muse-code/status`) so both the Settings +// control and the command can say "signed in" instead of leaving the user +// waiting after Meta confirmed. // // A second `@deepseek-ai/dsh-llm-pi-ai` instance is not an option: every // instance declares the whole installed provider catalog, so the extra instance @@ -35,37 +36,94 @@ const AUTH_METHOD = "oauth"; const ROUTE_LABEL = "Muse Code (Meta subscription)"; /** Web route the Settings control calls to start the sign-in. */ export const LOGIN_ROUTE = "/muse-code/login"; +/** Web route that reports whether the sign-in completed. */ +export const STATUS_ROUTE = "/muse-code/status"; /** How long the sign-in waits for the device-code notice before giving up. */ const NOTICE_TIMEOUT_MS = 20_000; +/** How long `/muse-login` waits for Meta's confirmation before answering. */ +const RESULT_WAIT_MS = 120_000; +/** Live chat catalog of the Meta Model API. */ +const MODELS_URL = "https://api.meta.ai/v1/models"; +const MODELS_TIMEOUT_MS = 8_000; +/** Endpoint families the Muse subscription also lists but DSH cannot chat with. */ +const NON_CHAT_PREFIXES = ["sam-", "muse-image-", "muse-voice-"]; +/** Family defaults mirrored from the sibling Muse plugins and the pi-ai catalog. */ +const MUSE_CONTEXT_WINDOW = 1_048_576; +const MUSE_MAX_OUTPUT_TOKENS = 131_072; +/** The five Muse Spark rows the shipped pi-ai catalog describes, with fidelity. */ +const SPARK_MODELS = { + "muse-spark-1.1": { name: "Muse Spark 1.1", max: false }, + "muse-spark-1.2": { name: "Muse Spark 1.2", max: false }, + "muse-spark-1.2-contributor": { name: "Muse Spark 1.2 Contributor", max: false }, + "muse-spark-1.3": { name: "Muse Spark 1.3", max: true }, + "muse-spark-1.3-contributor": { name: "Muse Spark 1.3 Contributor", max: false }, +}; +/** Create one empty sign-in state. */ +export function createSignInState() { + let snapshot = { state: "idle", at: 0 }; + return { + get: () => snapshot, + set: (next) => { + snapshot = next; + }, + }; +} /** - * Register the sign-in command, the Settings > Models sign-in route, and the - * settings write that makes the Muse Code provider visible. + * Register the sign-in command, the Settings > Models routes, the live catalog + * sync, and the settings write that makes the Muse Code provider visible. * @param ctx - Host plugin context. */ export function apply(ctx) { - ctx.effect(() => ctx.commands.register(museLoginCommand(ctx)), "dsh-muse-code: /muse-login"); + const state = createSignInState(); + let credentialsFace; + const options = { state, credentials: () => credentialsFace }; + ctx.effect(() => ctx.commands.register(museLoginCommand(ctx, options)), "dsh-muse-code: /muse-login"); if (typeof ctx.inject !== "function") return; + ctx.inject(["credentials"], (child) => { + credentialsFace = child.credentials; + }); ctx.inject(["webServer"], (child) => { const webServer = child.webServer; if (webServer === undefined) return; - child.effect(() => webServer.register({ - kind: "exact", - path: LOGIN_ROUTE, - handler: createLoginRouteHandler(ctx), - }), "dsh-muse-code: login route"); + child.effect(() => { + const disposeLogin = webServer.register({ + kind: "exact", + path: LOGIN_ROUTE, + handler: createLoginRouteHandler(ctx, options), + }); + const disposeStatus = webServer.register({ + kind: "exact", + path: STATUS_ROUTE, + handler: createStatusRouteHandler(ctx, options), + }); + return () => { + disposeStatus(); + disposeLogin(); + }; + }, "dsh-muse-code: login routes"); }); ctx.inject(["settings"], (child) => { child.effect(() => { const settings = child.settings; if (settings === undefined) return () => { }; - void ensureMuseRoute(settings, ctx.logger).catch((error) => { - warn(ctx.logger, "dsh-muse-code: could not declare the Muse Code route", error); + const refresh = () => { + void (async () => { + await ensureMuseRoute(settings, ctx.logger); + await syncMuseModels(settings, ctx.logger, options); + })().catch((error) => { + warn(ctx.logger, "dsh-muse-code: could not refresh the Muse Code settings", error); + }); + }; + refresh(); + const dispose = ctx.on?.("credentials/record-updated", (key) => { + if (key === AUTH_KEY) + refresh(); }); - return () => { }; - }, "dsh-muse-code: declare route"); + return dispose ?? (() => { }); + }, "dsh-muse-code: settings"); }); } /** Command definition for the sign-in; exported for tests. */ @@ -74,17 +132,37 @@ export function museLoginCommand(ctx, options = {}) { name: "muse-login", description: "Sign in to the Muse Code (Meta) subscription and enable its models", async handler() { - const outcome = await startSignIn(ctx, options); - if (outcome.ok) - return { kind: "success", text: outcome.text }; - return { kind: "error", text: outcome.text }; + let attempt; + const started = await startSignIn(ctx, { + ...options, + onAttempt: (pending) => { + attempt = pending; + }, + }); + if (!started.ok) + return { kind: "error", text: started.text }; + const waitMs = options.waitForResultMs ?? RESULT_WAIT_MS; + if (!started.opened || attempt === undefined || waitMs <= 0) { + return { kind: "success", text: started.text }; + } + const result = await waitForSettlement(attempt, waitMs); + if (result === undefined) { + return { kind: "success", text: `${started.text} Still waiting for Meta: the sign-in completes when you approve the page.` }; + } + if (result.status === "authorized") { + return { kind: "success", text: "Muse Code is signed in. Its models are available in the model picker." }; + } + return { + kind: "error", + text: `Muse Code sign-in ended (${result.status}). Run /muse-login again to retry.`, + }; }, }; } /** * Build the exact Web route that starts the same sign-in from the Settings - * page. The route answers JSON so the Client control can render the code and - * the failure reason next to its button. + * page. POST answers JSON for the Settings control; GET answers a small page + * that shows the code and polls the status route until Meta confirms. * @param ctx - Host plugin context. * @param options - injected side effects, overridable in tests. * @returns an HTTP handler for {@link LOGIN_ROUTE}. @@ -99,7 +177,7 @@ export function createLoginRouteHandler(ctx, options = {}) { } if (request.method === "GET") { // A plain navigation: this tab is the browser, so the flow need not open - // another one. The page shows the code and moves on to Meta's page. + // another one. The page itself polls the status route. const outcome = await startSignIn(ctx, { ...options, openBrowser: () => false }); sendHtml(response, signInPage(outcome)); return; @@ -110,13 +188,71 @@ export function createLoginRouteHandler(ctx, options = {}) { } const outcome = await startSignIn(ctx, options); if (outcome.ok) { - sendJson(response, 200, { ok: true, text: outcome.text, url: outcome.url, code: outcome.code }); + sendJson(response, 200, { + ok: true, + text: outcome.text, + url: outcome.url, + code: outcome.code, + opened: outcome.opened, + }); return; } const status = outcome.reason === "busy" ? 409 : outcome.reason === "timeout" ? 504 : 500; sendJson(response, status, { ok: false, reason: outcome.reason, text: outcome.text }); }; } +/** + * Build the exact Web route that reports the sign-in state, so the Settings + * control can flip to "Signed in" after Meta confirms. + * @param ctx - Host plugin context. + * @param options - injected side effects, overridable in tests. + * @returns an HTTP handler for {@link STATUS_ROUTE}. + */ +export function createStatusRouteHandler(ctx, options = {}) { + return async (request, response) => { + setCorsHeaders(response); + if (request.method === "OPTIONS") { + response.writeHead(204); + response.end(); + return; + } + if (request.method !== "GET") { + sendJson(response, 405, { ok: false, text: "Use GET for the sign-in status." }); + return; + } + const snapshot = options.state?.get(); + try { + const credentials = options.credentials?.(); + if (credentials !== undefined) { + const record = await credentials.describeRecord(AUTH_KEY); + if (record.configured) { + sendJson(response, 200, { state: "authorized", text: "Muse Code is signed in." }); + return; + } + } + } + catch { + // Fall through to the in-memory state. + } + const entry = ctx.authorization.describe(AUTH_KEY); + if (entry?.inFlight === true) { + sendJson(response, 200, { + state: "pending", + ...(snapshot?.text === undefined ? {} : { text: snapshot.text }), + ...(snapshot?.code === undefined ? {} : { code: snapshot.code }), + }); + return; + } + if (snapshot !== undefined && snapshot.state !== "idle") { + sendJson(response, 200, { + state: snapshot.state, + ...(snapshot.text === undefined ? {} : { text: snapshot.text }), + }); + return; + } + sendJson(response, 200, { state: "idle" }); + }; +} /** * Start one sign-in attempt and wait for the actionable device-code notice. * The attempt itself keeps running after the notice, so Meta's approval is @@ -126,29 +262,21 @@ export function createLoginRouteHandler(ctx, options = {}) { * @returns the user-facing outcome. */ export async function startSignIn(ctx, options = {}) { + const fail = (reason, text) => { + options.state?.set({ state: "failed", text, at: Date.now() }); + return { ok: false, reason, text }; + }; const entry = ctx.authorization.describe(AUTH_KEY); if (entry === undefined) { - return { - ok: false, - reason: "unavailable", - text: "Muse Code is unavailable: this DeepSeek Harness build has no Meta sign-in flow " + - `(${AUTH_KEY}). Update DeepSeek Harness or add the Meta provider under Settings > Models first.`, - }; + return fail("unavailable", "Muse Code is unavailable: this DeepSeek Harness build has no Meta sign-in flow " + + `(${AUTH_KEY}). Update DeepSeek Harness or add the Meta provider under Settings > Models first.`); } if (entry.inFlight) { - return { - ok: false, - reason: "busy", - text: "A Muse Code sign-in is already running. Approve it in the browser, then retry.", - }; + return fail("busy", "A Muse Code sign-in is already running. Approve it in the browser, then retry."); } const method = entry.methods.find((candidate) => candidate.id === AUTH_METHOD)?.id ?? entry.methods[0]?.id; if (method === undefined) { - return { - ok: false, - reason: "unavailable", - text: `The Muse Code sign-in flow offers no method to run (${AUTH_KEY}).`, - }; + return fail("unavailable", `The Muse Code sign-in flow offers no method to run (${AUTH_KEY}).`); } const openBrowser = options.openBrowser ?? openExternal; const timeoutMs = options.timeoutMs ?? NOTICE_TIMEOUT_MS; @@ -173,9 +301,23 @@ export async function startSignIn(ctx, options = {}) { }, }, }); + options.onAttempt?.(attempt); // The attempt outlives the request that started it: Meta approval arrives // later and the seam stores the credential then. - attempt.then((outcome) => ctx.logger?.info?.(`dsh-muse-code: sign-in settled as ${outcome.status}`), (failure) => warn(ctx.logger, "dsh-muse-code: sign-in failed", failure)); + attempt.then((outcome) => { + options.state?.set({ + state: outcome.status === "authorized" ? "authorized" : "cancelled", + text: outcome.status === "authorized" + ? "Muse Code is signed in." + : `Muse Code sign-in ended (${outcome.status}).`, + at: Date.now(), + }); + ctx.logger?.info?.(`dsh-muse-code: sign-in settled as ${outcome.status}`); + }, (failure) => { + const text = `Muse Code sign-in failed: ${messageOf(failure)}`; + options.state?.set({ state: "failed", text, at: Date.now() }); + warn(ctx.logger, "dsh-muse-code: sign-in failed", failure); + }); const announcedNotice = notice.then((instruction) => ({ kind: "notice", instruction })); const settled = attempt.then((outcome) => ({ kind: "settled", outcome }), (failure) => ({ kind: "failed", failure })); const timer = {}; @@ -185,51 +327,71 @@ export async function startSignIn(ctx, options = {}) { }); try { const outcome = await Promise.race([announcedNotice, settled, timeout]); - if (outcome.kind === "notice") { - return successOutcome(outcome.instruction, openBrowser); - } + if (outcome.kind === "notice") + return successOutcome(outcome.instruction, openBrowser, options.state); if (outcome.kind === "timeout") { ctx.authorization.cancel?.(AUTH_KEY); - return { - ok: false, - reason: "timeout", - text: "Muse Code sign-in did not answer in time. Check the connection and run /muse-login again.", - }; + return fail("timeout", "Muse Code sign-in did not answer in time. Check the connection and run /muse-login again."); } if (outcome.kind === "failed") { const text = messageOf(outcome.failure); if (/already running/i.test(text)) { - return { ok: false, reason: "busy", text: "A Muse Code sign-in is already running. Approve it in the browser, then retry." }; + return fail("busy", "A Muse Code sign-in is already running. Approve it in the browser, then retry."); } - return { ok: false, reason: "failed", text: `Muse Code sign-in failed: ${text}` }; + return fail("failed", `Muse Code sign-in failed: ${text}`); } if (announced !== undefined) - return successOutcome(announced, openBrowser); + return successOutcome(announced, openBrowser, options.state); if (outcome.outcome.status === "authorized") { - return { - ok: false, - reason: "ended", - text: "Muse Code sign-in finished without showing a code. Run /muse-login again if the models stay unavailable.", - }; + return fail("ended", "Muse Code sign-in finished without showing a code. Run /muse-login again if the models stay unavailable."); } - return { ok: false, reason: "ended", text: `Muse Code sign-in ended (${outcome.outcome.status}).` }; + return fail("ended", `Muse Code sign-in ended (${outcome.outcome.status}).`); } finally { if (timer.handle !== undefined) clearTimeout(timer.handle); } } -function successOutcome(instruction, openBrowser) { +function successOutcome(instruction, openBrowser, state) { // The browser hand-off is a courtesy for a purely local effect: the user must // still confirm the code on Meta's page, and both surfaces expose the URL. const opened = openBrowser(instruction.completeUrl); + const text = formatSignInText(instruction, opened); + state?.set({ + state: "pending", + text, + ...(instruction.code === undefined ? {} : { code: instruction.code }), + at: Date.now(), + }); return { ok: true, + opened, url: instruction.completeUrl, ...(instruction.code === undefined ? {} : { code: instruction.code }), - text: formatSignInText(instruction, opened), + text, }; } +/** + * Wait for one sign-in attempt to settle, bounded by the caller's deadline. + * @param attempt - the background attempt promise. + * @param waitMs - upper bound in milliseconds. + * @returns the settled outcome, or undefined on timeout/failure to settle. + */ +export async function waitForSettlement(attempt, waitMs) { + const settled = attempt.then((outcome) => outcome, () => ({ status: "failed" })); + let timer; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), waitMs); + timer.unref?.(); + }); + try { + return await Promise.race([settled, deadline]); + } + finally { + if (timer !== undefined) + clearTimeout(timer); + } +} /** * Read the actionable instruction out of one authorization notice. * @param notice - a seam notice (`message`/`url`/`code`) or a raw adapter one. @@ -268,12 +430,12 @@ export function readSignInNotice(notice) { */ export function formatSignInText(instruction, opened) { const openPart = opened - ? "A browser tab should have opened; if not, open it yourself." - : "Open this page in your browser."; + ? "A browser tab should have opened." + : "Open the sign-in page in your browser."; const codePart = instruction.code === undefined - ? "Confirm the sign-in on the Meta page." + ? "Confirm the sign-in on Meta's page." : `Confirm the code ${instruction.code} there.`; - return `Muse Code sign-in started. ${openPart} ${codePart} Page: ${instruction.completeUrl} Muse Code models become available as soon as Meta confirms.`; + return `Muse Code sign-in started. ${openPart} ${codePart} The models become available as soon as Meta confirms.`; } /** * Declare the shipped `meta` route in the `llm-pi-ai` settings section so the @@ -308,9 +470,139 @@ export async function ensureMuseRoute(settings, logger, options = {}) { logger?.debug?.(`dsh-muse-code: no configurable "${SETTINGS_NS}" entry; leaving the route to Settings > Models`); return false; } +/** + * Sync the account's live chat catalog into the declared route. + * + * Meta lists the subscription catalog behind the minted key and publishes no + * static metadata, so the ids are turned into conservative DSH model entries: + * the five Muse Spark rows keep the shipped catalog's fidelity, every other + * chat id (Muse Wonder included) takes the family defaults. A route that + * already declares its own `models` list is left untouched. + * + * @param settings - the Settings form service. + * @param logger - optional plugin logger. + * @param options - credentials access and fetch override, for tests. + * @returns whether this call wrote a model list. + */ +export async function syncMuseModels(settings, logger, options = {}) { + const credentials = await waitForCredentials(options.credentials); + if (credentials === undefined) + return false; + const accessKey = await readSubscriptionKey(credentials); + if (accessKey === undefined) + return false; + const row = settings.describe().find((candidate) => candidate.ns === SETTINGS_NS); + if (row === undefined) + return false; + const providers = row.value["providers"]; + if (!isRecord(providers) || !isRecord(providers[PROVIDER])) + return false; + const profile = providers[PROVIDER]; + if (profile["models"] !== undefined) + return false; + const ids = await (options.fetchModelIds ?? fetchLiveModelIds)(accessKey); + if (ids === null || ids.length === 0) { + logger?.debug?.("dsh-muse-code: live catalog unavailable; keeping the shipped pi-ai catalog"); + return false; + } + const models = ids.filter(isChatModelId).map(museModelEntry); + if (models.length === 0) + return false; + await settings.update(SETTINGS_NS, { providers: { [PROVIDER]: { displayName: ROUTE_LABEL, models } } }, row.revision); + logger?.info?.(`dsh-muse-code: synced ${String(models.length)} Muse models from the account catalog`); + return true; +} +/** Map one live model id to a conservative DSH pi-ai model entry. */ +export function museModelEntry(id) { + const known = SPARK_MODELS[id]; + const name = known?.name ?? prettifyModelName(id); + const withMax = known?.max === true; + const reasoningEfforts = { + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + ...(withMax ? { max: "max" } : {}), + }; + return { + id, + name, + contextWindow: MUSE_CONTEXT_WINDOW, + maxTokens: MUSE_MAX_OUTPUT_TOKENS, + input: ["text", "image"], + reasoningEfforts, + }; +} +/** Whether one catalog id is a chat model this route can serve. */ +export function isChatModelId(id) { + return !NON_CHAT_PREFIXES.some((prefix) => id.startsWith(prefix)); +} +/** Turn a live model id into a display name ("muse-wonder-full-1.0"). */ +export function prettifyModelName(id) { + if (!id.startsWith("muse-")) + return id; + return id + .split("-") + .map((part) => (/^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join(" "); +} +/** + * Fetch the account's live chat catalog. + * @param accessKey - minted subscription key stored in the OAuth record. + * @param timeoutMs - request bound. + * @returns the chat model ids, or null on any failure. + */ +export async function fetchLiveModelIds(accessKey, timeoutMs = MODELS_TIMEOUT_MS) { + try { + const response = await fetch(MODELS_URL, { + headers: { Accept: "application/json", Authorization: `Bearer ${accessKey}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) + return null; + const body = await response.json(); + if (!isRecord(body) || !Array.isArray(body["data"])) + return null; + const ids = []; + for (const row of body["data"]) { + if (isRecord(row) && typeof row["id"] === "string" && row["id"].length > 0) + ids.push(row["id"]); + } + return ids; + } + catch { + return null; + } +} +async function readSubscriptionKey(credentials) { + try { + const record = await credentials.readRecord(AUTH_KEY); + const payload = record?.payload; + if (!isRecord(payload)) + return undefined; + const access = payload["access"]; + return typeof access === "string" && access.length > 0 ? access : undefined; + } + catch { + return undefined; + } +} +async function waitForCredentials(getter, attempts = 8, delayMs = 250) { + if (getter === undefined) + return undefined; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const face = getter(); + if (face !== undefined) + return face; + if (attempt < attempts - 1 && delayMs > 0) + await sleep(delayMs); + } + return undefined; +} function setCorsHeaders(response) { response.setHeader("Access-Control-Allow-Origin", "*"); - response.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); response.setHeader("Access-Control-Allow-Headers", "content-type"); response.setHeader("Cache-Control", "no-store"); } @@ -324,29 +616,37 @@ function sendHtml(response, html) { response.end(html); } /** - * The page a plain navigation to {@link LOGIN_ROUTE} receives: the code, the - * fallback link, and an immediate move to Meta's verification page. + * The page a plain navigation to {@link LOGIN_ROUTE} receives: the code, a link + * to Meta, and a poll of {@link STATUS_ROUTE} until the sign-in completes. * @param outcome - result of the sign-in attempt. * @returns a self-contained HTML document. */ -function signInPage(outcome) { +export function signInPage(outcome) { const title = "Muse Code sign-in"; if (!outcome.ok) { - return `${title}` + - `` + - `

${escapeHtml(title)}

` + + return `${escapeHtml(title)}` + + `` + + `

${escapeHtml(title)}

` + `

${escapeHtml(outcome.text)}

` + - `

Try again

`; + `

Try again

`; } - const codePart = outcome.code === undefined + const codeBlock = outcome.code === undefined ? "" - : `

${escapeHtml(outcome.code)}

`; - return `${title}` + - `` + - `` + - `

${escapeHtml(title)}

` + - `

Confirm this code on Meta's page:

${codePart}` + - `

If the page does not open, continue on Meta.

` + + : `

${escapeHtml(outcome.code)}

`; + return `${escapeHtml(title)}` + + `` + + `

${escapeHtml(title)}

` + + `

Confirm this code on Meta's page:

${codeBlock}` + + `

Open the Meta sign-in page

` + + `

Waiting for Meta to confirm…

` + + `` + ``; } function escapeHtml(value) { diff --git a/package-lock.json b/package-lock.json index e589477..8cb0cc4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@thestreamcode/dsh-muse-code", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@thestreamcode/dsh-muse-code", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "@types/node": "^26.6.5", diff --git a/package.json b/package.json index a5fd445..f871472 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@thestreamcode/dsh-muse-code", - "version": "0.2.0", + "version": "0.2.1", "description": "Unofficial DeepSeek Harness integration for Meta's Muse Code subscription: declares the shipped Meta provider route and adds its sign-in command. Use at your own risk.", "license": "MIT", "author": { diff --git a/src/index.ts b/src/index.ts index 0179738..72f7088 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,19 +3,20 @@ // DeepSeek Harness already serves this subscription: the shipped pi-ai catalog // exposes the `meta` route (api.meta.ai/v1) and registers the `llm-pi-ai/meta` // authorization flow, which runs Meta's device-code login and stores the -// renewable credential record. The Web/Desktop Settings surface has no generic -// button for provider OAuth flows, so this bundle adds the pieces a user needs: +// renewable credential record. This bundle adds what the harness does not: // // - it declares the `meta` route in the `llm-pi-ai` settings section, so the // provider appears under Settings > Models and its models in the model picker; -// - it exposes the shipped sign-in as the `/muse-login` command; -// - it registers an exact Web route (`/muse-code/login`) and a Sign in control -// under Settings > Models that calls it, because the Settings page cannot -// reach the Host authorization seam through the Remote API. -// -// The Host opens the verification page in the user's default browser itself; the -// route and the command also return the code and the URL, so a blocked browser -// never strands the sign-in. +// - it syncs the account's live chat catalog (`GET /v1/models`) into that +// route, so models the shipped pi-ai catalog does not list (Muse Wonder and +// future ids) are selectable without a DSH update; +// - it exposes the shipped sign-in as `/muse-login` and as an exact local Web +// route (`POST /muse-code/login`) that the Settings control calls, because +// the Settings page cannot reach the authorization seam through the Remote +// API; +// - it reports the sign-in state (`GET /muse-code/status`) so both the Settings +// control and the command can say "signed in" instead of leaving the user +// waiting after Meta confirmed. // // A second `@deepseek-ai/dsh-llm-pi-ai` instance is not an option: every // instance declares the whole installed provider catalog, so the extra instance @@ -40,8 +41,29 @@ const AUTH_METHOD = "oauth" const ROUTE_LABEL = "Muse Code (Meta subscription)" /** Web route the Settings control calls to start the sign-in. */ export const LOGIN_ROUTE = "/muse-code/login" +/** Web route that reports whether the sign-in completed. */ +export const STATUS_ROUTE = "/muse-code/status" /** How long the sign-in waits for the device-code notice before giving up. */ const NOTICE_TIMEOUT_MS = 20_000 +/** How long `/muse-login` waits for Meta's confirmation before answering. */ +const RESULT_WAIT_MS = 120_000 +/** Live chat catalog of the Meta Model API. */ +const MODELS_URL = "https://api.meta.ai/v1/models" +const MODELS_TIMEOUT_MS = 8_000 +/** Endpoint families the Muse subscription also lists but DSH cannot chat with. */ +const NON_CHAT_PREFIXES = ["sam-", "muse-image-", "muse-voice-"] +/** Family defaults mirrored from the sibling Muse plugins and the pi-ai catalog. */ +const MUSE_CONTEXT_WINDOW = 1_048_576 +const MUSE_MAX_OUTPUT_TOKENS = 131_072 + +/** The five Muse Spark rows the shipped pi-ai catalog describes, with fidelity. */ +const SPARK_MODELS: Record = { + "muse-spark-1.1": { name: "Muse Spark 1.1", max: false }, + "muse-spark-1.2": { name: "Muse Spark 1.2", max: false }, + "muse-spark-1.2-contributor": { name: "Muse Spark 1.2 Contributor", max: false }, + "muse-spark-1.3": { name: "Muse Spark 1.3", max: true }, + "muse-spark-1.3-contributor": { name: "Muse Spark 1.3 Contributor", max: false }, +} type Logger = { info?(message: string): void @@ -110,6 +132,16 @@ type SettingsFace = { update(ns: string, patch: Record, expectedRevision: number): Promise } +type CredentialRecord = { + kind?: string + payload?: Record +} + +type CredentialsFace = { + readRecord(key: string): Promise + describeRecord(key: string): Promise<{ configured: boolean }> +} + type WebRoute = { kind: "exact" | "prefix" path: string @@ -123,6 +155,7 @@ type WebServerFace = { type ChildContext = HostContext & { settings?: SettingsFace webServer?: WebServerFace + credentials?: CredentialsFace } type HostContext = { @@ -131,12 +164,46 @@ type HostContext = { logger?: Logger effect(callback: () => () => void, label?: string): () => void inject?(dependencies: string[], callback: (child: ChildContext) => void): void + on?(event: string, listener: (...args: never[]) => void): () => void +} + +/** One sign-in lifecycle fact the surfaces can read. */ +export type SignInSnapshot = { + state: "idle" | "pending" | "authorized" | "failed" | "cancelled" + text?: string + code?: string + at: number +} + +/** Mutable snapshot shared by the command and the two Web routes. */ +export type SignInState = { + get(): SignInSnapshot + set(snapshot: SignInSnapshot): void +} + +/** Create one empty sign-in state. */ +export function createSignInState(): SignInState { + let snapshot: SignInSnapshot = { state: "idle", at: 0 } + return { + get: () => snapshot, + set: (next) => { + snapshot = next + }, + } } /** Injected side effects, overridable in tests. */ export type SignInOptions = { openBrowser?: (url: string) => boolean timeoutMs?: number + /** How long the command waits for Meta's confirmation after opening the page. */ + waitForResultMs?: number + state?: SignInState + credentials?: () => CredentialsFace | undefined + /** Capture the background attempt so the command can wait for its result. */ + onAttempt?: (attempt: Promise<{ status: string }>) => void + /** Override the live catalog fetch, for tests. */ + fetchModelIds?: (accessKey: string) => Promise } /** Actionable instructions extracted from one authorization notice. */ @@ -150,42 +217,65 @@ export type SignInInstruction = { } type SignInOutcome = - | { ok: true; text: string; url: string; code?: string } + | { ok: true; opened: boolean; text: string; url: string; code?: string } | { ok: false; reason: "unavailable" | "busy" | "timeout" | "failed" | "ended"; text: string } /** - * Register the sign-in command, the Settings > Models sign-in route, and the - * settings write that makes the Muse Code provider visible. + * Register the sign-in command, the Settings > Models routes, the live catalog + * sync, and the settings write that makes the Muse Code provider visible. * @param ctx - Host plugin context. */ export function apply(ctx: HostContext): void { - ctx.effect(() => ctx.commands.register(museLoginCommand(ctx)), "dsh-muse-code: /muse-login") + const state = createSignInState() + let credentialsFace: CredentialsFace | undefined + const options: SignInOptions = { state, credentials: () => credentialsFace } + ctx.effect(() => ctx.commands.register(museLoginCommand(ctx, options)), "dsh-muse-code: /muse-login") if (typeof ctx.inject !== "function") return + ctx.inject(["credentials"], (child) => { + credentialsFace = child.credentials + }) + ctx.inject(["webServer"], (child) => { const webServer = child.webServer if (webServer === undefined) return - child.effect( - () => - webServer.register({ - kind: "exact", - path: LOGIN_ROUTE, - handler: createLoginRouteHandler(ctx), - }), - "dsh-muse-code: login route", - ) + child.effect(() => { + const disposeLogin = webServer.register({ + kind: "exact", + path: LOGIN_ROUTE, + handler: createLoginRouteHandler(ctx, options), + }) + const disposeStatus = webServer.register({ + kind: "exact", + path: STATUS_ROUTE, + handler: createStatusRouteHandler(ctx, options), + }) + return () => { + disposeStatus() + disposeLogin() + } + }, "dsh-muse-code: login routes") }) ctx.inject(["settings"], (child) => { child.effect(() => { const settings = child.settings if (settings === undefined) return () => {} - void ensureMuseRoute(settings, ctx.logger).catch((error: unknown) => { - warn(ctx.logger, "dsh-muse-code: could not declare the Muse Code route", error) + const refresh = () => { + void (async () => { + await ensureMuseRoute(settings, ctx.logger) + await syncMuseModels(settings, ctx.logger, options) + })().catch((error: unknown) => { + warn(ctx.logger, "dsh-muse-code: could not refresh the Muse Code settings", error) + }) + } + refresh() + const dispose = ctx.on?.("credentials/record-updated", (key: string) => { + if (key === AUTH_KEY) refresh() }) - return () => {} - }, "dsh-muse-code: declare route") + return dispose ?? (() => {}) + }, "dsh-muse-code: settings") }) } @@ -198,17 +288,37 @@ export function museLoginCommand( name: "muse-login", description: "Sign in to the Muse Code (Meta) subscription and enable its models", async handler() { - const outcome = await startSignIn(ctx, options) - if (outcome.ok) return { kind: "success", text: outcome.text } - return { kind: "error", text: outcome.text } + let attempt: Promise<{ status: string }> | undefined + const started = await startSignIn(ctx, { + ...options, + onAttempt: (pending) => { + attempt = pending + }, + }) + if (!started.ok) return { kind: "error", text: started.text } + const waitMs = options.waitForResultMs ?? RESULT_WAIT_MS + if (!started.opened || attempt === undefined || waitMs <= 0) { + return { kind: "success", text: started.text } + } + const result = await waitForSettlement(attempt, waitMs) + if (result === undefined) { + return { kind: "success", text: `${started.text} Still waiting for Meta: the sign-in completes when you approve the page.` } + } + if (result.status === "authorized") { + return { kind: "success", text: "Muse Code is signed in. Its models are available in the model picker." } + } + return { + kind: "error", + text: `Muse Code sign-in ended (${result.status}). Run /muse-login again to retry.`, + } }, } } /** * Build the exact Web route that starts the same sign-in from the Settings - * page. The route answers JSON so the Client control can render the code and - * the failure reason next to its button. + * page. POST answers JSON for the Settings control; GET answers a small page + * that shows the code and polls the status route until Meta confirms. * @param ctx - Host plugin context. * @param options - injected side effects, overridable in tests. * @returns an HTTP handler for {@link LOGIN_ROUTE}. @@ -226,7 +336,7 @@ export function createLoginRouteHandler( } if (request.method === "GET") { // A plain navigation: this tab is the browser, so the flow need not open - // another one. The page shows the code and moves on to Meta's page. + // another one. The page itself polls the status route. const outcome = await startSignIn(ctx, { ...options, openBrowser: () => false }) sendHtml(response, signInPage(outcome)) return @@ -237,7 +347,13 @@ export function createLoginRouteHandler( } const outcome = await startSignIn(ctx, options) if (outcome.ok) { - sendJson(response, 200, { ok: true, text: outcome.text, url: outcome.url, code: outcome.code }) + sendJson(response, 200, { + ok: true, + text: outcome.text, + url: outcome.url, + code: outcome.code, + opened: outcome.opened, + }) return } const status = outcome.reason === "busy" ? 409 : outcome.reason === "timeout" ? 504 : 500 @@ -245,6 +361,61 @@ export function createLoginRouteHandler( } } +/** + * Build the exact Web route that reports the sign-in state, so the Settings + * control can flip to "Signed in" after Meta confirms. + * @param ctx - Host plugin context. + * @param options - injected side effects, overridable in tests. + * @returns an HTTP handler for {@link STATUS_ROUTE}. + */ +export function createStatusRouteHandler( + ctx: HostContext, + options: SignInOptions = {}, +): (request: IncomingMessage, response: ServerResponse) => Promise { + return async (request, response) => { + setCorsHeaders(response) + if (request.method === "OPTIONS") { + response.writeHead(204) + response.end() + return + } + if (request.method !== "GET") { + sendJson(response, 405, { ok: false, text: "Use GET for the sign-in status." }) + return + } + const snapshot = options.state?.get() + try { + const credentials = options.credentials?.() + if (credentials !== undefined) { + const record = await credentials.describeRecord(AUTH_KEY) + if (record.configured) { + sendJson(response, 200, { state: "authorized", text: "Muse Code is signed in." }) + return + } + } + } catch { + // Fall through to the in-memory state. + } + const entry = ctx.authorization.describe(AUTH_KEY) + if (entry?.inFlight === true) { + sendJson(response, 200, { + state: "pending", + ...(snapshot?.text === undefined ? {} : { text: snapshot.text }), + ...(snapshot?.code === undefined ? {} : { code: snapshot.code }), + }) + return + } + if (snapshot !== undefined && snapshot.state !== "idle") { + sendJson(response, 200, { + state: snapshot.state, + ...(snapshot.text === undefined ? {} : { text: snapshot.text }), + }) + return + } + sendJson(response, 200, { state: "idle" }) + } +} + /** * Start one sign-in attempt and wait for the actionable device-code notice. * The attempt itself keeps running after the notice, so Meta's approval is @@ -254,30 +425,24 @@ export function createLoginRouteHandler( * @returns the user-facing outcome. */ export async function startSignIn(ctx: HostContext, options: SignInOptions = {}): Promise { + const fail = (reason: Extract["reason"], text: string): SignInOutcome => { + options.state?.set({ state: "failed", text, at: Date.now() }) + return { ok: false, reason, text } + } const entry = ctx.authorization.describe(AUTH_KEY) if (entry === undefined) { - return { - ok: false, - reason: "unavailable", - text: - "Muse Code is unavailable: this DeepSeek Harness build has no Meta sign-in flow " + + return fail( + "unavailable", + "Muse Code is unavailable: this DeepSeek Harness build has no Meta sign-in flow " + `(${AUTH_KEY}). Update DeepSeek Harness or add the Meta provider under Settings > Models first.`, - } + ) } if (entry.inFlight) { - return { - ok: false, - reason: "busy", - text: "A Muse Code sign-in is already running. Approve it in the browser, then retry.", - } + return fail("busy", "A Muse Code sign-in is already running. Approve it in the browser, then retry.") } const method = entry.methods.find((candidate) => candidate.id === AUTH_METHOD)?.id ?? entry.methods[0]?.id if (method === undefined) { - return { - ok: false, - reason: "unavailable", - text: `The Muse Code sign-in flow offers no method to run (${AUTH_KEY}).`, - } + return fail("unavailable", `The Muse Code sign-in flow offers no method to run (${AUTH_KEY}).`) } const openBrowser = options.openBrowser ?? openExternal @@ -302,11 +467,26 @@ export async function startSignIn(ctx: HostContext, options: SignInOptions = {}) }, }, }) + options.onAttempt?.(attempt) // The attempt outlives the request that started it: Meta approval arrives // later and the seam stores the credential then. attempt.then( - (outcome) => ctx.logger?.info?.(`dsh-muse-code: sign-in settled as ${outcome.status}`), - (failure: unknown) => warn(ctx.logger, "dsh-muse-code: sign-in failed", failure), + (outcome) => { + options.state?.set({ + state: outcome.status === "authorized" ? "authorized" : "cancelled", + text: + outcome.status === "authorized" + ? "Muse Code is signed in." + : `Muse Code sign-in ended (${outcome.status}).`, + at: Date.now(), + }) + ctx.logger?.info?.(`dsh-muse-code: sign-in settled as ${outcome.status}`) + }, + (failure: unknown) => { + const text = `Muse Code sign-in failed: ${messageOf(failure)}` + options.state?.set({ state: "failed", text, at: Date.now() }) + warn(ctx.logger, "dsh-muse-code: sign-in failed", failure) + }, ) const announcedNotice = notice.then((instruction) => ({ kind: "notice" as const, instruction })) @@ -321,48 +501,78 @@ export async function startSignIn(ctx: HostContext, options: SignInOptions = {}) }) try { const outcome = await Promise.race([announcedNotice, settled, timeout]) - if (outcome.kind === "notice") { - return successOutcome(outcome.instruction, openBrowser) - } + if (outcome.kind === "notice") return successOutcome(outcome.instruction, openBrowser, options.state) if (outcome.kind === "timeout") { ctx.authorization.cancel?.(AUTH_KEY) - return { - ok: false, - reason: "timeout", - text: - "Muse Code sign-in did not answer in time. Check the connection and run /muse-login again.", - } + return fail( + "timeout", + "Muse Code sign-in did not answer in time. Check the connection and run /muse-login again.", + ) } if (outcome.kind === "failed") { const text = messageOf(outcome.failure) if (/already running/i.test(text)) { - return { ok: false, reason: "busy", text: "A Muse Code sign-in is already running. Approve it in the browser, then retry." } + return fail("busy", "A Muse Code sign-in is already running. Approve it in the browser, then retry.") } - return { ok: false, reason: "failed", text: `Muse Code sign-in failed: ${text}` } + return fail("failed", `Muse Code sign-in failed: ${text}`) } - if (announced !== undefined) return successOutcome(announced, openBrowser) + if (announced !== undefined) return successOutcome(announced, openBrowser, options.state) if (outcome.outcome.status === "authorized") { - return { - ok: false, - reason: "ended", - text: "Muse Code sign-in finished without showing a code. Run /muse-login again if the models stay unavailable.", - } + return fail("ended", "Muse Code sign-in finished without showing a code. Run /muse-login again if the models stay unavailable.") } - return { ok: false, reason: "ended", text: `Muse Code sign-in ended (${outcome.outcome.status}).` } + return fail("ended", `Muse Code sign-in ended (${outcome.outcome.status}).`) } finally { if (timer.handle !== undefined) clearTimeout(timer.handle) } } -function successOutcome(instruction: SignInInstruction, openBrowser: (url: string) => boolean): SignInOutcome { +function successOutcome( + instruction: SignInInstruction, + openBrowser: (url: string) => boolean, + state: SignInState | undefined, +): SignInOutcome { // The browser hand-off is a courtesy for a purely local effect: the user must // still confirm the code on Meta's page, and both surfaces expose the URL. const opened = openBrowser(instruction.completeUrl) + const text = formatSignInText(instruction, opened) + state?.set({ + state: "pending", + text, + ...(instruction.code === undefined ? {} : { code: instruction.code }), + at: Date.now(), + }) return { ok: true, + opened, url: instruction.completeUrl, ...(instruction.code === undefined ? {} : { code: instruction.code }), - text: formatSignInText(instruction, opened), + text, + } +} + +/** + * Wait for one sign-in attempt to settle, bounded by the caller's deadline. + * @param attempt - the background attempt promise. + * @param waitMs - upper bound in milliseconds. + * @returns the settled outcome, or undefined on timeout/failure to settle. + */ +export async function waitForSettlement( + attempt: Promise<{ status: string }>, + waitMs: number, +): Promise<{ status: string } | undefined> { + const settled = attempt.then( + (outcome) => outcome, + () => ({ status: "failed" }), + ) + let timer: ReturnType | undefined + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), waitMs) + timer.unref?.() + }) + try { + return await Promise.race([settled, deadline]) + } finally { + if (timer !== undefined) clearTimeout(timer) } } @@ -401,12 +611,12 @@ export function readSignInNotice(notice: AuthorizationNotice): SignInInstruction */ export function formatSignInText(instruction: SignInInstruction, opened: boolean): string { const openPart = opened - ? "A browser tab should have opened; if not, open it yourself." - : "Open this page in your browser." + ? "A browser tab should have opened." + : "Open the sign-in page in your browser." const codePart = instruction.code === undefined - ? "Confirm the sign-in on the Meta page." + ? "Confirm the sign-in on Meta's page." : `Confirm the code ${instruction.code} there.` - return `Muse Code sign-in started. ${openPart} ${codePart} Page: ${instruction.completeUrl} Muse Code models become available as soon as Meta confirms.` + return `Muse Code sign-in started. ${openPart} ${codePart} The models become available as soon as Meta confirms.` } /** @@ -445,9 +655,145 @@ export async function ensureMuseRoute( return false } +/** + * Sync the account's live chat catalog into the declared route. + * + * Meta lists the subscription catalog behind the minted key and publishes no + * static metadata, so the ids are turned into conservative DSH model entries: + * the five Muse Spark rows keep the shipped catalog's fidelity, every other + * chat id (Muse Wonder included) takes the family defaults. A route that + * already declares its own `models` list is left untouched. + * + * @param settings - the Settings form service. + * @param logger - optional plugin logger. + * @param options - credentials access and fetch override, for tests. + * @returns whether this call wrote a model list. + */ +export async function syncMuseModels( + settings: SettingsFace, + logger?: Logger, + options: Pick = {}, +): Promise { + const credentials = await waitForCredentials(options.credentials) + if (credentials === undefined) return false + const accessKey = await readSubscriptionKey(credentials) + if (accessKey === undefined) return false + const row = settings.describe().find((candidate) => candidate.ns === SETTINGS_NS) + if (row === undefined) return false + const providers = row.value["providers"] + if (!isRecord(providers) || !isRecord(providers[PROVIDER])) return false + const profile = providers[PROVIDER] + if (profile["models"] !== undefined) return false + const ids = await (options.fetchModelIds ?? fetchLiveModelIds)(accessKey) + if (ids === null || ids.length === 0) { + logger?.debug?.("dsh-muse-code: live catalog unavailable; keeping the shipped pi-ai catalog") + return false + } + const models = ids.filter(isChatModelId).map(museModelEntry) + if (models.length === 0) return false + await settings.update( + SETTINGS_NS, + { providers: { [PROVIDER]: { displayName: ROUTE_LABEL, models } } }, + row.revision, + ) + logger?.info?.(`dsh-muse-code: synced ${String(models.length)} Muse models from the account catalog`) + return true +} + +/** Map one live model id to a conservative DSH pi-ai model entry. */ +export function museModelEntry(id: string): Record { + const known = SPARK_MODELS[id] + const name = known?.name ?? prettifyModelName(id) + const withMax = known?.max === true + const reasoningEfforts: Record = { + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + ...(withMax ? { max: "max" } : {}), + } + return { + id, + name, + contextWindow: MUSE_CONTEXT_WINDOW, + maxTokens: MUSE_MAX_OUTPUT_TOKENS, + input: ["text", "image"], + reasoningEfforts, + } +} + +/** Whether one catalog id is a chat model this route can serve. */ +export function isChatModelId(id: string): boolean { + return !NON_CHAT_PREFIXES.some((prefix) => id.startsWith(prefix)) +} + +/** Turn a live model id into a display name ("muse-wonder-full-1.0"). */ +export function prettifyModelName(id: string): string { + if (!id.startsWith("muse-")) return id + return id + .split("-") + .map((part) => (/^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join(" ") +} + +/** + * Fetch the account's live chat catalog. + * @param accessKey - minted subscription key stored in the OAuth record. + * @param timeoutMs - request bound. + * @returns the chat model ids, or null on any failure. + */ +export async function fetchLiveModelIds( + accessKey: string, + timeoutMs: number = MODELS_TIMEOUT_MS, +): Promise { + try { + const response = await fetch(MODELS_URL, { + headers: { Accept: "application/json", Authorization: `Bearer ${accessKey}` }, + signal: AbortSignal.timeout(timeoutMs), + }) + if (!response.ok) return null + const body: unknown = await response.json() + if (!isRecord(body) || !Array.isArray(body["data"])) return null + const ids: string[] = [] + for (const row of body["data"]) { + if (isRecord(row) && typeof row["id"] === "string" && row["id"].length > 0) ids.push(row["id"]) + } + return ids + } catch { + return null + } +} + +async function readSubscriptionKey(credentials: CredentialsFace): Promise { + try { + const record = await credentials.readRecord(AUTH_KEY) + const payload = record?.payload + if (!isRecord(payload)) return undefined + const access = payload["access"] + return typeof access === "string" && access.length > 0 ? access : undefined + } catch { + return undefined + } +} + +async function waitForCredentials( + getter: (() => CredentialsFace | undefined) | undefined, + attempts = 8, + delayMs = 250, +): Promise { + if (getter === undefined) return undefined + for (let attempt = 0; attempt < attempts; attempt += 1) { + const face = getter() + if (face !== undefined) return face + if (attempt < attempts - 1 && delayMs > 0) await sleep(delayMs) + } + return undefined +} + function setCorsHeaders(response: ServerResponse): void { response.setHeader("Access-Control-Allow-Origin", "*") - response.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS") + response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") response.setHeader("Access-Control-Allow-Headers", "content-type") response.setHeader("Cache-Control", "no-store") } @@ -464,29 +810,37 @@ function sendHtml(response: ServerResponse, html: string): void { } /** - * The page a plain navigation to {@link LOGIN_ROUTE} receives: the code, the - * fallback link, and an immediate move to Meta's verification page. + * The page a plain navigation to {@link LOGIN_ROUTE} receives: the code, a link + * to Meta, and a poll of {@link STATUS_ROUTE} until the sign-in completes. * @param outcome - result of the sign-in attempt. * @returns a self-contained HTML document. */ -function signInPage(outcome: SignInOutcome): string { +export function signInPage(outcome: SignInOutcome): string { const title = "Muse Code sign-in" if (!outcome.ok) { - return `${title}` + - `` + - `

${escapeHtml(title)}

` + + return `${escapeHtml(title)}` + + `` + + `

${escapeHtml(title)}

` + `

${escapeHtml(outcome.text)}

` + - `

Try again

` + `

Try again

` } - const codePart = outcome.code === undefined + const codeBlock = outcome.code === undefined ? "" - : `

${escapeHtml(outcome.code)}

` - return `${title}` + - `` + - `` + - `

${escapeHtml(title)}

` + - `

Confirm this code on Meta's page:

${codePart}` + - `

If the page does not open, continue on Meta.

` + + : `

${escapeHtml(outcome.code)}

` + return `${escapeHtml(title)}` + + `` + + `

${escapeHtml(title)}

` + + `

Confirm this code on Meta's page:

${codeBlock}` + + `

Open the Meta sign-in page

` + + `

Waiting for Meta to confirm…

` + + `` + `` } diff --git a/test/client.test.mjs b/test/client.test.mjs index 9574d64..48a4464 100644 --- a/test/client.test.mjs +++ b/test/client.test.mjs @@ -3,10 +3,10 @@ import assert from "node:assert/strict" import { readFile } from "node:fs/promises" // DSH serves Client artifacts as CLASSIC SCRIPTS (they are concatenated into -// plain script bundles), not as ES modules. These tests are the closest -// browser-equivalent checks available without a page: `new Function` rejects -// `export`/`import` exactly like a classic script, and the captured factory is -// driven with a stub React, slot surface, and fetch. +// plain script bundles), not as ES modules. These tests evaluate the artifact +// with classic-script grammar (`new Function` rejects `export`/`import` exactly +// like a browser would) and drive the resulting component with a tiny hook +// runtime, a stub React, and stubbed fetch. async function loadClient() { const source = await readFile(new URL("../client/client.js", import.meta.url), "utf8") @@ -28,18 +28,37 @@ async function loadClient() { return modules[0] } -function makeReact() { - const slots = [] +function createHarness() { + const hooks = [] + let cursor = 0 + const react = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), + useState(initial) { + const index = cursor++ + if (hooks[index] === undefined) hooks[index] = { value: initial } + const slot = hooks[index] + return [ + slot.value, + (next) => { + slot.value = typeof next === "function" ? next(slot.value) : next + }, + ] + }, + useEffect(callback) { + const index = cursor++ + if (hooks[index] === undefined) hooks[index] = { ran: false } + if (!hooks[index].ran) { + hooks[index].ran = true + hooks[index].cleanup = callback() + } + }, + } return { - react: { - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), - useState(initial) { - const slot = { value: initial } - slots.push(slot) - return [slot.value, (next) => { slot.value = next }] - }, + react, + render(component) { + cursor = 0 + return component() }, - slots, } } @@ -47,7 +66,7 @@ function registerFooter(module, react) { const plugin = module.factory(() => react) assert.deepEqual(plugin.inject, ["slots"]) const registered = [] - const ctx = { + plugin.apply({ slots: { inject(ownerKey, callback) { assert.equal(ownerKey, "settings.models.footer") @@ -58,107 +77,144 @@ function registerFooter(module, react) { return () => {} }, }, - } - plugin.apply(ctx) + }) assert.equal(registered.length, 1) - assert.equal(registered[0].options.name, "settings.models.footer") + assert.equal(registered[0].options.id, "dsh-muse-code-sign-in") return registered[0] } -function findButton(node) { - if (node === null || typeof node !== "object") return undefined +function flatten(node, visit) { + if (node === null || node === undefined) return + if (typeof node === "string" || typeof node === "number") return if (Array.isArray(node)) { - for (const child of node) { - const hit = findButton(child) - if (hit !== undefined) return hit - } - return undefined - } - if (node.type === "button") return node - for (const child of node.children ?? []) { - const hit = findButton(child) - if (hit !== undefined) return hit + for (const child of node) flatten(child, visit) + return } - return undefined + visit(node) + for (const child of node.children ?? []) flatten(child, visit) } -function findLink(node) { - if (node === null || typeof node !== "object") return undefined - if (Array.isArray(node)) { - for (const child of node) { - const hit = findLink(child) - if (hit !== undefined) return hit - } - return undefined - } - if (node.type === "a") return node - for (const child of node.children ?? []) { - const hit = findLink(child) - if (hit !== undefined) return hit - } - return undefined +function findBy(node, predicate) { + let hit + flatten(node, (element) => { + if (hit === undefined && predicate(element)) hit = element + }) + return hit +} + +function findButton(node, label) { + return findBy(node, (element) => element.type === "button" && JSON.stringify(element.children).includes(label)) } function textOf(node) { - if (node === null || node === undefined) return "" + let text = "" + if (node === null || node === undefined) return text if (typeof node === "string" || typeof node === "number") return String(node) if (Array.isArray(node)) return node.map(textOf).join("") return (node.children ?? []).map(textOf).join("") } -test("client artifact registers the Models footer contribution", async () => { +function stubFetch(routes) { + const calls = [] + const original = globalThis.fetch + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), init }) + for (const [suffix, response] of routes) { + if (String(url).endsWith(suffix)) { + return new Response(JSON.stringify(response.body), { + status: response.status ?? 200, + headers: { "content-type": "application/json" }, + }) + } + } + return new Response("{}", { status: 404 }) + } + return { + calls, + restore() { + globalThis.fetch = original + }, + } +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) + +test("client artifact registers the Muse Code card", async () => { const module = await loadClient() - const { react } = makeReact() + const { react } = createHarness() const footer = registerFooter(module, react) - assert.equal(footer.options.id, "dsh-muse-code-sign-in") + assert.equal(footer.options.name, "settings.models.footer") }) -test("the footer control signs in through the Host login route and shows the code", async () => { +test("the card shows the signed-in state and hides the code", async () => { const module = await loadClient() - const { react, slots } = makeReact() + const harness = createHarness() globalThis.__DSH_TRANSPORT__ = { streamBaseUrl: "http://127.0.0.1:49806" } - const requests = [] - const originalFetch = globalThis.fetch - globalThis.fetch = async (url, init) => { - requests.push({ url: String(url), init }) - return new Response( - JSON.stringify({ - ok: true, - text: "Muse Code sign-in started. Confirm the code ABCD-EFGH. Page: https://auth.meta.com/oauth/device/?code=ABCD-EFGH", - url: "https://auth.meta.com/oauth/device/?code=ABCD-EFGH", - code: "ABCD-EFGH", - }), - { status: 200, headers: { "content-type": "application/json" } }, - ) - } + const fetch = stubFetch([ + ["/muse-code/status", { body: { state: "authorized", text: "Muse Code is signed in." } }], + ]) try { - const footer = registerFooter(module, react) - const tree = footer.component() - const button = findButton(tree) - assert.ok(button, "the control renders a Sign in button") - assert.match(textOf(button), /Sign in/) - const link = findLink(tree) - assert.ok(link, "the control renders a direct sign-in link") - assert.equal(link.props.href, "http://127.0.0.1:49806/muse-code/login") - assert.equal(link.props.target, "_blank") - await button.props.onClick() + const footer = registerFooter(module, harness.react) + harness.render(footer.component) + await tick() + await tick() + const tree = harness.render(footer.component) + const text = textOf(tree) + assert.match(text, /Muse Code \(Meta subscription\)/) + assert.match(text, /Signed in/) + assert.match(text, /Sign in again/) + assert.equal(findBy(tree, (element) => element.type === "code"), undefined) } finally { - globalThis.fetch = originalFetch + fetch.restore() delete globalThis.__DSH_TRANSPORT__ } +}) - assert.equal(requests.length, 1) - assert.equal(requests[0].url, "http://127.0.0.1:49806/muse-code/login") - assert.equal(requests[0].init.method, "POST") - assert.equal(slots[0].value.status, "success") - assert.match(slots[0].value.text, /ABCD-EFGH/) +test("the card starts the sign-in and shows the code while waiting", async () => { + const module = await loadClient() + const harness = createHarness() + globalThis.__DSH_TRANSPORT__ = { streamBaseUrl: "http://127.0.0.1:49806" } + const fetch = stubFetch([ + ["/muse-code/status", { body: { state: "idle" } }], + [ + "/muse-code/login", + { + body: { + ok: true, + text: "Muse Code sign-in started. Confirm the code ABCD-EFGH there.", + code: "ABCD-EFGH", + url: "https://auth.meta.com/oauth/device/?code=ABCD-EFGH", + opened: true, + }, + }, + ], + ]) + try { + const footer = registerFooter(module, harness.react) + harness.render(footer.component) + await tick() + const trigger = findButton(harness.render(footer.component), "Sign in") + assert.ok(trigger, "the card renders a Sign in button") + await trigger.props.onClick() + await tick() + await tick() + const tree = harness.render(footer.component) + const text = textOf(tree) + assert.match(text, /ABCD-EFGH/) + assert.match(text, /Waiting for Meta/) + const metaLink = findBy(tree, (element) => element.type === "a" && element.props.href === "https://auth.meta.com/oauth/device/?code=ABCD-EFGH") + assert.ok(metaLink, "the card links to the Meta page") + } finally { + fetch.restore() + delete globalThis.__DSH_TRANSPORT__ + } }) -test("without a discoverable Host origin the control points at the command", async () => { +test("without a discoverable Host origin the card points at the command", async () => { const module = await loadClient() - const { react } = makeReact() - const footer = registerFooter(module, react) - const tree = footer.component() - assert.equal(findButton(tree), undefined) + const harness = createHarness() + const footer = registerFooter(module, harness.react) + const tree = harness.render(footer.component) + assert.equal(findButton(tree, "Sign in"), undefined) assert.match(textOf(tree), /\/muse-login/) }) diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 60d6070..80da4a2 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -4,13 +4,21 @@ import { readFile } from "node:fs/promises" import { apply, createLoginRouteHandler, + createSignInState, + createStatusRouteHandler, ensureMuseRoute, + fetchLiveModelIds, formatSignInText, + isChatModelId, inject, LOGIN_ROUTE, museLoginCommand, + museModelEntry, name, + prettifyModelName, readSignInNotice, + STATUS_ROUTE, + syncMuseModels, } from "../lib/index.js" const PACKAGE = "@thestreamcode/dsh-muse-code" @@ -42,10 +50,22 @@ function makeSettings(rows) { return { settings, updates } } +function makeCredentials(configured = false, payload = undefined) { + return { + async describeRecord() { + return { configured } + }, + async readRecord() { + return configured ? { kind: "grant", payload } : undefined + }, + } +} + function makeContext(options = {}) { const flow = "flow" in options ? options.flow : FLOW + const credentials = options.credentials ?? makeCredentials(false) const settingsRows = options.settingsRows ?? [ - { ns: "llm-pi-ai", revision: 3, value: { providers: { meta: {} } } }, + { ns: "llm-pi-ai", revision: 3, value: { providers: { meta: { displayName: "Muse Code (Meta subscription)" } } } }, ] const commands = [] const children = [] @@ -53,10 +73,12 @@ function makeContext(options = {}) { const cancels = [] const { settings, updates } = makeSettings(settingsRows) const ctx = { + credentials, authorization: { describe(key) { assert.equal(key, "llm-pi-ai/meta") - return flow + if (flow === undefined) return undefined + return { ...flow, ...(options.inFlight === undefined ? {} : { inFlight: options.inFlight }) } }, async begin() { throw new Error("unexpected begin") @@ -71,30 +93,20 @@ function makeContext(options = {}) { return () => {} }, }, - logger: { - info() {}, - warn() {}, - debug() {}, - }, + logger: { info() {}, warn() {}, debug() {} }, effect(callback) { return callback() }, inject(dependencies, callback) { children.push({ dependencies }) - callback({ - ...ctx, - settings, - webServer: { - register(route) { - routes.push(route) - return () => {} - }, - }, - }) + callback({ ...ctx, settings, credentials, webServer: { register(route) { routes.push(route); return () => {} } } }) + return () => {} + }, + on() { return () => {} }, } - return { ctx, commands, children, routes, cancels, updates } + return { ctx, commands, children, routes, cancels, updates, settings, credentials } } function makeResponse() { @@ -129,68 +141,36 @@ test("manifest: bundle patch inserts one row and the client half is exported", a assert.doesNotMatch(patch, /^\s*name: '@deepseek-ai\/dsh-llm-pi-ai'/m) }) -test("plugin registers the command, the login route, and the settings write", () => { +test("plugin registers the command, both routes, the settings write, and credentials", () => { const { ctx, commands, children, routes } = makeContext() apply(ctx) assert.equal(name, "dsh-muse-code") assert.deepEqual(inject, ["authorization", "commands"]) assert.equal(commands.length, 1) assert.equal(commands[0].name, "muse-login") - assert.match(commands[0].description, /Muse Code/) - - assert.deepEqual(children.map((child) => child.dependencies), [["webServer"], ["settings"]]) - assert.equal(routes.length, 1) - assert.equal(routes[0].kind, "exact") - assert.equal(routes[0].path, LOGIN_ROUTE) - assert.equal(typeof routes[0].handler, "function") + assert.deepEqual(children.map((child) => child.dependencies), [["credentials"], ["webServer"], ["settings"]]) + assert.deepEqual( + routes.map((route) => [route.kind, route.path]), + [ + ["exact", LOGIN_ROUTE], + ["exact", STATUS_ROUTE], + ], + ) }) test("route declaration merges the meta provider only when absent", async () => { - const absent = makeSettings([ - { ns: "llm-pi-ai", revision: 7, value: { providers: { "opencode-go": { apiKeyEnv: "X" } } } }, - ]) + const absent = makeSettings([{ ns: "llm-pi-ai", revision: 7, value: { providers: { "opencode-go": { apiKeyEnv: "X" } } } }]) assert.equal(await ensureMuseRoute(absent.settings, undefined, { attempts: 1 }), true) assert.equal(absent.updates.length, 1) - assert.equal(absent.updates[0].ns, "llm-pi-ai") - assert.equal(absent.updates[0].revision, 7) assert.deepEqual(absent.updates[0].patch, { providers: { meta: { displayName: "Muse Code (Meta subscription)" } }, }) - const declared = makeSettings([ - { ns: "llm-pi-ai", revision: 1, value: { providers: { meta: { displayName: "kept" } } } }, - ]) + const declared = makeSettings([{ ns: "llm-pi-ai", revision: 1, value: { providers: { meta: { displayName: "kept" } } } }]) assert.equal(await ensureMuseRoute(declared.settings, undefined, { attempts: 1 }), false) assert.equal(declared.updates.length, 0) }) -test("route declaration retries while the llm-pi-ai entry activates", async () => { - const rows = [] - const updates = [] - let calls = 0 - const settings = { - describe() { - calls += 1 - return rows.slice() - }, - async update(ns, patch, revision) { - updates.push({ ns, patch, revision }) - }, - } - setTimeout(() => { - rows.push({ ns: "llm-pi-ai", revision: 2, value: {} }) - }, 5) - assert.equal(await ensureMuseRoute(settings, undefined, { attempts: 20, delayMs: 5 }), true) - assert.equal(updates.length, 1) - assert.ok(calls > 1) -}) - -test("route declaration gives up when the namespace never appears", async () => { - const { settings, updates } = makeSettings([]) - assert.equal(await ensureMuseRoute(settings, undefined, { attempts: 2, delayMs: 0 }), false) - assert.equal(updates.length, 0) -}) - test("seam notices and raw adapter notices both become instructions", () => { const neutral = readSignInNotice(NEUTRAL_NOTICE) assert.deepEqual(neutral, { @@ -199,58 +179,63 @@ test("seam notices and raw adapter notices both become instructions", () => { code: "ABCD-EFGH", message: NEUTRAL_NOTICE.message, }) - const raw = readSignInNotice({ type: "device_code", userCode: "WXYZ", verificationUri: "https://auth.meta.com/oauth/device/?theme=dark", }) assert.equal(raw.completeUrl, "https://auth.meta.com/oauth/device/?theme=dark&code=WXYZ") - assert.equal(raw.code, "WXYZ") - assert.equal(readSignInNotice({ message: "Signing in…" }), undefined) assert.equal(readSignInNotice({ url: "not a url" }), undefined) assert.equal(readSignInNotice({ url: "file:///etc/passwd" }), undefined) }) -test("instruction text carries the code, the page, and the browser hint", () => { +test("instruction text carries the code and the browser hint", () => { const instruction = readSignInNotice(NEUTRAL_NOTICE) const opened = formatSignInText(instruction, true) assert.match(opened, /ABCD-EFGH/) - assert.match(opened, /auth\.meta\.com/) assert.match(opened, /browser tab should have opened/) - const failed = formatSignInText(instruction, false) - assert.match(failed, /Open this page in your browser/) + assert.doesNotMatch(opened, /https:\/\/auth\.meta\.com/) + assert.match(formatSignInText(instruction, false), /Open the sign-in page/) }) -test("the command opens the browser and reports the code", async () => { +test("the command waits for Meta and reports the final state", async () => { const { ctx } = makeContext() const opened = [] ctx.authorization.begin = async (request) => { - assert.equal(request.method, "oauth") request.interaction.notify(NEUTRAL_NOTICE) + await new Promise((resolve) => setTimeout(resolve, 10)) return { status: "authorized" } } - const command = museLoginCommand(ctx, { + const result = await museLoginCommand(ctx, { openBrowser(url) { opened.push(url) return true }, - }) - const result = await command.handler({ rawInput: "" }) + }).handler({}) assert.equal(result.kind, "success") - assert.match(result.text, /ABCD-EFGH/) - assert.match(result.text, /auth\.meta\.com/) + assert.match(result.text, /signed in/i) assert.deepEqual(opened, [NEUTRAL_NOTICE.url + "?code=ABCD-EFGH"]) }) +test("the command returns the instructions when no browser opened", async () => { + const { ctx } = makeContext() + ctx.authorization.begin = async (request) => { + request.interaction.notify(NEUTRAL_NOTICE) + return new Promise((resolve) => setTimeout(() => resolve({ status: "cancelled" }), 60)) + } + const result = await museLoginCommand(ctx, { openBrowser: () => false, waitForResultMs: 0 }).handler({}) + assert.equal(result.kind, "success") + assert.match(result.text, /ABCD-EFGH/) +}) + test("the command reports a missing flow, a busy flow, and a failed attempt", async () => { const missing = makeContext({ flow: undefined }) const missingResult = await museLoginCommand(missing.ctx).handler({}) assert.equal(missingResult.kind, "error") assert.match(missingResult.text, /Muse Code is unavailable/) - const busy = makeContext({ flow: { ...FLOW, inFlight: true } }) + const busy = makeContext({ inFlight: true }) const busyResult = await museLoginCommand(busy.ctx).handler({}) assert.equal(busyResult.kind, "error") assert.match(busyResult.text, /already running/) @@ -278,10 +263,10 @@ test("the login route starts the sign-in and answers JSON with CORS", async () = const payload = JSON.parse(state.body) assert.equal(payload.ok, true) assert.equal(payload.code, "ABCD-EFGH") - assert.match(payload.text, /auth\.meta\.com/) + assert.equal(payload.opened, true) }) -test("the login route answers a plain navigation with the code and a Meta page", async () => { +test("the login route answers a plain navigation with a polling page", async () => { const { ctx } = makeContext() ctx.authorization.begin = async (request) => { request.interaction.notify(NEUTRAL_NOTICE) @@ -293,40 +278,137 @@ test("the login route answers a plain navigation with the code and a Meta page", assert.equal(state.status, 200) assert.match(state.headers["content-type"] ?? "", /text\/html/) assert.match(state.body, /ABCD-EFGH/) - assert.match(state.body, /http-equiv="refresh"/) - assert.match(state.body, /auth\.meta\.com/) + assert.match(state.body, new RegExp(STATUS_ROUTE)) }) -test("the login route refuses unsupported methods", async () => { - const { ctx } = makeContext() - const handler = createLoginRouteHandler(ctx) - const { response, state } = makeResponse() - await handler({ method: "PUT" }, response) - assert.equal(state.status, 405) - assert.equal(JSON.parse(state.body).ok, false) +test("the login route reports a busy flow and times out with a cancel", async () => { + const busy = makeContext({ inFlight: true }) + const busyResponse = makeResponse() + await createLoginRouteHandler(busy.ctx)({ method: "POST" }, busyResponse.response) + assert.equal(busyResponse.state.status, 409) + assert.equal(JSON.parse(busyResponse.state.body).reason, "busy") + + const slow = makeContext() + slow.ctx.authorization.begin = () => new Promise((resolve) => setTimeout(() => resolve({ status: "cancelled" }), 50)) + const slowResponse = makeResponse() + await createLoginRouteHandler(slow.ctx, { timeoutMs: 5 })({ method: "POST" }, slowResponse.response) + assert.equal(slowResponse.state.status, 504) + assert.deepEqual(slow.cancels, ["llm-pi-ai/meta"]) }) -test("the login route reports a busy flow", async () => { - const { ctx } = makeContext({ flow: { ...FLOW, inFlight: true } }) - const handler = createLoginRouteHandler(ctx) - const { response, state } = makeResponse() - await handler({ method: "POST" }, response) - assert.equal(state.status, 409) - assert.equal(JSON.parse(state.body).reason, "busy") +test("the status route reports authorized, pending, failed, and idle", async () => { + const authorized = makeContext({ credentials: makeCredentials(true) }) + const authorizedResponse = makeResponse() + await createStatusRouteHandler(authorized.ctx, { credentials: () => authorized.credentials })( + { method: "GET" }, + authorizedResponse.response, + ) + assert.equal(JSON.parse(authorizedResponse.state.body).state, "authorized") + + const state = createSignInState() + state.set({ state: "pending", text: "Confirm", code: "ABCD", at: Date.now() }) + const pending = makeContext({ inFlight: true }) + const pendingResponse = makeResponse() + await createStatusRouteHandler(pending.ctx, { state, credentials: () => pending.credentials })( + { method: "GET" }, + pendingResponse.response, + ) + const pendingBody = JSON.parse(pendingResponse.state.body) + assert.equal(pendingBody.state, "pending") + assert.equal(pendingBody.code, "ABCD") + + const failedState = createSignInState() + failedState.set({ state: "failed", text: "Denied", at: Date.now() }) + const failed = makeContext() + const failedResponse = makeResponse() + await createStatusRouteHandler(failed.ctx, { state: failedState, credentials: () => failed.credentials })( + { method: "GET" }, + failedResponse.response, + ) + assert.equal(JSON.parse(failedResponse.state.body).state, "failed") + + const idle = makeContext() + const idleResponse = makeResponse() + await createStatusRouteHandler(idle.ctx, { credentials: () => idle.credentials })( + { method: "GET" }, + idleResponse.response, + ) + assert.equal(JSON.parse(idleResponse.state.body).state, "idle") +}) + +test("model entries mirror the catalog for Spark and use family defaults otherwise", () => { + assert.equal(prettifyModelName("muse-wonder-full-1.0-contributor"), "Muse Wonder Full 1.0 Contributor") + const spark13 = museModelEntry("muse-spark-1.3") + assert.equal(spark13.name, "Muse Spark 1.3") + assert.equal(spark13.contextWindow, 1_048_576) + assert.equal(spark13.maxTokens, 131_072) + assert.equal(spark13.reasoningEfforts.max, "max") + assert.equal(museModelEntry("muse-spark-1.2").reasoningEfforts.max, undefined) + const wonder = museModelEntry("muse-wonder-1.0") + assert.equal(wonder.name, "Muse Wonder 1.0") + assert.equal(wonder.reasoningEfforts.max, undefined) + + assert.equal(isChatModelId("muse-wonder-1.0"), true) + assert.equal(isChatModelId("muse-image-1.0"), false) + assert.equal(isChatModelId("muse-voice-transcribe-1.0"), false) + assert.equal(isChatModelId("sam-segmentation"), false) +}) + +test("the model sync writes the live chat catalog into the declared route", async () => { + const { settings, updates } = makeContext() + const wrote = await syncMuseModels(settings, undefined, { + credentials: () => makeCredentials(true, { access: "secret-key" }), + fetchModelIds: async (access) => { + assert.equal(access, "secret-key") + return ["muse-spark-1.3", "muse-wonder-1.0", "muse-image-1.0"] + }, + }) + assert.equal(wrote, true) + assert.equal(updates.length, 1) + const profile = updates[0].patch.providers.meta + assert.deepEqual( + profile.models.map((model) => model.id), + ["muse-spark-1.3", "muse-wonder-1.0"], + ) + assert.equal(profile.displayName, "Muse Code (Meta subscription)") }) -test("the login route times out and cancels the attempt", async () => { - const { ctx, cancels } = makeContext() - // A slow flow that settles after the route's deadline: never leave an - // unresolved promise behind, the test runner treats one as a failure when the - // event loop drains. - ctx.authorization.begin = () => - new Promise((resolve) => { - setTimeout(() => resolve({ status: "cancelled" }), 50) +test("the model sync leaves an existing models list alone and skips without a key", async () => { + const withModels = makeContext({ + settingsRows: [ + { ns: "llm-pi-ai", revision: 1, value: { providers: { meta: { models: [{ id: "muse-spark-1.3" }] } } } }, + ], + }) + assert.equal( + await syncMuseModels(withModels.settings, undefined, { + credentials: () => makeCredentials(true, { access: "key" }), + fetchModelIds: async () => ["muse-wonder-1.0"], + }), + false, + ) + assert.equal(withModels.updates.length, 0) + + const unsigned = makeContext() + assert.equal( + await syncMuseModels(unsigned.settings, undefined, { + credentials: () => makeCredentials(false), + fetchModelIds: async () => ["muse-wonder-1.0"], + }), + false, + ) + assert.equal(unsigned.updates.length, 0) +}) + +test("fetchLiveModelIds parses the account catalog", async () => { + const original = globalThis.fetch + globalThis.fetch = async () => + new Response(JSON.stringify({ data: [{ id: "muse-spark-1.3" }, { id: "muse-wonder-1.0" }, { nope: 1 }] }), { + status: 200, + headers: { "content-type": "application/json" }, }) - const handler = createLoginRouteHandler(ctx, { timeoutMs: 5 }) - const { response, state } = makeResponse() - await handler({ method: "POST" }, response) - assert.equal(state.status, 504) - assert.deepEqual(cancels, ["llm-pi-ai/meta"]) + try { + assert.deepEqual(await fetchLiveModelIds("key"), ["muse-spark-1.3", "muse-wonder-1.0"]) + } finally { + globalThis.fetch = original + } })