diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/000_plan.md b/devlog/_plan/260903_muse_spark_plan_oauth/000_plan.md new file mode 100644 index 0000000000..8205510b6a --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/000_plan.md @@ -0,0 +1,134 @@ +# Meta Muse Spark: direct Model API provider + plan-credential question + +- Date: 2026-09-03 +- Session: `01a064b2-91b5-7272-b9ef-4db66bb46921` +- Work class: C3 — a new provider registry entry, a published-spec effort ladder, catalog metadata, and a plan-credential feasibility verdict. +- Status: A (wp0, docs-only roadmap cycle; three audit rounds folded). + +## Loop spec + +- Archetype: satisfy-spec integration, with one genuinely open question (wp2) that resolved to a recorded negative rather than code. +- Trigger: the user asked whether Meta's Muse Spark *plan* can be attached, after `878f75417` landed Muse Spark 1.3 through the Command Code and OpenCode Zen resellers. +- Goal: reach Muse Spark **directly** on Meta's own endpoint, and settle — with evidence, not inference — whether a Muse Code subscription can legitimately drive a local proxy. +- Non-goals: issuing a Meta API key, entering payment details, touching generated metadata, changing any `*-free` Zen id, retiring or redefaulting any model, altering the merged 1.3 work. +- Verifier: the canonical gate in `030` — focused `bun test` on the touched suites, `bun run test:changed`, `bun x tsc --noEmit`, `bun run privacy:scan`, and the `docs-site` frozen-lockfile install plus build. **The repository-wide local suite is forbidden by standing user instruction**; exact-head GitHub CI is the authoritative gate. +- Stop condition: every work-phase closed and the single implementation PR green on its exact head SHA and merged into `dev`. +- Memory artifact: this unit folder. +- Terminal outcomes: wp1 targets `DONE`; wp2 closed `NOOP` on a licence finding. `BLOCKED` remains available if CI or branch protection refuses for an unrelated reason. +- Escalation: each A gate dispatches one independent read-only reviewer on `gpt-5.6-sol` at high effort. Two failed correction loops on the same packet stops the phase and reports. + +## Revision after the A-gate audit (round 1: FAIL, 8 blockers) + +An independent `gpt-5.6-sol` reviewer failed the first draft, and a third-party user +report arrived in the same window. Between them, the plan changed shape: + +| Was | Now | Why | +|---|---|---| +| provider id `meta` | `meta-model` | `meta/muse-spark-1.3` is a LIVE Command Code selector; `router.ts:676` would have hijacked it, and `init.ts:72` would have derived `META_API_KEY` — the CLI's variable, not the API's | +| `liveModels: true` | `false` | no authenticated `/v1/models` payload was ever seen; Meta serves image and voice families on the same base URL | +| effort array only | plus identity `modelReasoningEffortMap` | `reasoning-effort.ts:171` rewrites `minimal` to `low`; the array assertion passed while the wire was wrong | +| "no OAuth exists" | a device-code-shaped login exists | `muse login` opens `auth.meta.com/oauth/device`; the docs simply do not mention it. Finding it did not make it usable — see below | +| 2-layer stack | 1 PR | the disclosure folds into wp1, and wp2 ships no code | +| wp3 as a work-phase | delivery ceremony inside each phase | delivery is not independently implementable | + +**The correction worth naming.** `001` §G concluded no third-party OAuth flow existed, +from a docs-site search returning *No matching results* and Authentication's flat "every +request needs an API key". Both readings were accurate; the inference was not. Installing +the CLI and running `muse login --help` disproved it in one command. Absence from a +vendor's docs is not absence from the product — and the reviewer catching the adjacent +SDK claim is what sent me to check. + +## The decision this plan turns on + +A reseller path already works. `command-code/meta-muse-spark-1.3` and `opencode-go/muse-spark-1.3-contributor` shipped in #3317, so nothing here is about *reaching* the model. What is missing is the direct route and, more importantly, an answer to the question the user actually asked. + +**The plan credential is scoped out by the vendor, in writing.** `dev.meta.ai/docs/muse-code/subscriptions` states it twice: + +> The subscription applies to the Muse Code API key that is automatically connected in the Muse Code CLI onboarding process. **This credential is for use with Muse Code only.** Any additional API keys you create under your Meta Model API account will be billed through pay-as-you-go. + +> Your subscription **only works through the Muse Code CLI** while signed in with your Meta Model API account. + +That is a licence boundary, not a technical one — and it survives the OAuth discovery +intact. The two questions are now cleanly separable: + +- **Mechanism:** could opencodex hold this credential? A device-code-shaped login + exists, so possibly. **Not measured, deliberately** — see below. +- **Entitlement:** may it be spent outside Muse Code? The vendor has answered no. + +Only the second question decides whether anything ships, and it is already answered. So +the mechanism was left unmeasured rather than tested: an experiment that can only +discover whether enforcement is absent cannot produce a result that licenses shipping. +wp2 closed `NOOP` on that basis (`020`). + +A third-party user report (Threads, 2026-09-03) claims pay-as-you-go bills through by +default under the plan, and that the endpoints are not separated. Both are **unverified** +and neither changes the outcome — the second is precisely the enforcement-absence +observation above. + +The user-visible consequence lands in wp1 regardless: the provider note says outright +that a Muse Code subscription does not apply and every call is metered. + +## The second decision: wire shape + +Meta publishes an OpenAI-compatible surface at `https://api.meta.ai/v1` carrying both `POST /v1/responses` and `POST /v1/chat/completions`, and the quickstart hands the OpenAI SDK that exact `base_url`. Responses is the documented recommendation for agentic work ("the recommended default for new work"), and it is the surface that carries `input_image` and reasoning replay. + +So the provider is `adapter: "openai-responses"`, not `openai-chat`. Registering it as a Chat provider would work but would forfeit the reasoning-replay and native-multimodal path the vendor recommends, and it would diverge from how `openai-apikey` is already registered against the same wire. + +## Work-phase map (dependency-ordered, PHASE-SPLIT-01) + +| Phase | Doc | Delivers | PR | +|---|---|---|---| +| wp0 | this folder + `001`, `002` | claim ledger, feasibility research, diff-level decade docs | — | +| wp1 | `010_wp1_direct_provider.md` | `meta-model` key provider, ladder + wire map, parity/pricing/docs updates, behavior tests | PR 1, base `dev` | +| wp2 | `020_wp2_device_oauth.md` | **CLOSED, `NOOP`** — recorded negative, no code | none | + +**One PR, no stack (`DEV-STACK-01`).** The first draft stacked wp2 on wp1 over a string +wp1 introduced; that string is folded into wp1, and wp2 now ships no code at all. Round +2 also caught that a "second independent PR" would still have consumed wp1's +module-private constants — so there was never a clean independence claim to make. +`030` is delivery procedure, not a work-phase. + +## Why wp2 closed instead of shipping + +A real `muse login` **does** open a browser device-approval flow — the docs simply never +mention it, and the first draft of `001` §G wrongly concluded no such flow existed. + +Finding it did not make it usable. The round-2 audit put it plainly: proving a credential +is technically reusable is not the same as being allowed to reuse it. The experiment I +had planned — extract the credential, fire it at `api.meta.ai`, ship if it returns 200 — +tested whether **enforcement was absent**, not whether **use was permitted**. Meta +answered the second question in writing before anyone asked: "This credential is for use +with Muse Code only." + +So no credential was extracted, no login was completed, and a targeted check confirms +none exists on this machine. The finding ships as `020` plus the user-facing disclosure +in wp1's note. + +## Scope + +### IN + +- `src/providers/registry.ts` — one new entry plus its effort/window/modality constants and wire map +- `tests/provider-registry-parity.test.ts` — the hardcoded key-provider roster +- `src/usage/expected-prices.ts` + `tests/usage-cost.test.ts` — two price rows and the pinned count (64 → 66) +- `docs-site/` English provider table (`src/AGENTS.md:29` requires it) +- `tests/` — a focused suite beside the existing provider tests +- `src/oauth/` — **nothing.** wp2 closed as a negative; no OAuth code ships. +- `devlog/_plan/260903_muse_spark_plan_oauth/` + +### OUT + +- `muse serve` / the MSP SDK — a stdio JSON-RPC **agent session** host, not a model endpoint. Bridging it would mean re-hosting an agent runtime inside a proxy and discarding the part that makes it an agent (`002`). +- Translated `docs-site` locales — English source only. +- `src/generated/model-metadata.ts`, `scripts/model-metadata.source.json` — generated from a vendor snapshot; hand-editing them is forbidden by the repo's own convention. +- (`src/usage/expected-prices.ts` moved to IN. `src/usage/cost.ts:267` resolves a generated-metadata alias first, and `meta-model` has none, so an unpriced row falls through and reports no cost at all. Two overlays are required, not optional.) +- Muse Voice Transcribe (`wss://api.meta.ai/v1/asr/realtime`, `POST /v1/asr/transcribe`) — a different transport, out of scope. + +## Accept criteria (goalplan c-1 through c-6) + +1. `c1` — this unit carries 000-range research plus a diff-level decade doc per implementation phase. +2. `c2` — every registry fact traces to a published vendor statement in `001`. +3. `c3` — no API key is issued and no billing detail is entered. +4. `c4` — the plan-credential question is answered by working wiring or a recorded negative with the blocking evidence. **Met by `020`'s negative.** +5. `c5` — `tsc` exits 0, focused tests pass, the full local suite is never run. +6. `c6` — the implementation PR green at its exact head SHA and merged into `dev`. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/001_vendor_claim_ledger.md b/devlog/_plan/260903_muse_spark_plan_oauth/001_vendor_claim_ledger.md new file mode 100644 index 0000000000..5f36e67e39 --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/001_vendor_claim_ledger.md @@ -0,0 +1,141 @@ +# Vendor claim ledger — Meta Model API and Muse Code + +Every row is a statement Meta publishes, retrieved 2026-09-03 through a signed-in +browser (Aside CLI `1.26.902.1732`, account u0) because `dev.meta.ai/docs` returns +HTTP 500 to a plain fetch and its `.md` exports 500 as well. Rendered DOM was the +only readable surface. + +Nothing here is inferred. A fact the vendor does not state is written `NOT STATED` +and does not reach the registry. + +## A. Transport + +| Claim | Value | Source | +|---|---|---| +| Base URL | `https://api.meta.ai/v1` | `/docs/quickstart`, `/docs/coding-agents` | +| Responses endpoint | `POST /v1/responses` | `/docs/protocols` | +| Chat endpoint | `POST /v1/chat/completions` | `/docs/protocols` | +| OpenAI compatibility | "It is OpenAI-compatible and exposes the full feature set" (Responses) | `/docs/protocols` | +| SDK base_url, verbatim | `base_url="https://api.meta.ai/v1"` / `baseURL: 'https://api.meta.ai/v1'` | `/docs/quickstart` | +| Auth header | `Authorization: Bearer $MODEL_API_KEY` | `/docs/api-reference`, `/docs/authentication` | +| Env var | `MODEL_API_KEY` (the CLI's own var is the different `META_API_KEY`) | `/docs/authentication`, `/docs/muse-code/auth` | +| Recommended surface | Responses is "the recommended default for new work" | `/docs/protocols` | + +**Independent liveness check, no key issued.** `GET https://api.meta.ai/v1/models` +returned `401 {"error":{"code":"invalid_api_key","message":"Unauthorized",...}}`. +That is worth more than a docs quote: it proves the host exists, terminates TLS, +routes `/v1`, and answers in OpenAI error shape — while confirming we hold no +credential. This is the whole of our contact with the endpoint. + +## B. Model facts + +| Claim | `muse-spark-1.3` | `muse-spark-1.3-contributor` | Source | +|---|---|---|---| +| Model id, verbatim | `muse-spark-1.3` | `muse-spark-1.3-contributor` | `/docs/models` | +| Context window | 1,048,576 | 1,048,576 | `/docs/models` | +| Max output tokens | NOT STATED | NOT STATED | see below | +| Input modalities | text, image, video, audio\*, PDF | same | `/docs/models` | +| Output | text only | text only | `/docs/models` | +| Input price /1M | $1.25 | $0.10 | `/docs/pricing-rate-limits` | +| Cached input /1M | $0.15 | $0.002 | `/docs/pricing-rate-limits` | +| Output price /1M | $4.25 | $0.20 | `/docs/pricing-rate-limits` | + +\* Audio on 1.3 is documented as "not fully supported" with degraded quality. + +**Max output tokens is genuinely unpublished, and the number that looks like an +answer is a trap.** `131072` appears in the docs only inside a third-party +`opencode.json` sample; a docs search for the literal returns *No matching results*. +The protocol pages say `max_completion_tokens` is "Model-dependent" and that +exceeding the model's configured maximum returns HTTP 400. So the registry declares +no `defaultMaxOutputTokens` for these models rather than promoting a sample value +into a capability claim. + +**Price cross-check.** These are the same numbers the Command Code models payload +carries for `meta/muse-spark-1.3` (1.25 / 4.25) and `meta/muse-spark-1.3-contributor` +(0.1 / 0.2), read independently on 2026-09-03. The reseller republishes Meta's list +price, which corroborates both readings. + +## C. Reasoning effort + +> Accepted values: "none", "minimal", "low", "medium", "high", "xhigh". When omitted, +> the model reasons by default. "none" (disable reasoning) is not supported by Muse +> Spark and returns HTTP 400. — `/docs/reasoning` + +Two consequences for the registry, and the second is the one that bites: + +- The usable ladder is `minimal, low, medium, high, xhigh`. `none` is published as an + API-wide value and separately excluded for this model family, so advertising it + would hand the user a picker entry that 400s. +- `max` and `ultra` are **not** in the vendor's set. Several opencodex ladders end in + `max` and it would be easy to append one by family resemblance; here that would + invent a wire value. + +Independent corroboration from the sibling gateway: an unauthenticated Zen probe of +`muse-spark-1.3-contributor-free` on 2026-09-03 accepted `minimal|low|medium|high|xhigh` +and rejected `max` and `ultra` with `unknown variant`, and rejected `none` with +"does not support none with this model". Two independent surfaces, same ladder. + +## D. Image input + +| Surface | Content-part type | Source | +|---|---|---| +| Responses | `input_image`, `image_url` a plain string | `/docs/image-understanding` | +| Chat Completions | `image_url` wrapping `{ url }` | `/docs/image-understanding` | + +Up to 50 images per request; more returns HTTP 400. Images only in user-role messages. + +## E. Muse Code subscription — the licence boundary + +| Tier | Price | Source | +|---|---|---| +| Everyday Usage | $5.00/mo | `/ai/products/muse-code/`, `/help/subscriptions/what-is-a-muse-code-subscription` | +| High Usage | $15.00/mo | same | +| Power Usage | $50.00/mo | same | + +> The subscription applies to the Muse Code API key that is automatically connected in +> the Muse Code CLI onboarding process. **This credential is for use with Muse Code +> only.** Any additional API keys you create under your Meta Model API account will be +> billed through pay-as-you-go. — `/docs/muse-code/subscriptions` + +> Your subscription **only works through the Muse Code CLI** while signed in with your +> Meta Model API account. — same page + +## F. CLI + +- Install: `curl -fsSL https://dev.meta.ai/install.sh | sh` — `/docs/muse-code/` +- The installer fetches a launcher from `https://api.meta.ai/muse-launcher.sh` + (`MUSE_LAUNCHER_URL`), installs to `${MUSE_INSTALL_DIR:-~/.local/bin}/muse`, and + verifies a sha256. Read directly from the retrieved script, HTTP 200, 9314 bytes. +- Auth precedence: `META_API_KEY` env, then a stored key, then a stored browser + session. "An API key always takes priority over a browser sign-in." — `/docs/muse-code/auth` +- The **docs** describe no dedicated login command — first run prompts, `/login` + re-opens, `muse auth set` stores a key, `muse logout` signs out. The installed CLI + does ship `muse login`, which the docs omit; that gap and what it does (and does not) + prove are recorded in `002`. + +## G. Third-party OAuth + +**NOT STATED — and searched for, not merely unseen.** The docs site search returns +*No matching results for "OAuth"*. Authentication states "Every request to Meta Model +API needs an API key". No device-code, PKCE, or authorization-code flow appears under +Authentication, API reference, SDKs, coding agents, or agent frameworks. The only +browser sign-in documented belongs to the Muse Code CLI and its wire protocol is not +published. + +## H. Account and payment + +Signup is email + confirmation with no card at account creation, but adding a payment +method is a listed prerequisite "to start making requests", alongside creating an API +key (`/help/accounts-and-login/sign-up`, `/docs/muse-code/auth`). Eligibility: 18+, +supported country, team-owner signup. + +**No account was created, no key issued, no payment method entered.** + +## Provenance caveat + +`/docs/pricing-rate-limits` carries an unremoved internal editorial note asking someone +to "confirm these rate-limit numbers against the launch configuration before +publishing". That caveat attaches to the **rate-limit** figures (Standard 3,000 RPM / +4M TPM; Contributor 100 RPM / 3M TPM), which is exactly why no RPM/TPM value is wired +into the registry. The per-token prices are corroborated by the Command Code payload +and are not affected. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/002_plan_credential_feasibility.md b/devlog/_plan/260903_muse_spark_plan_oauth/002_plan_credential_feasibility.md new file mode 100644 index 0000000000..44211b44df --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/002_plan_credential_feasibility.md @@ -0,0 +1,122 @@ +# Plan-credential feasibility — research + +Research doc. No diffs here (LEXICO-SPLIT-01); the implementation shape lives in the +decade docs. + +> **Outcome: this research closed wp2 as a `NOOP` negative. See `020`.** No credential +> was extracted, no login completed, and no Meta credential exists on this machine. + +## The docs were not the whole truth + +`001` §G recorded that no third-party OAuth flow is published, sourced from a docs-site +search returning *No matching results for "OAuth"* and from Authentication's flat +"Every request to Meta Model API needs an API key". Both readings were accurate. + +**The conclusion drawn from them was wrong.** A browser-approval login does exist; Meta +just does not document it. Measured on 2026-09-03 by installing the CLI and running it: + +``` +$ muse login +Open this page to sign in: + https://auth.meta.com/oauth/device/?code= +confirm this code matches: + + +Waiting for approval… +``` + +That is **device-code-shaped**: a user code approved in a browser against +`auth.meta.com/oauth/device`. It is deliberately not called RFC 8628 here. A user-code +URL does not establish the token endpoint, scopes, rotation, expiry semantics, or — the +part that actually matters — that any client other than Muse Code may hold the result. +`muse login --help` says only: "Log in with your Meta account: approve a code in your +browser. META_API_KEY always takes priority over the account login." + +The lesson worth keeping: **absence from a vendor's docs is not absence from the +product.** A docs search proved what Meta publishes, and I let it stand for what Meta +implements. One `--help` disproved it. The opposite error was available too, and the +A-gate caught it: finding an undocumented flow is not the same as being allowed to use +it. + +## What the CLI actually is + +`muse --version` → `Muse Code 1.0.2 (1.0.2-R2040.1)`, installed to `~/.local/bin/muse` +by `https://dev.meta.ai/install.sh` (which fetches a launcher from +`https://api.meta.ai/muse-launcher.sh` and verifies a sha256). + +Subcommands relevant here: `login`, `logout`, `auth set --api-key-stdin`, `serve`, +`exec`, `schema`. + +Its own reasoning ladder, from `muse --help`: + +> `--reasoning-effort ` Meta reasoning effort: none|minimal|low|medium|high|xhigh|ultra (default: high) + +Note `ultra`, which the public `/docs/reasoning` page does not list. Another instance of +the same gap. The registry ladder in `010` stays with the twice-corroborated +`minimal..xhigh` set, because `ultra` here is a CLI flag rather than a proven Model API +wire value, and Zen's probe rejected it. + +## `muse serve` is not an OpenAI-compatible endpoint + +The A-gate reviewer raised the published SDK +([meta-models/muse-code-sdk](https://github.com/meta-models/muse-code-sdk), HTTP 200) as +a route the categorical negative overlooked. It is a real route, and it is not the route +we want. + +`muse serve --help`: "serve an MSP session host over **stdio**. The client owns this +process's stdin and stdout and is its only connection." + +MSP is a JSON-RPC **agent session** protocol — `session/start`, `turn/start`, +`approval/decide`, `item/delta`, `subagent/*`, `view/page`. It owns the tool loop, +approvals, sandbox posture, and session durability. opencodex is a **model proxy**: it +forwards Responses/Chat requests and returns completions. Bridging MSP to +`/v1/responses` would mean re-hosting an entire agent runtime inside the proxy and +then discarding the half that makes it an agent. + +So the SDK is correctly out of scope — but for an architectural reason, not the licence +reason `020` originally gave. The reviewer was right that the stated ground was wrong. + +## The three routes, ranked + +| Route | Mechanism | Status | +|---|---|---| +| Direct API key | `MODEL_API_KEY` on `https://api.meta.ai/v1` | Implementable now, spec-only. **wp1.** | +| Device-code-shaped login | `auth.meta.com/oauth/device`, as `muse login` uses | Exists but undocumented; **wp2 closed `NOOP`** — the credential is licensed to Muse Code only. | +| MSP host bridge | `muse serve` over stdio | Out of scope: wrong protocol class. | + +## Why the investigation stopped here + +No login was ever approved. Both attempts were terminated with the grant pending, and a +targeted check for a Muse credential on this machine found none. + +The original next step was to complete a login and measure where the credential lands, +what it is, and whether it authenticates `https://api.meta.ai/v1`. That plan was +abandoned on review, and the reason is worth stating plainly: **it was a test for +whether enforcement is absent, not for whether use is permitted.** Meta answered the +second question in writing before anyone asked (`001` §E). Discovering that a +restriction is unenforced does not lift it, so completing the measurement could not have +produced a result that licensed shipping. + +A third-party report (Threads, 2026-09-03) claims the stored key is plaintext in the +macOS Keychain and that the endpoints are not separated. Both remain **unverified**, and +neither changes the outcome: the second, if true, is precisely the enforcement-absence +observation above. + +## The licence question is the whole answer + +`/docs/muse-code/subscriptions` says the subscription credential is "for use with Muse +Code only". Whether the artifact `muse login` stores **is** that credential was never +measured — no login completed — so the link is inferred from Meta's own description of +the CLI onboarding, not proven here. It does not need to be proven: `muse login` is the +Muse Code CLI's own sign-in, so any credential it yields is at best that credential and +at worst something with even less claim to third-party use. Either way the restriction +binds. + +Mechanism and entitlement are separable questions, and only entitlement decides whether +anything ships. The vendor has answered it. + +`src/oauth/index.ts` already carries the adjacent precedent on Anthropic — +`defaultRefreshPolicy: "disabled"`, with a comment recording that the vendor +server-side-blocks subscription OAuth outside its own clients. That posture mitigates a +risk on a flow that already exists; it does not authorize creating a new one against a +published prohibition. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/010_wp1_direct_provider.md b/devlog/_plan/260903_muse_spark_plan_oauth/010_wp1_direct_provider.md new file mode 100644 index 0000000000..23d9dc6aad --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/010_wp1_direct_provider.md @@ -0,0 +1,272 @@ +# wp1 — direct Meta Model API provider + +Single PR. Base: `dev`. Branch: `codex/meta-model-api-provider`. + +Every value below is a `001` ledger row. Nothing is added by resemblance to a +neighbouring provider. + +**Revised after the A-gate audit (round 1, FAIL, 8 blockers).** Six changes below carry +an audit provenance note. The two that mattered most were invisible from the docs and +only showed up in the repository: the provider id would have hijacked an existing model +namespace, and the advertised `minimal` effort would have been silently rewritten to +`low` on the wire. + +## MODIFY `src/providers/registry.ts` + +### 1. Constants, beside the other provider ladders (near `OPENAI_API_GPT56_REASONING_EFFORTS`, line ~437) + +```ts +/* + * Meta Model API (https://api.meta.ai/v1). Published ladder, NOT the usual house set: + * /docs/reasoning lists "none", "minimal", "low", "medium", "high", "xhigh" and then + * excludes "none" for Muse Spark specifically ("not supported by Muse Spark and + * returns HTTP 400"). "max" and "ultra" are absent from the vendor's list entirely, + * so appending one by family resemblance would invent a wire value. + * + * Corroborated against a second surface: an unauthenticated OpenCode Zen probe of + * muse-spark-1.3-contributor-free on 2026-09-03 accepted minimal..xhigh and rejected + * max/ultra with \`unknown variant\`, and rejected none with "does not support none + * with this model". + */ +const META_MUSE_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"]; + +/* + * Identity wire map (audit blocker 3). `requestToCodexEffort` in + * src/reasoning-effort.ts:171 rewrites `minimal` to `low` unless a model-scoped wire + * map says otherwise. Without this the picker would advertise an effort the wire never + * sends, and a registry-array assertion would happily pass while the request body was + * wrong. The map is identity because Meta's values ARE the Codex names. + */ +const META_MUSE_REASONING_EFFORT_MAP: Record = Object.fromEntries( + META_MUSE_REASONING_EFFORTS.map(effort => [effort, effort]), +); + +/** Muse Spark 1.3 and its Contributor tier both publish a 1,048,576-token window (/docs/models). */ +const META_MUSE_CONTEXT_WINDOW = 1_048_576; + +const META_MUSE_MODELS = ["muse-spark-1.3", "muse-spark-1.3-contributor"]; +``` + +### 2. The provider entry, after the `openai-apikey` entry (line ~1450) + +**Id is `meta-model`, not `meta` (audit blocker 1).** Two independent collisions, both +verified in the tree: + +- `src/router.ts:676` resolves a `/` prefix against configured + providers first. Registering `meta` would make the existing Command Code native + selector `meta/muse-spark-1.3` — already live on `dev` since #3317 — silently change + destination the moment a user configured the direct provider. A working model + reference would start billing somewhere else, with no error. +- `src/cli/init.ts:72` derives the env var as `${ID.toUpperCase()}_API_KEY`, so id + `meta` yields `META_API_KEY` — which is the **Muse Code CLI's** variable, not the + Model API's `MODEL_API_KEY`. Two different credentials under one name. + +`meta-model` derives `META_MODEL_API_KEY` and collides with neither. + +```ts + { + id: "meta-model", + label: "Meta Model API", + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + authKind: "key", + featured: false, + dashboardUrl: "https://dev.meta.ai/docs/authentication", + defaultModel: "muse-spark-1.3", + models: META_MUSE_MODELS, + /* + * Static roster (audit blocker 4). Meta serves several families on this base URL — + * Muse Image, Muse Voice Transcribe (wss://.../asr/realtime) — and we hold no key, + * so no authenticated /v1/models payload was ever observed. `liveModels: true` + * would publish that unseen roster into the picker, including models this + * Responses-agent provider cannot drive. Seed the two ids the vendor documents; + * revisit with a real payload fixture. + */ + liveModels: false, + /* + * Audit blocker 2. A user may already own a custom provider named `meta-model` + * pointing somewhere else; without this, registry.ts:2995 canonicalizes its + * adapter and base URL and their saved key gets sent to Meta. registry.ts:147 + * names this the required protection for a newly promoted id. + */ + preserveCustomDestination: true, + /* + * Responses, not Chat. Meta publishes both POST /v1/responses and + * POST /v1/chat/completions at the same base URL and calls Responses "the + * recommended default for new work ... OpenAI-compatible and exposes the full + * feature set", including reasoning replay across tool turns and native + * input_image parts. Registering this as openai-chat would reach the model and + * silently forfeit both. + */ + modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), + modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"]])), + modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), + modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), + /* + * The disclosure is folded in here rather than shipped as a second stacked PR + * (audit blocker 7): it is one string on this same entry, so a separate layer buys + * a second CI and review cycle and no reviewability. + */ + note: "Pay-as-you-go Meta Model API. Get a key at https://dev.meta.ai — a Meta developer account needs a payment method before it can serve requests, and every call is metered per token. A Muse Code subscription does NOT apply here: Meta scopes that credential to the Muse Code CLI and bills any other key pay-as-you-go (dev.meta.ai/docs/muse-code/subscriptions). The Contributor tier (muse-spark-1.3-contributor) is ~92% cheaper because Meta trains on your prompts; do not send confidential material through it. Muse Spark is also reachable through the command-code and opencode-go providers.", + }, +``` + +Three deliberate omissions, each one a fact the vendor does not publish: + +- **No `defaultMaxOutputTokens`.** `001` §B: the only number available (`131072`) + lives inside a third-party config sample and the docs call the real limit + "model-dependent". Declaring it would be a capability claim we cannot source. +- **No video/audio/PDF in `modelInputModalities`.** The catalog enum is + `text`/`image` — `tests/catalog-input-modality-enum.test.ts` exists precisely + because a provider once advertised `video` and poisoned the exported config. + Audio is documented as degraded on 1.3 anyway. +- **No rate-limit metadata.** The pricing page carries an unremoved internal note + asking someone to confirm those numbers pre-launch (`001` provenance caveat). +- **No `oauthId`.** A device-code-shaped login does exist (`002`), but the credential it + yields is licensed to the Muse Code CLI alone, so wp2 closed `NOOP` and no OAuth is + wired (`020`). This entry is key-auth only. + +## MODIFY `tests/provider-registry-parity.test.ts` + +`EXPECTED_KEY_PROVIDER_IDS` at line 33 is a hardcoded roster and the assertion compares +**order**, not set membership. Insert `"meta-model"` immediately after +`"openai-apikey"`, matching where the entry sits in the registry — appending it to the +end fails (audit round 3, blocker 1). + +## MODIFY `src/usage/expected-prices.ts` and `tests/usage-cost.test.ts` + +Decided, not deferred (audit round 2, blocker 4). `src/usage/cost.ts:267` resolves a +generated-metadata alias first and `meta-model` has none, so an unpriced row falls all +the way through and reports nothing. Two overlays, values from `001` §B and +corroborated by the Command Code payload: + +Complete `ExpectedPriceOverlay` objects — `source`, `verifiedAt`, and `status` are +required, and the earlier draft's trailing `...` would not compile (audit round 3, +blocker 2): + +```ts +const META_MODEL_PRICING = "https://dev.meta.ai/docs/pricing-rate-limits"; + + { provider: "meta-model", modelId: "muse-spark-1.3", + cost4: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, + source: `Meta Model API published price ${META_MODEL_PRICING}; cached input billed at 0.12x base input`, + verifiedAt: "2026-09-03", status: "verified" }, + { provider: "meta-model", modelId: "muse-spark-1.3-contributor", + cost4: { input: 0.10, output: 0.20, cacheRead: 0.002, cacheWrite: 0 }, + source: `Meta Model API published Contributor-tier price ${META_MODEL_PRICING}; data-sharing discount tier`, + verifiedAt: "2026-09-03", status: "verified" }, +``` + +`status: "verified"` rather than `"verified-derived"`: these are Meta's own list prices +for Meta's own endpoint, read from the vendor page and independently corroborated by the +Command Code payload — no cross-surface inference is involved. + +`cacheWrite` is `0` because Meta publishes no cache-write charge, the same shape +`GEMINI_31_PRO` already uses. + +`tests/usage-cost.test.ts:300` pins the overlay count at 64 — update to 66 in the same +commit and add exact-lookup assertions for both ids. + +## NEW `tests/meta-model-api-provider.test.ts` + +Seven tests, each pinning a ledger row that a future edit could silently break: + +```ts +import { describe, expect, test } from "bun:test"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { providerConfigSeed } from "../src/providers/derive"; + +describe("Meta Model API provider (meta-model)", () => { + test("routes to the published OpenAI-compatible Responses base URL", () => { + const entry = getProviderRegistryEntry("meta-model"); + expect(entry?.baseUrl).toBe("https://api.meta.ai/v1"); + expect(entry?.adapter).toBe("openai-responses"); + expect(entry?.authKind).toBe("key"); + }); + + test("advertises exactly the vendor's effort ladder", () => { + const entry = getProviderRegistryEntry("meta-model"); + for (const id of ["muse-spark-1.3", "muse-spark-1.3-contributor"]) { + expect(entry?.modelReasoningEfforts?.[id]).toEqual(["minimal", "low", "medium", "high", "xhigh"]); + } + }); + + test("never advertises an effort the vendor rejects", () => { + const entry = getProviderRegistryEntry("meta-model"); + const efforts = entry?.modelReasoningEfforts?.["muse-spark-1.3"] ?? []; + // none -> HTTP 400 on Muse Spark; max/ultra are not in the published set at all. + for (const forbidden of ["none", "max", "ultra"]) expect(efforts).not.toContain(forbidden); + }); + + test("declares the published 1M window for both tiers", () => { + const entry = getProviderRegistryEntry("meta-model"); + for (const id of ["muse-spark-1.3", "muse-spark-1.3-contributor"]) { + expect(entry?.modelContextWindows?.[id]).toBe(1_048_576); + } + }); + + test("advertises no modality outside the catalog enum", () => { + const entry = getProviderRegistryEntry("meta-model"); + for (const id of ["muse-spark-1.3", "muse-spark-1.3-contributor"]) { + expect(entry?.modelInputModalities?.[id]).toEqual(["text", "image"]); + } + }); + + test("claims no max-output limit, because the vendor publishes none", () => { + const entry = getProviderRegistryEntry("meta-model"); + expect(entry?.defaultMaxOutputTokens).toBeUndefined(); + }); + + test("the seed survives derive() intact", () => { + const entry = getProviderRegistryEntry("meta-model")!; + const seed = providerConfigSeed(entry); + expect(seed.baseUrl).toBe("https://api.meta.ai/v1"); + expect(seed.modelContextWindows?.["muse-spark-1.3"]).toBe(1_048_576); + }); +}); +``` + +## Behavior-level tests the audit demanded (blockers 1, 3, 6) + +Registry-shape assertions alone would have passed against all three defects. Add: + +**Every registry lookup in this suite uses `getProviderRegistryEntry("meta-model")`.** +The id changed after the first draft; a stale `"meta"` returns `undefined` and the +non-null seed lookup throws (audit round 2, blocker 3). Required cases: + +| Case | Asserts | Why a registry-shape check is not enough | +|---|---|---| +| namespace | `routeModel(cfg, "meta/muse-spark-1.3").providerName === "command-code"` with BOTH providers configured | the live Command Code selector must survive; note the field is `providerName`, not `provider` (`src/router.ts:61`) | +| wire effort | built Responses body has `reasoning.effort === "minimal"` | the registry array looked right while `reasoning-effort.ts:171` rewrote it | +| destination | a same-named custom provider keeps its base URL, adapter, and key | `preserveCustomDestination` | +| roster | `liveModels === false` | an unseen authenticated roster must not reach the picker | +| disclosure | note contains the subscription and training warnings | folding it into wp1 must not lose its regression (audit round 2, blocker 3) | +| transport | `baseUrl`, `adapter`, `authKind`, ladder, window, modalities, absent `defaultMaxOutputTokens` | ledger rows | + +B writes these against the real helpers — `routeModel` and the Responses adapter's +`buildRequest` — with real fixtures. + +## Documentation (audit blocker 6) + +`src/AGENTS.md:29` requires user-facing configuration changes to reach `docs-site/`. +A new provider is one. B adds the row to the English provider table only; translated +locales are left alone rather than machine-guessed. + +## Verification + +`bun test tests/meta-model-api-provider.test.ts tests/provider-registry-parity.test.ts tests/usage-cost.test.ts` +— all three unconditionally, since both overlays are now mandatory — then +`bun run test:changed` (`src/AGENTS.md:26` requires it once the touch set is broader +than one file; it is import-graph-scoped, not the forbidden repository-wide suite), then +`bun x tsc --noEmit` and `bun run privacy:scan` (this change ships credential guidance). + +Because the touch set includes `docs-site/`, `docs-site/AGENTS.md` additionally requires +the site build — "do not claim documentation validation passed unless this build +completes successfully": + +```bash +cd docs-site && bun install --frozen-lockfile && bun run build +``` + +Branch from the current `origin/dev` tip, not from a remembered SHA: `dev` moved during +the audit rounds. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/020_wp2_device_oauth.md b/devlog/_plan/260903_muse_spark_plan_oauth/020_wp2_device_oauth.md new file mode 100644 index 0000000000..311b50069d --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/020_wp2_device_oauth.md @@ -0,0 +1,91 @@ +# wp2 — Muse Code plan credential: CLOSED as a recorded negative + +**Outcome: `NOOP`. No code ships. No PR is opened.** + +This phase existed to answer whether a Muse Code subscription can drive opencodex. It +can be answered without building anything, and the answer is no. + +## What was measured + +A real `muse login` was run twice on this machine (Muse Code 1.0.2, installed from +`https://dev.meta.ai/install.sh`). Both reached: + +``` +Open this page to sign in: + https://auth.meta.com/oauth/device/?code= +Waiting for approval… +``` + +Neither was approved. Both processes were terminated, and a targeted check for a Muse +credential found none on this machine. + +That observation is **device-code-shaped**, and the round-2 audit was right to stop me +calling it RFC 8628. A URL carrying a user code proves a browser-approval login exists. +It does not establish the token endpoint, scopes, rotation, or expiry semantics, and it +certainly does not establish that another client may hold the result. `002` records the +observation with that narrower framing. + +## Why this closes rather than waits + +The first revision of this doc planned to measure whether the stored credential +authenticates `https://api.meta.ai/v1`, and to ship an OAuth provider if it did. The +round-2 reviewer named the flaw in one line, and it is correct: + +> Endpoint acceptance does not override the quoted restriction that the credential is +> "for use with Muse Code only." A warning records informed risk; it does not create +> vendor authorization. + +That test was designed to discover whether enforcement was **absent** — not whether use +was **permitted**. Those are different questions, and only the second one licenses +shipping. Meta has answered the second one already (`001` §E): + +> This credential is for use with Muse Code only. Any additional API keys you create +> under your Meta Model API account will be billed through pay-as-you-go. + +A user warning does not convert a prohibited use into an allowed one; it only documents +that we knew. The goal's own wording is "**legitimately** drive a local proxy", and an +unenforced restriction is still a restriction. + +So the credential is not extracted, not replayed, and not tested against the API. That +is a deliberate stop, not an incomplete measurement. + +## The third-party report + +A Threads user (2026-09-03) reported that pay-as-you-go bills by default under the plan, +that the Muse-scoped key sits in the macOS Keychain in plaintext, and that "the endpoint +is not separated" — i.e. the CLI credential works against the general API. + +Two of those are unverified, and the third does not change the outcome even if true. +"The endpoints are not separated" is exactly the enforcement-absence observation above. +If anything it makes the recorded negative more valuable: the only thing standing +between a user and an accidental ToS breach is knowing the boundary exists. + +The billing half **is** actionable, and it is why wp1's provider note states plainly +that a Muse Code subscription does not apply and every call is metered per token. + +## Reopen conditions + +Reopen only on a first-party change. **Not** on a discovery that enforcement is loose — +that distinction is the entire finding: + +1. Meta documents the device flow for third-party clients. +2. `/docs/muse-code/subscriptions` drops the "for use with Muse Code only" scoping. +3. Meta ships a documented plan-backed API tier, as Anthropic and Kimi did. +4. Meta explicitly authorizes third-party clients on a subscription credential. + +Recheck cost is one docs read. + +## If it is ever reopened + +The plan would need what this doc deliberately does not contain: exact token endpoint +and client id, request/response types, identity/expiry/refresh semantics, an error +taxonomy, cancellation behavior, the chosen `src/oauth/.ts` filename and registry +id, and — the seam the round-2 audit caught — a `gui/src/oauth-tos-risk.ts` entry with +its `tests/oauth-tos-warning.test.ts` coverage, since that is the login-time warning +gate a provider note bypasses. Writing those against an unproven protocol would be +fabrication, so they are not written. + +## What did ship from this phase + +The disclosure in wp1's provider note, which is the user-visible half of this finding +and the part that prevents a surprise bill. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/030_wp3_delivery.md b/devlog/_plan/260903_muse_spark_plan_oauth/030_wp3_delivery.md new file mode 100644 index 0000000000..3730569dd6 --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/030_wp3_delivery.md @@ -0,0 +1,60 @@ +# Delivery procedure + +Not a work-phase — delivery is not independently implementable, and modelling it as one +was audit blocker 8. This is the checklist each phase runs at its own C/D. + +## Shape: one PR, no stack + +``` +codex/meta-model-api-provider -> PR 1 (base: dev) wp1 +``` + +The first draft stacked two layers, then briefly claimed two independent PRs. Both were +wrong. wp2 closed as a `NOOP` negative (`020`) and ships no code, so there is one PR — +and with it, no cascade, no merge order, and no shared-constant coupling to reason +about. Branch from the current `origin/dev` tip; `dev` moved during the audit rounds. + +## Per-layer gate + +1. `git push --no-verify` (standing user instruction). +2. PR body fills every `.github/PULL_REQUEST_TEMPLATE.md` section. No GUI change, so + no screenshot is required. +3. Wait for the workflow runs on the **exact head SHA** — not the branch, the SHA. + `Cross-platform CI` plus `React Doctor`, and CodeRabbit's status. +4. Read CodeRabbit's findings. Fix anything materially wrong; record and rebut + anything that is not. A cosmetic nit does not block the merge. +5. Admin-merge (squash), pre-authorized by the user. +6. **No cascade.** One branch on `dev`. If `dev` moves under the open PR, rebase and + `git push --force-with-lease` — never a bare `--force`. + +## Verification budget + +The canonical gate for this unit, in order: + +```bash +bun test tests/meta-model-api-provider.test.ts tests/provider-registry-parity.test.ts tests/usage-cost.test.ts +bun run test:changed +bun x tsc --noEmit +bun run privacy:scan +cd docs-site && bun install --frozen-lockfile && bun run build +``` + +The last line applies because the touch set includes `docs-site/`; +`docs-site/AGENTS.md` treats that build as the documentation gate. `privacy:scan` runs +because the change ships credential guidance. + +`test:changed` is required rather than optional: `src/AGENTS.md:26` calls for it once a +touch set is broader than one file, and this one spans the registry, the price overlays, +and two test files. It follows Bun's import graph, so it is **not** the forbidden +repository-wide run. + +CI remains the full gate. If a focused run cannot cover an indirect dependency (a +subprocess, a golden file), name it in the PR's Verification section and let CI carry it +rather than reaching for the full suite. + +## Terminal outcomes + +- wp1 `DONE` when PR 1 is green at its head SHA and merged, and the registry serves + `meta-model/muse-spark-1.3` and `meta-model/muse-spark-1.3-contributor` without + capturing the existing `meta/…` Command Code selectors. +- wp2 `NOOP` — closed by a licence finding, with no code to deliver (`020`). diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 3a3276c602..eaea71d0bc 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -343,6 +343,7 @@ free-experimentation model. | Vultr Serverless Inference | `https://api.vultrinference.com/v1` | | Baseten Model APIs | `https://inference.baseten.co/v1` | | Command Code | `https://api.commandcode.ai/provider/v1` | +| Meta Model API | `https://api.meta.ai/v1` | | SambaNova Cloud | `https://api.sambanova.ai/v1` | | Nebius Token Factory | `https://api.tokenfactory.nebius.com/v1` | | DigitalOcean Serverless Inference | `https://inference.do-ai.run/v1` | @@ -437,6 +438,25 @@ preset (`commandcode`) uses the active configured Bearer key for chat requests; (`command-code`) uses the stored account bearer for authenticated discovery and chat. Create Provider-API keys at [Command Code Studio](https://commandcode.ai/studio/). +**Meta Model API (`meta-model`).** Muse Spark on Meta's own OpenAI-compatible endpoint, +served over `/v1/responses`. Create a key in +[the Meta developer console](https://dev.meta.ai/docs/authentication) — Meta calls this +variable `MODEL_API_KEY`, but opencodex derives the env var from the provider id, so +export it as **`META_MODEL_API_KEY`** (or paste it during `ocx init`). The account needs a +payment method before it will serve requests, and every call is metered per token. Two +models are seeded — `meta-model/muse-spark-1.3` and `meta-model/muse-spark-1.3-contributor` +— with the vendor's `minimal`/`low`/`medium`/`high`/`xhigh` ladder and a 1M context window. +Discovery stays off until an authenticated roster is verified, because Meta serves image and +voice models on the same host. + +Two things worth knowing before you pick it. **A Muse Code subscription does not apply +here:** Meta scopes that credential to the Muse Code CLI and bills any other key +pay-as-you-go. And the Contributor tier is cheap because Meta trains on your prompts — +roughly 92% off input, 95% off output, and 99% off cached input — so keep confidential +material off it. Muse Spark is also reachable through resellers, with a narrower roster: +`command-code` carries both tiers, while `opencode-go` serves only +`muse-spark-1.3-contributor`. + **Command Code quota.** The dashboard and `ocx account refresh` probe Command Code's `/alpha/billing/credits` windows (5-hour and weekly) on the canonical `https://api.commandcode.ai` host. The OAuth preset (`command-code`) uses the stored diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 01b4a0fa1c..20c1befd76 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -435,6 +435,32 @@ const OPENAI_API_GPT56_VIRTUAL_MODELS: Record = Object.fromEntries( + META_MUSE_REASONING_EFFORTS.map(effort => [effort, effort]), +); +/** Both Muse Spark 1.3 tiers publish a 1,048,576-token window (dev.meta.ai/docs/models). */ +const META_MUSE_CONTEXT_WINDOW = 1_048_576; +const META_MUSE_MODELS = ["muse-spark-1.3", "muse-spark-1.3-contributor"]; /** * Daybreak program aliases. These `-latest` ids are the stable contract: OpenAI repoints * them at newer snapshots over time (red -> gpt-5.6-cyber, blue -> gpt-5.6-sol as of @@ -1448,6 +1474,47 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, virtualModels: OPENAI_API_GPT56_VIRTUAL_MODELS, }, + /* [Decision Log] + - 목적과 의도: Reach Meta's Muse Spark models directly on Meta's own Model API, instead of only through the Command Code and OpenCode Zen resellers already in this registry. + - 기존 구현 및 제약 조건: Meta publishes both POST /v1/responses and POST /v1/chat/completions at https://api.meta.ai/v1, and no API key was issued for this change — every value here comes from the published spec (devlog/_plan/260903_muse_spark_plan_oauth/001). + - 검토한 주요 대안: register as openai-chat; use provider id "meta"; enable live discovery; wire the Muse Code subscription credential as OAuth. + - 선택한 방식: an openai-responses key provider under the id "meta-model", with a static two-model roster and no OAuth. + - 다른 대안 대신 이 방식을 선택한 이유: Meta calls Responses "the recommended default for new work ... OpenAI-compatible and exposes the full feature set", carrying reasoning replay and native input_image that Chat would forfeit. The id is "meta-model" because "meta" would capture the LIVE Command Code selector meta/muse-spark-1.3 at router.ts's provider-prefix branch, and would derive META_API_KEY — the Muse Code CLI's variable, not this API's MODEL_API_KEY. + - 장점, 단점 및 영향: users reach Muse Spark without a reseller; discovery stays off until an authenticated /v1/models payload is actually observed, so an unseen roster (Meta also serves image and voice families here) cannot leak into the picker. + */ + { + id: "meta-model", + label: "Meta Model API", + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + authKind: "key", + dashboardUrl: "https://dev.meta.ai/docs/authentication", + defaultModel: "muse-spark-1.3", + models: META_MUSE_MODELS, + // Static roster: no authenticated /v1/models payload was ever observed (the only + // contact was an unauthenticated GET returning 401 invalid_api_key), and Meta serves + // non-agent families on this same base URL. Turning discovery on would publish an + // unseen roster into the picker. + liveModels: false, + // A user may already own a custom provider named "meta-model" pointing elsewhere; + // without this, registry transport canonicalization would retarget it and send their + // saved key to Meta. + preserveCustomDestination: true, + modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), + // text+image only. Meta also documents video, audio (degraded on 1.3), and PDF, but + // the catalog modality enum is text/image and over-advertising poisons the exported + // client config (see tests/catalog-input-modality-enum.test.ts). + modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), + modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), + modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), + // No defaultMaxOutputTokens: Meta publishes none. The only number in its docs + // (131072) appears inside a third-party config sample, and the protocol pages call + // the real limit "model-dependent". + // Meta names its variable MODEL_API_KEY, but the env var opencodex reads is derived + // from the provider id (META_MODEL_API_KEY). Saying only Meta's name would send a + // user to export a variable this proxy never reads. + note: "Pay-as-you-go Meta Model API. Get a key at https://dev.meta.ai (Meta calls it MODEL_API_KEY; export it here as META_MODEL_API_KEY) — a Meta developer account needs a payment method before it can serve requests, and every call is metered per token. A Muse Code subscription does NOT work here: Meta scopes that credential to the Muse Code CLI and bills any other key pay-as-you-go (dev.meta.ai/docs/muse-code/subscriptions). The Contributor tier (muse-spark-1.3-contributor) is cheap because Meta trains on your prompts — about 92% off input, 95% off output, 99% off cached input; do not send confidential material through it. Muse Spark is also reachable through resellers: command-code carries both tiers, opencode-go serves only muse-spark-1.3-contributor.", + }, { id: "umans", label: "Umans AI Coding Plan", diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index fee0af8dd6..20ba598569 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -88,6 +88,7 @@ const GEMINI_37_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-0 const GEMINI_38_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-09-03); promotional rate through 2026-12-31, rises to 1.50/7.50 on 2027-01-01; cacheWrite=0: storage is billed per-hour, not per-token"; const MINIMAX_PRICING = "https://platform.minimax.io/docs/guides/pricing-paygo"; const OPENAI_GPT56_PRICING = "https://developers.openai.com/api/docs/pricing"; +const META_MODEL_PRICING = "https://dev.meta.ai/docs/pricing-rate-limits"; const DEEPSEEK_PRICING = "https://api-docs.deepseek.com/quick_start/pricing-details-usd; V4 Flash alias transition scheduled 2026-07-24 — re-verify after"; // Kimi official tables publish input/output/cache-hit only; cacheWrite is mapped to the // cache-miss input price (Kimi auto-caches with no separate write billing). 2026-07-20 re-verified. @@ -145,6 +146,14 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ { provider: "openai-apikey", modelId: "gpt-5.6-sol-pro", cost4: GPT56_SOL, source: `collapsed base ID ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-03", status: "verified-derived" }, { provider: "openai-apikey", modelId: "gpt-5.6-terra-pro", cost4: GPT56_TERRA, source: `collapsed base ID ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-03", status: "verified-derived" }, { provider: "openai-apikey", modelId: "gpt-5.6-luna-pro", cost4: GPT56_LUNA, source: `collapsed base ID ${OPENAI_GPT56_PRICING}`, verifiedAt: "2026-08-03", status: "verified-derived" }, + // Meta Model API direct provider. `meta-model` has no jawcode metadata alias, so an + // unpriced row falls through the whole resolution chain and the Logs cost column + // renders nothing — these exact overlays are the only source. Both are Meta's own + // published list prices for Meta's own endpoint (hence "verified", not derived), and + // they match the figures Command Code republishes for the same two models. + // cacheWrite=0: Meta publishes a cached-input price but no cache-write charge. + { provider: "meta-model", modelId: "muse-spark-1.3", cost4: { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }, source: `Meta Model API published price ${META_MODEL_PRICING}`, verifiedAt: "2026-09-03", status: "verified" }, + { provider: "meta-model", modelId: "muse-spark-1.3-contributor", cost4: { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }, source: `Meta Model API published Contributor-tier price ${META_MODEL_PRICING}; data-sharing discount tier`, verifiedAt: "2026-09-03", status: "verified" }, // Daybreak aliases: priced as their current snapshots (red -> gpt-5.6-cyber, // blue -> gpt-5.6-sol). The alias ids carry no rows of their own upstream, hence // verified-derived. Blue deliberately reuses GPT56_SOL rather than duplicating the tuple. diff --git a/tests/meta-model-api-provider.test.ts b/tests/meta-model-api-provider.test.ts new file mode 100644 index 0000000000..deb462874b --- /dev/null +++ b/tests/meta-model-api-provider.test.ts @@ -0,0 +1,154 @@ +/** + * Meta Model API direct provider (`meta-model`). + * + * Muse Spark reached opencodex through resellers first (Command Code, OpenCode Zen). + * This entry adds Meta's own endpoint, built entirely from published spec — no API key + * was issued, so every value here is a documented claim rather than a probe result. + * + * Three of these tests exist because a registry-shape assertion alone would have passed + * while the runtime was wrong: + * + * - the provider id would have captured a LIVE reseller model namespace at route time; + * - the advertised `minimal` effort would have been rewritten to `low` on the wire; + * - the note carrying the billing disclosure had no regression at all. + */ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { routeModel } from "../src/router"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +const META_MODELS = ["muse-spark-1.3", "muse-spark-1.3-contributor"] as const; + +function entry() { + const found = getProviderRegistryEntry("meta-model"); + if (!found) throw new Error("missing meta-model registry entry"); + return found; +} + +describe("Meta Model API provider (meta-model)", () => { + test("routes to the published OpenAI-compatible Responses base URL", () => { + expect(entry().baseUrl).toBe("https://api.meta.ai/v1"); + expect(entry().adapter).toBe("openai-responses"); + expect(entry().authKind).toBe("key"); + expect(entry().defaultModel).toBe("muse-spark-1.3"); + }); + + test("advertises exactly the vendor's effort ladder", () => { + for (const id of META_MODELS) { + expect(entry().modelReasoningEfforts?.[id]).toEqual(["minimal", "low", "medium", "high", "xhigh"]); + } + }); + + test("never advertises an effort the vendor rejects", () => { + // `none` returns HTTP 400 on Muse Spark; `max`/`ultra` are absent from the + // published set entirely, and an unauthenticated Zen probe rejected all three. + const efforts = entry().modelReasoningEfforts?.["muse-spark-1.3"] ?? []; + for (const forbidden of ["none", "max", "ultra"]) expect(efforts).not.toContain(forbidden); + }); + + test("declares the published 1M window and text+image only", () => { + for (const id of META_MODELS) { + expect(entry().modelContextWindows?.[id]).toBe(1_048_576); + // Meta also documents video, audio and PDF; the catalog modality enum is + // text/image and over-advertising poisons the exported client config. + expect(entry().modelInputModalities?.[id]).toEqual(["text", "image"]); + } + }); + + test("claims no max-output limit, because the vendor publishes none", () => { + expect(entry().defaultMaxOutputTokens).toBeUndefined(); + }); + + test("keeps live discovery off until an authenticated roster is observed", () => { + // The only contact with /v1/models was an unauthenticated 401. Meta serves image + // and voice families on this same base URL, so discovery would publish rows this + // Responses-agent provider cannot drive. + expect(entry().liveModels).toBeFalsy(); + expect(entry().models).toEqual([...META_MODELS]); + }); + + test("the seed survives derive() intact", () => { + const seed = providerConfigSeed(entry()); + expect(seed.baseUrl).toBe("https://api.meta.ai/v1"); + expect(seed.modelContextWindows?.["muse-spark-1.3"]).toBe(1_048_576); + expect(seed.modelReasoningEfforts?.["muse-spark-1.3"]).toEqual(["minimal", "low", "medium", "high", "xhigh"]); + }); + + /* + * The namespace-theft regression. `meta/muse-spark-1.3` is a live Command Code + * selector; the router resolves a `/` prefix against configured + * providers, so an id of `meta` would have silently redirected an already-working + * model reference to a different vendor and a different bill. + */ + test("meta/muse-spark-1.3 still reaches command-code with the direct provider configured", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "command-code", + providers: { + "command-code": { adapter: "command-code", baseUrl: "https://api.commandcode.ai", apiKey: "cc-test-key", authMode: "key" }, + "meta-model": { adapter: "openai-responses", baseUrl: "https://api.meta.ai/v1", apiKey: "meta-test-key", authMode: "key" }, + }, + }; + const route = routeModel(config, "meta/muse-spark-1.3"); + expect(route.providerName).toBe("command-code"); + expect(route.modelId).toBe("meta/muse-spark-1.3"); + + // The direct provider is still reachable under its own prefix. + expect(routeModel(config, "meta-model/muse-spark-1.3").providerName).toBe("meta-model"); + }); + + /* + * The wire-serialization regression. src/reasoning-effort.ts rewrites `minimal` to + * `low` unless a model-scoped wire map says otherwise — so this asserts the built + * request body, not the registry array that looked correct throughout. + */ + test("minimal reaches the wire as minimal, not low", () => { + const provider = { ...providerConfigSeed(entry()), apiKey: "meta-test-key" } as OcxProviderConfig; + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "muse-spark-1.3", + context: { messages: [] }, + stream: false, + options: { reasoning: "minimal" }, + _rawBody: { model: "muse-spark-1.3", input: "ping", reasoning: { effort: "minimal" } }, + }, { headers: new Headers() }); + const body = JSON.parse(request.body) as { reasoning?: { effort?: string } }; + expect(body.reasoning?.effort).toBe("minimal"); + }); + + /* + * A user may already own a custom provider under this id pointing elsewhere. + * preserveCustomDestination stops registry canonicalization from retargeting it and + * sending their saved key to Meta. + */ + test("a same-named custom provider keeps its own destination", () => { + expect(entry().preserveCustomDestination).toBe(true); + const custom: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://internal.example/v1", + apiKey: "someone-elses-key", + authMode: "key", + } as OcxProviderConfig; + enrichProviderFromRegistry("meta-model", custom); + expect(custom.baseUrl).toBe("https://internal.example/v1"); + expect(custom.apiKey).toBe("someone-elses-key"); + }); + + /* + * The note is load-bearing, not decoration. A user holding a Muse Code subscription + * will otherwise assume it applies here and get billed pay-as-you-go instead; Meta + * scopes that credential to its own CLI. Deleting this text removes the only + * in-product warning. + */ + test("the note discloses the subscription boundary and the Contributor training tradeoff", () => { + const note = entry().note ?? ""; + expect(note).toContain("Muse Code subscription does NOT work here"); + expect(note).toContain("metered per token"); + expect(note.toLowerCase()).toContain("trains on your prompts"); + }); +}); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 573af1b27b..b52a57583a 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -31,7 +31,7 @@ function nativeTemplate(): Record { } const EXPECTED_KEY_PROVIDER_IDS = [ - "anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", + "anthropic-apikey", "openai-apikey", "meta-model", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", "deepseek", "cerebras", "chutes", "deepinfra", "hyperbolic", "nscale", "vultr", "baseten", "commandcode", "sambanova", "nebius", "digitalocean", "scaleway", "featherless", "novita", "together", "fireworks", "firepass", "moonshot", "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "zhipu-bigmodel-coding", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index 14d839894a..d93ae813c4 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -297,8 +297,8 @@ describe("resolveMatchedPrice", () => { expect(resolveMatchedPrice("openrouter", "anthropic-claude-3.5-sonnet")).toBeNull(); }); - test("16. shipped overlay membership: 64 keys, including canonical Fable 5.1, Opus 5 and compatibility prices", () => { - expect(EXPECTED_PRICE_OVERLAYS.length).toBe(64); + test("16. shipped overlay membership: 66 keys, including canonical Fable 5.1, Opus 5 and compatibility prices", () => { + expect(EXPECTED_PRICE_OVERLAYS.length).toBe(66); expect(EXPECTED_PRICE_OVERLAYS.some(row => row.status === "unverified")).toBe(false); const keys = new Set(EXPECTED_PRICE_OVERLAYS.map(row => `${row.provider}/${row.modelId}`)); for (const expected of [ @@ -318,6 +318,10 @@ describe("resolveMatchedPrice", () => { "google-antigravity/gemini-3.8-flash", "google-antigravity/gemini-3.8-flash-low", "google-antigravity/gemini-3.8-flash-medium", + // meta-model has no jawcode alias, so these exact overlays are the only price + // source for the direct Meta provider. + "meta-model/muse-spark-1.3", + "meta-model/muse-spark-1.3-contributor", "google-antigravity/gemini-3.8-flash-high", "google/gemini-3.8-flash", "google-antigravity/gemini-3.1-pro-low",