diff --git a/.github/scripts/pr-carry-attribution.cjs b/.github/scripts/pr-carry-attribution.cjs new file mode 100644 index 0000000000..9e0ad1cabc --- /dev/null +++ b/.github/scripts/pr-carry-attribution.cjs @@ -0,0 +1,209 @@ +"use strict"; + +/** + * Attribution for work carried from another author's pull request. + * + * When a maintainer lands someone else's pull request by reimplementing, + * carrying, or rebasing it, the resulting commit is authored by the maintainer. + * The contributor survives only through a Co-authored-by trailer -- that trailer + * is what GitHub reads for the contributor graph, the repository's contributor + * list, and the author's own profile activity. + * + * This exists because the repository did it both ways for months. 53c09a247 + * says "Clean reimplementation of #3193" and names alan7629 in a trailer; + * 5734a1caf says "Reimplements #2797 by @rrmlima" and names nobody. Both + * sentences are equally sincere, and only the first is data. A scan of dev + * found 27 landings whose author is named in prose and nowhere a tool can read; + * CREDITS.md is the record of those, and this check is why the list should not + * grow. + * + * The check reads the pull request's own text, not its diff, because that is + * where a carry declares itself. + */ + +const CARRY_VERB_RE = + /\b(?:re-?implement(?:s|ed|ing|ation of)?|supersed(?:e|es|ed|ing)|carry(?: of)?|carries|carrying|carried(?: from)?|rebase(?: of)?|rebasing|adopts the design from)\b/gi; + +/** + * Every reference in one window, keeping any owner/repo qualifier. + * + * A bare "#2797" means this repository. "other/project#2797" does not, and + * resolving it here would look up an unrelated pull request of the same number + * in this one -- comparing the trailer against the wrong person. Qualified + * references are captured so they can be dropped rather than misread. + */ +const REF_RE = /(?:([\w.-]+\/[\w.-]+))?#(\d+)/g; + +/** + * The window a carry verb governs: to the end of its sentence, capped at 80 + * characters. Both bounds are load-bearing. + * + * The sentence bound is why "Supersedes #3193. Fixes #3192." reports only + * #3193 -- that is 53c09a247's real body, and a fixed-width window would have + * pulled the issue it closes into the carry set and demanded a trailer for the + * reporter. The width cap is why a verb cannot reach across a paragraph into an + * unrelated reference list. + */ +const SENTENCE_END_RE = /[.!?](?:\s|$)|\n/; + +function carryWindow(text, from) { + const slice = text.slice(from, from + 80); + const end = slice.search(SENTENCE_END_RE); + return end === -1 ? slice : slice.slice(0, end); +} + +const TRAILER_RE = /^[ \t]*co-authored-by:[ \t]*(.+)$/gim; + +const FENCED_CODE_RE = /^[ \t]*(\u0060{3,}|~{3,})[\s\S]*?^[ \t]*\1[ \t]*$/gm; +const INLINE_CODE_RE = /\u0060[^\u0060\n]*\u0060/g; +const HTML_COMMENT_RE = //g; + +/** + * Carry language inside a fenced block, an inline span, or an HTML comment is + * quoted material, not a declaration. A pull request that explains the gate + * itself -- this one does -- must not trip it. + */ +function strippedText(text) { + if (typeof text !== "string") return ""; + return text + .replace(FENCED_CODE_RE, "") + .replace(HTML_COMMENT_RE, "") + .replace(INLINE_CODE_RE, ""); +} + +function hasLabel(labels, name) { + return (labels || []).some( + (label) => (typeof label === "string" ? label : label?.name) === name, + ); +} + +/** Pull request numbers this text claims to carry, supersede, or rebase. */ +function referencedCarryNumbers(...texts) { + const found = new Set(); + for (const text of texts) { + const stripped = strippedText(text); + CARRY_VERB_RE.lastIndex = 0; + let verb; + while ((verb = CARRY_VERB_RE.exec(stripped)) !== null) { + const window = carryWindow(stripped, verb.index + verb[0].length); + REF_RE.lastIndex = 0; + let ref; + while ((ref = REF_RE.exec(window)) !== null) { + // A qualified reference names a pull request in another repository. + if (ref[1]) continue; + found.add(Number(ref[2])); + } + } + } + return found; +} + +function trailerValues(...texts) { + const values = []; + for (const text of texts) { + if (typeof text !== "string") continue; + TRAILER_RE.lastIndex = 0; + let match; + while ((match = TRAILER_RE.exec(text)) !== null) values.push(match[1].toLowerCase()); + } + return values; +} + +/** + * A GitHub login is not a git identity. The scan behind CREDITS.md produced + * eleven false positives from that assumption alone: a login like "asmith92" + * does not appear anywhere in a trailer that reads "A. Smith ", + * even though they are the same person. Match on any of the three identifiers + * the referenced pull request actually carries. + */ +function parseTrailer(value) { + const match = /^\s*(.*?)\s*<([^>]*)>\s*$/.exec(value); + if (match) return { name: match[1].toLowerCase(), email: match[2].toLowerCase() }; + return { name: value.trim().toLowerCase(), email: "" }; +} + +/** + * Substring matching is not good enough here, and the failure is not exotic: + * an author named "Ann" would be satisfied by "Co-authored-by: Joanne + * ", and a short login can appear inside an unrelated + * address. A trailer credits someone only when its name or its email equals an + * identifier the referenced pull request actually carries. + */ +function trailerNames(author, trailers) { + if (!author) return true; + const names = new Set( + [author.login, ...(author.names || [])] + .filter((value) => typeof value === "string" && value.trim() !== "") + .map((value) => value.trim().toLowerCase()), + ); + const emails = new Set( + (author.emails || []) + .filter((value) => typeof value === "string" && value.trim() !== "") + .map((value) => value.trim().toLowerCase()), + ); + if (names.size === 0 && emails.size === 0) return true; + return trailers.some( + (trailer) => + (trailer.name !== "" && names.has(trailer.name)) || + (trailer.email !== "" && emails.has(trailer.email)) || + // A GitHub noreply address carries the login after the numeric id, + // before the "@" -- that is the only identifier many trailers have. + (trailer.email.endsWith("@users.noreply.github.com") && + names.has(trailer.email.replace(/^[^@]*?(\d+\+)?/, "").split("@")[0])), + ); +} + +/** + * @returns {{ code: string, paths: string[] }[]} empty when the pull request may proceed + */ +function assessCarryAttribution({ + prAuthorLogin = "", + title = "", + body = "", + commits = [], + labels = [], + referencedAuthors = {}, +} = {}) { + if (hasLabel(labels, "attribution-approved")) return []; + + const referenced = referencedCarryNumbers(title, body, ...commits); + if (referenced.size === 0) return []; + + // The squash body is assembled from the pull request body and the branch's + // commit messages, so both are where an author can put the trailer today. + const trailers = trailerValues(body, ...commits).map(parseTrailer); + const uncredited = []; + + for (const number of referenced) { + const author = referencedAuthors[number]; + // An unresolved author is a pass. A rate limit or a deleted account must + // never be the reason a merge is blocked. + if (!author) continue; + // Referencing your own earlier branch is ordinary maintenance. + if ( + author.login && + prAuthorLogin && + author.login.toLowerCase() === prAuthorLogin.toLowerCase() + ) { + continue; + } + if (!trailerNames(author, trailers)) uncredited.push("#" + number); + } + + if (uncredited.length === 0) return []; + return [ + { + code: "missing_coauthor_credit", + paths: uncredited.sort(), + }, + ]; +} + +module.exports = { + CARRY_VERB_RE, + carryWindow, + assessCarryAttribution, + referencedCarryNumbers, + strippedText, + trailerValues, +}; diff --git a/.github/scripts/pr-carry-attribution.test.cjs b/.github/scripts/pr-carry-attribution.test.cjs new file mode 100644 index 0000000000..187469071e --- /dev/null +++ b/.github/scripts/pr-carry-attribution.test.cjs @@ -0,0 +1,225 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { assessCarryAttribution } = require("./pr-carry-attribution.cjs"); + +const RRMLIMA = { + login: "rrmlima", + names: ["Rodrigo Lima"], + emails: ["rrmlima@example.com"], +}; + +function base(overrides = {}) { + return { + prAuthorLogin: "lidge-jun", + title: "fix(doctor): diagnose the broken Codex env_key launch path", + body: "", + commits: [], + labels: [], + referencedAuthors: { 2797: RRMLIMA }, + ...overrides, + }; +} + +describe("assessCarryAttribution", () => { + it("fails a carry that names the author in prose but not in a trailer", () => { + const failures = assessCarryAttribution( + base({ body: "Reimplements #2797 by @rrmlima." }), + ); + assert.equal(failures.length, 1); + assert.equal(failures[0].code, "missing_coauthor_credit"); + assert.deepEqual(failures[0].paths, ["#2797"]); + }); + + it("accepts a trailer that names the login", () => { + assert.deepEqual( + assessCarryAttribution( + base({ + body: "Reimplements #2797 by @rrmlima.\n\nCo-authored-by: rrmlima ", + }), + ), + [], + ); + }); + + it("accepts a trailer that matches only the git author name", () => { + assert.deepEqual( + assessCarryAttribution( + base({ + body: "Reimplements #2797.", + commits: [ + "fix(doctor): diagnose\n\nCo-authored-by: Rodrigo Lima ", + ], + }), + ), + [], + ); + }); + + it("accepts a trailer that matches only the git author email", () => { + assert.deepEqual( + assessCarryAttribution( + base({ + body: "Supersedes #2797.\n\nCo-authored-by: R. L. ", + }), + ), + [], + ); + }); + + it("ignores a reference to the pull request author's own earlier work", () => { + assert.deepEqual( + assessCarryAttribution( + base({ + body: "Rebase of #3112.", + referencedAuthors: { 3112: { login: "lidge-jun", names: ["JUN"], emails: [] } }, + }), + ), + [], + ); + }); + + it("passes when the referenced author could not be resolved", () => { + assert.deepEqual( + assessCarryAttribution( + base({ body: "Reimplements #2797.", referencedAuthors: { 2797: null } }), + ), + [], + ); + }); + + it("passes when the label approves the attribution", () => { + assert.deepEqual( + assessCarryAttribution( + base({ + body: "Reimplements #2797 by @rrmlima.", + labels: ["attribution-approved"], + }), + ), + [], + ); + }); + + it("ignores carry language inside a fenced block or an HTML comment", () => { + assert.deepEqual( + assessCarryAttribution( + base({ + body: [ + "This is an ordinary fix.", + "", + "\u0060\u0060\u0060", + "Reimplements #2797", + "\u0060\u0060\u0060", + "", + "", + ].join("\n"), + }), + ), + [], + ); + }); + + it("passes an ordinary pull request with no carry language", () => { + assert.deepEqual( + assessCarryAttribution(base({ body: "Closes #2797." })), + [], + ); + }); + + it("stops at the sentence boundary so a Fixes line is not a carry", () => { + // 53c09a247's real body. A fixed-width window would have pulled #3192 -- + // the issue it closes -- into the carry set and demanded a trailer for the + // reporter of a bug, which is a different relationship entirely. + const failures = assessCarryAttribution( + base({ + body: "Supersedes #3193. Fixes #3192.", + referencedAuthors: { + 3193: { login: "alan7629", names: [], emails: [] }, + 3192: { login: "alan7629", names: [], emails: [] }, + }, + }), + ); + assert.deepEqual(failures[0].paths, ["#3193"]); + }); + + it("reads a trailer that only exists on a branch commit", () => { + assert.deepEqual( + assessCarryAttribution( + base({ + body: "Reimplements #2797.", + commits: [ + "fix: first", + "fix: second\n\nCo-authored-by: rrmlima ", + ], + }), + ), + [], + ); + }); + + + it("recognizes the -ing and bare forms of each carry verb", () => { + for (const phrase of [ + "Reimplementing #2797 on dev.", + "Rebasing #2797 onto the current head.", + "Carrying #2797 forward.", + "Carry #2797.", + "Rebase #2797.", + ]) { + const failures = assessCarryAttribution(base({ body: phrase })); + assert.equal(failures.length, 1, phrase); + assert.deepEqual(failures[0].paths, ["#2797"], phrase); + } + }); + + it("ignores a reference qualified with another repository", () => { + // other/project#2797 is not this repository's #2797. Resolving it here + // would compare the trailer against an unrelated person who happens to + // own the same number locally. + assert.deepEqual( + assessCarryAttribution(base({ body: "Supersedes other/project#2797." })), + [], + ); + }); + + it("does not accept a trailer that merely contains the identifier", () => { + const failures = assessCarryAttribution( + base({ + body: "Reimplements #2797.\n\nCo-authored-by: Joanne ", + referencedAuthors: { 2797: { login: "ann", names: ["Ann"], emails: [] } }, + }), + ); + assert.equal(failures.length, 1); + assert.deepEqual(failures[0].paths, ["#2797"]); + }); + + it("accepts a noreply address that carries the login", () => { + // Assembled rather than written out: the privacy scan reads a literal + // noreply address as a real one, and it is right to. + const noreply = "27862058+rrmlima@" + "users.noreply.github.com"; + assert.deepEqual( + assessCarryAttribution( + base({ + body: "Reimplements #2797.\n\nCo-authored-by: R L <" + noreply + ">", + }), + ), + [], + ); + }); + + + it("reports every uncredited reference once", () => { + const failures = assessCarryAttribution( + base({ + body: "Reimplements #2797 and #2796. Supersedes #2797.", + referencedAuthors: { + 2797: RRMLIMA, + 2796: { login: "someone", names: [], emails: [] }, + }, + }), + ); + assert.equal(failures.length, 1); + assert.deepEqual(failures[0].paths, ["#2796", "#2797"]); + }); +}); diff --git a/.github/scripts/pr-hygiene.cjs b/.github/scripts/pr-hygiene.cjs index 738e0af535..6a4989ae73 100644 --- a/.github/scripts/pr-hygiene.cjs +++ b/.github/scripts/pr-hygiene.cjs @@ -1,6 +1,7 @@ "use strict"; const { assessSponsoredSurface } = require("./pr-sponsored-surface.cjs"); +const { assessCarryAttribution } = require("./pr-carry-attribution.cjs"); const GENERATED_PREFIXES = [ "gui/dist/", @@ -239,6 +240,8 @@ const HYGIENE_FAILURE_HINTS = { "An empty catch block was added. Handle, report, or deliberately propagate the error.", unsponsored_surface: "This changes an authentication, workflow, release-automation, or dependency surface. `MAINTAINERS.md` requires security review for these; ask a maintainer to apply `maintainer-sponsored` once they have reviewed it.", + missing_coauthor_credit: + "This pull request says it reimplements, supersedes, carries, or rebases another author's pull request, but no `Co-authored-by` trailer names that author. Prose in a commit body is not read by anything; the trailer is what GitHub counts. Add it to the description or a commit, or obtain `attribution-approved`.", }; /** @@ -253,6 +256,7 @@ const HYGIENE_GATE_LABELS = [ "suppression-approved", "generated-change-approved", "dependency-change-approved", + "attribution-approved", ]; /** @@ -263,6 +267,11 @@ function collectDeterministicHygieneFailures({ files = [], labels = [], authorHasPushPermission = false, + prAuthorLogin = "", + title = "", + body = "", + commits = [], + referencedAuthors = {}, }) { // Renames must keep the source path: moving a restricted file to a // non-restricted destination must not drop the sponsorship requirement. @@ -281,6 +290,16 @@ function collectDeterministicHygieneFailures({ changedFiles, labels, }), + // Reads the pull request's text rather than its diff: a carry declares + // itself in prose, and the trailer it needs lives in the same place. + ...assessCarryAttribution({ + prAuthorLogin, + title, + body, + commits, + labels, + referencedAuthors, + }), ]; } diff --git a/.github/scripts/pr-referenced-authors.cjs b/.github/scripts/pr-referenced-authors.cjs new file mode 100644 index 0000000000..409ff0d934 --- /dev/null +++ b/.github/scripts/pr-referenced-authors.cjs @@ -0,0 +1,78 @@ +"use strict"; + +const { referencedCarryNumbers } = require("./pr-carry-attribution.cjs"); + +/** + * Resolve the authors of the pull requests a carry declaration names, so the + * hygiene gate can tell "you carried someone else's work" from "you rebased + * your own branch" and can match a trailer on git identity rather than login. + * + * Three properties this has to hold, each one a way the check could otherwise + * do harm: + * + * - Fail open. A rate limit, a deleted account, or a reference to a pull + * request in another repository resolves to null, and a null author is a + * pass. Blocking a merge because an API call failed would be worse than the + * omission this gate exists to prevent. + * - Bounded. At most MAX_LOOKUPS references are resolved. A description that + * discusses twenty prior pull requests must not turn one hygiene run into + * forty API calls. + * - Identity, not login. The referenced pull request's own commits supply the + * git author names and emails, because a trailer is written by a human who + * usually copies the git identity, not the GitHub handle. + */ + +const MAX_LOOKUPS = 5; + +async function resolveReferencedAuthors({ + github, + owner, + repo, + texts = [], + core = null, +}) { + const numbers = [...referencedCarryNumbers(...texts)].sort((a, b) => a - b); + const resolved = {}; + for (const number of numbers.slice(0, MAX_LOOKUPS)) { + try { + const { data: referenced } = await github.rest.pulls.get({ + owner, + repo, + pull_number: number, + }); + const identities = { login: referenced.user?.login ?? "", names: [], emails: [] }; + try { + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, + repo, + pull_number: number, + per_page: 100, + }); + for (const commit of commits) { + const author = commit.commit?.author; + if (author?.name) identities.names.push(author.name); + if (author?.email) identities.emails.push(author.email); + } + } catch (error) { + // The pull request resolved but its commits did not. Login-only + // matching is weaker, not absent, so keep what we have. + core?.info( + "Could not list commits for #" + number + ": " + error.message, + ); + } + identities.names = [...new Set(identities.names)]; + identities.emails = [...new Set(identities.emails)]; + resolved[number] = identities; + } catch (error) { + core?.info("Could not resolve #" + number + ": " + error.message); + resolved[number] = null; + } + } + return resolved; +} + +module.exports = { + MAX_LOOKUPS, + resolveReferencedAuthors, +}; + diff --git a/.github/scripts/pr-referenced-authors.test.cjs b/.github/scripts/pr-referenced-authors.test.cjs new file mode 100644 index 0000000000..a6070a4cbf --- /dev/null +++ b/.github/scripts/pr-referenced-authors.test.cjs @@ -0,0 +1,112 @@ +"use strict"; + +const { describe, it } = require("node:test"); +const assert = require("node:assert/strict"); +const { + MAX_LOOKUPS, + resolveReferencedAuthors, +} = require("./pr-referenced-authors.cjs"); + +function stubGithub({ pulls = {}, commits = {}, onGet = null }) { + return { + rest: { + pulls: { + get: async ({ pull_number }) => { + onGet?.(pull_number); + if (!(pull_number in pulls)) { + const error = new Error("Not Found"); + error.status = 404; + throw error; + } + return { data: pulls[pull_number] }; + }, + listCommits: "listCommits", + }, + }, + paginate: async (route, { pull_number }) => { + assert.equal(route, "listCommits"); + if (!(pull_number in commits)) throw new Error("commits unavailable"); + return commits[pull_number]; + }, + }; +} + +describe("resolveReferencedAuthors", () => { + it("returns login, git names, and git emails for a referenced pull request", async () => { + const github = stubGithub({ + pulls: { 2797: { user: { login: "rrmlima" } } }, + commits: { + 2797: [ + { commit: { author: { name: "Rodrigo Lima", email: "rrmlima@example.com" } } }, + { commit: { author: { name: "Rodrigo Lima", email: "rrmlima@example.com" } } }, + ], + }, + }); + const resolved = await resolveReferencedAuthors({ + github, + owner: "o", + repo: "r", + texts: ["Reimplements #2797."], + }); + assert.deepEqual(resolved, { + 2797: { + login: "rrmlima", + names: ["Rodrigo Lima"], + emails: ["rrmlima@example.com"], + }, + }); + }); + + it("resolves to null when the lookup fails, so the gate can fail open", async () => { + const github = stubGithub({ pulls: {}, commits: {} }); + const resolved = await resolveReferencedAuthors({ + github, + owner: "o", + repo: "r", + texts: ["Supersedes #9999."], + }); + assert.deepEqual(resolved, { 9999: null }); + }); + + it("keeps the login when only the commit listing fails", async () => { + const github = stubGithub({ pulls: { 42: { user: { login: "someone" } } }, commits: {} }); + const resolved = await resolveReferencedAuthors({ + github, + owner: "o", + repo: "r", + texts: ["Carry of #42."], + }); + assert.deepEqual(resolved, { 42: { login: "someone", names: [], emails: [] } }); + }); + + it("bounds the number of lookups", async () => { + const seen = []; + const pulls = {}; + const commits = {}; + for (let n = 1; n <= 9; n += 1) { + pulls[n] = { user: { login: "u" + n } }; + commits[n] = []; + } + const github = stubGithub({ pulls, commits, onGet: (n) => seen.push(n) }); + const body = [1, 2, 3, 4, 5, 6, 7, 8, 9] + .map((n) => "Reimplements #" + n + ".") + .join("\n"); + await resolveReferencedAuthors({ github, owner: "o", repo: "r", texts: [body] }); + assert.equal(seen.length, MAX_LOOKUPS); + assert.deepEqual(seen, [1, 2, 3, 4, 5]); + }); + + it("makes no request when nothing declares a carry", async () => { + const seen = []; + const github = stubGithub({ pulls: {}, commits: {}, onGet: (n) => seen.push(n) }); + const resolved = await resolveReferencedAuthors({ + github, + owner: "o", + repo: "r", + texts: ["Closes #2797.", "An ordinary description."], + }); + assert.deepEqual(resolved, {}); + assert.equal(seen.length, 0); + }); +}); + diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index a26ff02273..6743730206 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -199,6 +199,9 @@ jobs: } = require( path.join(process.cwd(), ".github", "scripts", "pr-hygiene.cjs"), ); + const { resolveReferencedAuthors } = require( + path.join(process.cwd(), ".github", "scripts", "pr-referenced-authors.cjs"), + ); const { parseGateState, gateStateMarker, @@ -659,6 +662,20 @@ jobs: // clear while those checks fail. Re-assess here from the same // trusted scripts so the gate cannot race ahead of hygiene. const labelNames = (pr.labels ?? []).map(label => label.name); + // Same inputs the hygiene workflow feeds the carry-attribution + // assessor. Both gates must reach the same verdict or Ready can + // clear while hygiene is still red. + const carryCommits = await github.paginate( + github.rest.pulls.listCommits, + { owner, repo, pull_number, per_page: 100 } + ); + const carryCommitMessages = carryCommits.map( + entry => entry.commit?.message ?? "" + ); + const carryReferencedAuthors = await resolveReferencedAuthors({ + github, owner, repo, core, + texts: [pr.title ?? "", pr.body ?? "", ...carryCommitMessages], + }); failures = [ ...failures, ...collectDeterministicHygieneFailures({ @@ -667,6 +684,11 @@ jobs: authorHasPushPermission: !permissionLookupFailed && authorHasPushPermission(authorPermission), + prAuthorLogin: pr.user?.login ?? "", + title: pr.title ?? "", + body: pr.body ?? "", + commits: carryCommitMessages, + referencedAuthors: carryReferencedAuthors, }), ]; diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml index 8d6cb3b1a4..b60943b93a 100644 --- a/.github/workflows/pr-hygiene.yml +++ b/.github/workflows/pr-hygiene.yml @@ -2,7 +2,13 @@ name: PR hygiene on: pull_request_target: - types: [opened, reopened, synchronize, labeled, unlabeled] + # `edited` is here for the carry-attribution check alone. Every other + # hygiene failure is about the diff, so only a push can clear it -- but the + # remedy this one asks for is a `Co-authored-by` trailer, which an author + # can add to the description without touching the branch. Without `edited` + # the fix would be invisible until an unrelated push, and the author would + # reasonably conclude the gate was broken. + types: [opened, reopened, synchronize, edited, labeled, unlabeled] # Trusted scripts from the PR base revision only. Patches are read through the # GitHub API; PR-head code is never checked out or executed. @@ -58,6 +64,9 @@ jobs: const { authorHasPushPermission } = require( path.join(process.cwd(), ".github", "scripts", "pr-quality.cjs"), ); + const { resolveReferencedAuthors } = require( + path.join(process.cwd(), ".github", "scripts", "pr-referenced-authors.cjs"), + ); const { GATE_MARKER, HYGIENE_MARKER, @@ -76,6 +85,7 @@ jobs: "suppression-approved": ["5319e7", "Maintainer approved a new type or lint suppression"], "generated-change-approved": ["5319e7", "Maintainer approved committed generated output"], "dependency-change-approved": ["5319e7", "Maintainer approved exceptional dependency or lockfile handling"], + "attribution-approved": ["5319e7", "Maintainer approved a carry whose original author is not named in a trailer"], "maintainer-sponsored": ["5319e7", "Maintainer sponsors this change to an auth, workflow, release, or dependency surface"], }; @@ -108,6 +118,7 @@ jobs: "suppression-approved", "generated-change-approved", "dependency-change-approved", + "attribution-approved", ]) { if (labels.has(name)) { await github.rest.issues.removeLabel({ @@ -138,12 +149,30 @@ jobs: `Could not look up collaborator permission: ${error.message}`, ); } + // The squash body is assembled from the description and the + // branch's commit messages, so a carry's trailer can live in + // either. Read both, and resolve the authors of whatever the text + // says it carries. + const prCommits = await github.paginate(github.rest.pulls.listCommits, { + owner, repo, pull_number, per_page: 100, + }); + const commitMessages = prCommits.map((entry) => entry.commit?.message ?? ""); + const referencedAuthors = await resolveReferencedAuthors({ + github, owner, repo, core, + texts: [pr.title ?? "", pr.body ?? "", ...commitMessages], + }); + const failures = collectDeterministicHygieneFailures({ files, labels: [...labels], authorHasPushPermission: !permissionLookupFailed && authorHasPushPermission(authorPermission), + prAuthorLogin: pr.user?.login ?? "", + title: pr.title ?? "", + body: pr.body ?? "", + commits: commitMessages, + referencedAuthors, }); async function setBlocked(blocked) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 458bb67e0a..f053574295 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,6 +67,14 @@ jobs: bump-dev-version: needs: publish if: ${{ inputs.dry-run != true }} + # A reusable-workflow CALL cannot grant the callee more than the calling job holds, + # and GitHub refuses the whole run at startup when the called workflow's own job + # declares permissions the caller did not pass down ("startup_failure", runs + # 33615174183 / 33615177849 — the first dispatches since #3129 wired this call). + # The callee's job declares exactly these two; nothing else in this file gains them. + permissions: + contents: write + pull-requests: write uses: ./.github/workflows/dev-version-bump.yml with: released-version: v${{ inputs.version }} @@ -257,7 +265,7 @@ jobs: # Keep in sync with the service-lifecycle.yml trigger paths. src/cli.ts is # the pre-restructure compat stub that durable launchers still execute. - if printf '%s\n' "$changed_files" | grep -Eq '^(src/service\.ts|src/cli\.ts|src/cli/index\.ts|src/lib/bun-runtime\.ts|package\.json|bun\.lock|\.github/workflows/service-lifecycle\.yml)$'; then + if printf '%s\n' "$changed_files" | grep -Eq '^(src/service\.ts|src/cli\.ts|src/cli/index\.ts|src/lib/bun-runtime\.ts|package\.json|bun\.lock|\.github/workflows/service-lifecycle\.yml|\.github/workflows/release\.yml)$'; then service_url="$( gh run list \ --workflow service-lifecycle.yml \ diff --git a/.github/workflows/service-lifecycle.yml b/.github/workflows/service-lifecycle.yml index 8e0513b459..df37f60561 100644 --- a/.github/workflows/service-lifecycle.yml +++ b/.github/workflows/service-lifecycle.yml @@ -14,6 +14,10 @@ on: - "package.json" - "bun.lock" - ".github/workflows/service-lifecycle.yml" + # release.yml gates on THIS workflow having run for the release SHA. A release-branch + # commit that touches only release.yml (e.g. the v2.40.0 permissions carry, #3263/#3264) + # produced no run and the gate dead-ended until a manual dispatch. + - ".github/workflows/release.yml" push: paths: - "src/service.ts" @@ -24,6 +28,10 @@ on: - "package.json" - "bun.lock" - ".github/workflows/service-lifecycle.yml" + # release.yml gates on THIS workflow having run for the release SHA. A release-branch + # commit that touches only release.yml (e.g. the v2.40.0 permissions carry, #3263/#3264) + # produced no run and the gate dead-ended until a manual dispatch. + - ".github/workflows/release.yml" workflow_dispatch: permissions: diff --git a/AGENTS.md b/AGENTS.md index 128e091223..8d07b948ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,6 +263,17 @@ than nudged. `Closes #` to link it. GitHub auto-closes the linked issue only when the PR merges into the default branch (`main`); PRs here target `dev`, so close the issue manually once the change is on `dev`. +- **Landing another author's work:** reimplementing, superseding, carrying, or + rebasing someone else's pull request requires a `Co-authored-by` trailer + naming that author, in the description or in a branch commit so it survives + the squash. Saying it in prose is not equivalent — the trailer is what GitHub + reads for the contributor graph, and a sentence in a commit body is read by + nothing. This repository did it both ways for months: `53c09a247` says "Clean + reimplementation of #3193" and names the author in a trailer, `5734a1caf` says + "Reimplements #2797 by @rrmlima" and names nobody, so that contribution is + invisible on its author's profile. The 27 landings already in that state are + recorded in [`CREDITS.md`](./CREDITS.md); `missing_coauthor_credit` in + `.github/scripts/pr-carry-attribution.cjs` is why the list should not grow. ## Branch policy diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e28f00f8eb..08db5a6bbb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,7 @@ Thanks for helping with opencodex. - Public user docs live in [`docs-site/`](./docs-site) - Current maintainer invariants live in [`structure/`](./structure) - Maintainer roles and merge policy live in [`MAINTAINERS.md`](./MAINTAINERS.md) +- Attribution for work landed through a maintainer carry lives in [`CREDITS.md`](./CREDITS.md) - Historical investigations live in [`docs/`](./docs) ## Branches diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000000..b9340055f6 --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,97 @@ +# Credits + +When a maintainer lands another author's pull request by reimplementing, +carrying, or rebasing it, the resulting commit is authored by the maintainer. +The contributor's name survives only through a `Co-authored-by` trailer — that +trailer is what GitHub reads for the contributor graph, the repository's +contributor list, and the author's own profile activity. + +Some of those landings carry the trailer. Others state the debt in the commit +body and omit it: + +``` + 53c09a247 "Clean reimplementation of #3193" Co-authored-by: alan7629 ... + 5734a1caf "Reimplements #2797 by @rrmlima." (no contributor trailer) +``` + +Both sentences are equally sincere. Only the first is data. + +The commits below are inside published release tags and behind branch rulesets +that block force-pushes, so the trailers cannot be added retroactively without +invalidating every tag and clone — +[`MAINTAINERS.md`](./MAINTAINERS.md) states the same principle in the other +direction: authorship credit in git history is not rewritten. This file is the +forward repair. + +Every entry cites the maintainer's own words from the closing comment or the +landing commit. Nothing here is inferred from a diff. + +This file is **not** a contributor list. Most contributions merged normally, +with authorship intact, and need no entry. Absence from this page means the +ordinary path worked. + +## Carried work + +Code, design, or tests from these pull requests shipped. + +| Pull request | Author | Landed as | What landed | +| --- | --- | --- | --- | +| [#1801](https://github.com/lidge-jun/opencodex/pull/1801) | [@jonathanli12](https://github.com/jonathanli12) | `cb48c2e11` | "carries all three of its unique tests" — the Cursor code-mode contract | +| [#2123](https://github.com/lidge-jun/opencodex/pull/2123) | [@chilung-cgu](https://github.com/chilung-cgu) | `ef7b3c9cf` | "Your account loop and the reuse of `getTokenForAccountQuotaProbe` are what shipped" | +| [#2655](https://github.com/lidge-jun/opencodex/pull/2655) | [@TooSpace](https://github.com/TooSpace) | `607042b02` | "re-implemented on current `dev` from your design" | +| [#2693](https://github.com/lidge-jun/opencodex/pull/2693) | [@yxr1995-maker](https://github.com/yxr1995-maker) | `d829215af`, `bdc1e97bb` | "carries your fix forward with the three review blockers closed" | +| [#2734](https://github.com/lidge-jun/opencodex/pull/2734) | [@TooSpace](https://github.com/TooSpace) | `88c427522` | "That carry keeps the adaptive effort-mode design" | +| [#2744](https://github.com/lidge-jun/opencodex/pull/2744) | [@yxr1995-maker](https://github.com/yxr1995-maker) | `8877df0ee` | "Your diagnosis held up"; the landed fix reimplements it narrowly | +| [#2796](https://github.com/lidge-jun/opencodex/pull/2796) | [@rrmlima](https://github.com/rrmlima) | `bb3321ca8` | "Reimplements #2796 by @rrmlima" | +| [#2797](https://github.com/lidge-jun/opencodex/pull/2797) | [@rrmlima](https://github.com/rrmlima) | `5734a1caf` | "Reimplements #2797 by @rrmlima" | +| [#2812](https://github.com/lidge-jun/opencodex/pull/2812) | [@gaoran1209](https://github.com/gaoran1209) | `c986d1d20` | "Reimplements #2812 by @gaoran1209 with the maintainer's blocker addressed" | +| [#2867](https://github.com/lidge-jun/opencodex/pull/2867) | [@Ingwannu](https://github.com/Ingwannu) | `8d1dc1f5d` | "That landed change includes this PR's strict LoadState parsing" | +| [#2870](https://github.com/lidge-jun/opencodex/pull/2870) | [@luvs01](https://github.com/luvs01) | `de91dfde4` | "the coalescing design here is right, and it is carried forward" | +| [#2884](https://github.com/lidge-jun/opencodex/pull/2884) | [@chilung-cgu](https://github.com/chilung-cgu) | `eb52973c5` | "Completes contributor PR #2884"; the exact-name approach carried as-is | +| [#3000](https://github.com/lidge-jun/opencodex/pull/3000) | [@MarcTCruz](https://github.com/MarcTCruz) | `fecb77a91` | "Your central insight" — the refresh lock and the file it protects live under different homes | +| [#3039](https://github.com/lidge-jun/opencodex/pull/3039) | [@ntdatt812](https://github.com/ntdatt812) | `b14b741dc` | "keeps your production logic exactly as written — the Windows budget, the `waited` guard, and the grace probe" | +| [#3041](https://github.com/lidge-jun/opencodex/pull/3041) | [@ntdatt812](https://github.com/ntdatt812) | `b46164e78` | "carries your three merge-loop tests … they came from this PR" | +| [#3067](https://github.com/lidge-jun/opencodex/pull/3067) | [@ntdatt812](https://github.com/ntdatt812) | `b14b741dc` | "keeps your diagnosis and your relocation", with the remedy narrowed | +| [#3078](https://github.com/lidge-jun/opencodex/pull/3078) | [@Veritas-7](https://github.com/Veritas-7) | `0ef04e640` | "reimplements both of your production hunks on `dev`" | +| [#3142](https://github.com/lidge-jun/opencodex/pull/3142) | [@olddonkey](https://github.com/olddonkey) | `52d941640` | "That carry keeps the measurement/refusal work and ships the guard default-off" | +| [#3300](https://github.com/lidge-jun/opencodex/pull/3300) | [@S0RYUASUKA](https://github.com/S0RYUASUKA) | `15b43e51c` | the same two test files made hermetic | + +## Report and diagnosis + +These fixes exist because of the report. The branch's own approach was not the +vehicle, and each author was told why at the time — recording them as carried +code would misstate what happened in the other direction. + +| Pull request | Author | Fix landed as | Maintainer's words | +| --- | --- | --- | --- | +| [#2925](https://github.com/lidge-jun/opencodex/pull/2925) | [@ncepuee](https://github.com/ncepuee) | `1d9b389c1` | "Credit to @ncepuee, whose #2925 identified this and argued the split" | +| [#3006](https://github.com/lidge-jun/opencodex/pull/3006) | [@Ingwannu](https://github.com/Ingwannu) | `870a2adb6` | "your PR correctly identified the broken invariant and verified the target was unused" | +| [#3038](https://github.com/lidge-jun/opencodex/pull/3038) | [@L-Y-J](https://github.com/L-Y-J) | `e9d198a3c` | "the defect is real and #3107 exists because you found it" | +| [#3040](https://github.com/lidge-jun/opencodex/pull/3040) | [@ntdatt812](https://github.com/ntdatt812) | `330470e74` | "The defect you found is real" | +| [#3117](https://github.com/lidge-jun/opencodex/pull/3117) | [@olddonkey](https://github.com/olddonkey) | `b46164e78` | "Thank you for the focused report and tests" | +| [#3143](https://github.com/lidge-jun/opencodex/pull/3143) | [@Ingwannu](https://github.com/Ingwannu) | `408652698` | "The diagnosis here was yours and it was right" | +| [#3223](https://github.com/lidge-jun/opencodex/pull/3223) | [@alex-jordan547](https://github.com/alex-jordan547) | `d23eab43a` | "The report itself was what made the fix quick; the wire capture pointed straight at the cause" | + +## Closed as landed, carry not stated + +Two more were closed with a landing commit and nothing further. The landing is +recorded; what was taken is not, and inventing an answer would be the same +inaccuracy this file exists to correct. + +- [#3020](https://github.com/lidge-jun/opencodex/pull/3020) by + [@luvs01](https://github.com/luvs01) — closed "Landed via #3119 at `a73a4c998`". +- [#2675](https://github.com/lidge-jun/opencodex/pull/2675) by + [@Ingwannu](https://github.com/Ingwannu) — closed "Landed via #2677 at `8412fe156`". + +## How this stays accurate + +This page is a repair, not a process. The process is +`missing_coauthor_credit` in +[`.github/scripts/pr-hygiene.cjs`](./.github/scripts/pr-hygiene.cjs): a pull +request whose own text says it reimplements, supersedes, carries, or rebases +another author's pull request fails the hygiene gate until a +`Co-authored-by` trailer names that author. New entries here should be +unnecessary. + +If you find a landing that belongs on this page, open an issue. Being missed is +the defect this file documents, not a claim you have to argue for. diff --git a/README.md b/README.md index c195a32d9d..f995366a49 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,10 @@ bun run test See **[Contributing](./CONTRIBUTING.md)**. +Contributor work that landed through a maintainer carry or reimplementation, +where the commit does not name its original author, is recorded in +**[CREDITS.md](./CREDITS.md)**. + ## Disclaimer opencodex is an independent, community-maintained project and is **not affiliated with or endorsed by OpenAI, Anthropic, or any other provider**. diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/000_research.md b/devlog/_plan/260902_cursor_bundle_effort_table/000_research.md new file mode 100644 index 0000000000..d22042c5c6 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/000_research.md @@ -0,0 +1,113 @@ +# 000 — Research: Cursor's effort table lives in the bundle, not the gateway + +Unit: `260902_cursor_bundle_effort_table`. Base: `origin/dev` at `ee24bab40`. Class C3 +(public inbound contract on `GET /v1/models`, management route, GUI, provider adapter, docs). +Research only; no diffs in this document. + +## Problem + +The Integrations > Cursor card predicts which routed models get a **Reasoning** control in +Cursor Private Inference. For `anthropic/claude-fable-5-1`, `cursor/claude-fable-5-1`, +`cursor/kimi-k3`, `google-antigravity/claude-opus-4-6-thinking`, +`opencode-free/muse-spark-1.2-contributor-free` and `lidge/qwen3.8-27b-nvfp4` it shows "—", +and the live picker agrees: no effort control. The user expected the gateway's ladder +(`reasoning_effort: [...]`) to drive the control. It does not. + +## Where the decision is made (Cursor 3.18.25, read from the shipped bundle) + +Bundle: `/Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js` +(9,932,774 bytes, md5 `c2b57b0141b05e7e6e56cdcc206b95a5`; byte-identical between +`Cursor.app` and `Cursor Private Inference.app`, so the table is shared and only the +`localMode` code path differs). + +1. `E(id)`: lower-case, keep the part after the last `/`, drop `@...`. +2. `I(id)`: first match in the family table `b` (verbatim in `001_bundle_protocol.md`): + + | family id | regex | ladder | param | default | outputCap | + |---|---|---|---|---|---| + | anthropic-opus-5 | `^claude-opus-5$` | low·medium·high·xhigh·max | output_config.effort | high | 128000 | + | anthropic-opus-4-7-4-8 | `^claude-opus-4[-.](?:7\|8)$` | same | same | high | 128000 | + | anthropic-opus-4-6 | `^claude-opus-4[-.]6$` | low·medium·high·max | same | high | 128000 | + | anthropic-opus-4-5 | `^claude-opus-4[-.]5$` | low·medium·high·max | same | high | 64000 | + | anthropic-sonnet-4-6 | `^claude-sonnet-4[-.]6$` | low·medium·high·max | same | high | 64000 | + | anthropic-sonnet-5 | `^claude-sonnet-5$` | low·medium·high·xhigh·max | same | high | 128000 | + | anthropic-sonnet-no-effort | `^claude-sonnet-4(?:[-.]5)?$` | none | — | — | 64000 | + | anthropic-haiku-4-5 | `^claude-haiku-4[-.]5$` | none | — | — | 32768 | + | grok-4.3 / 4.5 / 4.6 / grok-build-latest | `^grok-4[.-]3$` etc. | minimal·low·medium·high·xhigh | reasoning_effort | high | — | + | grok-reasoning-no-effort | composer / 4.20 variants | none | — | — | — | + | gpt-5.6 | `^gpt-5[.-]6-(?:luna\|sol\|terra)$` | low·medium·high·xhigh | reasoning_effort | medium | — | + | gemini-no-effort | `^gemini-3\.[1-9].*flash-lite` | none | — | — | — | + | gemini | `^gemini-` | minimal·low·medium·high | reasoning_effort | medium | — (needs `supports_reasoning`) | + + Plus `_(id)`: bare `^gpt-5(?:\.\d+)?$` → low·medium·high·xhigh, default medium. +3. `J(model, tier)` attaches the Reasoning parameter only when `I(id).effort` exists AND + `extendedCapabilitiesDetected === true` (row passed the `fme` schema). For a model with + no family it falls through to `_(id)`, and otherwise the control is absent. +4. `x(model)` / `C(models)`: a row with `capabilities.supports_reasoning === true` and no + family is reported as drift. The workbench logs it as + `"Local provider advertises reasoning support for a model with no hardcoded Bottlerocket + effort family; reasoning controls will be unavailable until it is added to + bottlerocket-families"` (`reportLocalProviderReasoningDrift`, + `out/vs/workbench/workbench.desktop.main.js`). + +Consequence: no `/v1/models` field can add a ladder for `fable`, `kimi`, `qwen` or +`muse`. `fable` appears in the bundle only in the Bedrock id list and the +`isFable5` heuristic; there is no effort family for it in 3.18.25. + +Why regular Cursor showed Fable 5.1 with effort tiers: that picker is Cursor's cloud +catalog (`GetUsableModels`), which carries effort-suffixed ids. The local build reads +only the gateway list and this table. + +## What opencodex does today + +- `src/server/models-capabilities.ts` `CURSOR_EFFORT_FAMILIES`: a hand-copied static + mirror of the table above (3.18.25). It cannot follow a Cursor update. +- `src/server/management/cursor-integration-routes.ts`: `reasoning: cursorEffortFamily(id)` + per visible model; `null` renders as "—". No provenance, no hint. +- `src/integrations/cursor-detect.ts`: finds the install root and version from + `product.json` (`nameLong`), injectable deps, read-only. +- `src/adapters/cursor/{catalog,effort-map,discovery}.ts` + `src/usage/expected-prices.ts`: + Fable 5.1 is seeded three times (`claude-fable-5-1`, `claude-fable-5.1`, + `claude-5.1-fable`) because Cursor has used both Anthropic-style and version-first + spellings and the live roster decides which one survives. +- Guide `docs-site/src/content/docs/guides/cursor-private-inference.md`: documents the + table and the "no control" rows; no install/identify section beyond "opencodex does not + distribute it", no bundle path, no env-var setup. + +## Levers, in dependency order + +1. **Read the table from the installed bundle** (wp1). The proxy already knows the install + root; the table is a stable minified literal (`{id:"…",matches:e=>/…/u.test(e),effort:X}` + with `X` one of `w/T/k/S` or an inline object). Parse regex + ladder + default + + outputCap; cache by path+mtime+size; fall back to the static mirror when there is no + install, the literal is not found, or a regex fails to compile. Surface + `{ source: "bundle" | "static", version }` in the status route. +2. **Send everything the bundle reads** (wp2): top-level `long_context_threshold_tokens` + is read directly by the picker (`kye(e.long_context_threshold_tokens)`) alongside + `pricing.overrides[].min_prompt_tokens`; `capabilities.max_output_tokens` is used when + the family has no `outputCap`. Both are missing today. +3. **Effort-variant rows** (wp3, opt-in): the only way a table-less model gets an effort + choice inside Cursor is separate rows. Off by default, byte-identical list when off. +4. **GUI provenance + hint** (wp4). 5. **Adapter normalizer** (wp5). 6. **Guide** (wp6). + +## Distribution stance (unchanged) + +Cursor does not document or link the Private Inference build; the update endpoint +`api2.cursor.sh/updates/api/update/darwin-arm64/cursor-local/3.18.25` answered 404 on +2026-09-02. The guide identifies an already-installed build and configures it; it never +hosts, links, or scripts a download (`rg 'downloads.cursor.com|cursor-local/'` stays 0). + +## Verifiers (PLAN-VERIFIER-REAL-01, run 2026-09-02) + +| Command | Exit | Reads the change target? | +|---|---|---| +| `bun run typecheck` | 0 (baseline) | yes — tsc over `src/**` | +| `bun test tests/cursor-integration-status.test.ts` | 0 (baseline) | yes — imports `cursorEffortFamily`, starts the server, reads the status route | +| `bun test tests/cursor-local-models-schema.test.ts` | 0 (baseline) | yes — starts the server and reads `/v1/models` | +| `bun test tests/cursor-catalog.test.ts` | 0 (baseline) | yes — adapter catalog/effort-map | +| `bun run privacy:scan` | 0 (baseline) | reads docs-site + devlog | +| `bun run lint:gui && bun run build:gui` | 0 (baseline) | wp4 only | + +Repository-wide `bun run test` is forbidden for this unit (user instruction); exact-head +CI on each PR is the full gate. + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/001_bundle_protocol.md b/devlog/_plan/260902_cursor_bundle_effort_table/001_bundle_protocol.md new file mode 100644 index 0000000000..0ab24b5f9f --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/001_bundle_protocol.md @@ -0,0 +1,366 @@ +# 001 — Cursor Private Inference bundle protocol + +Scope: static inspection of Cursor Private Inference 3.18.25. Evidence comes from the installed macOS bundle; Windows/Linux layout is derived from the repository’s existing install detector, not from inspected binaries. + +## Build identity and bundle paths + +`product.json` reports: + +```json +{ + "nameLong": "Cursor Private Inference", + "version": "3.18.25", + "quality": "stable", + "commit": "280eca2911f1774689696e5f1efa5a4f97a87af0", + "realCommit": "280eca2911f1774689696e5f1efa5a4f97a87af3", + "date": "2026-08-31T23:07:17.484Z", + "applicationName": "cursor", + "dataFolderName": ".cursor" +} +``` + +`buildFlags` and `releaseTrack` are absent. `quality: "stable"` is the only release-channel field. The workbench bundle, not `product.json`, enables the build: + +```js +fl={...,localMode:!1},fl.localMode=!0 +``` + +Paths relative to the install root: + +| Platform | product.json | agent bundle | +|---|---|---| +| macOS | `Contents/Resources/app/product.json` | `Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js` | +| Windows | `resources/app/product.json` | `resources/app/extensions/cursor-agent-exec/dist/main.js` | +| Linux package/extracted AppImage | `resources/app/product.json` | `resources/app/extensions/cursor-agent-exec/dist/main.js` | + +The inspected bundle is 9,932,774 bytes, MD5 `c2b57b0141b05e7e6e56cdcc206b95a5`. Regular Cursor 3.18.25 has a byte-identical `cursor-agent-exec` bundle; local-mode activation differs in the workbench. + +## Configuration inputs + +Workbench provider resolution precedence for both API key and Base URL is: + +1. requested model credentials: `modelDetails.apiKey`, `modelDetails.openaiApiBaseUrl`, or `modelDetails.apiKeyCredentials.{apiKey,baseUrl}`; +2. stored secret `openAIKey` and application storage `openAIBaseUrl`; +3. `CURSOR_LOCAL_AGENT_API_KEY` / `CURSOR_LOCAL_AGENT_BASE_URL`; +4. compatibility variables `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL`. + +Therefore environment variables do not override an already-saved gateway. A URL whose path is `/` becomes `/v1`; trailing slashes are removed. + +Provider-specific environment variables: + +| Variable | Reader/effect | +|---|---| +| `CURSOR_LOCAL_AGENT_BASE_URL` | fallback Base URL | +| `CURSOR_LOCAL_AGENT_API_KEY` | fallback API key | +| `CURSOR_LOCAL_AGENT_HEADERS` | custom headers; newline-separated `Name: value`, not `key=value` | +| `CURSOR_LOCAL_AGENT_ALLOW_CURSOR_HOST` | comma-separated hosts for which an Anthropic `/messages` Base URL is stripped before SDK construction | +| `CURSOR_LOCAL_AGENT_INFERENCE_METADATA` | outgoing `x-cursor-metadata` header | +| `CURSOR_AGENT_LOCAL_REQUEST_LOG` | JSONL request log path; `0`, `false`, or `off` disables | +| `CURSOR_AGENT_LOCAL_REQUEST_LOG_HTML` | companion rendered-log path | +| `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` | lowest-precedence compatibility fallback | + +`CURSOR_LOCAL_AGENT_HEADERS` rejects invalid HTTP names/values, `User-Agent`, and unresolved `{...}` placeholders. It expands `{gitOrgRepo}` and `{gitBranch}`. + +Persistent settings/state read by the desktop path include `useOpenAIKey`, `openAIBaseUrl`, secure `openAIKey`, `availableDefaultModels2`, `localProviderModelIds`, `localProviderAgentModelIds`, `modelPickerDisplayConfiguration`, and `aiSettings.modelConfig.composer`. + +The agent library also supports: + +```ts +localProvider: { + kind: "http", + endpoints: Array<{ + baseUrl: string; + apiKey?: string; + apiKeyHelper?: { scriptPath: string; ttlMs: number }; + }>; +} +customHeaders?: Record; +``` + +`apiKeyHelper` runs the readable script through `/bin/sh`, caches stdout by `scriptPath + ttlMs`, and sets both `Authorization: Bearer ...` and `X-Api-Key`. The desktop IPC currently constructs exactly one endpoint and passes no `apiKeyHelper`; these are library capabilities, not exposed desktop settings. + +## `/models` discovery, cache, fallback, and endpoint selection + +There are two fetch paths: + +- `spe`, the per-turn metadata probe, uses `Vme=2e3`: a 2-second abort timeout. +- `vye` / `fetchLocalProviderModels`, used for full picker enrichment, has no explicit timeout. + +Both send `GET {normalizedBaseUrl}/models`, `User-Agent: Cursor/`, custom headers, and `Authorization: Bearer `. A helper-derived key additionally sends `X-Api-Key`. + +`npe` caches successful raw catalogs forever by Base URL string only. `rpe` deduplicates concurrent probes. A full enrichment fetch overwrites `npe`; per-turn probes never revalidate it. + +The curated fallback list is verbatim: + +```js +const P=["claude-opus-4-8","claude-sonnet-5","claude-sonnet-4-6","gemini-3-pro-preview","grok-4.5"]; +``` + +It is used only when discovery fails for `inference.tesla.com` or a subdomain. Generic gateways do not receive this fallback; they retain persisted/local companion models where available. + +For multiple endpoints, all are probed concurrently. Model resolution attempts: + +1. exact full-id match; +2. Composer compatibility aliases; +3. a unique match after stripping the prefix before the last `/`; +4. a unique `-preview` suffix match. + +An exact requested id across endpoints wins; otherwise the first resolved candidate in endpoint order wins. If the selected catalog id differs, `remappedModelId` replaces the request’s model id. If none resolve, the first endpoint is used. + +## Wire API selection and request rewriting + +A Base URL ending in `/messages` forces `anthropic_messages`. + +Otherwise: + +- only `anthropic_messages`, with no OpenAI-family entry, selects Anthropic Messages; +- `responses` or `openai_responses` selects Responses; +- otherwise `chat_completions` or `openai_chat` selects Chat Completions; +- Responses wins when both Responses and Chat are advertised; +- an explicit caller `apiType` overrides discovery. + +Effort rewriting reads model parameter ids `reasoning`, `effort`, or `thought_level`: + +| Selected wire | Request fields | +|---|---| +| Responses | `reasoning: { ...existing, effort }`; remove `reasoning_effort` | +| Chat Completions | `reasoning_effort: effort` | +| Anthropic Messages | `thinking:{type:"adaptive",display:"summarized"}` and `output_config.effort`; remove `top_p` and `top_k` | + +The selected value must occur in Cursor’s hard-coded ladder. `output_config.effort` is accepted only on Anthropic Messages; `reasoning_effort` families are accepted only on OpenAI-compatible wires. Unsupported combinations have both `reasoning` and `reasoning_effort` removed. + +Consequently, with the documented `/v1` Base URL, Claude controls can render but their `output_config.effort` is removed because Responses is selected. A `/v1/messages` Base URL enables Claude effort but removes GPT/Grok/Gemini effort. The desktop’s singleton endpoint cannot automatically split these families. + +For Anthropic Messages only, `max_tokens` is overwritten when extended capabilities were detected: + +```js +family.outputCap !== undefined + ? family.outputCap + : advertised capabilities.max_output_tokens +``` + +Known Claude families therefore prefer Cursor’s hard-coded 32K/64K/128K cap over the advertised value. OpenAI-compatible requests do not consume the advertised maximum in this rewrite layer. + +## Extended-capability schema and optional-field sources + +Exact schema fragment: + +```js +const lme=new Set(["chat_completions","responses","openai_chat","openai_responses","anthropic_messages"]); +const mme=on.KC([on.g1(on.L5()),on.YO(on.g1(on.L5()))]); +const pme=on.Ik({ + context_length:on.ai().finite().positive().optional(), + max_output_tokens:on.ai().finite().positive().optional(), + output_modalities:on.YO(on.Yj()).optional(), + input_modalities:on.YO(on.Yj()).optional(), + supports_tool_use:on.zM().optional(), + supports_streaming:on.zM().optional(), + supports_reasoning:on.zM().optional(), + supports_vision:on.zM().optional(), + reasoning_effort:on.YO(on.Yj()).optional(), + cost:mme.optional() +}); +const fme=on.Ik({ + api_types:on.YO(on.Yj().min(1)).min(1).refine(e=>e.some(e=>lme.has(e))), + capabilities:pme.optional(), + cost:mme.optional() +}); +``` + +`extendedCapabilitiesDetected = data.some(row => fme.safeParse(row).success)`. One qualifying row flips the endpoint globally. + +When extended mode is true, an individual picker row requires `api_types`, `capabilities`, a supported API type, `supports_tool_use === true`, `supports_streaming === true`, and `output_modalities` containing `"text"`. Mixed legacy/extended catalogs can therefore lose otherwise valid rows. + +Optional-field normalization: + +- `context_length`: `capabilities.context_length` wins; top-level `context_length` fills it only when absent. +- `max_output_tokens`: read only from `capabilities.max_output_tokens`. +- modalities, support booleans, reasoning ladder: read only from `capabilities`. +- long-context threshold precedence: + 1. `cost.long_context.threshold_tokens`; + 2. `capabilities.cost.long_context.threshold_tokens`; + 3. smallest positive `pricing.overrides[].min_prompt_tokens`. +- Raw top-level `long_context_threshold_tokens` is not read. The parser creates its internal top-level field only from the three sources above. +- Nested `cost.long_context` conflicts with `mme`’s numeric-record schema and can prevent that row from satisfying `fme`. `pricing` is outside `fme`, making it the safe encoding currently used by OpenCodex. + +## Feature toggles gated by extended capabilities + +Direct uses found across all 34 occurrences: + +- expose the model-family Reasoning parameter; +- switch local web-search requests from `web_search_preview` to `web_search`; +- enable strict row admission during picker enrichment; +- allow Anthropic `max_tokens` injection from family/advertised limits; +- carry the flag into request rewriting and picker metadata. + +No additional image, MCP, ordinary function-tool, streaming, or vision feature toggle is directly keyed on this flag. + +## Unsupported reasoning drift + +`findUnsupportedReasoningModelIds` normalizes ids exactly like the effort table, deduplicates them, and reports ids whose row has `supports_reasoning === true` but matches neither a table family nor bare GPT-5. + +After a successful non-empty local picker enrichment, the workbench emits one structured `transport` error per id: + +> Local provider advertises reasoning support for a model with no hardcoded Bottlerocket effort family; reasoning controls will be unavailable until it is added to bottlerocket-families + +Malformed/dropped rows and failed enrichment do not reach this log. + +## Effort table, verbatim + +```js +const w={param:"reasoning_effort",values:["low","medium","high","xhigh"],defaultValue:"medium"}; +function _(e){const t=function(e){let t=e.trim().toLowerCase();const n=t.lastIndexOf("/");-1!==n&&(t=t.slice(n+1));const r=t.indexOf("@");return-1!==r&&(t=t.slice(0,r)),t}(e);if(/^gpt-5(?:\.\d+)?$/u.test(t))return w} +const T={param:"output_config.effort",values:["low","medium","high","max"],defaultValue:"high"}; +const k={param:"output_config.effort",values:["low","medium","high","xhigh","max"],defaultValue:"high"}; +const S={param:"reasoning_effort",values:["minimal","low","medium","high","xhigh"],defaultValue:"high"}; +const b=[ +{id:"anthropic-opus-5",matches:e=>/^claude-opus-5$/u.test(e),effort:k,outputCap:128e3}, +{id:"anthropic-opus-4-7-4-8",matches:e=>/^claude-opus-4[-.](?:7|8)$/u.test(e),effort:k,outputCap:128e3}, +{id:"anthropic-opus-4-6",matches:e=>/^claude-opus-4[-.]6$/u.test(e),effort:T,outputCap:128e3}, +{id:"anthropic-opus-4-5",matches:e=>/^claude-opus-4[-.]5$/u.test(e),effort:T,outputCap:64e3}, +{id:"anthropic-sonnet-4-6",matches:e=>/^claude-sonnet-4[-.]6$/u.test(e),effort:T,outputCap:64e3}, +{id:"anthropic-sonnet-5",matches:e=>/^claude-sonnet-5$/u.test(e),effort:k,outputCap:128e3}, +{id:"anthropic-sonnet-no-effort",matches:e=>/^claude-sonnet-4(?:[-.]5)?$/u.test(e),outputCap:64e3}, +{id:"anthropic-haiku-4-5",matches:e=>/^claude-haiku-4[-.]5$/u.test(e),outputCap:32768}, +{id:"grok-4.3",matches:e=>/^grok-4[.-]3$/u.test(e),effort:S}, +{id:"grok-4.5",matches:e=>/^grok-4[.-]5(?:-(?:batch|build|nocomp))?$/u.test(e),effort:S}, +{id:"grok-4.6",matches:e=>/^grok-4[.-]6(?:-(?:batch|build|nocomp))?$/u.test(e),effort:S}, +{id:"grok-build-latest",matches:e=>/^grok-build-latest$/u.test(e),effort:S}, +{id:"grok-reasoning-no-effort",matches:e=>/^grok-(?:composer(?:-2\.5(?:-fast)?)?|4\.20-0309-reasoning|4\.20-multi-agent-0309|420-clanker-reasoning)$/u.test(e)}, +{id:"gpt-5.6",matches:e=>/^gpt-5[.-]6-(?:luna|sol|terra)$/u.test(e),effort:{param:"reasoning_effort",values:["low","medium","high","xhigh"],defaultValue:"medium"}}, +{id:"gemini-no-effort",matches:e=>/^gemini-3\.[1-9].*flash-lite/u.test(e)}, +{id:"gemini",matches:e=>/^gemini-/u.test(e),effort:{param:"reasoning_effort",values:["minimal","low","medium","high"],defaultValue:"medium"},effortRequiresReasoningCapability:!0} +]; +``` + +## Diff-level implications for wp2 + +Do not add top-level `long_context_threshold_tokens`; this build ignores the raw field. Keep `pricing.overrides[].min_prompt_tokens`. + +Modify `src/server/models-capabilities.ts`. + +Before: + +```ts +export interface ModelCapabilityInput { + reasoningEfforts?: readonly string[]; + contextWindow?: number; + longContextWindow?: number; + inputModalities?: readonly string[]; +} +``` + +After: + +```ts +export interface ModelCapabilityInput { + reasoningEfforts?: readonly string[]; + contextWindow?: number; + longContextWindow?: number; + maxOutputTokens?: number; + inputModalities?: readonly string[]; +} +``` + +Add `max_output_tokens?: number` to `ModelCapabilityFields.capabilities`, compute `const maxOutputTokens = positiveInt(input.maxOutputTokens)`, and spread it into `capabilities` only when defined. + +Modify `src/server/index.ts`: + +```ts +import { modelRecordValue } from "../reasoning-effort"; +``` + +Routed-row call, before: + +```ts +contextWindow: m.contextWindow, +inputModalities: m.inputModalities, +``` + +After: + +```ts +contextWindow: m.contextWindow, +maxOutputTokens: provider + ? modelRecordValue(provider.modelMaxOutputTokens, m.id) ?? provider.defaultMaxOutputTokens + : undefined, +inputModalities: m.inputModalities, +``` + +Do not invent native limits where OpenCodex has no authoritative output-limit source. + +Modify `tests/cursor-local-models-schema.test.ts`: + +- Add test `"max_output_tokens is emitted only from an authoritative provider output limit"`. +- Extend `capabilityConfig()` with `defaultMaxOutputTokens: 16000` and `modelMaxOutputTokens: { k3: 32768 }`. +- Assert `k3.capabilities.max_output_tokens === 32768`. +- Assert `kimi-for-coding.capabilities.max_output_tokens === 16000`. +- Assert native `gpt-5.6-sol` omits `max_output_tokens`. +- In `"a larger opt-in window becomes context_length with the default window as the long-context threshold"`, assert no top-level `long_context_threshold_tokens` is emitted and retain the `pricing` assertion. + +Focused verifier: + +```sh +bun run typecheck +bun test tests/cursor-local-models-schema.test.ts tests/grok-models-effort-list.test.ts tests/server-combo-failover-e2e.test.ts +``` + +## Diff-level implications for wp6 + +Modify `docs-site/src/content/docs/guides/cursor-private-inference.md`. + +Replace the `CURSOR_LOCAL_AGENT_HEADERS` claim that it uses `key=value` pairs with: + +```md +`CURSOR_LOCAL_AGENT_HEADERS` is optional. Its value is newline-separated HTTP header +lines (`Header-Name: value`). It rejects `User-Agent` and invalid or unresolved values. +``` + +Add gateway precedence immediately after the environment block: + +```md +Saved Gateway settings and per-model credentials take precedence over these environment +variables. Clear the saved gateway first if you intend to switch it through the environment. +`ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are lower-precedence compatibility fallbacks. +``` + +Correct the wire section: + +```md +With a `/v1` Base URL, Cursor prefers Responses. GPT/Grok/Gemini effort is sent on that +wire, but Claude's `output_config.effort` is removed because it is Messages-only. A Base URL +ending in `/messages` reverses that behavior: Claude effort is sent, while OpenAI-family +effort fields are removed. One desktop gateway cannot split both families automatically. +``` + +Add an “Identify the installed build” subsection containing the platform-relative bundle paths, `nameLong`, `version`, `quality`, and the fact that `localMode` is in the workbench bundle rather than `product.json`. + +Update troubleshooting to say the cache has no TTL; Refresh performs full discovery, while restart or a changed Base URL is the fallback if stale metadata remains. + +Verification: + +```sh +cd docs-site && bun run build +bun run privacy:scan +rg -n 'downloads.cursor.com|cursor-local/' docs-site/src/content/docs/guides/cursor-private-inference.md +``` + +## RISKS + +- This is an undocumented, minified private protocol and can change without schema versioning. +- Static bundle inspection does not prove every provider/wire combination end to end. +- One extended row globally enables strict filtering and can make mixed legacy rows disappear. +- Cursor’s hard-coded Claude output cap overrides the gateway-advertised maximum; OpenCodex must still enforce its own limit. +- The desktop exposes only one endpoint even though the library supports several. +- Windows/Linux bundle contents were not inspected; only their repository-defined layout is recorded. +- `apiKeyHelper` hard-codes `/bin/sh`, making its cross-platform behavior doubtful even if a future desktop path exposes it. + +## OPEN QUESTIONS + +- Should wp2 advertise provider output limits now, given that known Claude families ignore them in favor of Cursor’s cap? +- Should wp6 document `/messages` as a supported Claude-only profile, or only warn that Claude effort is inert on `/v1`? +- Does OpenCodex’s Messages ingress preserve `thinking + output_config.effort` for every routed Claude provider? +- Does Refresh reliably overwrite `npe` in all desktop flows, or are restart/Base-URL changes still required in practice? +- Are Windows and Linux 3.18.25 bundles byte-identical to the inspected macOS bundle? + + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/005_audit_round1.md b/devlog/_plan/260902_cursor_bundle_effort_table/005_audit_round1.md new file mode 100644 index 0000000000..77de5339ce --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/005_audit_round1.md @@ -0,0 +1,41 @@ +# 005 — Audit round 1 (roadmap, wp0) + +Two dispatched reviewers (gpt-5.6-sol high, agents 01a06204… and 01a06210…) produced no output +within 6 and 6 wait cycles and were retired (DISPATCH-RETIRE-01). The main agent audited +directly against the tree on 2026-09-02; evidence below is from commands run in this session. + +## Checks + +1. 010 parser vs the real bundle: `bun /tmp/ocx-effort-probe.js` applying the 010 regexes to + `main.js` → 4 effort constants (w/T/k/S), **16 families**, every `effort:` ref resolves, + outputCaps 128000/64000/32768 read, bare rule `^gpt-5(?:\.\d+)?$` → w. PASS. +2. Before-snippets: `cursor-integration-routes.ts:64-72` matches 010; `context.ts:60` + `readRuntimePort` seam exists for the injected loader; `responses/core.ts:2753` + `parsed = parseRequest(body)`; `chat-completions.ts:136` `isNativeChatRouteEligible`; + `claude-messages.ts:646` `wantsNativePassthrough` — all match 030. PASS. +3. Grammar collision (030): `rg -- '--(low|medium|high|xhigh|max|minimal|none|ultra)\b'` over + registry.ts, effort-map.ts, generated/model-metadata.ts → 0 hits. PASS. +4. Lab boundary: `models-capabilities.ts` has no imports; the planned + `cursor-effort-table.ts` imports node:fs/path + a type. Nothing reaches src/lab. PASS. +5. 050 vs `tests/cursor-catalog.test.ts:101-103` (exact ids `gpt-5.1-codex-max`, + `gpt-5.5-extra`): the normalizer only accepts `claude-*` stems, so ordering it first cannot + mis-parse those. REAL_1M ordering is stated in 050. PASS. +6. 040 i18n: `gui/src/i18n/provider.tsx:25` falls back to `en` per key. Residual resolved. +7. 020 vs 001 contradiction (top-level long_context_threshold_tokens): resolved in 020 by + dropping the field; bundle check `void 0!==i?{long_context_threshold_tokens:i}:{}` confirms + Cursor derives it. PASS. +8. Field chains: effortTable/family (010) create in the route → JSON → cursor-api.ts type → + rendered in 040; tableLess/effortRows (030) same; cursorEffortRows (030) config type + zod + + effort-row.ts + three ingress handlers + status route; maxOutputTokens (020) metadata → + CatalogModel → provider-fetch → aggregation → index.ts row. Complete. + +## Blockers + +1. Medium — 030 §6: `claude-messages.ts:648-654` already applies an `effortOverride` + (`extractOcxEffortDirective`) via `output_config.effort` before translation. wp3's P must + reconcile the row effort with that path (reuse it, or justify injecting the internal + Responses `reasoning.effort` as the lane proposed because of the `none` rung). Folded as a + P-phase task of wp3; not a wp0 blocker. + +VERDICT: GO-WITH-FIXES (blockers=1) + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md b/devlog/_plan/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md new file mode 100644 index 0000000000..df04aa8361 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/010_wp1_bundle_effort_table.md @@ -0,0 +1,283 @@ +# 010 — wp1: read Cursor's effort table from the installed bundle + +Depends on: 000. Delivers: `src/integrations/cursor-effort-table.ts` (NEW), a resolver in +`src/server/models-capabilities.ts`, provenance on the status route, tests. PR 1; targets +`dev` directly (independent of wp2..wp6). + +Loop-spec: archetype spec-satisfaction; trigger = status card shows "—" for ids the bundle +would render; goal = the card follows the installed Cursor build instead of a hand copy; +non-goals = changing what Cursor renders, writing into a Cursor install; verifier = +`bun test tests/cursor-integration-status.test.ts tests/cursor-effort-table.test.ts` + typecheck; +stop = both green and exact-head CI green; escalation = if the minified literal shape differs on +Windows/Linux builds, keep the static fallback and record it in 011. + +## File change map + +### NEW `src/integrations/cursor-effort-table.ts` + +```ts +/** + * Cursor's local-agent effort table, read from the installed bundle. + * + * Cursor Private Inference decides which model rows get a Reasoning control from a table + * compiled into extensions/cursor-agent-exec/dist/main.js, not from the gateway's + * reasoning_effort list (devlog 260902_cursor_bundle_effort_table/000). Reading that table + * from the install the dashboard already detects lets the prediction follow a Cursor update + * instead of a hand-copied mirror. Read-only, size-bounded, cached by (path, mtime, size); + * any parse failure yields null so the caller falls back to the static mirror. + */ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { CursorInstall } from "./cursor-detect"; + +export interface CursorEffortFamily { + id: string; + pattern: RegExp; + /** [] = family matched but Cursor shows no control. */ + ladder: readonly string[]; + param?: "reasoning_effort" | "output_config.effort"; + defaultValue?: string; + outputCap?: number; + requiresReasoningCapability: boolean; +} + +export interface CursorEffortTable { + families: readonly CursorEffortFamily[]; + /** The bare gpt-5 / gpt-5.x rule that runs when no family matched. */ + bareGpt5: { pattern: RegExp; ladder: readonly string[]; defaultValue: string } | null; + version: string | null; + bundlePath: string; +} + +const BUNDLE_MAX_BYTES = 32 * 1024 * 1024; + +/** Bundle path under the install root cursor-detect reports. */ +export function cursorAgentBundlePath(install: Pick, platform = process.platform): string { + const tail = ["extensions", "cursor-agent-exec", "dist", "main.js"]; + return platform === "darwin" + ? join(install.path, "Contents", "Resources", "app", ...tail) + : join(install.path, "resources", "app", ...tail); +} + +/** + * Parse the family table out of the minified source: + * const w={param:"reasoning_effort",values:[...],defaultValue:"medium"}; + * ...const T={...},k={...},S={...},b=[{id:"...",matches:e=>/.../u.test(e),effort:k,outputCap:128e3},...]; + * Identifier names are minifier-assigned, so binding is by structure: every + * ={param:"...",values:[...],defaultValue:"..."} is an effort constant, and a family's + * effort: is either such an identifier or an inline object. + */ +export function parseCursorEffortTable(source: string): Omit | null { + const constants = new Map(); + const constRe = /(?:const |,)([A-Za-z_$][\w$]*)=\{param:"(reasoning_effort|output_config\.effort)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/gu; + for (const m of source.matchAll(constRe)) { + constants.set(m[1]!, { param: m[2]!, values: splitStrings(m[3]!), defaultValue: m[4]! }); + } + const tableStart = source.indexOf('=[{id:"anthropic-'); + if (tableStart === -1) return null; + const tableEnd = source.indexOf("];", tableStart); + if (tableEnd === -1) return null; + const body = source.slice(tableStart + 2, tableEnd + 1); + const entryRe = /\{id:"([^"]+)",matches:e=>\/((?:\\\/|[^/])+)\/([a-z]*)\.test\(e\)((?:,(?:effort:(?:[A-Za-z_$][\w$]*|\{[^}]*\})|outputCap:[\de.]+|effortRequiresReasoningCapability:!0))*)\}/gu; + const families: CursorEffortFamily[] = []; + for (const m of body.matchAll(entryRe)) { + let pattern: RegExp; + try { pattern = new RegExp(m[2]!, m[3]!); } catch { return null; } + const tail = m[4]!; + const effortRef = /effort:([A-Za-z_$][\w$]*)(?:,|$)/u.exec(tail)?.[1]; + const inline = /effort:\{param:"([^"]+)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/u.exec(tail); + const effort = inline + ? { param: inline[1]!, values: splitStrings(inline[2]!), defaultValue: inline[3]! } + : effortRef ? constants.get(effortRef) : undefined; + if (effortRef && !inline && !effort) return null; // unknown constant: structure changed + const cap = /outputCap:([\de.]+)/u.exec(tail)?.[1]; + families.push({ + id: m[1]!, + pattern, + ladder: effort?.values ?? [], + ...(effort ? { param: effort.param as CursorEffortFamily["param"], defaultValue: effort.defaultValue } : {}), + ...(cap ? { outputCap: Number(cap) } : {}), + requiresReasoningCapability: tail.includes("effortRequiresReasoningCapability:!0"), + }); + } + if (families.length === 0) return null; + const bareRe = /if\(\/(\^gpt-5[^/]+)\/([a-z]*)\.test\(t\)\)return ([A-Za-z_$][\w$]*)\}/u.exec(source); + const bareConst = bareRe ? constants.get(bareRe[3]!) : undefined; + const bareGpt5 = bareRe && bareConst + ? { pattern: new RegExp(bareRe[1]!, bareRe[2]!), ladder: bareConst.values, defaultValue: bareConst.defaultValue } + : null; + return { families, bareGpt5 }; +} + +function splitStrings(list: string): string[] { + return [...list.matchAll(/"([^"]+)"/gu)].map(m => m[1]!); +} + +export interface CursorEffortTableDeps { + platform: string; + stat(path: string): { mtimeMs: number; size: number } | null; + readText(path: string): string | null; +} + +export function realCursorEffortTableDeps(): CursorEffortTableDeps { + return { + platform: process.platform, + stat: path => { try { const s = statSync(path); return { mtimeMs: s.mtimeMs, size: s.size }; } catch { return null; } }, + readText: path => { try { return readFileSync(path, "utf8"); } catch { return null; } }, + }; +} + +let cache: { key: string; table: CursorEffortTable | null } | null = null; + +/** Table from the Private Inference install, else null (caller falls back to the static mirror). */ +export function loadCursorEffortTable(install: CursorInstall | undefined, deps: CursorEffortTableDeps = realCursorEffortTableDeps()): CursorEffortTable | null { + if (!install) return null; + const bundlePath = cursorAgentBundlePath(install, deps.platform); + const st = deps.stat(bundlePath); + if (!st || st.size > BUNDLE_MAX_BYTES) return null; + const key = `${bundlePath}|${st.mtimeMs}|${st.size}`; + if (cache?.key === key) return cache.table; + const text = deps.readText(bundlePath); + const parsed = text ? parseCursorEffortTable(text) : null; + const table = parsed ? { ...parsed, version: install.version, bundlePath } : null; + cache = { key, table }; + return table; +} + +export function resetCursorEffortTableCacheForTests(): void { cache = null; } +``` + +### MODIFY `src/server/models-capabilities.ts` + +Keep `CURSOR_EFFORT_FAMILIES` as the static mirror (comment becomes "fallback mirror of the +3.18.25 table; the live table is read by src/integrations/cursor-effort-table.ts"). Add: + +```ts +import type { CursorEffortTable } from "../integrations/cursor-effort-table"; + +export interface CursorEffortPrediction { + ladder: string[] | null; + source: "bundle" | "static"; + /** Bundle family id when one matched (e.g. "anthropic-opus-5"); null otherwise. */ + family: string | null; + outputCap?: number; +} + +export function normalizeCursorPickerId(modelId: string): string { + let id = modelId.trim().toLowerCase(); + const slash = id.lastIndexOf("/"); + if (slash !== -1) id = id.slice(slash + 1); + const at = id.indexOf("@"); + if (at !== -1) id = id.slice(0, at); + return id; +} + +export function predictCursorEffort(modelId: string, table: CursorEffortTable | null): CursorEffortPrediction { + const id = normalizeCursorPickerId(modelId); + if (table) { + for (const family of table.families) { + if (family.pattern.test(id)) { + return { + ladder: family.ladder.length > 0 ? [...family.ladder] : null, + source: "bundle", + family: family.id, + ...(family.outputCap !== undefined ? { outputCap: family.outputCap } : {}), + }; + } + } + if (table.bareGpt5?.pattern.test(id)) return { ladder: [...table.bareGpt5.ladder], source: "bundle", family: "gpt-5" }; + return { ladder: null, source: "bundle", family: null }; + } + return { ladder: cursorEffortFamily(modelId), source: "static", family: null }; +} +``` + +`cursorEffortFamily` is unchanged in behavior (the existing test keeps passing) and reuses +`normalizeCursorPickerId`. + +### MODIFY `src/server/management/cursor-integration-routes.ts` + +Before (lines 64-72): +```ts + const models = ids.map(id => { + const tier = nativeOpenAiContextTier(id, limits); + return { + id, + reasoning: cursorEffortFamily(id), + context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null, + }; + }); +``` +After: +```ts + const table = (deps.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference); + const models = ids.map(id => { + const tier = nativeOpenAiContextTier(id, limits); + const predicted = predictCursorEffort(id, table); + return { + id, + reasoning: predicted.ladder, + family: predicted.family, + context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null, + }; + }); + const effortTable = table + ? { source: "bundle" as const, version: table.version, families: table.families.length } + : { source: "static" as const, version: null, families: null }; +``` + +- `CursorIntegrationStatus` gains `effortTable: { source: "bundle" | "static"; version: string | null; families: number | null }` + and each model row gains `family: string | null`; `effortTable` is added to the returned object. +- `ManagementContext.deps` (`src/server/management/context.ts`, next to `readRuntimePort`) gains + optional `loadCursorEffortTable?: (install: CursorInstall | undefined) => CursorEffortTable | null` + so the route test injects a fixture table without touching /Applications. +- Imports: `predictCursorEffort` replaces `cursorEffortFamily`; `loadCursorEffortTable` from + `../../integrations/cursor-effort-table`. + +### MODIFY `gui/src/pages/integrations/cursor-api.ts` + +Add `effortTable` and `family` to the TS interface only (rendering is wp4). No behavior change. + +### NEW `tests/fixtures/cursor-agent-exec-effort-table.min.js` + +The verbatim literal window from 3.18.25 (`const w={param:...}` through +`effortRequiresReasoningCapability:!0}];`, ~3.6 KB) with unrelated minified code before and +after, so the parser proves it scans rather than matches at offset 0. + +### NEW `tests/cursor-effort-table.test.ts` + +1. `parseCursorEffortTable(fixture)`: 16 families; `anthropic-opus-5` → ladder low..max, param + output_config.effort, default high, outputCap 128000; `gemini` → requiresReasoningCapability + true; `anthropic-haiku-4-5` → ladder [] and outputCap 32768; `bareGpt5` default medium. +2. `predictCursorEffort("anthropic/claude-opus-5", table)` → source bundle, family anthropic-opus-5; + `"anthropic/claude-fable-5-1"` and `"cursor/kimi-k3"` → ladder null, source bundle, family null; + `"gpt-5.4"` → bareGpt5 ladder; `"xai/grok-4.6@main"` → grok-4.6 (the @ strip). +3. Fallback activation (C-ACTIVATION-GROUNDING-01): `loadCursorEffortTable` with `stat` → null + returns null; with a bundle lacking the literal → null; with a malformed regex (`/[/u`) → null; + `predictCursorEffort(id, null)` → source static with the mirror ladder. +4. Cache: two loads with equal stat call `readText` once; a changed mtime re-reads. + +### MODIFY `tests/cursor-integration-status.test.ts` + +Existing route case passes `deps: { loadCursorEffortTable: () => null }` and asserts +`effortTable.source === "static"`; a new case injects the parsed fixture and asserts +`source === "bundle"`, `version === "3.18.25"`, `families === 16`, and +`models.find(m => m.id === "kimi/k3").family === null`. + +## Scope boundary + +IN: files above. OUT: GUI rendering (wp4), `/v1/models` row shape (wp2), any write into a +Cursor install. The bundle read is bounded (32 MiB), read-only, and never executes Cursor code. + +## Accept criteria + +- `bun run typecheck` 0; `bun test tests/cursor-effort-table.test.ts tests/cursor-integration-status.test.ts` 0. +- On this machine `curl /api/native-integrations/cursor` shows `effortTable.source: "bundle"`, + `version: "3.18.25"`, `families: 16`, and `anthropic/claude-fable-5-1` keeps `reasoning: null`. +- `tests/core-lab-boundary.test.ts` unaffected (no import from src/lab). + +## Bypass fields (PLAN-BYPASS-NAMED-01) + +Tier E3 (runtime read with fallback); surface: the status route; bypass: a build whose literal +shape changed falls back to static, and the GUI shows "static" so the drift is visible; residual +risk: a newer build that renamed a param; wording: "prediction", never "enforcement". diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md b/devlog/_plan/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md new file mode 100644 index 0000000000..260ad12885 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/020_wp2_models_max_output.md @@ -0,0 +1,416 @@ +# 020 — wp2: /v1/models hardening (max_output_tokens; threshold stays in pricing.overrides) + +Depends on: 010. Own PR against `dev`. + +Loop-spec: spec-satisfaction; trigger = rows omit `capabilities.max_output_tokens` although the +bundle reads it (Anthropic wire `max_tokens` when no family outputCap; tooltip "Max output"); +goal = advertise an authoritative output ceiling where opencodex has one; non-goals = inventing +limits, changing `supports_reasoning`; verifier = the focused list below + typecheck; stop = +green + exact-head CI. + +## Decision recorded at P (conflict between research lanes) + +Lane B proposed also emitting a top-level `long_context_threshold_tokens`. Lane D read the +parser (001 §"Extended-capability schema"): Cursor's row normaliser computes that field itself +from `cost.long_context.threshold_tokens` → `capabilities.cost.long_context.threshold_tokens` → +smallest `pricing.overrides[].min_prompt_tokens`, and the raw top-level key is never read +(bundle: `void 0!==i?{long_context_threshold_tokens:i}:{}` where `i` is derived from those +three). Emitting it would be dead data and a nested `cost.long_context` breaks the `mme` +numeric-record schema. **wp2 keeps `pricing.overrides` as the only threshold carrier and adds +no top-level key.** The test asserts its absence so nobody re-adds it. + +## Design (Lane B, folded; threshold item removed) + +## Findings + +- `modelCapabilityFields` currently emits `pricing.overrides` for long tiers but omits Cursor’s validated top-level `long_context_threshold_tokens` ([models-capabilities.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/models-capabilities.ts:97)). +- The generated tuple’s third column is `maxTokens`; `rowToMetadata` exposes it as `ModelMetadata.maxTokens`. It is the model output-token budget, not an input limit ([model-metadata.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/generated/model-metadata.ts:38), [generator](/Users/jun/.codex/worktrees/4ed0/opencodex/scripts/generate-model-metadata.ts:90)). +- `CatalogModel` has `contextWindow`, `maxInputTokens`, and `inputModalities`, but no output-token field ([parsing.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/parsing.ts:95)). +- Routed context/modalities arrive through provider configuration and live `/models` parsing; generated metadata also supplies them when jawcode rows are appended ([provider-fetch.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/provider-fetch.ts:682), [provider-fetch.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/provider-fetch.ts:1209), [provider-fetch.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/provider-fetch.ts:2364)). +- `supports_reasoning` is already honest: it is `true` only when the advertised ladder is non-empty. Generated metadata’s boolean `reasoning` flag is not consulted, and should remain unused here ([models-capabilities.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/models-capabilities.ts:118)). + +## Diff-level design + +### 1. Extend the Cursor capability projection + +[src/server/models-capabilities.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/models-capabilities.ts:57) + +Change the contracts to: + +```ts +export interface ModelCapabilityInput { + reasoningEfforts?: readonly string[]; + contextWindow?: number; + longContextWindow?: number; + maxOutputTokens?: number; + inputModalities?: readonly string[]; +} + +export interface ModelCapabilityFields { + api_types: readonly string[]; + capabilities: { + context_length?: number; + max_output_tokens?: number; + output_modalities: string[]; + input_modalities?: string[]; + supports_tool_use: true; + supports_streaming: true; + supports_reasoning: boolean; + supports_vision?: boolean; + reasoning_effort?: string[]; + }; + long_context_threshold_tokens?: number; + pricing?: { overrides: Array<{ min_prompt_tokens: number }> }; +} +``` + +The exact function signature remains: + +```ts +export function modelCapabilityFields( + input: ModelCapabilityInput, +): ModelCapabilityFields +``` + +Inside it, add: + +```ts +const maxOutputTokens = positiveInt(input.maxOutputTokens); +``` + +Then emit: + +```ts +capabilities: { + ...(hasLongTier + ? { context_length: longContextLength } + : contextLength !== undefined ? { context_length: contextLength } : {}), + ...(maxOutputTokens !== undefined ? { max_output_tokens: maxOutputTokens } : {}), + output_modalities: ["text"], + // existing fields unchanged +}, +...(hasLongTier + ? { + long_context_threshold_tokens: contextLength, + pricing: { overrides: [{ min_prompt_tokens: contextLength }] }, + } + : {}), +``` + +Do not add `cost.long_context`: the requested contract is the validated top-level threshold while retaining the existing pricing override. + +### 2. Expose native output limits from canonical metadata + +[src/codex/catalog/metadata.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/metadata.ts:266) + +Add beside the native context helpers: + +```ts +export function nativeOpenAiMaxOutputTokens(slug: string): number | undefined { + const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug); + return positiveInt(getModelMetadata("openai", sourceSlug)?.maxTokens); +} +``` + +This also gives `gpt-daybreak-blue-latest` Sol’s inherited 128k output limit. + +Export it through [src/codex/catalog.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog.ts:5): + +```ts +nativeOpenAiMaxOutputTokens, +``` + +Do not edit `src/generated/model-metadata.ts` or its generator; the required column already exists. + +### 3. Add output limits to `CatalogModel` + +[src/codex/catalog/parsing.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/parsing.ts:113) + +```ts +contextWindow?: number; +maxInputTokens?: number; +/** Model-scoped output-token ceiling; omitted when no authoritative value is known. */ +maxOutputTokens?: number; +``` + +### 4. Carry routed values through provider discovery + +[src/codex/catalog/provider-fetch.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/provider-fetch.ts:566) + +Include model-scoped output metadata in the gather fingerprint: + +```ts +maxOut: prov.modelMaxOutputTokens ?? null, +``` + +Add a resolver near the existing configured-limit helpers: + +```ts +function generatedMaxOutputTokens( + providerName: string, + id: string, +): number | undefined { + const metadataProvider = resolveMetadataProvider(providerName); + if (!metadataProvider) return undefined; + const metadata = getModelMetadata(metadataProvider, id) + ?? (shouldCaseFoldMetadataModelId(providerName) + ? getModelMetadataCaseInsensitive(metadataProvider, id) + : undefined); + return positiveSafeInteger(metadata?.maxTokens); +} + +function routedMaxOutputTokens( + providerName: string, + provider: OcxProviderConfig, + model: CatalogModel, +): number | undefined { + const discovered = positiveSafeInteger(model.maxOutputTokens); + const generated = generatedMaxOutputTokens(providerName, model.id); + const configured = positiveSafeInteger( + modelRecordValue(provider.modelMaxOutputTokens, model.id), + ); + const authoritative = discovered ?? generated; + if (configured === undefined) return authoritative; + return authoritative === undefined + ? configured + : Math.min(authoritative, configured); +} +``` + +Intentionally exclude `defaultMaxOutputTokens`: it is a request default, not a model-specific ceiling. + +In `applyProviderConfigHints`: + +```ts +const maxOutputTokens = routedMaxOutputTokens(name, prov, model); +``` + +and in `hinted`: + +```ts +...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), +``` + +In `catalogHintsFromModelsApiItem`, parse only explicit output-limit fields: + +```ts +const maxOutputTokens = positiveSafeInteger( + capabilityRecord?.max_output_tokens, + limits?.max_output_tokens, + metadata?.max_output_tokens, + item.max_output_tokens, +); +``` + +Return it alongside the existing limits: + +```ts +...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), +``` + +When `augmentRoutedModelsWithMetadata` constructs missing jawcode rows, add: + +```ts +...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 + ? { maxOutputTokens: meta.maxTokens } + : {}), +``` + +For trusted OpenAI API rows, call `routedMaxOutputTokens` using the existing live row as the discovered input and emit the result. Add `maxOutputTokens` to `normalizedOpenAiApiSignature` so metadata collisions remain observable. + +For custom-model replacement merging, conservatively take the minimum positive value from `base.maxOutputTokens` and `replaced?.maxOutputTokens`, exactly as `maxInputTokens` is currently merged. + +### 5. Preserve output limits through combos + +[src/codex/catalog/aggregation.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/codex/catalog/aggregation.ts:122) + +A combo has a known output ceiling only when every member has one: + +```ts +const knownMaxOutputTokens = members + .map(member => member.maxOutputTokens) + .filter((value): value is number => typeof value === "number" && value > 0); +const maxOutputTokens = knownMaxOutputTokens.length === members.length + ? Math.min(...knownMaxOutputTokens) + : undefined; +``` + +Add to the returned row: + +```ts +...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), +``` + +In `provider-fetch.ts`, add `maxOutputTokens` to `ComboCatalogMemberFallback`, native synthetic members, native-alias fallback metadata, and `withFallbackMetadata`. A fallback may fill an unknown output limit, but must never replace a smaller discovered one. + +### 6. Wire the fields into `/v1/models` + +[src/server/index.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/index.ts:1357) + +Add `nativeOpenAiMaxOutputTokens` to the dynamic catalog import. + +Native call: + +```ts +...modelCapabilityFields({ + reasoningEfforts: nativeReasoningEfforts(metadataId), + ...nativeContextInput(metadataId), + maxOutputTokens: nativeOpenAiMaxOutputTokens(metadataId), + inputModalities: nativeInputModalities(metadataId), +}), +``` + +Routed call: + +```ts +...modelCapabilityFields({ + reasoningEfforts: m.reasoningEfforts, + contextWindow: m.contextWindow, + maxOutputTokens: m.maxOutputTokens, + inputModalities: m.inputModalities, +}), +``` + +No change to reasoning derivation: neither `cursorEffortFamily()` nor generated `metadata.reasoning` should affect `supports_reasoning`. + +## Exact test changes + +### `tests/cursor-local-models-schema.test.ts` + +Update `capabilityConfig()`: + +```ts +modelMaxOutputTokens: { k3: 64_000 }, +``` + +In `a larger opt-in window becomes context_length...`, add: + +```ts +expect(tiered.long_context_threshold_tokens).toBe(272000); +expect("long_context_threshold_tokens" in flat).toBe(false); +``` + +Add test: + +```ts +test("max output tokens are sanitized independently of reasoning", () => { + expect(modelCapabilityFields({ maxOutputTokens: 128000 }).capabilities.max_output_tokens) + .toBe(128000); + expect("max_output_tokens" in modelCapabilityFields({ maxOutputTokens: 0 }).capabilities) + .toBe(false); + expect(modelCapabilityFields({ maxOutputTokens: 1.9 }).capabilities.supports_reasoning) + .toBe(false); +}); +``` + +In `routed rows carry api_types...`: + +```ts +expect(k3Caps.max_output_tokens).toBe(64_000); +expect(solCaps.max_output_tokens).toBe(128_000); +expect(sol!.long_context_threshold_tokens).toBe(272_000); +expect("max_output_tokens" in plainCaps).toBe(false); +``` + +### `tests/provider-model-discovery-contract.test.ts` + +Extend `accepts only positive safe-integer token limits from live metadata`: + +```ts +expect(catalogHintsFromModelsApiItem("example", { + id: "valid-output", + capabilities: { max_output_tokens: 8192 }, +})).toEqual({ maxOutputTokens: 8192 }); + +expect(catalogHintsFromModelsApiItem("example", { + id: "invalid-output", + capabilities: { max_output_tokens: 0.5 }, +})).toEqual({}); +``` + +Also cover `Number.MAX_SAFE_INTEGER + 1`. + +### `tests/codex-catalog.test.ts` + +In `DeepSeek catalog sync appends V4 rows missing from /v1/models`, assert: + +```ts +expect(models.find(model => model.id === "deepseek-v4-flash")?.maxOutputTokens) + .toBe(384_000); +``` + +Add: + +```ts +test("combo output ceiling is the smallest known member ceiling and stays unknown if any member is unknown", () => { + const known = deriveComboCatalogModel("known-output", normalizedCombo(), [ + { provider: "a", id: "m1", contextWindow: 128_000, maxOutputTokens: 64_000 }, + { provider: "b", id: "m2", contextWindow: 128_000, maxOutputTokens: 32_000 }, + ]); + expect(known?.maxOutputTokens).toBe(32_000); + + const partial = deriveComboCatalogModel("partial-output", normalizedCombo(), [ + { provider: "a", id: "m1", contextWindow: 128_000, maxOutputTokens: 64_000 }, + { provider: "b", id: "m2", contextWindow: 128_000 }, + ]); + expect(partial).not.toHaveProperty("maxOutputTokens"); +}); +``` + +### `tests/grok-models-effort-list.test.ts` + +Keep all current Grok assertions. In `models with an empty tier list advertise no effort fields`, add: + +```ts +const capabilities = plain!.capabilities as Record; +expect(capabilities.supports_reasoning).toBe(false); +expect("reasoning_effort" in capabilities).toBe(false); +``` + +This pins the independent Grok/Cursor representations to the same ladder truth. + +### `tests/server-combo-failover-e2e.test.ts` + +In `ordinary /v1/models restores a non-OpenAI selector...`, add: + +```ts +modelMaxOutputTokens: { "deepseek-chat": 64_000 }, +``` + +Extend the response-row type with: + +```ts +capabilities?: { max_output_tokens?: number }; +``` + +Assert both the combo alias and restored routed row advertise `64_000`. Existing `toMatchObject` row-literal assertions remain valid because they intentionally match subsets. + +## Worker verification + +```bash +bun test tests/cursor-local-models-schema.test.ts +bun test tests/provider-model-discovery-contract.test.ts +bun test tests/codex-catalog.test.ts +bun test tests/grok-models-effort-list.test.ts +bun test tests/server-combo-failover-e2e.test.ts +bun run typecheck +bun run test:changed +``` + +Do not run `bun run test` or bare `bun test`. + +## RISKS + +- `modelMaxOutputTokens` is currently used as an adapter fallback, not a runtime-enforced hard ceiling. Treating a model-scoped value as an advertised ceiling is conservative when it lowers the generated/live value, but `defaultMaxOutputTokens` must remain excluded. +- Generated metadata can become stale; live explicit capability data should therefore win, with configured values allowed only to narrow. +- Combo propagation must require every member to be known. Taking the minimum of only known members would overstate a route whose unknown target may support less. +- Cursor Private Inference behavior remains statically established, not end-to-end verified against this endpoint. + +## OPEN QUESTIONS + +- Non-blocking policy choice: should user-supplied `modelMaxOutputTokens` be considered authoritative enough to advertise? Recommendation: yes for the model-scoped map, no for the provider-wide default. +- Should a future phase emit `cost.long_context.threshold_tokens` as well? Recommendation: no in wp2; top-level threshold plus the retained pricing override satisfies the stated schema without inventing cost data. +- A controlled Cursor Private Inference E2E should still verify that the new fields actually render Context/output controls in build 3.18.25. + + + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md b/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md new file mode 100644 index 0000000000..ae113988fe --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/030_wp3_effort_variant_rows.md @@ -0,0 +1,424 @@ +# 030 — wp3: opt-in effort-variant rows for table-less models + +Depends on: 010 (wp1 resolver `predictCursorEffort`; "table-less" = `ladder === null` from the +bundle table, falling back to `cursorEffortFamily(id) === null`), 020 (row shape). Own PR. + +Loop-spec: spec-satisfaction; trigger = fable/kimi/qwen rows render no Reasoning control in +Cursor and no gateway field can add one (000); goal = with `cursorEffortRows: true` the picker +lists `--` rows that route to the base model with that effort; non-goals = any change +when the flag is off (byte-identical list), rows for models Cursor already renders; verifier = +`bun test tests/cursor-effort-rows.test.ts tests/cursor-local-models-schema.test.ts tests/cursor-integration-status.test.ts` ++ typecheck; stop = green + exact-head CI. Grammar decision: `--` (evidence below); +NEEDS_HUMAN condition from the goal (ambiguity) is NOT triggered. + +Design produced by a sol/high research lane on 2026-09-02 (read-only, no files changed), folded +verbatim below. Amendments made at P of the wp3 cycle: (a) "table-less" must consult +`predictCursorEffort(id, table).ladder === null` once wp1 has landed, with `cursorEffortFamily` +as the static fallback, so the projection follows the installed bundle; (b) exact known full +model ids take precedence over the synthetic grammar (open question 1 → yes). + +Amendment (c), audit blocker 1 (005): on `/v1/messages` reuse the existing `effortOverride` +slot (`claude-messages.ts:603/649`, written as `output_config.effort` before translation and +already respected by `anthropicToResponsesTranslation`) instead of injecting the internal +Responses `reasoning.effort`: `effortOverride = effortRow?.effort ?? extractOcxEffortDirective(...)`. +The `none` rung the lane worried about is never published as a row (Cursor's own ladders have +no `none`; filter it from the row set), so the translator's exclusion of `none` is moot. +Amendment (d): `tableLess` in the status route uses `predictCursorEffort(id, table, supportsReasoning).ladder === null` +(wp1 landed the `supportsReasoning` parameter). + +--- + +No files were modified. The untracked `devlog/_plan/260902_cursor_bundle_effort_table/` appeared concurrently and was left untouched. No tests were run. + +## Recommendation + +Use `--`. + +Evidence: + +- `@` is stripped by Cursor’s matcher and already appears in Codex account-selector values ([models-capabilities.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/models-capabilities.ts:45), [config.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/types/config.ts:681)). +- `:` is already a family separator in `modelRecordValue()` and is common in real model tags ([reasoning-effort.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/reasoning-effort.ts:115)). +- Single `-` collides with ordinary IDs such as `gpt-5.1-codex-max` and Cursor’s own effort suffixes. +- `--` has no routing/catalog semantics today. `routedSlug()` preserves hyphens while only encoding inner slashes ([slug-codec.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/providers/slug-codec.ts:24)). + +Enabling the feature should explicitly reserve terminal `--(none|minimal|low|medium|high|xhigh|max|ultra)` for synthetic rows. The parser must only activate when the flag is exactly `true` and the base is table-less. + +## Diff-level design + +### 1. Configuration contract + +[src/types/config.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/types/config.ts:366) + +Before: + +```ts +defaultModelAliases?: boolean; +``` + +After: + +```ts +defaultModelAliases?: boolean; +/** + * Opt-in Cursor Private Inference compatibility rows. When true, `/v1/models` + * adds `--` selectors for reasoning-capable model ids absent + * from Cursor's built-in effort table. Omitted/false preserves discovery output. + */ +cursorEffortRows?: boolean; +``` + +[src/config.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/config.ts:1046) + +Before: + +```ts +defaultProvider: z.string().min(1).default("openai"), +defaultModelAliases: z.boolean().optional(), +``` + +After: + +```ts +defaultProvider: z.string().min(1).default("openai"), +defaultModelAliases: z.boolean().optional(), +// Malformed hand edits disable this opt-in projection without rejecting providers. +cursorEffortRows: z.boolean().optional().catch(false), +``` + +### 2. Single grammar owner + +Create `src/server/effort-row.ts`: + +```ts +import { + canonicalizeReasoningEfforts, + isDeclaredReasoningEffort, +} from "../reasoning-effort"; +import type { OcxConfig } from "../types"; +import { cursorEffortFamily } from "./models-capabilities"; + +const EFFORT_ROW_SEPARATOR = "--"; + +export interface ParsedEffortRowId { + baseId: string; + effort: string; +} + +export function effortRowId(baseId: string, effort: string): string { + return `${baseId}${EFFORT_ROW_SEPARATOR}${effort}`; +} + +export function parseEffortRowId( + id: string, + config: Pick, +): ParsedEffortRowId | null { + if (config.cursorEffortRows !== true) return null; + + const separator = id.lastIndexOf(EFFORT_ROW_SEPARATOR); + if (separator <= 0) return null; + + const baseId = id.slice(0, separator); + const effort = id.slice(separator + EFFORT_ROW_SEPARATOR.length); + if (!isDeclaredReasoningEffort(effort)) return null; + + // Cursor-table models retain Cursor's native control and never gain variants. + if (cursorEffortFamily(baseId) !== null) return null; + return { baseId, effort }; +} + +export function expandCursorEffortRow( + row: T, + efforts: readonly string[] | undefined, + config: Pick, +): T[] { + if (config.cursorEffortRows !== true || cursorEffortFamily(row.id) !== null) { + return [row]; + } + + const supported = canonicalizeReasoningEfforts( + (efforts ?? []).filter(isDeclaredReasoningEffort), + ); + return [ + row, + ...supported.map(effort => ({ ...row, id: effortRowId(row.id, effort) })), + ]; +} +``` + +Cloning the complete base row and changing only `id` preserves `api_types`, `capabilities`, modalities, context fields, `long_context_threshold_tokens`, `pricing.overrides`, and `cost.long_context` without reconstructing Cursor’s validated schema. + +### 3. `/v1/models` expansion + +[src/server/index.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/index.ts:1511) + +Import `expandCursorEffortRow`. Refactor each row site to pass the same ladder already used by `modelCapabilityFields()`. + +Before: + +```ts +const data = [ + ...visibleNatives.map(id => nativeModelRow(id)), + ...visibleAccountNatives.map(({ id, metadataId }) => nativeModelRow(id, metadataId)), + ...await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + // ... + return { id: publicId, /* complete row */ }; + })), +]; +``` + +After: + +```ts +const routedRows = await Promise.all( + uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + // Existing publicId/provider/alias calculation remains unchanged. + const row = { id: publicId, /* existing complete row, unchanged */ }; + return expandCursorEffortRow(row, m.reasoningEfforts, config); + }), +); + +const data = [ + ...visibleNatives.flatMap(id => + expandCursorEffortRow( + nativeModelRow(id), + nativeReasoningEfforts(id), + config, + ) + ), + ...visibleAccountNatives.flatMap(({ id, metadataId }) => + expandCursorEffortRow( + nativeModelRow(id, metadataId), + nativeReasoningEfforts(metadataId), + config, + ) + ), + ...routedRows.flat(), +]; +``` + +When omitted/false, `expandCursorEffortRow()` returns the original row only, preserving order and serialized bytes. + +Do not modify `routeModel()`, `routeConcreteModel()`, `knownModelIdsForProvider()`, `routedSlug()`, or alias resolution. Synthetic IDs are removed before those parsers run. + +### 4. Responses inbound + +[src/server/responses/core.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/responses/core.ts:2750) + +Immediately after `parseRequest(body)`, before logging, shadow interception, or `routeModel()`: + +```ts +parsed = parseRequest(body); +const effortRow = parseEffortRowId(parsed.modelId, config); +if (effortRow) { + parsed.modelId = effortRow.baseId; + parsed.options.reasoning = effortRow.effort; + + const raw = parsed._rawBody as Record; + raw.model = effortRow.baseId; + raw.reasoning = { + ...(isRec(raw.reasoning) ? raw.reasoning : {}), + effort: effortRow.effort, + }; +} +``` + +The row effort intentionally overrides any contradictory body effort: selecting the row is the user’s effort choice. + +Writing both `parsed.options.reasoning` and `_rawBody.reasoning.effort` follows the existing dual-shape contract used by `applyEffortCap()` and `nativeEffortClamp()` ([effort-policy.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/effort-policy.ts:159), [core.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/responses/core.ts:2140)). + +### 5. Chat Completions inbound + +Modify [src/server/chat-completions.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/chat-completions.ts:89), not `chat-native.ts`. + +Before routing: + +```ts +const requestedModel = chatBody.model as string; +``` + +After: + +```ts +const requestedModel = chatBody.model as string; +const effortRow = parseEffortRowId(requestedModel, config); +if (effortRow) chatBody.model = effortRow.baseId; +``` + +After `chatCompletionsToResponsesBody(chatBody)`: + +```ts +if (effortRow) { + internalBody.reasoning = { + ...(isRec(internalBody.reasoning) ? internalBody.reasoning : {}), + effort: effortRow.effort, + }; +} +``` + +Prevent only synthetic-row requests from taking the native-chat shortcut: + +```ts +if (!effortRow && isNativeChatRouteEligible(route, chatBody)) { + chatNativeRoute = route; +} +``` + +This is necessary because [chat-native.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/chat-native.ts:120) sends directly and does not enter the Responses choke point where the existing cap/clamp runs. Ordinary Chat requests remain unchanged. + +Keep `requestedModel` as the original synthetic ID so Chat response-model echoing remains stable. + +### 6. Anthropic Messages inbound + +[src/server/claude-messages.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/claude-messages.ts:605) + +Resolve after `ocx-route` extraction, but before native passthrough and before translation: + +```ts +let effortRow: ParsedEffortRowId | null = null; +let requestedModel = ""; + +if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { + requestedModel = anthropicBody.model; + effortRow = parseEffortRowId(requestedModel, config); + if (effortRow) anthropicBody.model = effortRow.baseId; +} +``` + +Change native passthrough: + +```ts +if ( + !effortRow + && isRec(anthropicBody) + && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model) +) { + return await anthropicNativePassthrough(/* unchanged arguments */); +} +``` + +After `anthropicToResponsesTranslation()`: + +```ts +internalBody = translation.body; +if (effortRow) { + internalBody.reasoning = { + ...(isRec(internalBody.reasoning) ? internalBody.reasoning : {}), + effort: effortRow.effort, + }; +} +``` + +Do not inject through `output_config.effort`: the current Claude translator excludes `none`, while OpenCodex ladders may legitimately publish it. Directly injecting the internal Responses shape supports every declared effort and still reaches the existing cap/clamp. + +Use the preserved `requestedModel` for Anthropic response conversion. + +### 7. Cursor status projection + +[src/server/management/cursor-integration-routes.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/src/server/management/cursor-integration-routes.ts:23) + +Extend each model: + +```ts +{ + id: string; + reasoning: string[] | null; + tableLess: boolean; + effortRows: string[]; + context: { defaultWindow: number; longWindow: number } | null; +} +``` + +Projection: + +```ts +const family = cursorEffortFamily(id); +return { + id, + reasoning: family, + tableLess: family === null, + effortRows: config.cursorEffortRows === true && family === null + ? canonicalizeReasoningEfforts(reasoningEfforts) + .map(effort => effortRowId(id, effort)) + : [], + context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null, +}; +``` + +Thus “table-less” has one owner: `cursorEffortFamily(id) === null`. Do not duplicate Cursor’s regex in management or GUI code. + +Mirror the fields in [gui/src/pages/integrations/cursor-api.ts](/Users/jun/.codex/worktrees/4ed0/opencodex/gui/src/pages/integrations/cursor-api.ts:12). Rendering can remain a later lane; the status contract is sufficient for the GUI to distinguish native controls from variant rows. + +Update existing exact-object assertions in `tests/cursor-integration-status.test.ts` with `tableLess` and `effortRows`. + +### 8. Documentation + +Update: + +- `docs-site/src/content/docs/reference/configuration/providers.md`: add `cursorEffortRows?: boolean`, default off, grammar, and reservation warning. +- `docs-site/src/content/docs/guides/cursor-private-inference.md`: replace “set a provider default” as the sole workaround with the opt-in variant-row workflow and examples: + - `anthropic/claude-fable-5-1--high` + - `cursor/kimi-k3--max` +- State that existing table-matched IDs receive no variants and that refresh/restart may be required because Cursor caches `/models`. + +## Tests + +Create `tests/cursor-effort-rows.test.ts` with these exact cases: + +1. `parseEffortRowId enables only the -- grammar behind cursorEffortRows` + - Off/omitted returns `null`. + - `@high`, `:high`, and `-high` return `null`. + - Invalid/empty suffixes return `null`. + - `kimi/k3--high` resolves to `{ baseId: "kimi/k3", effort: "high" }`. + +2. `Cursor-table model ids never become effort rows` + - `anthropic/claude-opus-5--high` and `gpt-5.6-sol--high` return `null`. + +3. `cursorEffortRows false is byte-identical to an omitted flag` + - Compare raw `/v1/models` response text for otherwise identical configs. + +4. `raw model discovery clones one complete row per supported effort only for table-less ids` + - Fable/Kimi get one row per exact ladder member. + - GPT-5.6/Opus do not. + - Strip only `id` and assert every variant’s remaining object deeply equals its base row. + +5. `Responses effort rows route the base model and pass through the existing cap` + - Request `...--max` with child marker and `subagentEffortCap: "high"`. + - Captured upstream body contains base model and `reasoning.effort: "high"`. + +6. `Chat effort rows use Responses normalization instead of the native-chat shortcut` + - OpenAI-chat provider; `...--max` plus child cap. + - Captured upstream Chat body has base model and capped `reasoning_effort`. + +7. `Messages effort rows resolve after route directives and before native passthrough` + - Assert translated upstream request uses the base model and chosen/capped effort. + - Include a `none` row to prove translation does not depend on `output_config`. + +8. `Cursor integration status marks table-less bases and reports generated row ids` + - Fable/Kimi: `tableLess: true`, populated `effortRows` when enabled. + - Opus/GPT: `tableLess: false`, empty `effortRows`. + +Focused verification: + +```bash +bun test tests/cursor-effort-rows.test.ts +bun test tests/cursor-local-models-schema.test.ts +bun test tests/cursor-integration-status.test.ts +bun run typecheck +``` + +Do not run the repository-wide suite in this lane. + +## RISKS + +- `--` is not globally forbidden in upstream model IDs. Enabling the flag reserves a terminal `--` suffix; document this. A later hardening can give exact known full model IDs precedence over synthetic parsing. +- Combo/policy aliases can be table-less. Their generated effort must continue through normal combo/policy target selection; never resolve a concrete target inside `effort-row.ts`. +- Chat native and Anthropic native passthrough bypass the shared cap/clamp. Synthetic rows must force the existing Responses replay path as specified. +- Mutating only the parsed effort or only the raw body creates adapter-dependent behavior. Both representations are mandatory on direct Responses requests. +- Cursor caches model discovery by Base URL, so correct server behavior may not appear until refresh/restart. + +## OPEN QUESTIONS + +- Should an exact real model ID ending in `--high` always beat the synthetic grammar, even after `cursorEffortRows` is enabled? Recommended: yes, once an exact-known-ID check can cover static, live, custom, combo, policy, and alias rows consistently. +- Should the dashboard merely report `effortRows`, or render them inline under each table-less base? This lane recommends the API contract now and leaves presentation to the UI/UX lane. +- Should synthetic rows be added for table-less aliases of otherwise table-matched models? Recommended: yes—the matcher sees the public ID, so an alias such as `opus` genuinely has no Cursor control. + diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md b/devlog/_plan/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md new file mode 100644 index 0000000000..47079cd593 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/040_wp4_gui_provenance_hint.md @@ -0,0 +1,101 @@ +# 040 — wp4: Integrations > Cursor shows ladder provenance and a table-less hint + +Depends on: 010 (status fields `effortTable`, `family`), 030 (`tableLess`, `effortRows`). Own PR; +title/description mention "gui", so the PR must carry a screenshot (enforce-target). + +Loop-spec: spec-satisfaction; trigger = "—" in the Reasoning column explains nothing; goal = the +user sees WHY a row has no control (Cursor's table, which build) and WHAT to do (turn on +`cursorEffortRows`, or set a provider default); non-goals = new pages, other locales than en/ko; +verifier = `bun run lint:gui && bun run build:gui` + a rendered screenshot; stop = green + +exact-head CI. + +## File change map + +### MODIFY `gui/src/pages/integrations/cursor-api.ts` + +```ts +export interface CursorIntegrationStatus { + // ...existing + effortTable: { source: "bundle" | "static"; version: string | null; families: number | null }; + models: Array<{ + id: string; + reasoning: string[] | null; + family: string | null; + tableLess: boolean; + effortRows: string[]; + context: { defaultWindow: number; longWindow: number } | null; + }>; +} +``` + +### MODIFY `gui/src/pages/integrations/CursorIntegrationPage.tsx` + +1. Under the "What Cursor will show" heading (line ~141), replace the static hint paragraph with a + provenance line: +```tsx +

+ {status.effortTable.source === "bundle" + ? t("integrations.cursor.ladderFromBundle", { version: status.effortTable.version ?? "?" }) + : t("integrations.cursor.ladderFromStatic")} +

+``` +2. Reasoning cell (line ~155): when `model.reasoning` is null render +```tsx + + + {model.effortRows.length > 0 + ? {t("integrations.cursor.effortRowsOn", { n: model.effortRows.length })} + : {t("integrations.cursor.effortRowsOff")}} + +``` + otherwise the existing `join(" · ")`. +3. After the table, one paragraph (only when any row is table-less): +```tsx +{status.models.some(m => m.tableLess) && ( +

{t("integrations.cursor.tableLessHint")}

+)} +``` + +### MODIFY `gui/src/styles-integrations.css` + +`.cursor-effort-rows { margin-left: .5rem; font-size: .85em; }` — nothing else. + +### MODIFY `gui/src/i18n/en.ts` (after `integrations.cursor.modelsHint`) + +```ts +"integrations.cursor.ladderFromBundle": "Reasoning ladders read from the installed Cursor Private Inference {version} bundle. Cursor decides them; opencodex only reports its table.", +"integrations.cursor.ladderFromStatic": "Reasoning ladders are a static mirror of Cursor 3.18.25 (no Private Inference install found to read). Context lists the default and the opt-in window.", +"integrations.cursor.noControlTitle": "This id is not in Cursor's built-in effort table, so Cursor shows no Reasoning control.", +"integrations.cursor.effortRowsOn": "{n} effort rows published", +"integrations.cursor.effortRowsOff": "no effort rows", +"integrations.cursor.tableLessHint": "Rows marked — get no Reasoning control in Cursor. Turn on cursorEffortRows to publish one picker entry per effort (id--effort), or set modelDefaultReasoningEfforts on the provider for a fixed default.", +``` + +### MODIFY `gui/src/i18n/ko.ts` (same keys) + +```ts +"integrations.cursor.ladderFromBundle": "Reasoning 사다리는 설치된 Cursor Private Inference {version} 번들에서 읽었습니다. 사다리는 Cursor가 정하고 opencodex는 그 표를 보여줄 뿐입니다.", +"integrations.cursor.ladderFromStatic": "Reasoning 사다리는 Cursor 3.18.25의 정적 미러입니다(읽을 Private Inference 설치를 찾지 못함). Context는 기본 창과 옵트인 창입니다.", +"integrations.cursor.noControlTitle": "이 id는 Cursor 내장 effort 표에 없어서 Cursor가 Reasoning 컨트롤을 보여주지 않습니다.", +"integrations.cursor.effortRowsOn": "effort 행 {n}개 게시됨", +"integrations.cursor.effortRowsOff": "effort 행 없음", +"integrations.cursor.tableLessHint": "—로 표시된 행은 Cursor에서 Reasoning 컨트롤이 없습니다. cursorEffortRows를 켜면 effort마다 picker 항목(id--effort)을 하나씩 게시하고, 고정 기본값은 provider의 modelDefaultReasoningEfforts로 정합니다.", +``` + +Other locales are untouched; `t()` falls back to en for missing keys (verify in +`gui/src/i18n/index.ts` at P of this cycle; if there is no fallback, add the en strings to the +other locale files verbatim). + +### MODIFY `tests/cursor-integration-status.test.ts` + +No new server behaviour; keep. GUI evidence is the screenshot (C-RENDER-GROUNDING-01): run +`bun run build:gui`, start the proxy from this checkout on a temp `OPENCODEX_HOME`, open +`/#/integrations/cursor` in agbrowse at 1280x720, capture with a table-less row visible and +attach to the PR and to `041_wp4_screenshot.png` in this unit. + +## Accept criteria + +- `bun run lint:gui` 0; `bun run build:gui` 0; typecheck 0. +- Screenshot shows the provenance line reading "3.18.25 bundle" on this machine and the hint + paragraph under the table. +- With `cursorEffortRows` off nothing else on the page changes. diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/041_wp4_screenshot.png b/devlog/_plan/260902_cursor_bundle_effort_table/041_wp4_screenshot.png new file mode 100644 index 0000000000..59a5a941e4 Binary files /dev/null and b/devlog/_plan/260902_cursor_bundle_effort_table/041_wp4_screenshot.png differ diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md b/devlog/_plan/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md new file mode 100644 index 0000000000..abed83b024 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/050_wp5_claude_id_normalizer.md @@ -0,0 +1,200 @@ +# 050 — wp5: canonical Claude-id normalizer for the Cursor adapter + +Depends on: 000 (independent of wp1-wp4; own PR against `dev`). Design produced by a sol/high +research lane on 2026-09-02 and folded here; the lane read the current tree and changed no files. + +Loop-spec: spec-satisfaction; trigger = Fable 5.1 seeded three times because Cursor spells Claude +ids both Anthropic-style (`claude-fable-5-1`) and version-first (`claude-5.1-fable`); goal = one +capability base per Claude model, any live spelling resolves to it, wire ids are composed back in +the spelling the live roster exposed; non-goals = picker id churn for saved configs, non-Claude +families; verifier = the focused test list at the bottom + typecheck; stop = green + exact-head CI. + +## File change map + +### NEW `src/adapters/cursor/claude-id.ts` + +```ts +export type CursorClaudeSpelling = "anthropic" | "version-first"; + +export interface NormalizedCursorClaudeId { + /** The sole key used by CURSOR_CAPABILITIES and pricing metadata. */ + canonicalBaseId: string; + /** Exact input stem, preserving `5-1` versus `5.1` for wire round-trips. */ + sourceBaseId: string; + spelling: CursorClaudeSpelling; + thinking: boolean; + fast: boolean; + level?: string; +} + +const CLAUDE_LEVELS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "extra-high"]); + +/** Existing picker bases whose canonical key stays version-first (saved configs). */ +const VERSION_FIRST_CANONICAL_BASES = new Set([ + "claude-4.5-haiku", "claude-4.5-opus", "claude-4.6-opus", "claude-4.5-sonnet", "claude-4.6-sonnet", "claude-4-sonnet", +]); + +function parseClaudeBase(raw: string): { canonicalBaseId: string; sourceBaseId: string; spelling: CursorClaudeSpelling } | undefined { + const anthropic = /^claude-(fable|haiku|opus|sonnet)-(\d+(?:[.-]\d+)*)$/.exec(raw); + if (anthropic) { + const family = anthropic[1]!; + const version = anthropic[2]!.replaceAll(".", "-"); + const versionFirst = `claude-${version.replaceAll("-", ".")}-${family}`; + return { + canonicalBaseId: VERSION_FIRST_CANONICAL_BASES.has(versionFirst) ? versionFirst : `claude-${family}-${version}`, + sourceBaseId: raw, + spelling: "anthropic", + }; + } + const versionFirst = /^claude-(\d+(?:\.\d+)*)-(fable|haiku|opus|sonnet)$/.exec(raw); + if (!versionFirst) return undefined; + const sourceBaseId = `claude-${versionFirst[1]!}-${versionFirst[2]!}`; + return { + canonicalBaseId: VERSION_FIRST_CANONICAL_BASES.has(sourceBaseId) ? sourceBaseId : `claude-${versionFirst[2]!}-${versionFirst[1]!.replaceAll(".", "-")}`, + sourceBaseId, + spelling: "version-first", + }; +} + +export function normalizeCursorClaudeId(raw: string): NormalizedCursorClaudeId | undefined { + const id = raw.trim().toLowerCase(); + const patterns: ReadonlyArray { base: string; thinking: boolean; fast: boolean; level?: string }]> = [ + [/^(.*)-thinking-([a-z-]+)-fast$/, m => ({ base: m[1]!, thinking: true, fast: true, level: m[2]! })], + [/^(.*)-([a-z-]+)-thinking-fast$/, m => ({ base: m[1]!, thinking: true, fast: true, level: m[2]! })], + [/^(.*)-thinking-([a-z-]+)$/, m => ({ base: m[1]!, thinking: true, fast: false, level: m[2]! })], + [/^(.*)-([a-z-]+)-thinking$/, m => ({ base: m[1]!, thinking: true, fast: false, level: m[2]! })], + [/^(.*)-([a-z-]+)-fast$/, m => ({ base: m[1]!, thinking: false, fast: true, level: m[2]! })], + [/^(.*)-thinking-fast$/, m => ({ base: m[1]!, thinking: true, fast: true })], + [/^(.*)-thinking$/, m => ({ base: m[1]!, thinking: true, fast: false })], + [/^(.*)-fast$/, m => ({ base: m[1]!, thinking: false, fast: true })], + ]; + for (const [pattern, dims] of patterns) { + const match = pattern.exec(id); + if (!match) continue; + const parsed = dims(match); + if (parsed.level && !CLAUDE_LEVELS.has(parsed.level)) continue; + const base = parseClaudeBase(parsed.base); + if (base) return { ...base, ...parsed, sourceBaseId: base.sourceBaseId }; + } + const base = parseClaudeBase(id); + return base ? { ...base, thinking: false, fast: false } : undefined; +} + +export function composeCursorClaudeWireId( + identity: Pick, + options: { thinking: boolean; fast: boolean; effort?: string; bareThinking?: boolean }, +): string { + const { sourceBaseId: base, spelling } = identity; + const fast = options.fast ? "-fast" : ""; + if (!options.thinking) return options.effort ? `${base}-${options.effort}${fast}` : `${base}${fast}`; + if (options.bareThinking || !options.effort) return `${base}-thinking${fast}`; + return spelling === "version-first" ? `${base}-${options.effort}-thinking${fast}` : `${base}-thinking-${options.effort}${fast}`; +} +``` + +`sourceBaseId` is required: `claude-fable-5-1` and `claude-fable-5.1` are both "anthropic" spelling +but differ on the wire. + +### MODIFY `src/adapters/cursor/catalog.ts` + +1. Replace the three Fable 5.1 entries (lines ~123-155) with one `"claude-fable-5-1"` entry + (displayName "Claude Fable 5.1", CONTEXT_1M, defaultVariant thinking, regular/thinking FULL, order T). +2. At the top of `parseCursorVariantId` (before the exact-identity lookup, line ~383): +```ts + const claude = normalizeCursorClaudeId(id); + if (claude && CURSOR_CAPABILITIES[claude.canonicalBaseId]) { + const explicitVariant = claude.thinking || claude.fast || claude.level !== undefined; + return { + baseId: claude.canonicalBaseId, + kind: explicitVariant + ? claude.thinking ? (claude.fast ? "thinkingFast" : "thinking") : claude.fast ? "fast" : "regular" + : defaultKindFor(claude.canonicalBaseId), + ...(claude.level ? { level: claude.level } : {}), + ultra: false, + known: true, + }; + } +``` + `REAL_1M_WIRE_IDS` (`claude-4-sonnet-1m`) must still be checked first; the normalizer does not + recognise `-1m`, so ordering: REAL_1M check → normalizer → existing chain. +3. Beside `liveCursorMaxModeBases` (line ~606): +```ts +type CursorLiveClaudeWireIdentity = Pick; +let liveCursorClaudeWireIdentities: ReadonlyMap = new Map(); + +export function recordLiveCursorClaudeModels(liveIds: readonly string[]): void { + const next = new Map(); + for (const rawId of liveIds) { + const n = normalizeCursorClaudeId(rawId.startsWith("cursor-") ? rawId.slice(7) : rawId); + if (!n || !CURSOR_CAPABILITIES[n.canonicalBaseId]) continue; + if (!next.has(n.canonicalBaseId)) next.set(n.canonicalBaseId, { sourceBaseId: n.sourceBaseId, spelling: n.spelling }); + } + liveCursorClaudeWireIdentities = next; // replaced, never merged: a renamed model must not keep a stale spelling +} +export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap { return liveCursorClaudeWireIdentities; } +export function resetLiveCursorClaudeWireIdentitiesForTests(): void { liveCursorClaudeWireIdentities = new Map(); } +``` +4. `composeWireId(baseId, kind, effort, claudeIdentity?)` (line ~545): when `claudeIdentity` is + given, return `composeCursorClaudeWireId(claudeIdentity, { thinking, fast, effort, bareThinking: spec.order === "bare" })`; + the non-Claude body is unchanged. +5. `resolveCursorSelection`: `const claudeIdentity = liveCursorClaudeWireIdentities.get(parsed.baseId) ?? (requestedClaude ? { sourceBaseId, spelling } : undefined)` + where `requestedClaude = normalizeCursorClaudeId(pickedId)`. Precedence: live roster spelling → + the spelling the saved config used → capability base. + +### MODIFY `src/codex/catalog/provider-fetch.ts` (line ~1424) + +`recordLiveCursorClaudeModels(liveResult.models);` immediately inside `if (liveResult.ok)`, before +`filterCursorConfiguredModelsByLiveDiscovery`. Not cleared on failure (stale-cache parity). + +### MODIFY `src/adapters/cursor/effort-map.ts` + +- Keep only `claude-fable-5-1` and `claude-fable-5-1-thinking`; delete the `5.1`/`5.1-fable` rows + and their thinking rows (lines ~28-30, ~63-65) and the matching `CURSOR_THINKING_FAMILIES` rows. +- Add `cursorEffortLookupId(modelId)`: normalise via `normalizeCursorClaudeId`, return + `canonicalBaseId + (thinking ? "-thinking" : "") + (fast ? "-fast" : "")`, else the input. Use it in + `cursorEffortSuffix`, `cursorModelEffortLadder`, `cursorModelHasEffortTiers`, `cursorWireModelIdWithEffort`. +- `cursorWireModelIdWithEffort` composes Claude ids through `composeCursorClaudeWireId` with the + input's own spelling, so a version-first saved alias keeps effort-then-thinking order. + +### MODIFY `src/adapters/cursor/discovery.ts` + +No structural change: once `parseCursorVariantId` canonicalises, the base comparison in +`isCursorModelAvailableForAccount` (line ~86) matches across spellings. `CURSOR_STATIC_MODELS` +now derives one Fable 5.1 row. + +### MODIFY `src/usage/expected-prices.ts` + +Keep only the `cursor / claude-fable-5-1` overlay row (delete lines 108-109). In +`findExpectedPriceOverlay`, after the exact lookup misses and only when `provider === "cursor"`, +retry with `normalizeCursorClaudeId(modelId)?.canonicalBaseId`. + +## Tests + +NEW `tests/cursor-claude-id.test.ts`: normalizes the three Fable 5.1 spellings to one base; extracts +thinking/fast/effort from both marker orders; preserves `sourceBaseId` for dotted round-trips; does +not absorb `claude-4-sonnet-1m` or unknown products; composes both orders correctly. + +MODIFY `tests/cursor-catalog.test.ts`: all three spellings parse to `baseId: "claude-fable-5-1"`; +legacy aliases stay routable with no live roster; live roster spelling overrides; dotted spelling +preserved exactly; Fable 5.1 contributes one umbrella row. +MODIFY `tests/cursor-effort-suffix.test.ts`: keep the three wire cases (renamed group), add the +shared-ladder case, keep ERROR_BAD_MODEL_NAME order cases. +MODIFY `tests/cursor-discovery.test.ts`: one canonical-row assertion replaces the three-seed loop; +cross-spelling live ids admit the row; sibling Claude versions do not cross-activate. +MODIFY `tests/cursor-umbrella-rows.test.ts`: count comment; aliases are not rows; live spelling map +resets atomically. +MODIFY `tests/usage-cost.test.ts`: three-spelling resolution loop stays; overlay membership has only +`cursor/claude-fable-5-1`; overlay count 61 → 59. + +Verifier: `bun test tests/cursor-claude-id.test.ts tests/cursor-catalog.test.ts tests/cursor-effort-suffix.test.ts tests/cursor-discovery.test.ts tests/cursor-umbrella-rows.test.ts tests/usage-cost.test.ts` +then `bun run typecheck` and `bun run test:changed`. + +## Risks / open decisions (carried into this cycle's P) + +- The module-global spelling map follows the Max-Mode precedent; if one process ever routes two + Cursor accounts with different rosters it must be keyed by provider. Not the case today + (one `cursor` provider entry); record as accepted. +- When a roster exposes both spellings, first-seen wins (roster order). Acceptable: both are + callable by construction. +- Pricing fallback is bounded to `provider === "cursor"` and recognised Claude ids; other providers + keep exact-only lookup. diff --git a/devlog/_plan/260902_cursor_bundle_effort_table/060_wp6_guide.md b/devlog/_plan/260902_cursor_bundle_effort_table/060_wp6_guide.md new file mode 100644 index 0000000000..65dd8f7726 --- /dev/null +++ b/devlog/_plan/260902_cursor_bundle_effort_table/060_wp6_guide.md @@ -0,0 +1,132 @@ +# 060 — wp6: guide — identify the build, isolate it, wire the gateway, read the table + +Depends on: 010, 030, 040 (documents what they shipped). Own PR against `dev`. Docs only. + +Loop-spec: spec-satisfaction; trigger = the guide names the table but not how to tell which +build you have, where the table lives, or the env-var path; goal = a reader with the app already +installed can identify it, keep it apart from regular Cursor, connect opencodex, and understand +every "—"; non-goals = hosting/linking a download (`rg 'downloads.cursor.com|cursor-local/'` +stays 0), other locales; verifier = `bun run privacy:scan`, `cd docs-site && bun run build`, +the rg check; stop = green + exact-head CI. + +## MODIFY `docs-site/src/content/docs/guides/cursor-private-inference.md` + +### 1. New section after "Before you start": "Identify the installed build" + +```md +## Identify the installed build + +Both builds are named "Cursor" in the Dock and share the bundle id, so check `product.json`: + +| Platform | product.json | +|---|---| +| macOS | `/Applications/Cursor Private Inference.app/Contents/Resources/app/product.json` | +| Windows | `%LOCALAPPDATA%\\Programs\\cursor-private-inference\\resources\\app\\product.json` | +| Linux | `/resources/app/product.json` (an AppImage must be extracted first) | + +`nameLong` is `"Cursor Private Inference"` for the local-agent build and `"Cursor"` for the +regular one; `version` is the build (3.18.25 at the time of writing). The dashboard's +Integrations > Cursor card runs the same check and lists what it found. Local mode is switched +on inside the workbench bundle, not in `product.json`, so there is no flag to flip: if +`nameLong` says regular Cursor, that install cannot reach a loopback gateway. + +opencodex does not distribute this build and Cursor does not document it. If you do not have it, +this page does not apply; use the [`ocx-cursor`](https://www.npmjs.com/package/ocx-cursor) bridge +with a public HTTPS endpoint instead. +``` + +### 2. "Configure the gateway": add the environment path and precedence (after the Settings steps) + +```md +### Through the environment + +`CURSOR_LOCAL_AGENT_BASE_URL` and `CURSOR_LOCAL_AGENT_API_KEY` are read when no gateway has +been saved in Settings. They must be in the login environment (launchctl setenv on macOS, the +user environment on Windows, the session on Linux), not only in an interactive shell rc file, +because a GUI-launched app does not read your shell. + +Precedence, highest first: per-model credentials → the saved Settings gateway → +`CURSOR_LOCAL_AGENT_*` → `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` (compatibility +fallback). Clear the saved gateway before switching through the environment. + +`CURSOR_LOCAL_AGENT_HEADERS` is optional: newline-separated `Header-Name: value` lines +(`User-Agent` and unresolved `{...}` placeholders are rejected; `{gitOrgRepo}` and +`{gitBranch}` are expanded). +``` + +Fix the existing sentence that describes `CURSOR_LOCAL_AGENT_HEADERS` as `key=value` pairs if +present (001 §"Configuration inputs"). + +### 3. "Models and reasoning effort": replace the intro and the closing paragraph + +Before: +```md +2. The model id, after stripping everything up to the last `/`, must match Cursor's own + effort table. Cursor decides the ladder, not opencodex: +``` +After: +```md +2. The model id, after stripping everything up to the last `/` and any `@…` suffix, must + match Cursor's own effort table. That table is compiled into the app at + `/…/app/extensions/cursor-agent-exec/dist/main.js`; opencodex reads it from the + detected install so the dashboard prediction follows a Cursor update (the card says which + build it read, or "static mirror" when none was found). Cursor decides the ladder, not + opencodex, and no `/v1/models` field can add a model to that table: +``` + +Before (closing paragraph): +```md +So `anthropic/claude-opus-5` works, and opencodex's `max`/`ultra` tiers for GPT-5.6 are not +reachable from this picker. For a model with no control, set a default in opencodex instead +(`modelDefaultReasoningEfforts` on the provider); that default applies when Cursor sends no +effort. +``` +After: +```md +So `anthropic/claude-opus-5` works, and opencodex's `max`/`ultra` tiers for GPT-5.6 are not +reachable from this picker. + +### Models with no control + +`anthropic/claude-fable-5-1`, `cursor/kimi-k3`, and anything else outside the table get no +Reasoning control, and Cursor logs one line per such id when the gateway advertises +`supports_reasoning`: "Local provider advertises reasoning support for a model with no +hardcoded Bottlerocket effort family". Two ways to still choose an effort: + +- **Effort rows** (`cursorEffortRows: true` in opencodex config, default off): the gateway + publishes one picker entry per effort for table-less models, `anthropic/claude-fable-5-1--high`, + `cursor/kimi-k3--max`, and routes each to the base model with that effort. Models Cursor + already renders get no extra rows. Press Refresh model list after turning it on. +- **A fixed default** (`modelDefaultReasoningEfforts` on the provider): applies when Cursor + sends no effort. +``` + +### 4. "Max is two different things": append the wire caveat (from 001) + +```md +With a `/v1` Base URL Cursor sends turns to `/v1/responses`, so GPT/Grok/Gemini effort travels +as `reasoning.effort`; Claude's `output_config.effort` is Messages-only and is dropped on that +wire, which is why a Claude row that does show a control still runs at the provider default. +A Base URL ending in `/messages` reverses it: Claude effort is sent, OpenAI-family effort is +dropped. One gateway entry cannot serve both families; effort rows (above) side-step this +because opencodex applies the effort itself. +``` + +### 5. "Verify": two table rows + +```md +| models listed but no Reasoning control | opencodex older than v2.41, or the id is not in Cursor's table (dashboard shows —); turn on `cursorEffortRows` or set a provider default | +| a schema change is not picked up | Cursor caches `/models` per Base URL string with no expiry; Refresh model list re-reads, otherwise restart or temporarily change the URL spelling (`localhost` vs `127.0.0.1`) | +``` + +### 6. Configuration reference + +`docs-site/src/content/docs/reference/configuration/providers.md` (or the top-level config +reference, verified at P): one entry for `cursorEffortRows` (boolean, default false, grammar +`--`, reserved suffix warning). + +## Accept criteria + +- `rg -n 'downloads.cursor.com|cursor-local/' docs-site/src/content/docs/guides/cursor-private-inference.md` → 0 hits. +- `bun run privacy:scan` 0; `cd docs-site && bun run build` 0. +- Every claim about the bundle cites 001 (bundle path, precedence, header format, cache). diff --git a/devlog/_plan/260902_windows_ci_release/070_outcome.md b/devlog/_plan/260902_windows_ci_release/070_outcome.md new file mode 100644 index 0000000000..a0791df4ea --- /dev/null +++ b/devlog/_plan/260902_windows_ci_release/070_outcome.md @@ -0,0 +1,62 @@ +# 070 — Outcome: v2.40.0 released, Windows shards repaired + +## Windows CI (wp2/wp3) + +Landed on dev as #3257 (`19b0157bb`) and #3258 (`a6ee24f5b`). Root cause was the product: +`e5d588669` turned the config-dir ACL harden into a fire-and-forget `icacls.exe` child that +`server.stop()` never waited for; mandatory locking made every fixture teardown EPERM/EBUSY. + +Dispatch history on immutable refs of the stack tip (windows-latest, all four shards were red on +every branch since 2026-08-30): + +| run | head | 1/4 | 2/4 | 3/4 | 4/4 | +|---|---|---|---|---|---| +| 33595585136 | e1acb7f7a | 8 fails | 2 | 1 | cancelled | +| 33597649234 | 079bec4e0 | ✓ | ✓ | ✓ | 25-min ceiling (native-main-refresh microtask spin) | +| 33601508392 | 2bf189d9f | ✓ | ✓ | ✓ | ceiling (two more spins) | +| 33603770447 | 5ffba3b0a | ✓ | fail (write-lock hold) | ✓ | 2 (oauth-manual-code) | +| 33605723635 | 477c64e50 | ✓ | ✓ | ✓ | 2 (oauth-manual-code, fixed next) | +| 33605898170 | 2e2b411ba | 1 (retained-root wait) | ✓ | ✓ | 2 (reauth-bind EPERM, native-main EBUSY) | +| 33610501053 | 26de9cac0 (codemod) | ✓ | ✓ | ✓ | 2 (reauth-bind, startup port wait) | +| 33612731522 | f85978251 | ✓ | in flight at merge | 4 (oauth-public-surface, fixed) | in flight | + +Every shard that finished ran the full file set; each residual was a distinct bare-`rmSync` +or child-boot-timing site and was fixed at that site (or, for teardown, by the 870-site codemod). +The user chose to merge and release on this evidence rather than wait for one more 25-minute +round; the fixes for the last two residuals are on dev. + +Reviewers (read-only, sol/high): Lovelace FAIL→fixed (finally), Hooke FAIL→fixed (win32 +admission coverage), Euler FAIL→fixed (listener-close oracle, marker deadlines). Codemod builder +Gauss: 381 files / 870 sites, test:changed 14165 pass. + +fuck-powershell: 56e1801, 2f2107d — 87→90 cases, graph 313/643, validate OK. + +## Regression audit (wp4) + +Reused `devlog/_plan/260902_bug_label_drawdown/071_regaudit_landing.md` (four reviewers, +no regression main..dev at 5bc6939d8) plus the Windows repair reviews above for the delta since. + +## Release (wp5) + +- Promotions: #3260 → preview `7fd141f2a`, #3261 → main `ac7864785`. +- First dispatches (33615174183 / 33615177849) died at `startup_failure`: `release.yml`'s + reusable call to `dev-version-bump.yml` (#3129) had never run live and the caller job lacked + the callee's `contents`/`pull-requests` write. Fixed as #3262 (`7ce0ba518`), carried onto + main (#3263 → `35ff3a462`) and preview (#3264 → `49812c9e8`). +- Service-lifecycle's push trigger is path-filtered and the workflow-only cherry-pick touched + none of its paths, so the release gate found no run for the new tips; dispatched by hand on + both refs (33617431510, 33617434280), green. +- Release runs 33617562805 (preview) and 33617573070 (main): publish SUCCESS. +- Proof: npm `latest=2.40.0` gitHead `35ff3a462…`, `preview=2.40.0-preview.20260902` gitHead + `49812c9e8…`; GitHub releases v2.40.0 / v2.40.0-preview.20260902; tags equal branch tips. +- Dev bump: the bot job pushed `codex/dev-version-2.41.0` but `gh pr create` was refused + ("GitHub Actions is not permitted to create or approve pull requests" — repository Actions + setting). Opened by hand as #3265 → `272ff6b11`; dev now carries 2.41.0. + +## Follow-ups (not blocking) + +1. Repo setting: allow Actions to create PRs, or the bump will need a hand each release. +2. A release-branch commit that touches only `.github/workflows/release.yml` needs a manual + `service-lifecycle.yml` dispatch before the release gate passes (path filter). +3. One more Windows dispatch on dev after #3258 to confirm 4/4 with the last two residual fixes + (dispatched below). Result: 33618250161 on 272ff6b11 — windows 1/4, 2/4, 3/4, 4/4 SUCCESS; every other job SUCCESS. diff --git a/devlog/_plan/260902_windows_ci_release/080_release_automation_followups.md b/devlog/_plan/260902_windows_ci_release/080_release_automation_followups.md new file mode 100644 index 0000000000..5c38f123f3 --- /dev/null +++ b/devlog/_plan/260902_windows_ci_release/080_release_automation_followups.md @@ -0,0 +1,51 @@ +# 080 — Release automation follow-ups: bot PR creation, service-lifecycle trigger + +## Provenance: was "Allow GitHub Actions to create and approve pull requests" turned off? + +No. It was never on. + +| date | source | `can_approve_pull_request_reviews` | +|---|---|---| +| 2026-07-27 | chat tool log (`gh api …/actions/permissions/workflow`) | false | +| 2026-08-01 | chat tool log, Windows CI server session | false | +| 2026-09-02 | this train, after release 33617573070 | false | + +No commit, devlog note, or chat turn in the recall index mentions disabling it. GitHub creates +repositories with this toggle OFF, so the value is the default, not a maintainer decision. +#3013 (open bumps as PRs) and #3129 (call the bump from release.yml) both assumed the bot could +open a PR with `GITHUB_TOKEN`; neither was exercised by a live release until v2.40.0, which is +why the gap surfaced only now. + +## Decision + +Flip the repository toggle (option a). Rejected: a PAT secret for `gh pr create` (option b) — +a long-lived write credential in Actions is a wider blast radius than a repo-scoped toggle. + +What the toggle grants: any workflow running with `GITHUB_TOKEN` may create pull requests and +submit approving reviews. What still holds: `Protect dev` requires a reviewed pull request and +blocks direct pushes; `MAINTAINERS.md` forbids self-approval; `dev-version-bump.yml` runs only as +a `workflow_call` from `release.yml` (no `workflow_dispatch`), with `contents: write` scoped to +the unprotected `codex/dev-version-*` branch. A bot-created PR cannot merge itself; it waits for +the same admin merge every bump has had by hand (#3045, #3076, #3127, #3265). + +Route: REST `PUT /repos/{owner}/{repo}/actions/permissions/workflow` with +`can_approve_pull_request_reviews=true` (the user's `gh` session is an admin). Aside against the +Settings page only if the API refuses. + +Verification: re-read the setting; the exact failing step (`gh pr create` under `GITHUB_TOKEN`) +is proven live by the next release's bump job — a synthetic probe would need its own workflow on +`dev` and is not worth landing for one step. + +Applied 2026-09-02 via `gh api -X PUT repos/lidge-jun/opencodex/actions/permissions/workflow +-f default_workflow_permissions=read -F can_approve_pull_request_reviews=true`; the API accepted +it, so Aside was not needed. Read-back: `{"default_workflow_permissions":"read", +"can_approve_pull_request_reviews":true}`. Default token permission stays `read`. + +## service-lifecycle trigger + +`release.yml`'s gate requires a successful `service-lifecycle.yml` run for the release SHA when +any of its watched paths changed since the previous tag. `service-lifecycle.yml`'s own +`push.paths` did not include `.github/workflows/release.yml`, so #3263/#3264 (workflow-only +cherry-picks onto main/preview) produced no run and both v2.40.0 dispatches needed a manual +`workflow_dispatch`. Add `.github/workflows/release.yml` to both trigger path lists and to the +regex the gate applies, so the two stay in sync as the file comment already demands. diff --git a/devlog/_plan/260903_bug_drawdown_bcda/000_plan.md b/devlog/_plan/260903_bug_drawdown_bcda/000_plan.md new file mode 100644 index 0000000000..bd2e4260ae --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/000_plan.md @@ -0,0 +1,86 @@ +# 000 — bug_drawdown_bcda: Plan + +> DIFFLEVEL-ROADMAP-01: write this doc to full diff-level precision (exact paths, +> NEW/MODIFY/DELETE, before/after diffs) BEFORE P -> A. An empty scaffold does not +> satisfy the rule; the A-phase reviewer FAILS outline-only phase docs. + +## Objective + +Drive every open `bug`-labelled pull request and `bug`-labelled issue in +lidge-jun/opencodex to a terminal state on `dev`: squash-merged, closed with +evidence, or explicitly recorded as blocked. The campaign runs as a chain of +PABCD work-phases in the managed worktree +`/Users/jun/.codex/worktrees/bcda/opencodex`, with parallel `gpt-5.6-sol` +(effort high) read-only investigators feeding each phase's plan. + +### Evidence base (captured 2026-09-03, live `gh`) + +Open bug-labelled PRs: + +| PR | Title | Draft | Mergeable | Head | CI at capture | +|----|-------|-------|-----------|------|----------------| +| #3254 | fix(chat): share transient retry budget across native recovery legs | no | MERGEABLE / UNSTABLE | `49858a2d` | every check SUCCESS (31 checks, CodeRabbit neutral) | +| #3256 | fix(oauth): honor Kiro reset-aligned cooldown without Retry-After | no | MERGEABLE / BLOCKED | `821462f9` | `enforce-target` FAIL `unsponsored_surface`, `hygiene` FAIL | +| #3246 | fix(responses): bridge write_stdin through exec | yes | MERGEABLE / BLOCKED | `db96ae50` | `enforce-target` CANCELLED, hygiene SUCCESS | +| #3270 | fix(usage): aggregate complete ledger incrementally | yes | MERGEABLE / BLOCKED | `f5aaf120` | `enforce-target` FAIL x2 + CANCELLED | + +Open bug-labelled issues: #3280 (GUI full-config PUT rejected after providers +JSON save), #3279 (GUI 401 flap on `/api/*` while health is OK), #3245 (macOS +Codex 0.152.0 stream disconnects, `upstream-tracking`), #3152 (dashboard log +panel jitter), #3141 (aggressive `responses-state.json` disk writes), #1527 +(Cursor adapter large-context collapse). + +## Loop-spec + +- Loop archetype: verifier-defined (spec-satisfaction repair). Each phase's + verifier is the exact-head GitHub check rollup plus a focused local test. +- Write scope: `src/`, `gui/`, `tests/`, `docs-site/`, `devlog/_plan`, + `devlog/_fin` in this worktree only. Branches carry the `codex/` prefix and + target `dev` through a pull request. +- Out of scope: releases, tag pushes, promotion to `main`/`preview`, `go/`, + unrelated dependency bumps, credential-spending actions, and any + pre-disclosure security note inside a tracked directory. +- User-imposed constraints: never run the repository-wide suite (no bare + `bun test`, no `bun run test`); push with `--no-verify`; merge with + `gh pr merge --squash --admin`; close linked issues manually after the change + lands on `dev`. +- Bounds: for phases that produce a diff, exact-head CI is the verification + signal, backed by a focused local test. For phases that terminate as + NEEDS_HUMAN (wp6, wp9) neither exists: there is no PR head and no executable + RED assertion, and manufacturing one would encode a guess. Their verification + is the posted analysis — ruled-out causes with file:line citations plus the + exact capture the reporter must supply. + +## Work-phase map (one phase = one full PABCD cycle) + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp0 | `000_plan.md` | This roadmap; locks the phase map | — | +| wp1 | `010_phase1.md` | PR #3254 — land the green approved fix | wp0 | +| wp2 | `020_phase2.md` | PR #3256 — clear `unsponsored_surface`, then land | wp0 | +| wp3 | `030_phase3.md` | PR #3246 — repair, mark ready, land | wp0 | +| wp4 | `040_phase4.md` | PR #3270 — repair, mark ready, land | wp0 | +| wp5 | `050_phase5.md` | Issue #3280 — GUI full-config PUT rejection | wp0 | +| wp6 | `060_phase6.md` | Issue #3279 — dashboard 401 flap | wp0 | +| wp7 | `070_phase7.md` | Issue #3141 — `responses-state.json` write storm | wp0 | +| wp8 | `080_phase8.md` | Issue #3152 — log panel jitter | wp0 | +| wp9 | `090_phase9.md` | Issues #3245 and #1527 — disposition with evidence | wp0 | + +## Accept criteria + +Mirrored into the goalplan `criteria[]` as `c-1` through `c-11`. Terminal state +is outcome-dependent, not uniformly a merge: + +- MERGED items (wp1-wp4, and any issue whose fix lands): the squash-merge sha + must be an ancestor of `origin/dev`, proved by `git fetch origin dev` plus + `git merge-base --is-ancestor`; the full exact-head check rollup must have + been inspected rather than `gh pr checks --required` being empty; and any + linked issue must be closed with a comment naming the merge commit. +- NEEDS_HUMAN items (wp6 for #3279, wp9 for #3245 and #1527): no merge sha + exists and none is required. The evidence is the posted analysis — the + ruled-out causes with file:line citations, and the exact capture the reporter + must supply. These are terminal despite having no diff. +- BLOCKED / UNSAFE items: terminal on a recorded blocker naming the specific + gate, dependency, or unreviewed security surface. + +A remembered green is never evidence for any of these. diff --git a/devlog/_plan/260903_bug_drawdown_bcda/010_phase1.md b/devlog/_plan/260903_bug_drawdown_bcda/010_phase1.md new file mode 100644 index 0000000000..6c14f27cb9 --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/010_phase1.md @@ -0,0 +1,73 @@ +# 010 — Phase 1 (wp1): PR #3254 — native chat transient send budget + +## Item + +`fix(chat): share transient retry budget across native recovery legs`, head +`49858a2df56d4c0aa0043d6483d50bf865c58918`, author luvs01, labels `bug` + +`review-ready`, reviewDecision APPROVED, 174 additions / 5 deletions across 2 files. + +## Phase class: ADOPTION, not authoring + +This phase writes no source. The unit of work is a merge decision on a diff a +contributor already wrote and CI already exercised, so DIFFLEVEL-ROADMAP-01 is +satisfied by naming the exact incoming hunks rather than authoring new ones. The +"before" is `origin/dev` at `529639a57`; the "after" is that tree plus the diff +below, transcribed from `gh pr diff 3254`. + +## MODIFY / NEW / DELETE map (incoming diff, verbatim) + +MODIFY `src/server/chat-native.ts`, three hunks: + +1. `@@ -204,12 +204,24 @@` in `handleNativeChatCompletions` — BEFORE: `send()` + recomputed `const transientPolicy = transientRetryPolicyFor(activeProvider)` on + every call. AFTER: the policy is captured once per inbound request as + `requestTransientPolicy`, with `transientSendsUsed`, `remainingTransientSends()` + returning `Math.max(0, attempts - used)` (or `Number.POSITIVE_INFINITY` with no + policy), and `transientSendAvailable()`. `send()` throws + "native Chat transient send budget exhausted before recovery dispatch" when the + remainder reaches zero. +2. `@@ -232,7 +244,12 @@` — BEFORE: + `...(transientPolicy ? { attempts: transientPolicy.attempts } : {})`. AFTER: + `attempts: remaining` plus + `onSendsConsumed: (sends) => { transientSendsUsed += Math.max(0, sends); }`. +3. `@@ -245,7 +262,12 @@` with `@@ -263,6 +285,10 @@` — BEFORE the 429 loop read + `response.status === 429 && retryPolicy && retries < retryPolicy.attempts`. + AFTER `&& transientSendAvailable()` is appended, and the rotation branch keeps + the failed key's cooldown bookkeeping while preserving the terminal 429 once + the request has spent its final send. + +MODIFY `tests/chat-completions-endpoint.test.ts` — the only other file in the +diff. BEFORE: the native-chat suite covered the 429 rotation path without +constraining how many upstream sends a single inbound request could produce, so +a rotation that reset the ceiling passed unnoticed. AFTER: a case drives one +inbound request through a 429 plus a key rotation against a provider configured +with a transient policy, counts upstream sends across BOTH legs, and asserts the +total never exceeds the policy's `attempts`, plus that the terminal 429 is +preserved once the budget is spent. + +## TESTS — the assertion that is RED before the fix + +Contract that fails on `529639a57` without hunk 3: given a transient policy of N +attempts, a request whose upstream returns 429 and whose key then rotates issues +MORE than N upstream sends, because the pre-fix loop is bounded by +`retryPolicy.attempts` alone and rotation mints a fresh ceiling. The PR's +regression counts sends across the rotation boundary and fails on the pre-fix +tree. It is the contributor's test; this phase confirms CI executed it rather +than re-authoring it. + +The exact-head rollup is the binding verifier. Captured 2026-09-03 on +`49858a2d`: 31 checks, all SUCCESS or SKIPPED (`test 1..4/4`, `macos`, `gates`, +`storage policy`, `api usage`, `keyring ubuntu/windows/macos`, `npm-global` x3, +`hygiene`, `react-doctor`, `enforce-target` x4, `ci`), CodeRabbit neutral. +`mergeStateStatus: UNSTABLE` reflects that neutral status, not a failure. + +## Verification (C) + +``` +gh pr view 3254 --json headRefOid,statusCheckRollup +gh pr merge 3254 --squash --admin +git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD +``` + +Terminal outcome: DONE when the squash sha is an ancestor of `origin/dev`. + diff --git a/devlog/_plan/260903_bug_drawdown_bcda/020_phase2.md b/devlog/_plan/260903_bug_drawdown_bcda/020_phase2.md new file mode 100644 index 0000000000..1e5c184504 --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/020_phase2.md @@ -0,0 +1,95 @@ +# 020 — Phase 2 (wp2): PR #3256 — Kiro reset-aligned cooldown + +## Item + +`fix(oauth): honor Kiro reset-aligned cooldown without Retry-After`, head +`821462f9a3f887ba2c913b7a7ca62cb624498a19`, base `origin/dev` at `529639a57`, +labels `bug`, `maintainer-sponsored`, `review-ready`. + +## Phase class: ADOPTION on a restricted surface + +No source is authored here. Per-file incoming change map (from +`gh pr view 3256 --json files`, 222 additions / 9 deletions): + +| File | Role in this diff | +|------|-------------------| +| `src/combos/failover.ts` | the exported `parseRetryAfterMs()` shared HTTP-date parser | +| `src/oauth/generic-account-failover.ts` | the cooldown-selection call site | +| `tests/combos.test.ts` | parser regressions | +| `tests/kiro-pool-rank.test.ts` | failover-ranking regressions | + +## Actual pre-fix behavior (corrected) + +The first draft of this doc claimed an absent header caused a zero-delay retry. +That is wrong, and the correction matters because it changes what the fix is +for. Reading the current tree: + +- `src/combos/failover.ts:29-45` — `parseRetryAfterMs()` returns `undefined` for an + empty, unparseable, or already-elapsed value; it returns a clamped millisecond + delay otherwise. +- `src/oauth/generic-account-failover.ts:205-211` — `const parsed = parseRetryAfterMs(...)`, + then the exhausted-account branch is taken only when `parsed === null`, and + `cooldownMs = exhausted ?? Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS)`. + +So on the pre-fix tree an unusable header yields `undefined`, not `null`. The +`parsed === null` test never fires, `exhaustedCooldownMs()` is never consulted, and +the account falls back to `DEFAULT_COOLDOWN_MS` — sixty seconds, not zero. The +defect is therefore a wasted 60-second retry cycle against an account whose +allowance is provably spent until its window rolls over, exactly what the +comment at `:206-208` says the code intends to avoid. The fix makes the absent / +malformed case reach the reset-aligned cooldown instead of the default minute. + +## TESTS — the assertion that is RED before the fix + +In `tests/kiro-pool-rank.test.ts`: an exhausted Kiro account 429s with a missing +or malformed `Retry-After`. Assert the recorded `cooldownUntil` equals the +reset-aligned deadline from `exhaustedCooldownMs()`. On the pre-fix tree it +equals `now + DEFAULT_COOLDOWN_MS` (60 s) instead, so the assertion fails. +In `tests/combos.test.ts`: the parser cases — case-insensitive HTTP-date tokens, +the RFC 850 relative-year rule, UTC asctime, and elapsed dates — fail on the +pre-fix parser. Author-reported post-fix run: 72 pass across both files. + +## Security review — explicit, not inferred + +`MAINTAINERS.md` requires explicit security review for OAuth surfaces; +`.github/scripts/pr-sponsored-surface.cjs:24-27` restricts the `src/oauth/` prefix +and `assessSponsoredSurface()` at `:78` clears the CI code when the +`maintainer-sponsored` label is present. The label clears the gate; it is not +the review. The review: + +- Blast radius, corrected and widened: this diff substantially rewrites the + EXPORTED `parseRetryAfterMs()` in `src/combos/failover.ts`, which is also the + parser behind combo-target cooldowns (`coolComboTarget()` at `failover.ts:62`). + A parser change is therefore not confined to Kiro account ranking — it moves + combo cooldown timing too. `tests/combos.test.ts` is the regression surface + that must cover that second consumer, and it is in the diff. +- Direction of the shared-parser change, mode by mode: the rewritten parser is + NARROWER, not broader. It implements the three HTTP-date grammars explicitly + (`src/combos/failover.ts:41`) and rejects non-HTTP strings the prior bare + `Date.parse` happened to accept, while gaining an opt-in `preserveImmediate` + mode. + In the DEFAULT mode — the one `coolComboTarget()` uses — an elapsed or + unparseable date still yields `undefined`, so combo cooldown timing keeps its + existing fallback semantics. In the OAuth call site's mode, a valid but + already-elapsed date is converted to a 1 ms delay rather than discarded, + which is what lets an explicit "retry now" instruction survive instead of + being replaced by a 60-second default. Both modes keep the `MAX_COOLDOWN_MS` + clamp. These are timing changes, not authorization changes, and the two modes + must not be conflated: only the OAuth path takes the immediate branch. +- Credential handling: unchanged. Nothing here reads, writes, logs, or + serializes a token, refresh credential, or account identifier. +- Workflow and release surfaces: untouched. +- Conclusion: accepted. Admin merge bypasses the approval requirement only; the + green exact-head rollup, the combo-parser regressions, and this review are the + non-bypassable evidence. + +## Verification (C) + +``` +gh pr checks 3256 # full rollup, never --required alone +gh pr merge 3256 --squash --admin +git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD +``` + +If the gate is red on the current head, the outcome is BLOCKED, not merged. + diff --git a/devlog/_plan/260903_bug_drawdown_bcda/030_phase3.md b/devlog/_plan/260903_bug_drawdown_bcda/030_phase3.md new file mode 100644 index 0000000000..5c819e15c7 --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/030_phase3.md @@ -0,0 +1,68 @@ +# 030 — Phase 3 (wp3): PR #3246 — bridge write_stdin through exec + +## Item + +`fix(responses): bridge write_stdin through exec`, head +`db96ae50d787df10dc5e3c5767776bfa8fb7d115`, base `8fb4e6e797d4e0d44425a0167b399fa573c3226d`, 162 additions / +15 deletions across 6 files, label `bug`. + +## Phase class: ADOPTION, gate-blocked + +Per-file incoming change map (`gh pr view 3246 --json files`): + +| File | +/- | Role | +|------|-----|------| +| `src/responses/code-mode-helper-compat.ts` | +4 / -1 | the bridge itself | +| `src/types/tools.ts` | +12 / -9 | tool declaration typing | +| `tests/legacy-shell-compat.test.ts` | +22 / -0 | new coverage | +| `tests/bridge-legacy-shell-normalization.test.ts` | +19 / -3 | normalization | +| `tests/responses-custom-tool-repair.test.ts` | +68 / -0 | repair path | +| `tests/responses-undeclared-tool-guard.test.ts` | +37 / -2 | the guard boundary | + +Four of six files are tests: 146 of the 162 added lines are coverage, and the +production delta is 16 lines across two files. Per the PR description the bridge +is request-scoped and fail-closed — it activates only for an exact bare `exec` +declaration, preserves an explicitly declared `write_stdin`, and refuses unknown +or namespaced tools — so the exec surface is not widened. + +## Gate analysis and the draft question + +`resolve-pr`, `label`, `hygiene` passed; `enforce-target` failed while the PR sat +in draft with three of four readiness boxes unticked, so the full matrix never +ran on `db96ae50`. + +`AGENTS.md:303` is precise about what that checklist is: the local-CI box is an +author attestation the gate never disproves, because fork contributors cannot +start repository CI — a maintainer has to. The other three boxes are the +author's own to tick, and when all four are ticked the gate itself marks the PR +ready. So a maintainer marking it ready EARLY is a deliberate override of the +contributor flow, not a step the policy prescribes. + +The justification for doing it here is narrow: this campaign's acceptance +requires exact-head CI evidence, and no such evidence can exist while the PR +stays in draft with the matrix unrun. Marking ready starts the matrix a fork +author cannot start. The override buys evidence, nothing else — the merge +decision still rests entirely on the resulting green rollup, and a red matrix +ends the phase as BLOCKED regardless of the checklist. + +## TESTS — the assertion that is RED before the fix + +The PR reports a red-first run of four expected failures. The concrete +pre-fix behavior: a model emitting `write_stdin` against a bare `exec` declaration +is rejected by the undeclared-tool guard +(`tests/responses-undeclared-tool-guard.test.ts`) instead of being bridged onto +`exec`, and the normalization path leaves the call unmapped +(`tests/bridge-legacy-shell-normalization.test.ts`). Post-fix those four files +report 120 pass / 0 fail. Only those focused files may run locally. + +## Verification (C) + +``` +gh pr ready 3246 +gh pr view 3246 --json headRefOid,statusCheckRollup +gh pr merge 3246 --squash --admin +git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD +``` + +Terminal outcome: DONE on merge, or BLOCKED naming the exact failing gate. + diff --git a/devlog/_plan/260903_bug_drawdown_bcda/040_phase4.md b/devlog/_plan/260903_bug_drawdown_bcda/040_phase4.md new file mode 100644 index 0000000000..2b478fbfba --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/040_phase4.md @@ -0,0 +1,71 @@ +# 040 — Phase 4 (wp4): PR #3270 — incremental usage ledger aggregation + +## Item + +`fix(usage): aggregate complete ledger incrementally`, head +`f5aaf12071043bb1adaaf75217d62b53145d74ef`, base `ee24bab40004f4e3698636cba64f5bb6d18438fd`, 3535 additions / +1287 deletions across 21 files, label `bug` (+ `gui-screenshot-waived`, see below). + +## Phase class: ADOPTION of a large diff + +Per-file incoming change map, grouped: + +New modules — `src/usage/ledger-scanner.ts` (+448), +`src/server/management/usage-aggregate-cache.ts` (+464). +Rewritten core — `src/usage/summary.ts` (+915 / -655), +`src/server/management/api-key-usage.ts` (+97 / -43), +`src/server/management/logs-usage-routes.ts` (+64 / -87). +Wiring — `src/config.ts` (+3/-1), `src/types/config.ts` (+4/-1), +`src/lib/app-owned-memory-stores.ts` (+27/-8), +`src/server/management/usage-summary-cache.ts` (+4), `src/usage/log.ts` (+1/-1). +GUI — `gui/src/pages/use-dashboard-data.ts` (+1/-1) and +`gui/tests/dashboard-contracts.test.ts` (+1/-1): a single dashboard refresh +constant, nothing visual. +Docs — `docs-site/src/content/docs/reference/management-api.md` (+20/-1), +`structure/05_gui-and-management-api.md` (+33/-12). +Tests — `tests/usage-ledger-scanner.test.ts` (+498), +`tests/usage-summary.test.ts` (+311), `tests/usage-aggregate-cache.test.ts` (+301), +`tests/api-usage.test.ts` (+202/-473), `tests/api-key-attribution.test.ts` (+135/-3), +plus two-line touches to `tests/memory-watchdog.test.ts` and +`tests/settings-stream-mode.test.ts`. 1447 added test lines. + +## Gate analysis + +`enforce-target` failed with `PR quality gate failed: missing UI screenshot` (run +33660610072). The gate triggers on any `gui/` path, but the entire GUI delta here +is one refresh-interval constant and its contract test — there is no UI change to +screenshot. This is the false positive that `gui-screenshot-waived` exists for. Its +authority is the enforcement workflow itself: `GUI_SCREENSHOT_WAIVER_LABEL` is +declared at `.github/workflows/enforce-pr-target.yml:259`, matched against the +PR labels at `:678`, and removes the screenshot failure from `failures` at +`:727-730`. `AGENTS.md` does not mention the label; the workflow is the only +authority, and PR #2805 carries the same label as precedent. The label was +applied rather than demanding a screenshot of a one-constant change. + +## TESTS — the assertion that is RED before the fix (corrected) + +The earlier draft claimed incremental-equals-full-recompute as the red +assertion. That is not red: the pre-fix implementation recomputes wholesale, so +it satisfies that equality trivially. The actual defect, per the PR title and +CodeRabbit's summary, is COMPLETENESS — the pre-fix aggregation is bounded by +read and row limits, so earlier history is silently omitted from usage reports. + +The red assertion is therefore: build a ledger larger than the pre-fix read/row +bound, request the usage summary, and assert the reported totals include the +oldest rows. On the pre-fix tree the early rows are missing and the totals come +back short. `tests/usage-ledger-scanner.test.ts` and `tests/usage-summary.test.ts` +are the files carrying that case; `tests/api-key-attribution.test.ts` carries the +per-key equivalent. Locally, only those files may be run. + +## Verification (C) + +``` +gh pr view 3270 --json headRefOid,statusCheckRollup +gh run view --log-failed # when any check is red +git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD +``` + +Merge requires the green exact-head matrix AND a read confirming the new scanner +still reads a ledger written by the old aggregator. Otherwise the outcome is +BLOCKED or NEEDS_HUMAN with the concrete reason. + diff --git a/devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md b/devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md new file mode 100644 index 0000000000..caa9a83cbe --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md @@ -0,0 +1,79 @@ +# 050 — Phase 5 (wp5): Issue #3280 — GUI full-config PUT rejection + +## Finding (gpt-5.6-sol investigator, high effort) + +VERDICT FIXABLE_NOW, confidence high, credential-surface risk YES. + +`gui/src/hooks/useJsonConfigEditor.ts:27-39` serializes the redacted config DTO +and submits `PUT /api/config`. The server deliberately rejects every such +request at `src/server/management/config-routes.ts:248-253`, reinforced by +`src/server/management/route-registry.ts:165`. Fanning out to per-provider +POST/PATCH/DELETE is unsafe: each operation persists independently +(`src/server/management/provider-routes.ts:652-655`, `779-781`, `1101-1138`), +which allows partial saves and loss of fields absent from the public DTO. + +## MODIFY / NEW / DELETE map + +- MODIFY `src/server/auth-cors.ts` — typed provider-editor DTO plus a single + public-field projection, so a redacted or derived field can never become write + authority. +- MODIFY `src/server/management/provider-routes.ts` — NEW atomic + `PUT /api/providers` taking `{ baseline, next }`; compare `baseline` against + the latest public projection, merge `next` into freshly read persisted + providers while preserving API keys, pools, headers and credentials, validate + every provider/default/deletion, then commit once through + `mutatePersistedConfig` and reconcile caches/accounts/catalog a single time. +- MODIFY `src/server/management/route-registry.ts` — register the new route; + keep the `/api/config` 405 exactly as is. +- MODIFY `gui/src/hooks/useJsonConfigEditor.ts` — expose only + `{ defaultProvider, providers }`, send one `{ baseline, next }` request, and + keep parse failures distinct from network/server failures. + +## TESTS + +- NEW `tests/provider-config-batch-management.test.ts` — the PUT updates several + providers in one commit, preserves masked credentials and private fields, + returns 400 with zero persisted change when any row is invalid, and 409 on a + stale baseline. +- NEW `gui/tests/use-json-config-editor.test.tsx` — Save issues exactly one + `PUT /api/providers` carrying baseline and next, never `PUT /api/config`, never + a POST/PATCH/DELETE fan-out, and refreshes only after success. + +Both are red on current HEAD. + +## Verification (C) + +``` +bun test tests/provider-config-batch-management.test.ts +bun test gui/tests/use-json-config-editor.test.tsx +bun run typecheck +``` + +The credential-preservation assertion is the load-bearing one: the endpoint must +never persist `hasApiKey`/`hasHeaders` or any other derived marker. + + +## Security review checkpoint (required before merge) + +This phase creates a NEW write endpoint that must preserve secrets the caller +never sees. `MAINTAINERS.md` requires explicit security review for credential +surfaces, and a green CI run is not that review. Record all of the following in +the PR description before requesting merge: + +- Threat model: the GUI holds only the redacted public projection. A naive + round-trip therefore writes `hasApiKey: true` back over a real `apiKey`. The + `{ baseline, next }` shape exists so the server, which alone holds the secret, + performs the merge. +- Non-authority invariant: no field originating from the public projection may + become write authority. `hasApiKey`, `hasHeaders`, and every other derived + marker must be rejected, not persisted. +- Atomicity invariant: one `mutatePersistedConfig` commit. A partial save on this + surface can strand a provider without its credential. +- Concurrency invariant: a stale `baseline` returns 409 rather than overwriting a + concurrent edit. +- Unchanged: the `/api/config` 405 stays exactly as is. This phase does not + re-enable full-config PUT. + +Merge is blocked until this block is filled in on the PR. If review concludes the +merge semantics cannot be made safe, the outcome is UNSAFE, not merged. + diff --git a/devlog/_plan/260903_bug_drawdown_bcda/060_phase6.md b/devlog/_plan/260903_bug_drawdown_bcda/060_phase6.md new file mode 100644 index 0000000000..812a5c0a1b --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/060_phase6.md @@ -0,0 +1,56 @@ +# 060 — Phase 6 (wp6): Issue #3279 — dashboard 401 flap + +## Finding (gpt-5.6-sol investigator, high effort) + +VERDICT NEEDS_REPRO, confidence medium, auth-surface risk YES. + +No intermittent invalidation mechanism could be established. An unexpired +session 401s only when absent/evicted or when its exact server-origin, +browser-origin, or CSRF binding fails (`src/server/gui-session.ts:417`); +loopback sessions otherwise expire deterministically after five minutes +(`src/server/gui-session.ts:62`, `:428`). Management auth state initializes once +per server process, so admin-token rotation is not involved +(`src/server/index.ts:653`). The GUI installs its auth wrapper before React +renders and its 401 recovery is single-flight (`gui/src/App.tsx:42`, +`gui/src/api.ts:247`, `:299`). The reported "online then offline" may be cached +health followed by the first failed authenticated poll +(`gui/src/pages/use-dashboard-data.ts:97`, `:220`, `:307`). + +PR #3080 is NOT the same fix: it persists a 12-hour opaque session for remote +dashboards, is draft and conflicting, does not change the injected loopback +session path, and cannot survive a proxy restart. + +## MODIFY / NEW / DELETE map + +None. Making a production change here without the trace would mean weakening +loopback-origin equality on a guess, on an authentication surface. + +## Action + +Comment on #3279 requesting the exact failing request URL, the session meta +origins, whether the Authorization header was present, and the immediate +`GET /opencodex-session` result. The issue already carries `needs-info`. + +Terminal outcome: NEEDS_HUMAN — reproduction requires the reporter's browser and +machine. + + +## TESTS — what would be RED, once the trace exists + +No test can be written yet, and that is the finding rather than an omission: the +report supplies no constructible failing sequence, and the existing suite already +covers deterministic expiry and concurrent refresh. When the reporter supplies +the trace, the RED assertion is: + +- `tests/server-management-auth.test.ts` — replay the captured Host/Origin/header + request against a session that is still valid, and assert + `GET /api/system/health` returns 200. This must fail on the then-current HEAD + before any production change. +- `gui/tests/api-auth-memory.test.ts` — replay the first-401 → + `/opencodex-session` → parallel-retry sequence and assert exactly one + bootstrap, no admin-token prompt, and 200 for both health and providers. + +Writing either test against a guess would encode the guess. That is why this +phase's terminal outcome is NEEDS_HUMAN rather than a speculative patch on an +authentication surface. + diff --git a/devlog/_plan/260903_bug_drawdown_bcda/070_phase7.md b/devlog/_plan/260903_bug_drawdown_bcda/070_phase7.md new file mode 100644 index 0000000000..737e1f1ad6 --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/070_phase7.md @@ -0,0 +1,45 @@ +# 070 — Phase 7 (wp7): Issue #3141 — responses-state.json write storm + +## Finding (gpt-5.6-sol investigator, high effort) + +VERDICT FIXABLE_NOW, confidence high, no auth/release risk. + +Every eligible completed response mutates the continuation cache and calls +`schedulePersist()` (`src/responses/state.ts:2182-2232`). A process-level timer +coalesces triggers every 2–30 s depending on snapshot size +(`src/responses/state.ts:1562-1573`) and byte-identical snapshots are skipped +(`:1521-1530`). Under concurrent completions, though, the revision changes +during async disk I/O, so the loop at `src/responses/state.ts:1479-1536` +performs up to four immediate full atomic rewrites per background tick. The +current tests codify that: four background writes and eight shutdown writes at +`tests/responses-state.test.ts:2204-2240`. + +## MODIFY / NEW / DELETE map + +- MODIFY `src/responses/state.ts` — parameterize `writeBoundedSnapshot()` with an + attempt limit; pass `1` for ordinary background persistence and, when the + snapshot is unstable, keep the existing delayed `schedulePersistAt(path, true)` + follow-up instead of rewriting immediately. Retain bounded retry only for + graceful shutdown after request draining. Leave the byte-identity check and + `atomicWriteFileAsync()` untouched. +- MODIFY `tests/responses-state.test.ts` — update the background-churn + expectation from four attempts to one plus a pending follow-up. +- MODIFY `docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md` — + document the one-rewrite-per-background-cadence guarantee. + +## TESTS + +`tests/responses-state.test.ts`, case "background revision churn schedules +exactly one follow-up pass": assert `attempts === 1` with a pending follow-up +timer. Red on current HEAD, where the observed contract is `attempts === 4`. + +## Verification (C) + +``` +bun test tests/responses-state.test.ts -t 'background revision churn' +bun run typecheck +``` + +Accepted tradeoff: crash-recovery state may lag by one extra debounce interval +under sustained traffic. + diff --git a/devlog/_plan/260903_bug_drawdown_bcda/080_phase8.md b/devlog/_plan/260903_bug_drawdown_bcda/080_phase8.md new file mode 100644 index 0000000000..f48aef4f87 --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/080_phase8.md @@ -0,0 +1,65 @@ +# 080 — Phase 8 (wp8): Issue #3152 — dashboard log panel jitter + +## Finding (gpt-5.6-sol investigator, high effort) + +VERDICT FIXABLE_NOW, confidence medium, no auth/release risk. + +`gui/src/pages/Logs.tsx:521-533` virtualizes dynamically measured rows with a +44px estimate while the multiline cells at `:746-826` are far taller. The table +stays on automatic layout with no fixed column schema +(`gui/src/styles.css:1992-1995`), so every changed mounted-row subset +recalculates intrinsic column widths; model wrapping (`gui/src/styles.css:1985`) +then changes row heights and feeds another virtualizer measurement +(`gui/src/pages/Logs.tsx:741-744`). Native scroll anchoring and the 2 s refresh +(`:458-468`) amplify it rather than cause it. + +PR #3250 replaces only the polling at `gui/src/pages/Logs.tsx:429-456` with delta +merging. It touches neither table geometry nor virtualization, so it does not +fix or supersede #3152 — but it will need a small same-file rebase. + +## MODIFY / NEW / DELETE map + +- MODIFY `gui/src/pages/Logs.tsx` — add a ten-column `` before + ``; change `estimateSize` 44 → 92; supply `getItemKey` from + `requestId` with a timestamp/model/provider fallback so measurements survive + prepends. +- MODIFY `gui/src/styles.css` — `table.logs-table { table-layout: fixed; }`, ten + explicit `` widths (12/9/7/8/15/9/13/8/11/8 %), and `overflow-anchor: none` + plus `scrollbar-gutter: stable` on `.logs-table-wrap`. + +## TESTS + +- `gui/tests/viewport-scroll-caps.test.ts` — effective `table-layout` is + `fixed`, all ten width declarations exist and total 100 %, and + `.logs-table-wrap` carries `overflow-anchor: none` + `scrollbar-gutter: stable`. + The `table-layout` assertion is red on HEAD. +- `gui/tests/logs-auto-refresh.test.tsx` — the rendered table contains the + ordered ten-column ``. + +## RED-before-fix status of each assertion + +- `table-layout: fixed` on `table.logs-table` — RED on HEAD. `gui/src/styles.css` + currently leaves the table on automatic layout, so the computed value is + `auto`. +- The ten `` width declarations totalling 100% — RED on HEAD. No + `` exists in `gui/src/pages/Logs.tsx`, so there is nothing to sum. +- `overflow-anchor: none` and `scrollbar-gutter: stable` on `.logs-table-wrap` — + RED on HEAD; neither declaration is present. +- The ordered ten-column `` in the rendered table + (`gui/tests/logs-auto-refresh.test.tsx`) — RED on HEAD for the same reason. + +All four are red by absence, which is a legitimate red so long as the assertion +is written and observed failing BEFORE the fix lands, not asserted afterwards. + +## Verification (C) + +``` +bun test gui/tests/viewport-scroll-caps.test.ts +bun test gui/tests/logs-auto-refresh.test.tsx +bun run lint:gui +``` + +Both focused GUI test files must be run — the `` render assertion lives +in the second one. A `gui`-labelled PR also requires a screenshot in the +description per `enforce-target`. + diff --git a/devlog/_plan/260903_bug_drawdown_bcda/090_phase9.md b/devlog/_plan/260903_bug_drawdown_bcda/090_phase9.md new file mode 100644 index 0000000000..7141f62586 --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/090_phase9.md @@ -0,0 +1,66 @@ +# 090 — Phase 9 (wp9): Issues #3245 and #1527 — evidence-backed disposition + +Two items that investigation shows cannot be fixed from this machine. Each gets +a recorded disposition rather than a speculative patch. + +## Issue #3245 — macOS Codex 0.152.0 stream disconnect + +VERDICT NEEDS_REPRO / upstream. OpenCodex returns 426 by design when WebSockets +are disabled (`src/server/index.ts:1107-1126`), and its Responses data plane +only begins on the subsequent POST (`:1755-1787`). The reporter's probe shows no +POST and no usage-log entry, so SSE relay, terminal repair, timeout, and +outbound connection reuse were never reached +(`src/server/responses/core.ts:4657-4675`, `src/lib/upstream-retry.ts:294-311`). +Codex itself routes 426 to HTTP and already tests for the resulting POST. The +control test `tests/server-auth.test.ts:1384-1422` asserts 426 followed by HTTP +200 and predates v2.39.0. + +Action: no OpenCodex diff. Comment with this trace, keep `upstream-tracking`, +and ask for a 0.152.1+ re-run recording `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, +and the localhost probe. + +## Issue #1527 — Cursor adapter large-context collapse + +VERDICT NEEDS_REPRO, confidence high. The original full-history and overflow +defects are already fixed: checkpoints are reused and final replay envelopes are +bounded (`src/adapters/cursor/request-builder.ts:438`, +`src/adapters/cursor/protobuf-request.ts:1580`); rate limits are explicitly +non-retryable and post-terminal aborts no longer reclassify completed turns +(`src/adapters/cursor/transport-retry.ts:20`, +`src/adapters/cursor/live-transport.ts:716`). `max_output_tokens` is not lowered +— it has no wire field at all (`src/adapters/cursor/types.ts:12`, +`src/adapters/cursor/gen/agent_pb.ts:2736`). The only live candidate is cold +full replay after a missing/expired checkpoint, which needs a failing turn's +`continuationMode`, `rootBytes`, and direct-client cache evidence. + +Action: no diff. Comment with the ruled-out causes and the exact capture needed +(matched direct-vs-proxy run on one account, redacted `run-request` fields). + +## Verification (C) + +Both are terminal as NEEDS_HUMAN with the analysis posted to the issue. No merge +proof applies; the evidence is the comment plus the file:line trace above. + + +## TESTS — why no RED assertion exists for either item + +Both items are NEEDS_REPRO, so there is no honest failing unit test to write, and +manufacturing one would encode a guess as a contract. What each needs first: + +- #3245: the control test `tests/server-auth.test.ts:1384-1422` already asserts + 426 followed by HTTP 200 and it PASSES on HEAD, which is precisely why the + OpenCodex side is exonerated. A red test would have to live upstream in + `codex-rs/core/tests/suite/websocket_fallback.rs`, asserting + `websocket_attempts == 1 && http_attempts == 1` under the reporter's proxy + environment; the reported failure is `http_attempts == 0`. +- #1527: the first artifact is a secret-free matched probe under `.tmp/` whose + failure condition is that direct Cursor completes the workload without 429 + while OpenCodex returns 429 or fails the same completion rubric. Only after + that isolates a cause does a red assertion become writable — + `tests/cursor-request-builder.test.ts` asserting + `continuationMode === "checkpoint"` with retained `checkpointBytes`, or + `tests/cursor-blob.test.ts` asserting a captured direct wire parameter decodes. + +Writing either assertion before its evidence exists is the failure mode this +campaign is supposed to avoid. + diff --git a/devlog/_plan/260903_bug_drawdown_bcda/100_closeout.md b/devlog/_plan/260903_bug_drawdown_bcda/100_closeout.md new file mode 100644 index 0000000000..8c4fbd976f --- /dev/null +++ b/devlog/_plan/260903_bug_drawdown_bcda/100_closeout.md @@ -0,0 +1,96 @@ +# 100 — Closeout: bug-labelled drawdown, 2026-09-03 + +## Outcome + +DONE. The open `bug`-labelled pull-request queue reached zero, and every +`bug`-labelled issue open at arming time reached a terminal state. Sixteen pull +requests were squash-merged to `dev`, each proved an ancestor of `origin/dev` +with `git merge-base --is-ancestor`. `origin/dev` moved from `529639a57` to +`b3e205e99`. + +## Merged + +| PR | Item | Merge | +|----|------|-------| +| #3254 | native chat transient send budget | `b0a42ca2f` | +| #3256 | Kiro reset-aligned cooldown (OAuth) | `fd324dc88` | +| #3246 | write_stdin bridged through exec | `938c0136a` | +| #3289 | responses-state.json write storm (#3141) | `34c9e9802` | +| #3290 | log panel jitter (#3152) | `fc08fc2f7` | +| #3294 | combo request-rate cooldown + Retry-After | `6b2dfde11` | +| #3270 | incremental usage ledger aggregation | `85d40ca35` | +| #3296 | atomic provider editor save (#3280) | `3c7c021ec` | +| #3297 | claude launcher liveness retry | `4cf3e9187` | +| #3298 | provider-scoped quota cap failover | `e9a5b0f13` | +| #3301 | hermetic provider-option E2E (#3299) | `15b43e51c` | +| #3302 | cached-quota pre-emption | `2e74a35d4` | +| #3307 | rotation createdAt (#3303) | `eac662eb1` | +| #3308 | reachable status dashboard URL (#3304) | `472c785c2` | +| #3310 | catalog inactivity timeout (#3305) | `906511f73` | +| #3309 | hub-local loopback integrations (#3306) | `b3e205e99` | + +Issues #3141, #3152, #3280, #3299, #3303, #3304, #3305 and #3306 were closed with +their merge commit named. + +## Terminal without a diff + +Three issues ended NEEDS_HUMAN with the analysis posted rather than a guess: + +- **#3245** — the 426 is deliberate (`src/server/index.ts:1107-1126`) and the + reporter's probe shows no POST at all, so the SSE, timeout and reuse paths were + never reached. The control test `tests/server-auth.test.ts:1384-1422` passes. + The candidate fix is upstream in the Codex client's 426 fallback. +- **#1527** — the full-replay, retry-amplification and `max_output_tokens` + theories are all ruled out by current code; `max_output_tokens` has no wire + field on that path at all. Only a matched direct-vs-proxy capture can isolate + the remaining cold-replay candidate. +- **#3279** — no intermittent-invalidation mechanism exists in + `src/server/gui-session.ts`; the only available "fix" would be weakening + loopback-origin equality on a guess, on an authentication surface. + +## What the process caught that a green build would not + +**A plan audit that failed four times.** The wp0 roadmap passed only on round 5. +Two of the reviewer's findings were factual errors in my own writeup, verified +against source: an unusable `Retry-After` yields `DEFAULT_COOLDOWN_MS` (60 s), +not a zero-delay retry (`src/oauth/generic-account-failover.ts:205-211`); and +"incremental equals full recompute" is trivially true pre-fix, so #3270's real +RED assertion is ledger completeness under read and row bounds. + +**A browser, not a test.** #3280's first implementation used an allowlist of 11 +editable provider fields. Every test passed. Saving a real untouched config in +the dashboard rejected it with `provider "woong" contains non-editable field "note"` +— trading a clear 405 for a save that refuses the user's own config. The policy +became an exhaustive `Record` that `tsc` enforces. + +**CI, on my own change.** #3296 broke two contracts that focused tests missed: a +route-inventory count and a runtime-metadata rejection. Fixing the second, a +subagent relaxed an existing `safeConfigDTO` assertion so its implementation +would pass. `dev` already listed `modelMaxInputTokens` among values that DTO +must never serialize, so the test was restored verbatim and the implementation +made to satisfy it. + +**A revert hiding inside a contribution.** #3302 arrived branched before #3301 +and silently reverted it, restoring a public-WebSocket reach and real Windows ACL +subprocesses. Only its genuinely new part — cached-quota pre-emption — was kept. + +## Flaky tests observed + +Three distinct macOS timing failures recurred and passed on rerun, unrelated to +any change here. Worth their own unit if they keep costing reruns: + +- `shutdown-launcher`: `waitUntil(() => healthy(port), 20_000)` at + `tests/shutdown-launcher.test.ts:111` — proxy startup exceeds 20 s on a loaded + runner. +- `Response spill shutdown fallback budget exhausted` — a 4 s wall-clock reserve + (`RESPONSE_SPILL_SHUTDOWN_FALLBACK_RESERVE_MS`). +- `CL-07 task effectiveness producer > inactivity timeout is bounded`. + +Plus `minimax-clients`, which assumes a just-closed port stays free. + +## Constraint honored + +No repository-wide local suite was run at any point. Verification was focused +test files plus the exact-head GitHub check rollup, per the maintainer +instruction for this campaign. + diff --git a/devlog/_plan/260903_contributor_credit_restoration/000_plan.md b/devlog/_plan/260903_contributor_credit_restoration/000_plan.md new file mode 100644 index 0000000000..20e38b6349 --- /dev/null +++ b/devlog/_plan/260903_contributor_credit_restoration/000_plan.md @@ -0,0 +1,119 @@ +# 000 — contributor_credit_restoration: Plan + +> DIFFLEVEL-ROADMAP-01: exact paths, NEW/MODIFY, before/after shapes, written +> before P -> A. + +## Objective + +Restore contributor attribution that git history cannot carry, and make the +omission mechanically impossible to repeat. + +Three deliverables, each its own PABCD work-phase after this roadmap cycle: + +1. `CREDITS.md` — a durable attribution record in the tree. +2. A hygiene gate plus an `AGENTS.md` rule so a future carry cannot merge + without naming the author it carried. +3. Credit sections appended to the affected GitHub release bodies. + +## The defect + +When a maintainer reimplements, carries, or rebases a contributor's pull +request, the landing commit is authored by the maintainer. The contributor's +name survives only if a `Co-authored-by` trailer names them — that trailer is +what GitHub reads for the contributor graph, the repository's contributor list, +and the author's own profile activity. + +Some landings carry it. Others state the debt in prose and omit the trailer: + +``` + 53c09a247 "Clean reimplementation of #3193" Co-authored-by: alan7629 ... ✓ + 5734a1caf "Reimplements #2797 by @rrmlima." (no contributor trailer) ✗ +``` + +Both sentences are equally sincere. Only the first is data. The second is a +string in a commit body that no tool reads, which is why the omission was +invisible until someone went looking. + +## Evidence base (captured 2026-09-03) + +Two independent scans, both re-runnable: + +- **Commit-side.** Every `origin/dev` commit whose body matches + `reimplement|supersede|carry of|rebase of|adopts the design from` followed by + `#N`, joined against its own `Co-authored-by` trailers. 23 commits matched; + 12 name an author in prose whose trailer is absent. +- **PR-side.** All 674 closed-unmerged pull requests, narrowed to the 119 + authored by someone other than the maintainer since #2400. Maintainer closure + comments were parsed for a landing reference (`landed via #N`, + `superseded by #N`, `closing in favor of #N`); each landing PR's merge commit + was then checked for a trailer naming the original author, matched on the + GitHub login, the git author name, and the git author email taken from the + original PR's own commits. + +The two scans overlap and disagree in useful ways, which is why both are kept. +Three PR-side hits were false positives cleared by walking the merge range +rather than the squash commit (#2989, #2828, #2638 — luvs01's commits are +inside those merges). Eleven more were cleared once the login-to-git-identity +mapping was applied (`terrytan95` is `Terry Tan`, `ntdatt812` is +`Nguyen Thanh Dat`, and so on). + +After both passes, 27 landing commits carry an uncredited origin. + +## Why git history is not the repair + +`dev`, `main`, and `preview` each carry an active GitHub ruleset that blocks +force-push (rulesets 20763889 / 20764415 / 20764486). Every affected commit but +one is already an ancestor of `origin/main` and sits inside a published release +tag — `v2.23.0` through `v2.40.0`. Adding a trailer means rewriting those +commits, which invalidates the tags, the npm `gitHead` values, and every clone. + +`MAINTAINERS.md:26` already states the principle in the other direction: +authorship credit in git history is not rewritten. The repair therefore goes +*forward* — into files, gates, and release bodies, all of which are mutable. + +## Evidence grading + +Not every closed PR earns a row, and the difference is not a judgment call — it +is what the maintainer's own closure comment says. Two grades: + +- **Carried** — the comment or commit states the contributor's code, design, or + tests were taken. "keeps your production logic exactly as written", + "reimplements both of your production hunks", "re-implemented on current dev + from your design", "carries all three of its unique tests". +- **Diagnosed** — the fix exists because of the report, but the branch's + approach was explicitly rejected. "#3107 exists because you found it" while + the comment then explains why that layer was wrong. + +Both belong in `CREDITS.md`; they do not belong in the same column. Recording a +rejected approach as carried code would be its own inaccuracy, and the +contributors who were told plainly why their patch was not the vehicle deserve +the record to say what actually happened. + +A third class is excluded: PRs closed as duplicates where nothing of the +contributor's was taken and the report itself was not the trigger. + +## Work-phase map + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp0 | `000_plan.md` | This roadmap | — | +| wp1 | `010_credits_file.md` | `CREDITS.md` + README/CONTRIBUTING links | wp0 | +| wp2 | `020_hygiene_gate.md` | `AGENTS.md` rule + deterministic co-author gate | wp0 | +| wp3 | `030_release_notes.md` | Credit sections on the affected releases | wp1 | + +wp3 depends on wp1 because the release sections link to `CREDITS.md` and must +not name a row that the file does not carry. + +## Loop-spec + +- Write scope: `CREDITS.md`, `README.md`, `CONTRIBUTING.md`, `AGENTS.md`, + `.github/scripts/`, `devlog/_plan/260903_contributor_credit_restoration`. +- Out of scope: rewriting git history, force-pushing, retagging, re-releasing, + `src/` and `gui/` runtime changes, reopening closed issues, and DMing + contributors. +- Verification: focused `node --test` on the changed `.cjs` test file, + `bun run typecheck`, and `git merge-base --is-ancestor` for every SHA in the + table. No repository-wide suite, per the standing constraint. +- Terminal outcomes: DONE when all three deliverables are verified. + NEEDS_HUMAN if a row cannot be sourced to explicit maintainer language. + BLOCKED if a GitHub write is refused. diff --git a/devlog/_plan/260903_contributor_credit_restoration/010_credits_file.md b/devlog/_plan/260903_contributor_credit_restoration/010_credits_file.md new file mode 100644 index 0000000000..bda7c7129d --- /dev/null +++ b/devlog/_plan/260903_contributor_credit_restoration/010_credits_file.md @@ -0,0 +1,101 @@ +# 010 — wp1: `CREDITS.md` + +## Slice + +NEW `CREDITS.md` at the repository root. MODIFY `README.md` and +`CONTRIBUTING.md` to link it. + +## Why a file and not a history rewrite + +See `000_plan.md`. Short version: the commits are inside published tags and +behind force-push rulesets, and `MAINTAINERS.md:26` already forbids rewriting +authorship in history. + +## Row selection rule + +A row exists only where the maintainer's own words — in the closure comment or +the landing commit body — state what the contributor supplied. Every row's +"what landed" cell is a quotation or a close paraphrase of that sentence, never +an inference from the diff. + +Two grades, kept in separate tables because collapsing them would misreport +both: + +- **Carried** — code, design, or tests were taken. +- **Report and diagnosis** — the fix exists because of the report, and the + branch's own approach was explicitly not the vehicle. These contributors were + told exactly why. The record should say the same thing. + +## Table 1 — carried work + +| Original PR | Author | Landed as | What landed | +|---|---|---|---| +| #1801 | @jonathanli12 | `cb48c2e11` | "carries all three of its unique tests"; the code-mode contract and its tests | +| #2123 | @chilung-cgu | `ef7b3c9cf` | "Your account loop and the reuse of `getTokenForAccountQuotaProbe` are what shipped" | +| #2655 | @TooSpace | `607042b02` | "re-implemented on current `dev` from your design" | +| #2693 | @yxr1995-maker | `d829215af`, `bdc1e97bb` | "carries your fix forward with the three review blockers closed" | +| #2734 | @TooSpace | `88c427522` | "That carry keeps the adaptive effort-mode design" | +| #2744 | @yxr1995-maker | `8877df0ee` | "The landed version reimplements that narrowly on current `dev`" | +| #2796 | @rrmlima | `bb3321ca8` | "Reimplements #2796 by @rrmlima" | +| #2797 | @rrmlima | `5734a1caf` | "Reimplements #2797 by @rrmlima" | +| #2812 | @gaoran1209 | `c986d1d20` | "Reimplements #2812 by @gaoran1209 with the maintainer's blocker addressed" | +| #2867 | @Ingwannu | `8d1dc1f5d` | "That landed change includes this PR's strict LoadState parsing" | +| #2870 | @luvs01 | `de91dfde4` | "the coalescing design here is right, and it is carried forward in #2872" | +| #2884 | @chilung-cgu | `eb52973c5` | "Completes contributor PR #2884"; the exact-name approach carried as-is | +| #3000 | @MarcTCruz | `fecb77a91` | "Your central insight": the refresh lock and the file it protects live under different homes | +| #3039 | @ntdatt812 | `b14b741dc` | "keeps your production logic exactly as written — the Windows budget, the `waited` guard, and the grace probe" | +| #3041 | @ntdatt812 | `b46164e78` | "carries your three merge-loop tests … they came from this PR" | +| #3067 | @ntdatt812 | `b14b741dc` | "keeps your diagnosis and your relocation", with the remedy narrowed | +| #3078 | @Veritas-7 | `0ef04e640` | "reimplements both of your production hunks on `dev`" | +| #3142 | @olddonkey | `52d941640` | "That carry keeps the measurement/refusal work and ships the guard default-off" | +| #3300 | @S0RYUASUKA | `15b43e51c` | the same two files made hermetic, landed through #3301 | + +## Table 2 — report and diagnosis + +| Original PR | Author | Fix landed as | Maintainer's words | +|---|---|---|---| +| #2925 | @ncepuee | `1d9b389c1` | "Credit to @ncepuee, whose #2925 identified this and argued the split" | +| #3006 | @Ingwannu | `870a2adb6` | "your PR correctly identified the broken invariant and verified the target was unused" | +| #3038 | @L-Y-J | `e9d198a3c` | "the defect is real and #3107 exists because you found it" | +| #3040 | @ntdatt812 | `330470e74` | "The defect you found is real"; the branch's remedy was the wrong direction | +| #3117 | @olddonkey | `b46164e78` | "Thank you for the focused report and tests" | +| #3143 | @Ingwannu | `408652698` | "The diagnosis here was yours and it was right" | +| #3223 | @alex-jordan547 | `d23eab43a` | "The report itself was what made the fix quick; the wire capture pointed straight at the cause" | + +## Deliberately not tabulated + +#3020 (@luvs01) and #2675 (@Ingwannu) were closed with "Landed via #3119" and +"Landed via #2677" and nothing further. The landing is recorded, the carry is +not stated, and inventing one would be exactly the inaccuracy this file exists +to correct. They are named in a closing paragraph instead of a table row. + +## File shape + +``` +# Credits + + +## Carried work -> Table 1 +## Report and diagnosis -> Table 2 +## Also closed as landed -> the two above +## How this is maintained -> pointer to the hygiene gate +``` + +## Link edits + +`README.md` — MODIFY. Its contributing block gains one line pointing at +`CREDITS.md`. + +`CONTRIBUTING.md` — MODIFY. The top bullet list already names `MAINTAINERS.md`, +`structure/`, and `docs/`; add `CREDITS.md` beside them. + +## Verification + +```bash +for sha in ; do + git merge-base --is-ancestor "$sha" origin/dev || echo "NOT AN ANCESTOR: $sha" +done +``` + +Silence is the pass. The check is real: it caught `d975feaa4` being quoted from +a stale scan during drafting. diff --git a/devlog/_plan/260903_contributor_credit_restoration/020_hygiene_gate.md b/devlog/_plan/260903_contributor_credit_restoration/020_hygiene_gate.md new file mode 100644 index 0000000000..714743ca68 --- /dev/null +++ b/devlog/_plan/260903_contributor_credit_restoration/020_hygiene_gate.md @@ -0,0 +1,129 @@ +# 020 — wp2: the co-author gate + +## Slice + +MODIFY `AGENTS.md` (the rule), `.github/scripts/pr-hygiene.cjs` (the check), +`.github/scripts/pr-hygiene.test.cjs` (the coverage). + +## What the gate has to catch + +The exact shape that produced this whole unit: a pull request whose own text +says it reimplements, supersedes, carries, or rebases someone else's pull +request, merging without a `Co-authored-by` trailer naming that person. + +``` + title/body: "Reimplements #2797 by @rrmlima." + trailers: (none) -> FAIL +``` + +## Where it goes + +`collectDeterministicHygieneFailures` in `.github/scripts/pr-hygiene.cjs` is the +single entry point both `pr-hygiene.yml` and the quality gate call, and it +already composes two assessors: `assessHygiene` (patch shape) and +`assessSponsoredSurface` (paths). This is a third assessor over PR text, not a +change to either. + +That matters for the input contract. `assessHygiene` reads `files`; the new +check reads the PR title, body, and commit messages. Those are already +reachable from the workflow — it calls `pulls.get` and can call +`pulls.listCommits` — but they are not currently passed down. The workflow +gains that fetch and passes them through. + +## The check + +```js +const CARRY_RE = + /\b(?:re-?implements?|re-?implementation of|supersedes?|carry of|carries|rebase of|adopts the design from)\b[^\n]{0,80}?#(\d+)/gi; +const TRAILER_RE = /^co-authored-by:\s*(.+)$/gim; + +function assessCarryAttribution({ title, body, commits, labels, referencedAuthors }) +``` + +Rules, each one earned from a real case in the scan: + +1. **Self-reference is not a carry.** A PR that says it supersedes an earlier + PR by the same author must pass. The check therefore needs the referenced + PR's author, which means one API lookup per referenced number. Cap it: at + most five lookups, and a lookup failure is a pass, never a fail. A rate + limit must not block a merge. +2. **Referencing your own earlier branch is routine.** #3112 and #3104 are the + maintainer's own rebase branches. Same-author references are dropped before + the trailer comparison. +3. **Match on identity, not on login.** The scan's eleven false positives all + came from comparing a GitHub login against a git trailer: `terrytan95` never + appears in `Co-authored-by: Terry Tan `. Compare + against login, git author name, and git author email from the referenced + PR's own commits — the three-way match the scan ended up needing. +4. **Trailers live in the squash body, which does not exist yet at PR time.** + So the check reads the union of the PR body and every commit message on the + branch: that is what the squash body is assembled from, and it is what the + author can act on before merge. +5. **Escape hatch consistent with the existing design.** A new + `attribution-approved` label clears it, entered in `labelDefinitions` and + `HYGIENE_GATE_LABELS` beside the other five. It joins the head-specific + sweep on `synchronize`, because a new commit can add a new carry reference. + +## Failure code and hint + +```js +missing_coauthor_credit: + "This PR says it reimplements, supersedes, carries, or rebases another " + + "author's pull request. Add a Co-authored-by trailer naming that author so " + + "the credit survives the squash, or obtain attribution-approved.", +``` + +## Tests — RED before GREEN + +In `.github/scripts/pr-hygiene.test.cjs`, beside the existing `assessHygiene` +cases: + +| Case | Expect | +|---|---| +| body says "Reimplements #2797 by @rrmlima", no trailer | `missing_coauthor_credit` | +| same, with a trailer naming the login | pass | +| same, matched by git author name rather than login | pass | +| same, matched by email | pass | +| reference to a PR by the same author | pass | +| reference whose author lookup is unavailable | pass (fail-open) | +| `attribution-approved` present | pass | +| ordinary PR with no carry language | pass | +| "supersedes" inside a fenced code block | pass | + +The first case is driven red against the unmodified assessor before the +implementation exists. + +## Audit correction (A-phase, folded) + +The draft said to reuse `stripNonRenderedRegions` for the fenced-code case. It +is defined at `.github/scripts/pr-quality.cjs:247` and is **not** in that file's +`module.exports` — the exported list ends at `stripPrTemplateBoilerplate`. So +the plan as written would not have run. + +Two options, and the choice is not cosmetic. Exporting it from `pr-quality.cjs` +and importing it into `pr-hygiene.cjs` makes the hygiene assessor depend on the +quality gate's module, and the dependency currently runs the other way: +`pr-hygiene.yml` imports `authorHasPushPermission` from `pr-quality.cjs` at the +workflow level, while the two assessor modules stay independent. Inverting that +for one small regex helper buys a cycle risk for no benefit. + +So `pr-hygiene.cjs` gets its own local fence/comment stripper. It is four lines, +it keeps the module standalone, and the two copies cannot drift in a way that +matters — each is asserted by its own test. + +## AGENTS.md rule + +A short paragraph under the issues-and-pull-requests section: + +> Landing another author's work — reimplementing it, superseding it, carrying +> it, or rebasing it — requires a `Co-authored-by` trailer naming that author in +> the squash body. Saying so in prose is not equivalent: the trailer is what +> GitHub reads for the contributor graph, and a sentence in a commit body is +> read by nobody. `missing_coauthor_credit` enforces this. + +## Verification + +```bash +node --test .github/scripts/pr-hygiene.test.cjs +bun run typecheck +``` diff --git a/devlog/_plan/260903_contributor_credit_restoration/030_release_notes.md b/devlog/_plan/260903_contributor_credit_restoration/030_release_notes.md new file mode 100644 index 0000000000..486b35b3af --- /dev/null +++ b/devlog/_plan/260903_contributor_credit_restoration/030_release_notes.md @@ -0,0 +1,66 @@ +# 030 — wp3: release-note credit sections + +## Slice + +No file changes. Edits GitHub release bodies through `gh release edit`. + +## Why this is possible at all + +A release body is mutable; a tag is not. This is the only surface where the +credit can be added to the artifact that shipped the code, rather than beside +it. + +## Which releases + +Resolved by `git tag --contains ` for every SHA in `CREDITS.md`, taking +the earliest non-preview tag per commit: + +| Release | Uncredited landings inside it | +|---|---| +| `v2.23.0` | `cb48c2e11` (#1801 @jonathanli12) | +| `v2.34.0` | `8412fe156` (#2675 @Ingwannu) | +| `v2.35.0` | `d829215af`, `bdc1e97bb` (#2693 @yxr1995-maker) | +| `v2.36.0` | `1d9b389c1`, `eb52973c5`, `de91dfde4`, `8d1dc1f5d`, `c986d1d20`, `5734a1caf`, `bb3321ca8`, `8877df0ee`, `607042b02` | +| `v2.37.0` | `870a2adb6` | +| `v2.39.0` | `b46164e78`, `0ef04e640`, `330470e74`, `e9d198a3c`, `a73a4c998` | +| `v2.40.0` | `d23eab43a`, `408652698`, `52d941640`, `b14b741dc`, `fecb77a91`, `88c427522`, `ef7b3c9cf` | + +`15b43e51c` (#3300 @S0RYUASUKA) is on `dev` and in no tag yet. It needs no +edit — the next release note covers it, and `CREDITS.md` already carries it. + +Preview tags are skipped: they carry the same commits as their release and +would double-name the same people. + +## Section shape + +Appended, never replacing the existing body: + +```markdown +## Contributor credit + +This release contains work carried from contributor pull requests whose landing +commits do not name their authors in a `Co-authored-by` trailer. The omission is +in git history and cannot be repaired there; the record is +[CREDITS.md](https://github.com/lidge-jun/opencodex/blob/dev/CREDITS.md). + +- #2797 by @rrmlima — landed as `5734a1caf` +- ... +``` + +## Order of operations + +wp3 runs after `CREDITS.md` is on `dev`, so the link resolves when the note is +published. A release note pointing at a 404 would be worse than no note. + +## Verification + +`gh release view ` after each edit, confirming the section is present, the +pre-existing body is intact, and the link resolves. Read back, not write-and- +assume: `gh release edit --notes` replaces the whole body, so the existing text +must be fetched, appended to, and written in one pass. + +## Risk + +This is the only phase that writes to a published artifact. It is idempotent by +construction — the appended section is detected by its heading before writing, +so a re-run does not stack duplicates. diff --git a/devlog/_plan/260903_contributor_credit_restoration/100_closeout.md b/devlog/_plan/260903_contributor_credit_restoration/100_closeout.md new file mode 100644 index 0000000000..b9f657b416 --- /dev/null +++ b/devlog/_plan/260903_contributor_credit_restoration/100_closeout.md @@ -0,0 +1,70 @@ +# 100 — Closeout: contributor credit restoration, 2026-09-03 + +## Outcome + +DONE. All three deliverables shipped. + +`CREDITS.md` and the co-author gate landed on `dev` as `7a529a2e8` (PR #3318, +squash-merged with `--admin`, every check green on the exact head). Credit +sections were appended to six release bodies afterwards, in that order, so the +`CREDITS.md` link in each note resolves. + +## What was found + +27 landing commits carry an uncredited origin — 26 contributor pull requests +across `v2.23.0` through `v2.40.0`, plus one still only on `dev`. + +Two scans, both re-runnable, and they disagreed in useful ways: + +- Commit-side: `origin/dev` bodies matching the carry verbs, joined against + their own trailers. 23 matched, 12 name an author in prose with no trailer. +- PR-side: maintainer closure comments on the 119 closed-unmerged external pull + requests since #2400, each landing commit checked for a trailer naming the + original author. + +Fourteen PR-side hits were false positives. Three resolved by walking the merge +range instead of the squash commit. Eleven resolved once login was matched +against git identity — that failure mode then became the gate's rule 3, and its +own regression test. + +## What the process caught + +**The privacy scan, twice.** A comment explaining why login matching is +insufficient quoted a contributor's real git email out of the scan data, and a +test fixture used a literal noreply address. Both were caught by +`bun run privacy:scan` rather than by review. Illustrating a rule about +attribution by publishing someone's address is a bad trade. + +**The test suite, on a defect the plan did not anticipate.** The first +implementation took a fixed 80-character window after each carry verb. +`tests` caught it missing the second number in "Reimplements #2797 and #2796", +and the fix — a sentence bound — turned out to matter more than the bug: a +fixed window would have pulled the issue out of `53c09a247`'s real +"Supersedes #3193. Fixes #3192." into the carry set, demanding a trailer for +someone who reported a bug. + +**`tests/ci-workflows.test.ts`, on a vacuous filter.** Adding the new read to +the write-audit exclusion list, the first attempt appended it after a comma +instead of an `&&`, turning the whole predicate into a comma expression that +always returned its last operand. Every filter case would have passed +vacuously. The suite failed immediately. + +**Review, on four real holes.** All four made the gate quietly weaker rather +than louder: unmatched verb inflections (`Reimplementing`, `Carrying`, +`Rebasing`), cross-repository references resolved against the wrong repository, +substring identity matching where "Ann" is satisfied by "Joanne", and +`pr-hygiene.yml` not subscribing to `edited` — so an author who added the +trailer exactly as instructed would have seen nothing change. + +## What is deliberately not in `CREDITS.md` + +#3020 and #2675 were closed with a landing commit and no statement of what was +taken. They are named in a closing paragraph rather than a table row: inventing +a "what landed" cell would be the same inaccuracy the file exists to correct. + +## Constraint honored + +No repository-wide local suite at any point. Verification was +`node --test .github/scripts/*.test.cjs`, `bun test tests/ci-workflows.test.ts`, +`bun run test:changed`, `privacy:scan`, `typecheck`, and the exact-head GitHub +rollup. diff --git a/devlog/_plan/260903_gemini_38_rollout/000_plan.md b/devlog/_plan/260903_gemini_38_rollout/000_plan.md new file mode 100644 index 0000000000..939854b3a7 --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/000_plan.md @@ -0,0 +1,72 @@ +# Gemini 3.8 Flash rollout plan + +- Date: 2026-09-03 +- Session: `01a062e6-43d4-7ad2-8236-c75a8fb66a12` +- Work class: C3 — provider catalog, CCA wire routing, persisted config surface, usage pricing, generated metadata, docs and tests move together. +- Status: P (wp0, docs-only roadmap cycle). + +## Loop spec + +- Archetype: satisfy-spec integration. +- Trigger: Google shipped Gemini 3.8 Flash on 2026-09-02, and authenticated Antigravity discovery already returns three 3.8 wire ids ranked FIRST in the Recommended sort. +- Goal: make Gemini 3.8 Flash the selectable, correctly tiered Antigravity Flash model, and carry the same spec to every other surface that already names 3.6/3.7 — without inventing anything the vendor has not published. +- Non-goals: Vertex routing, OrcaRouter/OpenRouter seeding, widening request transport beyond `text`+`image`, hand-editing generated metadata, deleting historical price rows or usage attribution, any release or publish. +- Verifier: focused `bun test ` runs on the touched subsystems plus `bun run typecheck`. **The repository-wide local suite is forbidden by the user** ("로컬스위트는 절대 돌리지 말고"); exact-head GitHub CI is the authoritative full gate. +- Stop condition: 3.8 is picker-visible with a working low/medium/high ladder, every inventoried 3.6/3.7 surface is updated or carries a recorded reason not to be, focused tests and typecheck pass, CI is green on the exact head SHA, and the PR is merged into `dev` with ancestry proof. +- Memory artifact: this unit folder. +- Expected terminal outcomes: `DONE`; `BLOCKED` if CI or branch protection refuses for a reason outside this change; `NEEDS_HUMAN` if a pricing claim turns out unprovable. +- Escalation: each A gate dispatches one independent read-only reviewer on `gpt-5.6-sol` at high reasoning effort. After two failed reviewer correction loops on the same packet, the main session stops and reports. + +## The decision this plan turns on + +The 3.6 to 3.7 rollout (`devlog/_fin/260814_overnight_triage_release/020_gemini_37_flash.md`) was a **replacement**: the maintainer's operational fact was that Google pulls the previous Antigravity Flash model almost immediately, so 3.6 had to be deprecated in the same commit that introduced 3.7. + +**That premise does not hold for this launch, and both halves of the disproof are first-hand:** + +1. Google's own `latest-model` guide says Gemini 3.7 Flash "remains fully supported" and still lists it as Stable (see `001`). +2. A live CCA `:fetchAvailableModels` call on 2026-09-03 returns 3.5, 3.6, 3.7 **and** 3.8 wire ids simultaneously (see `002`). + +So 3.8 lands **additively**: it becomes the default and the recommended Flash row, while 3.7 stays picker-visible and every existing retirement mapping is left exactly where it is. Copying the 3.7 unit's deprecation section would delete a model the backend is still serving. + +## The second decision: wire shape + +3.7 expresses its tiers as `thinkingLevel` against ONE wire id (`gemini-3.7-flash-tiered`). 3.8 does not: CCA publishes three suffixed wire ids and no `-tiered` row. That makes 3.8 structurally a **3.6-shaped** model, and it must be registered through `ANTIGRAVITY_EFFORT_WIRE_MAP` (rule 2/3), never through `ANTIGRAVITY_THINKING_LEVEL_MODELS` (rule 1b). Registering it the 3.7 way would send `thinkingLevel` against a nonexistent `gemini-3.8-flash-tiered` wire id. + +## Work-phase map (dependency-ordered, PHASE-SPLIT-01) + +| Phase | Doc | Consumes | Delivers | +|---|---|---|---| +| wp0 | this folder | — | research + diff-level roadmap | +| wp1 | `010_wp1_antigravity_core.md` | wp0 | `antigravity-models.ts` catalog/ladder/routing + registry default | +| wp2 | `020_wp2_metadata_pricing.md` | wp1 | expected-prices rows, metadata source + regen | +| wp3 | `030_wp3_peripheral_surfaces.md` | wp2 | direct Google seed, free-directory, Cursor seed, sidecar default, docs | +| wp4 | `040_wp4_delivery.md` | wp3 | branch, `--no-verify` push, PR, exact-head CI, merge | + +wp1 is first because every later surface keys off the picker id and ladder it establishes. wp2 depends on wp1 because the price overlay is keyed by the picker id and the suffix wire ids wp1 introduces. wp3 is last among the code phases because it is the set of surfaces that merely *reference* the model rather than define it. + +## Scope + +### IN + +- `src/providers/antigravity-models.ts`, `src/providers/registry.ts` +- `src/usage/expected-prices.ts`, `scripts/model-metadata.source.json` (plus `bun run generate:model-metadata`) +- `src/providers/free-directory.ts`, `src/adapters/cursor/effort-map.ts`, `src/adapters/cursor/catalog.ts`, `src/web-search/index.ts` +- `docs-site/` provider and sidecar tables +- focused tests beside the existing Antigravity/catalog/price tests + +### OUT + +- `src/adapters/google.ts` `GEMINI_DIRECT_WIRE_RENAMES`: no `gemini-3.8-flash-tiered` id is proven on any surface, so adding a rename would invent a wire id. Recorded in `030`. +- `src/providers/model-rename-migration.ts`: nothing is retired by this change, so no new rename entry. The existing 3.6/3.5 to 3.7 entries stay, because 3.7 is still live. +- `RETIRED_FLASH_TIERS` and `ANTIGRAVITY_USAGE_BASE_BY_ID`: unchanged for the same reason. +- Vertex (`google-vertex` `defaultModel` stays frozen), OrcaRouter, OpenRouter, GitHub Copilot. + +## Accept criteria (goalplan c-1 through c-7) + +1. `gemini-3.8-flash` is one collapsed picker row, not three suffix rows. +2. Each of `low`/`medium`/`high` resolves to its own `gemini-3.8-flash-{tier}` wire id. +3. `gemini-3.7-flash` remains picker-visible and its `-tiered` routing is untouched. +4. Retired 3.6/3.5 ids still route to 3.7 with their recorded tier and stay picker-invisible. +5. Historical usage rows carrying 3.6/3.7 ids still aggregate under their own base. +6. `bun run typecheck` exits 0; only focused test files are run locally. +7. CI green on the exact head SHA and the PR merged into `dev`. diff --git a/devlog/_plan/260903_gemini_38_rollout/001_vendor_claim_ledger.md b/devlog/_plan/260903_gemini_38_rollout/001_vendor_claim_ledger.md new file mode 100644 index 0000000000..04c0983de4 --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/001_vendor_claim_ledger.md @@ -0,0 +1,54 @@ +# 001 — Gemini 3.8 Flash: vendor claim ledger + +Research snapshot 2026-09-03 (KST), collected by an independent read-only research lane on +`gpt-5.6-sol` at high reasoning effort. Every row was verified by opening the linked official +page. Unprovable fields say `NOT PROVEN` rather than borrowing 3.7's value. + +| Claim | Value | Source | Page date | +|-------|-------|--------|-----------| +| Canonical Developer API id | `gemini-3.8-flash` | ai.google.dev/gemini-api/docs/models/gemini-3.8-flash | 2026-09-02 | +| Published aliases | stable id only; `-preview`/dated/`-latest` NOT PROVEN | same + docs/models | 2026-09-02 | +| Release date | 2026-09-02 | deepmind.google model card; docs.cloud.google.com | 2026-09-02 | +| Availability | GA, production-ready (not Preview) | latest-model guide | 2026-09-02 | +| Context window | 1,048,576 input tokens | model page | 2026-09-02 | +| Max output | 65,536 tokens | model page | 2026-09-02 | +| Input price | $0.75 / 1M through 2026-12-31, $1.50 / 1M from 2027-01-01 | Developer API pricing | 2026-09-02 | +| Output price (incl. thinking) | $3.75 / 1M through 2026-12-31, $7.50 / 1M from 2027-01-01 | Developer API pricing | 2026-09-02 | +| Separate thinking price | none — thinking billed as output | pricing | 2026-09-02 | +| Cache read | $0.075 / 1M through 2026-12-31, then $0.15 | pricing | 2026-09-02 | +| Cache storage | $0.50 / 1M tokens/hour through 2026-12-31, then $1.00 | pricing | 2026-09-02 | +| Batch / Flex | half of standard input and output | pricing | 2026-09-02 | +| Priority | $1.35 in / $6.75 out per 1M through 2026-12-31 | pricing | 2026-09-02 | +| Thinking parameter | `generation_config.thinking_level` (replaces `thinking_budget`) | latest-model | 2026-09-02 | +| Thinking values | `low` / `medium` / `high`, default `medium` | latest-model; Cloud guide | 2026-09-02 | +| `minimal` | unsupported — setting it returns a validation error | model page; Cloud guide | 2026-09-02 | +| Inputs | text, image, video, audio, PDF | model page | 2026-09-02 | +| Outputs | text only (no image/audio generation, no Live API) | model page | 2026-09-02 | +| Knowledge cutoff | March 2026 (some domains still January 2025) | DeepMind model card | 2026-09-02 | +| Antigravity default | proven for the Managed Agents agent and the Antigravity SDK; the desktop/CCA backend default is NOT PROVEN | latest-model | 2026-09-02 | +| Vertex / Agent Platform id | `gemini-3.8-flash`, `publishers/google/models/gemini-3.8-flash:generateContent` | Cloud developer guide | 2026-09-02 | +| **3.7 Flash deprecated?** | **No — Google says 3.7 Flash "remains fully supported" and still lists it Stable** | latest-model; models catalog | 2026-09-02 | +| CCA billing equivalence | NOT PROVEN — the listed prices are Developer API prices | pricing | 2026-09-02 | + +## Other providers OpenCodex integrates + +| Provider | 3.8 model id published? | Source | +|---|---|---| +| OpenRouter | YES — `google/gemini-3.8-flash` | openrouter.ai model page | +| Cursor | NO — models page and changelog still stop at 3.7 Flash | cursor.com/docs/models-and-pricing; /changelog | +| GitHub Copilot | NO — supported-model table lists 3.5/3.6/3.7 only | docs.github.com Copilot supported models | + +## Unprovable fields + +- 3.8-specific preview, dated, or `-latest` aliases. +- A standalone `blog.google` launch post (the date rests on the DeepMind card and the Cloud record). +- Cloud Code Assist billing equivalence to Developer API list prices. +- Cursor and GitHub Copilot 3.8 model ids. + +## Why the pricing row cannot be `verified` for Antigravity + +OpenCodex routes this model through CCA, and the pricing page distinguishes Developer API, +Enterprise Agent Platform, and managed Antigravity-agent pricing without proving equivalence +for the Cloud Code Assist backend. This is exactly the provenance caveat the 3.7 unit already +recorded, and `src/usage/expected-prices.ts` already has the right enum member for it: +`verified-derived`. Only a `google`-provider row may claim `verified`. diff --git a/devlog/_plan/260903_gemini_38_rollout/002_live_cca_probe.md b/devlog/_plan/260903_gemini_38_rollout/002_live_cca_probe.md new file mode 100644 index 0000000000..4a74fa3d98 --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/002_live_cca_probe.md @@ -0,0 +1,62 @@ +# 002 — Live Cloud Code Assist evidence + +Probes run 2026-09-03 from this worktree against `daily-cloudcode-pa.googleapis.com` using the +already-stored local Antigravity OAuth credential and the repository's own +`ANTIGRAVITY_REQUEST_UA`. No token, refresh token, or project id was printed or persisted; the +probe scripts were removed after the run. + +## `v1internal:fetchAvailableModels` — the 3.8 rows + +| Wire id | displayName | maxTokens | maxOutputTokens | supportsThinking | thinkingBudget | minThinkingBudget | supportsImages | supportsVideo | +|---|---|---:|---:|---|---:|---:|---|---| +| `gemini-3.8-flash-low` | Gemini 3.8 Flash (Low) | 1048576 | 65536 | true | 1000 | 32 | true | true | +| `gemini-3.8-flash-medium` | Gemini 3.8 Flash (Medium) | 1048576 | 65536 | true | 4000 | 32 | true | true | +| `gemini-3.8-flash-high` | Gemini 3.8 Flash (High) | 1048576 | 65536 | true | -1 | 32 | true | true | + +**There is no `gemini-3.8-flash-tiered` row.** The payload does contain +`gemini-3.7-flash-tiered` and `gemini-3.6-flash-tiered`, so its absence for 3.8 is a fact about +this generation, not a gap in the probe. + +## `agentModelSorts` Recommended order (verbatim) + +``` +gemini-3.8-flash-high, gemini-3.8-flash-medium, gemini-3.8-flash-low, +gemini-3.7-flash-high, gemini-3.7-flash-medium, gemini-3.7-flash-low, +gemini-3.6-flash-high, gemini-3.6-flash-medium, gemini-3.6-flash-low, +gemini-pro-agent, gemini-3.1-pro-low, claude-sonnet-4-6, +claude-opus-4-6-thinking, gpt-oss-120b-medium +``` + +Two things follow. 3.8 outranks every other Flash generation, so it is the natural default. And +**3.7 and 3.6 are both still being served** — the "previous Flash is pulled immediately" +premise behind the 3.6 deprecation does not apply here. + +## `v1internal:generateContent` — all three tiers accept inference + +Minimal one-line prompts with `generationConfig.thinkingConfig.thinkingLevel` set to the +matching tier: + +| Wire model | HTTP | Output marker | +|---|---:|---| +| `gemini-3.8-flash-low` | 200 | `OK-LOW` | +| `gemini-3.8-flash-medium` | 200 | `OK-MEDIUM` | +| `gemini-3.8-flash-high` | 200 | `OK-HIGH` | + +This is the same pre-exposure proof the 3.6 rollout recorded: all three ids accept inference +before any catalog change ships, so the ladder in `010` cannot advertise a rung the backend +would reject. + +## What the running proxy does with them today + +`ocx models live --provider google-antigravity` currently publishes the three 3.8 ids as +**separate uncollapsed rows with `reasoningEfforts: []`** — the same broken shape #1897 +described. Discovery finds them, and no static rule knows they are one model, so they arrive as +three effortless picker entries. That is the defect wp1 closes. + +## Security boundary for these probes + +- Assets: local Antigravity OAuth access token and discovered project id. +- Trust boundary: local read of the existing credential store, then HTTPS to the fixed + registry-owned base URL. Model text cannot choose the destination, headers, or credential. +- Controls: nothing credential-bearing printed or written; probe files deleted after the run. +- Blast radius: three minimal quota-consuming inference calls. No configuration mutated. diff --git a/devlog/_plan/260903_gemini_38_rollout/003_audit_round1_synthesis.md b/devlog/_plan/260903_gemini_38_rollout/003_audit_round1_synthesis.md new file mode 100644 index 0000000000..ff1003789e --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/003_audit_round1_synthesis.md @@ -0,0 +1,136 @@ +# 003 — A-gate round 1: reviewer verdict and synthesis + +Reviewer: independent read-only lane on `gpt-5.6-sol`, high reasoning effort, anchored at +`529639a57`. Verdict: **FAIL**, blockers 1, 2, 3. + +Two of the three blockers were settled by running probes rather than by argument. Both +reviewer claims survived contact with the backend. + +## Blocker 1 (High) — static and discovered resolution return different shapes + +**Accepted, with a narrower fix than proposed.** + +The reviewer is right that `discoveredAntigravityEffortWireModelId` returns before +`hasOwnEffortLadder` is consulted, so once a live ladder is registered the resolver returns +`{ wireModelId }` with **no** `thinkingLevel`, while static rule 2/3 returns +`{ wireModelId, thinkingLevel }`. Same model, two request bodies. + +Probe (2026-09-03, CCA `:generateContent`) settles which is canonical: + +| Case | HTTP | Result | +|---|---:|---| +| `gemini-3.8-flash-medium`, no `thinkingConfig` | 200 | `OK` | +| `gemini-3.8-flash-low` + `thinkingLevel: HIGH` | 200 | `OK` | + +A suffixed wire id needs no `thinkingLevel`, and CCA silently accepts a **contradictory** +pairing rather than rejecting it — which is worse than an error, because the tier that +actually ran is unknowable from the response. So the suffix must be the sole carrier. + +**Fix (amends `010`):** rule 2/3 omits `thinkingLevel` when the resolved wire id already +encodes the tier. Scope it with an explicit set rather than a regex over all models: + +```ts +/** + * Base models whose every effort maps to a wire id that ALREADY encodes the tier. + * + * For these, sending thinkingLevel alongside the suffix states the effort twice, and CCA + * accepts a contradictory pair (probe: `-low` wire + HIGH level returns 200), so a mismatch + * would run at an unknown tier instead of failing loudly. It also makes static resolution + * byte-identical to the discovery path, which never emits thinkingLevel. + * + * gemini-3.1-pro is deliberately absent: its `high` rung is `gemini-pro-agent`, which carries + * no tier suffix, so there the level is the only thing naming the effort. + */ +const ANTIGRAVITY_SUFFIX_TIER_MODELS = new Set(["gemini-3.8-flash"]); +``` + +and in rule 2/3: + +```ts +if (effort && effort in effortMap) { + const wireModelId = effortMap[effort]!; + return ANTIGRAVITY_SUFFIX_TIER_MODELS.has(modelId) + ? { wireModelId } + : { wireModelId, thinkingLevel: effort }; +} +``` + +`gemini-3.1-pro` behavior is unchanged — deliberately, since altering it is outside this unit. + +Required test (activation scenario): `parse -> register -> resolve` and `resolve` without +discovery must return the SAME object for explicit `low`/`medium`/`high`, for unset effort, and +for clamped `max`/`xhigh`/`ultra`. That equality assertion is the regression guard; asserting +each path separately is what let the divergence exist. + +The reviewer's sidecar note is covered by the same fix: `src/web-search/gemini-executor.ts:51` +destructures `thinkingLevel` and only sends `thinkingConfig` when present, so once both paths +omit it the sidecar body stops depending on whether discovery has run. + +## Blocker 2 (High) — the Claude SDK identity paragraph guard is 3.7-only + +**Accepted. Reproduced, and it is not theoretical.** + +`src/adapters/google.ts:750` strips `ANTIGRAVITY_REJECTED_CLAUDE_SDK_PARAGRAPH` only when +`parsed.modelId === "gemini-3.7-flash"`. Probes with that exact paragraph in +`systemInstruction`: + +| Case | HTTP | Result | +|---|---:|---| +| `gemini-3.8-flash-medium` + paragraph | 429 | `RESOURCE_EXHAUSTED` | +| `gemini-3.8-flash-high` + paragraph | 429 | `RESOURCE_EXHAUSTED` | +| `gemini-3.7-flash-tiered` + paragraph | 429 | `RESOURCE_EXHAUSTED` (control: known behavior) | +| `gemini-3.8-flash-medium`, paragraph stripped | 200 | `OK` | +| `gemini-3.8-flash-medium` + paragraph again | 429 | `RESOURCE_EXHAUSTED` | + +The strip/restore pair rules out incidental quota exhaustion: the same account, seconds apart, +succeeds without the paragraph and fails with it. A policy rejection surfacing as a quota 429 is +exactly the failure mode the original 3.7 fix documented. + +**This is the highest-value finding of the audit.** Shipping 3.8 as the default without it +would 429 every Claude-Agent-shaped request the moment the default moved, and the error text +would send users hunting a quota problem that does not exist. + +**Fix (amends `010`):** widen the guard from an equality check to the set of CCA Flash models +that reject the paragraph: + +```ts +const stripRejectedClaudeSdkParagraph = provider.googleMode === "cloud-code-assist" + && ANTIGRAVITY_CLAUDE_SDK_PARAGRAPH_REJECTORS.has(parsed.modelId); +``` + +with the set holding `gemini-3.7-flash` and `gemini-3.8-flash`, and a comment recording that +membership is probe-established per generation, not assumed. A regression test beside +`tests/google-adapter.test.ts:250` asserts the paragraph is absent from the serialized +`systemInstruction` for both models, and still present for a non-CCA Google request. + +## Blocker 3 (Medium) — stale exact assertions and thin focused-test commands + +**Accepted in full.** These tests assert exact arrays and lengths, so they fail the moment the +catalogs grow: + +| Test | Line | What breaks | +|---|---:|---| +| `tests/google-hardening.test.ts` | 777 | exact `google.models` array | +| `tests/google-models-listing.test.ts` | 360 | exact discovered-id array | +| `tests/provider-registry-parity.test.ts` | 771 | `toHaveLength(6)` on Antigravity models | +| `tests/oauth-provider-reconcile.test.ts` | 142 | `toHaveLength(6)` after reconcile | + +`010`/`020`/`030` are amended to name these edits, and the focused commands now include +`google-hardening`, `google-models-listing`, `google-adapter`, and `usage-cost`. + +## Blockers 4-9 + +| # | Severity | Disposition | +|---|---|---| +| 4 | Medium | Accepted — `020` gains a dedicated test asserting an explicit `gemini-3.7-flash` default SURVIVES reconciliation, separate from the stale-default healing case. | +| 5 | Medium | Accepted — `010` gains the missing consumers: discovery-map completion (L164-166), discovery suppression (L597-601), discovery default selection (L405-410), and the context-window spread/alias derivation (L272-277). | +| 6 | Medium | Accepted — the Gemini free-directory row gets a row-specific `lastVerified: "2026-09-03"`; the shared `LAST_VERIFIED` constant is untouched so unrelated providers keep their real dates. | +| 7 | Low | Accepted — see `004_no_change_inventory.md`. | +| 8 | Low | Accepted — `GEMINI_FLASH_WIRE_ID` is renamed `GEMINI_RETIRED_FLASH_TARGET_WIRE_ID` and the rule-0 comment is corrected to say retired ids route to 3.7, not to "the current generation". | +| 9 | Low | Accepted with a correction to the reviewer's framing. `ANTIGRAVITY_WIRE_MODELS` is indeed consumed nowhere, so the plan's step 2 is cosmetic. Rather than edit dead data or delete a constant unrelated to this unit, `010` drops the step and records the observation as a follow-up. Deleting it is a separate cleanup with its own blast radius. | + +## Round outcome + +Every blocker is folded into the plan as a concrete amendment; none was rebutted on judgment +alone, and the two High findings were confirmed against the live backend. Round 2 re-audits the +amended plan with the same reviewer. diff --git a/devlog/_plan/260903_gemini_38_rollout/004_no_change_inventory.md b/devlog/_plan/260903_gemini_38_rollout/004_no_change_inventory.md new file mode 100644 index 0000000000..9b94d511b2 --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/004_no_change_inventory.md @@ -0,0 +1,58 @@ +# 004 — no-change inventory + +Answers audit blocker 7: every remaining `gemini-3.6-flash` / `gemini-3.7-flash` occurrence +that this unit does NOT touch, with the reason. Criterion c-5 requires a recorded reason for +each, not silence. + +## Runtime and metadata + +| Location | Reason | +|---|---| +| `scripts/model-metadata.source.json` Kilo rows (~16356) | Third-party gateway roster captured from Kilo. Adding a 3.8 row would assert Kilo serves it; nothing proves that. | +| same, OpenCode Zen rows (~61353) | Same reason, different gateway. | +| same, Vercel AI Gateway rows (~77014) | Same reason. | +| `src/types/provider.ts:300` | Doc comment illustrating `directGeminiWireRenames` with the 3.7 `-tiered` rename. 3.8 has no `-tiered` id, so replacing the example would document a rename that does not exist. | +| `src/adapters/client-fingerprint.ts:56` | Explanatory prose about UA-gated 404s, not a model list. Reviewer independently confirmed. | +| `src/providers/command-code-efforts.ts:47` | Keyed by Command Code's own live roster, which has no 3.8 row. | +| `src/adapters/google.ts` `GEMINI_DIRECT_WIRE_RENAMES` | Would invent `gemini-3.8-flash-tiered`; the reviewer confirmed no such string exists anywhere in the tree, and CCA does not publish one. | +| `docs-site/.../providers.md:683` | `--retain-models` usage example. Any valid id works; churn without benefit. | +| `tests/google-output-clamp.test.ts` | `maxOutputTokensForGoogleModel` (`src/adapters/google.ts:83-89`) is FAMILY-based: any `gemini` id not matching the `pro` pattern returns 65536. `gemini-3.8-flash` already gets the right ceiling with no table entry, and `001` confirms 65,536 is the documented value. Adding a case would assert the family rule twice. (Round-2 blocker 5.) | + +## Tests using 3.6/3.7 as opaque fixtures + +These assert transport, quota, signature, vision, or listing behavior and merely need *a* +valid Gemini id. Rewriting them to 3.8 would enlarge the diff without testing anything new, +and would weaken coverage of the ids real users still have saved. + +`tests/antigravity-baseurl-override.test.ts:20`, `claude-agent-startup-sync.test.ts:46`, +`cli-headless-parity.test.ts:350`, `command-code-provider.test.ts:515`, +`commandcode-provider.test.ts:74`, `cursor-fast-listing.test.ts:44`, +`cursor-fast-tier.test.ts:43`, `cursor-integration-status.test.ts:89`, +`google-claude-prefill-guard.test.ts:85`, `google-errors.test.ts:13`, +`google-signature-history-roundtrip.test.ts:34`, `google-vertex-thought-signature.test.ts:15`, +`images/gemini-inline.test.ts:252`, `management-provider-validation.test.ts:338`, +`model-visibility-management-api.test.ts:31`, `provider-account-quota.test.ts:435`, +`provider-quota.test.ts:252`, `thought-signature-credential-scope.test.ts:32`, +`vision-backend-union.test.ts:60`. + +## Tests that DO change (behavioral assertions) + +| Test | Why it must change | +|---|---| +| `tests/google-antigravity-wire.test.ts` | Owns the ladder and collapse behavior 3.8 introduces. | +| `tests/gemini-37-flash-migration.test.ts` | Owns retirement semantics; must prove 3.7 is NOT retired by this change. | +| `tests/google-hardening.test.ts:777` | Exact `google.models` array. | +| `tests/google-models-listing.test.ts:360` | Exact discovered-id array. | +| `tests/provider-registry-parity.test.ts:771` | `toHaveLength(6)` on the Antigravity model list. | +| `tests/oauth-provider-reconcile.test.ts:82,142` | Default model and post-reconcile length. | +| `tests/google-adapter.test.ts:250` | Claude SDK paragraph strip guard (audit blocker 2). | +| `tests/gemini-web-search.test.ts:80,146` | Sidecar default model and resolved wire id. | +| `tests/cursor-effort-table.test.ts`, `cursor-catalog.test.ts` | Only if the preemptive Cursor seed is kept. | +| `tests/sidecar-settings-web-search-gate.test.ts:222` | Uses 3.7 as an available management row; changes only if the sidecar default assertion moves. | + +## Sidecar test note + +`tests/gemini-web-search.test.ts:146` currently expects `gemini-3.7-flash-tiered` for a `low` +effort call. After the default moves, the 3.8 equivalent expects `gemini-3.8-flash-low` and +**no** `thinkingConfig` (per `003` blocker 1). That difference is itself the proof the +suffix-tier decision reached the sidecar path. diff --git a/devlog/_plan/260903_gemini_38_rollout/005_audit_round2_synthesis.md b/devlog/_plan/260903_gemini_38_rollout/005_audit_round2_synthesis.md new file mode 100644 index 0000000000..de5079b6d9 --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/005_audit_round2_synthesis.md @@ -0,0 +1,124 @@ +# 005 — A-gate round 2: verdict and synthesis + +Same reviewer, re-audit of the amended plan. Verdict **FAIL**, blockers 1 and 2 blocking. +Round 1's nine findings were all confirmed adequately fixed except where noted below; these +two are NEW defects introduced by the round-1 amendments themselves, which is exactly what a +second round is for. + +## Blocker 1 (High) — the suffix-tier fix does not cover clamped efforts + +**Accepted. Verified in code.** + +`ANTIGRAVITY_SUFFIX_TIER_MODELS` equalizes the two paths for `unset`/`low`/`medium`/`high` and +leaves `max`/`xhigh`/`ultra` diverging: + +| Path | `effort = "max"` | Why | +|---|---|---| +| discovered | `gemini-3.8-flash-high` | `resolveAntigravityThinkingLevel` clamps to `high` first (L400-408) | +| static rule 2/3 | `gemini-3.8-flash-medium` | `"max" in effortMap` is false, so it falls to `ANTIGRAVITY_DEFAULT_EFFORT` (L644-645) | + +A user asking for `max` gets `high` or `medium` depending on whether discovery has run. The +reviewer also correctly notes this is reachable in production, not just theoretically: +`src/web-search/gemini-executor.ts:51` passes the raw effort straight through without going +via `mapReasoningEffort`. + +Worse for the plan's own credibility: `010` test items 6 and 11 as written would FAIL against +the code `010` proposed. The plan contradicted itself. + +**Fix (amends `010` section 8a):** clamp before the map lookup for suffix-tier models, so both +paths perform the same normalization in the same order: + +```ts +const effortMap = ANTIGRAVITY_EFFORT_WIRE_MAP[modelId]; +if (effortMap) { + const suffixTiered = ANTIGRAVITY_SUFFIX_TIER_MODELS.has(modelId); + // Normalize FIRST for suffix-tiered models: the discovery path clamps max/xhigh/ultra to + // `high` before its lookup, so a static path that skips the clamp answers `medium` for the + // same request. Same input, two tiers, decided by whether discovery happened to run. + const requested = suffixTiered && effort + ? resolveAntigravityThinkingLevel(effort) ?? effort + : effort; + if (requested && requested in effortMap) { + const wireModelId = effortMap[requested]!; + return suffixTiered ? { wireModelId } : { wireModelId, thinkingLevel: requested }; + } + const defaultEffort = ANTIGRAVITY_DEFAULT_EFFORT[modelId]!; + return { wireModelId: effortMap[defaultEffort]! }; +} +``` + +The `suffixTiered &&` guard keeps `gemini-3.1-pro` byte-identical to today: it has no `medium` +rung, and clamping there would change which wire id a `medium` request reaches — a behavior +change outside this unit. + +Test item 11 is extended to `max`, `xhigh`, `ultra`, and the stale lines at `010:13-14` and +`010:252-253` that still promise a `thinkingLevel` are corrected. + +## Blocker 2 (High) — partial-ladder suffix rows bypass the paragraph guard + +**Accepted. This is a genuinely subtle interaction and the reviewer found it by composing two +separate parts of the plan.** + +The guard set holds picker ids (`gemini-3.8-flash`) and compares against `parsed.modelId`, +which is correct for the collapsed row. But `010` section 3 deliberately keeps raw suffix ids +visible when CCA returns a PARTIAL ladder — that is the documented degradation path. In that +state a user selects `gemini-3.8-flash-high` directly, so `parsed.modelId` IS the suffix id, it +misses the base-only set, and the paragraph survives. + +The 429 probes in `003` were run against exactly those suffix wire ids, so this is not a +hypothetical gap: the ids proven to reject the paragraph are precisely the ones that would slip +past the guard. + +**Fix (amends `010` section 8b):** canonicalize before the membership test rather than +enumerating every spelling: + +```ts +/** + * Whether CCA rejects the Claude-Agent identity paragraph for this selector. + * + * Canonicalize first: when discovery returns a partial ladder the picker publishes RAW suffix + * ids (see parseAntigravityAvailableModels), so `parsed.modelId` can be `gemini-3.8-flash-high` + * rather than the collapsed base. Those are the exact ids the 429 probe used, so a base-only + * membership test would miss the degraded path — the one users hit when CCA is flaky, i.e. the + * worst possible time to also lose the guard. + */ +function rejectsClaudeSdkParagraph(modelId: string): boolean { + return ANTIGRAVITY_CLAUDE_SDK_PARAGRAPH_REJECTORS.has(canonicalAntigravityUsageModel(modelId)); +} +``` + +`canonicalAntigravityUsageModel` already collapses `gemini-3.8-flash-high` onto +`gemini-3.8-flash` via the `ANTIGRAVITY_EFFORT_WIRE_MAP` derivation, and leaves unknown ids as +identity, so it adds no new mapping surface. It must be exported from +`src/providers/antigravity-models.ts` (it already is) and imported by `src/adapters/google.ts`. + +Required test: a partial-discovery payload publishing `gemini-3.8-flash-high` as its own row, +then a serialized request asserting the paragraph is absent. + +## Blocker 3 (Medium) — direct Google 3.8 activation untested + +Accepted. `030` gains a direct AI Studio test: bare `gemini-3.8-flash` reaches the wire with no +synthetic `-tiered` rename, and the configured-ladder branch at `google.ts:782-790` emits +`thinkingConfig`. Adding the model to `modelReasoningEfforts` is what newly activates that +branch for 3.8, so it needs its own activation scenario. + +## Blocker 4 (Medium) — the rename plan contradicted itself + +Accepted, and embarrassing: `010` line 47 said the constant keeps its name while section 8c +renamed it. Section 8c now enumerates all three call sites — `antigravity-models.ts:201`, +`:233`, `:618` — and the contradictory sentence is removed. A declaration-only rename would not +even typecheck. + +## Blocker 5 (Low) — `tests/google-output-clamp.test.ts` + +**Accepted as a documentation gap, resolved as no-change with evidence.** + +`maxOutputTokensForGoogleModel` (`src/adapters/google.ts:83-89`) is family-based: any id +starting `gemini` and not matching the `pro` pattern returns 65536. `gemini-3.8-flash` therefore +already receives the correct documented ceiling with no table entry, which `001` confirms is +65,536. Recorded in `004` rather than changed. + +## Round outcome + +Both High blockers folded as concrete code amendments; three lesser findings folded or resolved +with evidence. Round 3 re-audits with the same reviewer. diff --git a/devlog/_plan/260903_gemini_38_rollout/006_maintainer_review_fold.md b/devlog/_plan/260903_gemini_38_rollout/006_maintainer_review_fold.md new file mode 100644 index 0000000000..eb4e39561e --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/006_maintainer_review_fold.md @@ -0,0 +1,56 @@ +# 006 — maintainer review fold (PR #3286) + +The maintainer review bot found a defect three audit rounds missed, and it was reproduced +against the live backend before being fixed. + +## The finding: retired ids reach the rejecting generation unguarded + +`rejectsClaudeSdkParagraph` keyed on the SELECTOR via `canonicalAntigravityUsageModel`. That +covers the collapsed base and the raw suffix rows, but not the third path into the same +generation: + +``` +gemini-3.6-flash --rule 0--> gemini-3.7-flash-tiered (a rejecting generation) +``` + +Retired ids deliberately keep their OWN identity in `ANTIGRAVITY_USAGE_BASE_BY_ID` — that is +the rule protecting historical spend from being relabelled — so they can never canonicalize +into the generation they actually call. The two mechanisms were each correct and combined into +a hole. + +Probe, 2026-09-03, live CCA: + +``` +resolveAntigravityEffortWireModel("gemini-3.6-flash") + -> { wireModelId: "gemini-3.7-flash-tiered", thinkingLevel: "medium" } +saved 3.6 selection + Claude SDK paragraph -> 429 RESOURCE_EXHAUSTED +``` + +So every saved 3.6/3.5 config would have kept 429ing after this PR — the exact class of +silent breakage the retirement machinery exists to prevent. + +**Fix:** judge on the ROUTED WIRE id, with the selector kept as a fallback. Naming a wire +spelling once now covers every selector that can reach that generation, instead of requiring +the set to enumerate selectors that redirect into it. + +The test that asserted the old behavior (`preserves the paragraph for another Cloud Code +Assist model`, using 3.6) was asserting the bug. It is replaced by one proving the retired id +IS stripped, plus a real control on `claude-sonnet-4-6` — a model with no recorded rejection, +where the paragraph is literally true. + +## Second finding: direct Google 3.7 advertises `minimal` + +Recorded in `050` as a follow-up; the maintainer asked whether to fold it in. Folded, because +the evidence is identical to 3.8's (Google documents `minimal` as a validation error for that +generation) and the line was already being edited in this PR. Leaving it would ship a catalog +that offers a rung the API rejects, in the same file where the neighbouring row was just +corrected for the same reason. 3.5 and 3.6 keep theirs — their pages still list it, and this +unit has no evidence about them. + +## Not folded + +| Item | Disposition | +|---|---| +| `ANTIGRAVITY_WIRE_MODELS` dead list | Stays in `050`. Deleting an unrelated dead constant mid-rollout widens the diff for no behavioral gain. | +| `gemini-3.5-flash` empty `modelInputModalities` | Pre-existing, unrelated to this diff, and changing the DEFAULT model's advertised modalities deserves its own evidence. Added to `050`. | +| Cursor preemptive seed | Kept. The static catalog is intersected with the live roster, so the row stays invisible until Cursor lists it, and the `glm-5.3` precedent is explicit. | diff --git a/devlog/_plan/260903_gemini_38_rollout/007_closeout.md b/devlog/_plan/260903_gemini_38_rollout/007_closeout.md new file mode 100644 index 0000000000..be6144f576 --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/007_closeout.md @@ -0,0 +1,55 @@ +# 007 — closeout + +Terminal outcome: **DONE**. + +## What landed + +PR [#3286](https://github.com/lidge-jun/opencodex/pull/3286), squash-merged as `3d3c4fe26` +into `dev`. Ancestry proven: + +``` +git merge-base --is-ancestor 3d3c4fe26 FETCH_HEAD -> LANDED-ON-DEV +``` + +Five commits, one per work-phase plus the review fold: + +| Commit | Phase | Content | +|---|---|---| +| `be0cda383` | wp0 | 11-doc roadmap unit | +| `b460299dc` | wp1 | Antigravity catalog, suffix ladder, suffix-tier rule, paragraph guard, constant rename | +| `bd2b03089` | wp2 | metadata source + regen, 5 price rows, reconcile preservation test | +| `a8c2314f3` | wp3 | direct Google, free-directory, sidecar default, Cursor seed, docs | +| `ea79ec132` | wp3 | maintainer-review fold: routed-generation guard, 3.7 `minimal` removal | + +## Verification actually performed + +- `bun run typecheck` — exit 0 at every phase boundary. +- Focused `bun test` only, never the repository-wide suite (maintainer instruction). Final + focused set: 681 pass, 0 fail across 12 files. +- Full GitHub CI on the exact merged head `ea79ec132`: **25 success, 1 skipped, 0 failures**, + including all four Linux test shards, macOS, Windows keyring, npm-global on three OSes, + gates, storage policy, and `enforce-target`. +- Live CCA probes at three points: discovery shape, per-tier inference, and two adversarial + probes that each disproved a plan assumption. + +## What the process actually caught + +Worth recording, because the interesting failures were all invisible from the diff: + +| Round | Finding | How it was settled | +|---|---|---| +| A round 1 | 9 blockers, 2 High | folded; the two High ones were probe-confirmed | +| A round 2 | 5 more, 2 High — introduced BY the round-1 fixes | folded | +| A round 3 | PASS | — | +| Maintainer review on the pushed PR | retired ids reach the rejecting generation unguarded | reproduced at 429, fixed in `ea79ec132` | + +The last one is the lesson. Three adversarial rounds against the plan missed it because it +lives in the interaction between two mechanisms that are each individually correct: retired +ids keep their own usage identity (protecting historical spend), and the paragraph guard keyed +on the selector. Neither is wrong. Their composition left every saved 3.6/3.5 config 429ing. + +## Follow-ups + +Recorded in `050`: the dead `ANTIGRAVITY_WIRE_MODELS` list, `gemini-3.5-flash`'s empty +modalities entry, OpenRouter's published `google/gemini-3.8-flash`, Vertex's frozen default, +and the `gemini-3.1-pro` suffix-tier asymmetry. diff --git a/devlog/_plan/260903_gemini_38_rollout/010_wp1_antigravity_core.md b/devlog/_plan/260903_gemini_38_rollout/010_wp1_antigravity_core.md new file mode 100644 index 0000000000..716fc45854 --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/010_wp1_antigravity_core.md @@ -0,0 +1,326 @@ +# 010 — wp1: Antigravity core surface + +One file does almost all of the work: `src/providers/antigravity-models.ts`. Plus one line in +`src/providers/registry.ts`. Everything here is diff-level and copy-paste executable. + +## Design decision restated (do not skip) + +3.8 uses the **suffix-wire** shape (`ANTIGRAVITY_EFFORT_WIRE_MAP`), like 3.6 did, NOT the +single-wire `thinkingLevel` shape 3.7 uses (`ANTIGRAVITY_THINKING_LEVEL_MODELS`). Evidence: +`002` — CCA serves `gemini-3.8-flash-{low,medium,high}` and no `-tiered` row. + +Trace through `resolveAntigravityEffortWireModel` to see why the map is mandatory rather than +cosmetic. With an `ANTIGRAVITY_EFFORT_WIRE_MAP` entry, rule 2/3 returns +`{ wireModelId: "gemini-3.8-flash-high" }` — the suffix alone, no `thinkingLevel`; see section +8a and `005` for why the level must NOT accompany it. Without the map, `gemini-3.8-flash` +is not a suffix id (rule 1 skips), has no thinking-level entry (rule 1b skips), has no effort map +(rule 2/3 skips), is not `claude-` (rule 4 skips), and falls to **rule 5**, which returns the bare +id with no tier at all — a picker row whose effort selector does nothing. + +## MODIFY `src/providers/antigravity-models.ts` + +### 1. Current-generation constants (near L16) + +Before: + +```ts +/** Current Antigravity Flash generation. */ +const GEMINI_FLASH_CURRENT = "gemini-3.7-flash"; +``` + +After: + +```ts +/** Current Antigravity Flash generation. */ +const GEMINI_FLASH_CURRENT = "gemini-3.8-flash"; + +/** + * Previous Flash generation, still served by CCA. + * + * 3.6 was pulled the moment 3.7 shipped, which is why RETIRED_FLASH_TIERS exists. 3.8 did not + * do that: Google documents 3.7 Flash as "remains fully supported", and a 2026-09-03 + * :fetchAvailableModels call returns 3.8, 3.7 AND 3.6 wire ids together. So 3.7 stays a + * first-class picker row instead of joining the retired map. + */ +const GEMINI_FLASH_PREVIOUS = "gemini-3.7-flash"; +``` + +`GEMINI_FLASH_WIRE_ID` keeps its VALUE (`gemini-3.7-flash-tiered`) — it is the retired-tier +redirect target, which is still 3.7 — but is RENAMED per section 8c. + +### 2. Wire model list (L52) — DROPPED after audit + +`ANTIGRAVITY_WIRE_MODELS` has no consumer outside its own declaration; discovery does not read +it (audit blocker 9). Editing it would change dead data and imply a behavioral effect that does +not exist. Left alone; whether the dead mirror should be deleted is a separate cleanup with its +own blast radius, recorded as a follow-up in `050`. + +### 3. Picker collapse map (`ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID`, L63) + +```ts +const ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID: Record = { + "gemini-3.8-flash-low": "gemini-3.8-flash", + "gemini-3.8-flash-medium": "gemini-3.8-flash", + "gemini-3.8-flash-high": "gemini-3.8-flash", + "gemini-3.1-pro-low": "gemini-3.1-pro", + "gemini-pro-agent": "gemini-3.1-pro", +}; +``` + +This is what makes `ANTIGRAVITY_WIRE_IDS_BY_PICKER_MODEL` require all three rungs before the +collapsed row appears, so a partial CCA payload degrades to visible wire ids rather than a +ladder with missing rungs. The generic `-(low|medium|high)$` branch in +`pickerModelIdForDiscoveredWireId` would also collapse these, but only once +`gemini-3.8-flash` is in `ANTIGRAVITY_MODELS`; the explicit map is the belt to that suspenders +and mirrors how 3.1 Pro is handled. + +### 4. Effort ladder (`ANTIGRAVITY_MODEL_EFFORTS`, L145) + +```ts +export const ANTIGRAVITY_MODEL_EFFORTS: Record = { + "gemini-3.8-flash": ["low", "medium", "high"], + "gemini-3.7-flash": ["low", "medium", "high"], + ... +}; +``` + +No `minimal`: Google documents it as an error for this generation (`001`), and CCA exposes only +the three tiers (`002`). + +### 5. Effort-to-wire map (`ANTIGRAVITY_EFFORT_WIRE_MAP`, L153) + +```ts +const ANTIGRAVITY_EFFORT_WIRE_MAP: Record> = { + "gemini-3.8-flash": { + low: "gemini-3.8-flash-low", + medium: "gemini-3.8-flash-medium", + high: "gemini-3.8-flash-high", + }, + "gemini-3.1-pro": { low: "gemini-3.1-pro-low", high: "gemini-pro-agent" }, +}; +``` + +### 6. Default effort (`ANTIGRAVITY_DEFAULT_EFFORT`, L180) + +```ts +const ANTIGRAVITY_DEFAULT_EFFORT: Record = { + "gemini-3.8-flash": "medium", + "gemini-3.1-pro": "high", +}; +``` + +`medium` matches Google's documented `thinking_level` default (`001`) and the tier CCA marks +`recommended` with a finite 4000 thinking budget (`002`). Rule 2/3 requires this key: with an +effort map present and no default, `effortMap[defaultEffort]!` dereferences `undefined`. + +This constant has a SECOND consumer the first draft missed (audit blocker 5): +`discoveredAntigravityEffortWireModelId` (L405-410) reads it to pick the default rung from a +DISCOVERED ladder. So the value governs both the static and the live path, and an omission +would make live discovery fall back to `Object.values(effortMap)[0]` — an arbitrary rung +determined by CCA's key order. + +### 7. Picker list (`ANTIGRAVITY_MODELS`, L243) + +```ts +export const ANTIGRAVITY_MODELS = [ + GEMINI_FLASH_CURRENT, // gemini-3.8-flash + GEMINI_FLASH_PREVIOUS, // gemini-3.7-flash — still served, see 002 + "gemini-3.1-pro", + "gemini-3.1-flash-image", + "claude-sonnet-4-6", + "claude-opus-4-6-thinking", + "gpt-oss-120b-medium", +]; +``` + +### 8. Context windows + +`ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS` (L257) gains the three wire ids at `1_048_576`; +`ANTIGRAVITY_MODEL_CONTEXT_WINDOWS` (L267) gains the collapsed `"gemini-3.8-flash": 1_048_576`. +Both are needed: the map has no fallback, and the collapsed id is not derivable from an alias +because 3.8 has no alias entry. + +### 8a. Suffix-tier carrier set (NEW — audit blocker 1) + +Static rule 2/3 returns `{ wireModelId, thinkingLevel }` while the discovery path returns +`{ wireModelId }` only. Same model, two different request bodies depending on whether discovery +has run. A probe (`003`) shows CCA accepts `gemini-3.8-flash-low` paired with +`thinkingLevel: HIGH` and returns 200 — a contradiction it will not reject, so the effective +tier becomes unknowable. The suffix must be the sole carrier: + +```ts +/** + * Base models whose every effort maps to a wire id that ALREADY encodes the tier. + * + * Sending thinkingLevel alongside such a suffix states the effort twice, and CCA accepts a + * contradictory pair rather than failing, so a mismatch would silently run at an unknown tier. + * Membership also makes static resolution byte-identical to the discovery path, which never + * emits thinkingLevel. + * + * gemini-3.1-pro is deliberately absent: its `high` rung is `gemini-pro-agent`, which carries + * no tier suffix, so there the level is the only thing naming the effort. + */ +const ANTIGRAVITY_SUFFIX_TIER_MODELS = new Set(["gemini-3.8-flash"]); +``` + +Rule 2/3 becomes (round-2 form — the round-1 draft left `max`/`xhigh`/`ultra` diverging, +see `005` blocker 1): + +```ts +const effortMap = ANTIGRAVITY_EFFORT_WIRE_MAP[modelId]; +if (effortMap) { + const suffixTiered = ANTIGRAVITY_SUFFIX_TIER_MODELS.has(modelId); + // Normalize FIRST for suffix-tiered models: the discovery path clamps max/xhigh/ultra to + // `high` before its lookup (L400-408), so a static path that skips the clamp answers + // `medium` for the same request. Same input, two tiers, decided by whether discovery ran. + const requested = suffixTiered && effort + ? resolveAntigravityThinkingLevel(effort) ?? effort + : effort; + if (requested && requested in effortMap) { + const wireModelId = effortMap[requested]!; + return suffixTiered ? { wireModelId } : { wireModelId, thinkingLevel: requested }; + } + const defaultEffort = ANTIGRAVITY_DEFAULT_EFFORT[modelId]!; + return { wireModelId: effortMap[defaultEffort]! }; +} +``` + +The `suffixTiered &&` guard keeps `gemini-3.1-pro` byte-identical: it has no `medium` rung, so +clamping there would change which wire id a request reaches — outside this unit's scope (`050`). + +### 8b. Claude SDK paragraph guard (NEW — audit blocker 2) + +`src/adapters/google.ts:750` strips the rejected Claude-Agent identity paragraph only for +`gemini-3.7-flash`. Probes in `003` prove 3.8 rejects the same paragraph with a 429 that reads +as quota exhaustion, and succeeds the moment it is stripped. Making 3.8 the default without +this change would 429 every Claude-Agent-shaped request. + +```ts +// Membership is probe-established per generation, never assumed: 3.7 and 3.8 both answer 429 +// RESOURCE_EXHAUSTED when this paragraph survives into systemInstruction, and 200 without it. +const ANTIGRAVITY_CLAUDE_SDK_PARAGRAPH_REJECTORS = new Set([ + "gemini-3.7-flash", + "gemini-3.8-flash", +]); + +/** + * Canonicalize before the membership test: when discovery returns a PARTIAL ladder the picker + * publishes raw suffix ids, so `parsed.modelId` can be `gemini-3.8-flash-high` rather than the + * collapsed base. Those are the exact ids the 429 probe used, so a base-only test would miss + * the degraded path — the moment CCA is flaky is the worst time to also lose the guard. + * `canonicalAntigravityUsageModel` already collapses suffix ids via ANTIGRAVITY_EFFORT_WIRE_MAP + * and returns unknown ids unchanged, so this adds no new mapping surface. + */ +function rejectsClaudeSdkParagraph(modelId: string): boolean { + return ANTIGRAVITY_CLAUDE_SDK_PARAGRAPH_REJECTORS.has(canonicalAntigravityUsageModel(modelId)); +} + +const stripRejectedClaudeSdkParagraph = provider.googleMode === "cloud-code-assist" + && rejectsClaudeSdkParagraph(parsed.modelId); +``` + +`canonicalAntigravityUsageModel` is already exported from `src/providers/antigravity-models.ts`; +`src/adapters/google.ts` gains the import. + +### 8c. Constant rename (audit blocker 8) + +`GEMINI_FLASH_WIRE_ID` becomes `GEMINI_RETIRED_FLASH_TARGET_WIRE_ID`. After 3.8 becomes +current, a constant named "the Flash wire id" holding `gemini-3.7-flash-tiered` reads as a bug. +Its rule-0 comment is corrected too: retired ids route to **3.7**, not to "the current +generation". + +**All four sites move together or typecheck fails** (audit round 2, blocker 4): the declaration +at `src/providers/antigravity-models.ts:23`, plus references at `:201` +(`ANTIGRAVITY_PICKER_TO_WIRE`), `:233` (the retired-alias `Object.fromEntries`), and `:618` +(rule 0's return). + +### 9. Input modalities (`ANTIGRAVITY_MODEL_INPUT_MODALITIES`, L281) + +```ts + "gemini-3.8-flash": ["text", "image"], +``` + +Google lists video, audio and PDF (`001`) and CCA reports `supportsVideo: true` (`002`), but +this proxy transports only `OcxTextContent` and `OcxImageContent`, and the Codex catalog +normalizes `input_modalities` against a closed enum where one out-of-enum value rejects the +ENTIRE catalog. The vendor capability is recorded in `001` as a fact about Google, not a claim +about this proxy. Same reasoning, same values as every other Gemini row. + +## What is deliberately NOT touched + +| Symbol | Why untouched | +|---|---| +| `RETIRED_FLASH_TIERS` | 3.7 is not retired (`001`, `002`). Adding it would strand a live model. | +| `ANTIGRAVITY_THINKING_LEVEL_MODELS` | 3.7 keeps its single-wire tiering; 3.8 must not join it. | +| `ANTIGRAVITY_PICKER_TO_WIRE` | Only for the `-tiered` rename; 3.8 has no `-tiered` id. | +| `ANTIGRAVITY_COMPATIBILITY_MODEL_ALIASES` | No saved config can name a 3.8 id yet. | +| `ANTIGRAVITY_USAGE_BASE_BY_ID` | Derives 3.8 automatically from `ANTIGRAVITY_EFFORT_WIRE_MAP`. | + +## Complete consumer chain (PLAN-FIELD-CHAIN-01, completed after audit) + +| Symbol | Consumers | +|---|---| +| `ANTIGRAVITY_EFFORT_WIRE_MAP` | static rule 2/3 (L639-645); discovery-map completion `completeDiscoveredEffortWireModelIds` (L164-166); discovery suppression via `hasOwnEffortLadder` (L597-601); `ANTIGRAVITY_USAGE_BASE_BY_ID` derivation | +| `ANTIGRAVITY_DEFAULT_EFFORT` | static rule 2/3 (L644); discovered-ladder default selection (L405-410) | +| `ANTIGRAVITY_MODEL_EFFORTS` | registry `modelReasoningEfforts` (`registry.ts:1753`) | +| `ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS` | exported-map spread and alias derivation (L272-277) | +| `ANTIGRAVITY_MODEL_CONTEXT_WINDOWS` | registry `modelContextWindows` | +| `ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID` | reverse derivation `ANTIGRAVITY_WIRE_IDS_BY_PICKER_MODEL` (L67-95); `pickerModelIdForDiscoveredWireId` | + +That last row is worth verifying rather than assuming: the IIFE walks +`ANTIGRAVITY_EFFORT_WIRE_MAP` and maps every wire value back to its base, so +`gemini-3.8-flash-high` collapses onto `gemini-3.8-flash` for usage aggregation with no new code. + +## MODIFY `src/providers/registry.ts` (L1753) + +`defaultModel: "gemini-3.7-flash"` becomes `defaultModel: "gemini-3.8-flash"`. The `models`, +`modelContextWindows`, `modelInputModalities` and `modelReasoningEfforts` fields already +reference the exported maps, so they follow automatically. + +## Tests — MODIFY `tests/google-antigravity-wire.test.ts` + +Add a `Gemini 3.8 Flash` describe block asserting: + +1. `ANTIGRAVITY_MODELS` contains `gemini-3.8-flash` **and** still contains `gemini-3.7-flash`. +2. Registry `google-antigravity` `defaultModel === "gemini-3.8-flash"`. +3. `ANTIGRAVITY_MODEL_EFFORTS["gemini-3.8-flash"]` equals `["low","medium","high"]`. +4. Each effort resolves to its own wire id, table-driven over the three tiers, each returning + NO `thinkingLevel` (the suffix is the sole tier carrier — section 8a). +5. No effort resolves to `gemini-3.8-flash-medium` by default — i.e. an unset effort returns the + `medium` wire id (activation scenario for the `ANTIGRAVITY_DEFAULT_EFFORT` branch). +6. `xhigh`/`max`/`ultra` clamp to the `gemini-3.8-flash-high` wire id on BOTH the static and + the discovered path (round-2 blocker 1: the round-1 draft returned the `medium` wire id + statically and the `high` one after discovery). +7. A discovery payload containing all three 3.8 wire ids collapses to exactly one + `gemini-3.8-flash` row carrying the full `effortWireModelIds` triple. +8. A payload containing only two of the three rungs does NOT collapse (partial-ladder guard). +9. Regression: `resolveAntigravityEffortWireModel("gemini-3.6-flash-high")` still returns + `gemini-3.7-flash-tiered` with `thinkingLevel: "high"`. +10. `canonicalAntigravityUsageModel("gemini-3.8-flash-high") === "gemini-3.8-flash"`, and + `canonicalAntigravityUsageModel("gemini-3.6-flash-high") === "gemini-3.6-flash-high"`. +11. **Path-equality (audit blocker 1):** for each of unset, `low`, `medium`, `high`, `max`, + `xhigh`, `ultra`, resolving WITH a registered discovery ladder returns an object deep-equal + to resolving WITHOUT one. Asserting the two paths separately is what allowed them to + diverge; the clamped efforts are the cases the round-1 fix missed. +12. **Paragraph guard (audit blocker 2):** the serialized CCA `systemInstruction` omits the + Claude SDK identity paragraph for both `gemini-3.7-flash` and `gemini-3.8-flash`, and a + non-CCA Google request still contains it. Add beside `tests/google-adapter.test.ts:250`. +13. **Partial-ladder guard (round-2 blocker 2):** a discovery payload publishing only + `gemini-3.8-flash-high` as its own row, then a serialized request selecting that suffix id, + still omits the paragraph. This is the case a base-only membership test would miss. + +## Stale exact assertions this phase must update (audit blocker 3) + +- `tests/provider-registry-parity.test.ts:771` — `toHaveLength(6)` becomes 7, plus 3.8 ladder + and context-window assertions mirroring the 3.7 ones. + +Item 8 is the activation scenario for the `requiredWireIds.every(...)` guard; item 5 for the +default-effort branch; item 6 for `resolveAntigravityThinkingLevel`'s clamp. + +## Focused verification for this phase + +```bash +bun test tests/google-antigravity-wire.test.ts tests/gemini-37-flash-migration.test.ts \ + tests/google-adapter.test.ts tests/provider-registry-parity.test.ts +bun run typecheck +``` diff --git a/devlog/_plan/260903_gemini_38_rollout/020_wp2_metadata_pricing.md b/devlog/_plan/260903_gemini_38_rollout/020_wp2_metadata_pricing.md new file mode 100644 index 0000000000..8a0d98b622 --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/020_wp2_metadata_pricing.md @@ -0,0 +1,138 @@ +# 020 — wp2: metadata, pricing, and config migration + +Depends on wp1: every key below is the picker id or the wire ids wp1 introduces. + +## The trap this phase exists to avoid + +`resolveMatchedPriceExact()` (`src/usage/cost.ts:247-258`) returns bundled generated metadata +with `status: "verified"` **before** it consults the expected-price overlay. So if the new +`scripts/model-metadata.source.json` row copies its 3.6 neighbour and includes a `cost` block, +the `google-antigravity` `verified-derived` row below becomes unreachable and CCA cost is +reported as `verified` — asserting exactly the billing equivalence `001` says is NOT PROVEN. + +**The generated `google/gemini-3.8-flash` record must omit `cost`.** The 3.7 row at +`scripts/model-metadata.source.json:12046` already does this; copy that one, not the 3.6 one +at L12021 which carries a `cost` block. + +## MODIFY `scripts/model-metadata.source.json` + +Insert next to the existing `gemini-3.7-flash` record (L12046), under the `google` provider: + +```json +"gemini-3.8-flash": { + "id": "gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "api": "google-generative-ai", + "provider": "google", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { "mode": "google-level", "minLevel": "low", "maxLevel": "high" } +} +``` + +`minLevel: "low"` (not `minimal`) because `001` proves `minimal` errors on this generation — +the same value the 3.7 record uses and the 3.6 record does not. + +`input` is `["text","image"]` for the transport reason in `010` section 9, even though the +vendor also accepts video/audio/PDF. + +Antigravity resolves generated metadata through the `google` bundle +(`src/generated/model-metadata.ts:27` maps `google-antigravity` to `google`), so this single +`google` record serves both surfaces. + +## Regenerate, never hand-edit + +```bash +bun run generate:model-metadata +``` + +`src/generated/model-metadata.ts` is byte-compared by `tests/model-metadata-sync.test.ts`, so +the regen must land in the same commit as the source edit. + +## MODIFY `src/usage/expected-prices.ts` + +### New price constant (beside `GEMINI_37_FLASH`, L60) + +```ts +// Gemini 3.8 Flash carries the same published promotional rate as 3.7 through 2026-12-31, +// rising to 1.50/7.50 on 2027-01-01 (ai.google.dev/gemini-api/docs/pricing, read 2026-09-03). +const GEMINI_38_FLASH: Cost4 = { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }; +``` + +Equal values to 3.7 today, but a SEPARATE constant: aliasing them would silently move 3.8 if +3.7's promotional rate is ever re-verified to a different number. + +### New source string (beside `GEMINI_37_PRICING`, L83) + +```ts +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"; +``` + +### New rows + +```ts +// CCA billing equivalence is unproven (see devlog 001), so the Antigravity rows are +// verified-derived: the NUMBER is proven, the claim that Antigravity charges it is inferred. +{ provider: "google-antigravity", modelId: "gemini-3.8-flash", cost4: GEMINI_38_FLASH, source: `derived: Gemini 3.8 Flash promotional rate through 2026-12-31 ${GEMINI_38_PRICING}`, verifiedAt: "2026-09-03", status: "verified-derived" }, +{ provider: "google-antigravity", modelId: "gemini-3.8-flash-low", cost4: GEMINI_38_FLASH, source: `derived: gemini-3.8-flash ${GEMINI_38_PRICING}`, verifiedAt: "2026-09-03", status: "verified-derived" }, +{ provider: "google-antigravity", modelId: "gemini-3.8-flash-medium", cost4: GEMINI_38_FLASH, source: `derived: gemini-3.8-flash ${GEMINI_38_PRICING}`, verifiedAt: "2026-09-03", status: "verified-derived" }, +{ provider: "google-antigravity", modelId: "gemini-3.8-flash-high", cost4: GEMINI_38_FLASH, source: `derived: gemini-3.8-flash ${GEMINI_38_PRICING}`, verifiedAt: "2026-09-03", status: "verified-derived" }, +// Developer API row: the price IS published for this surface, so `verified`. +{ provider: "google", modelId: "gemini-3.8-flash", cost4: GEMINI_38_FLASH, source: GEMINI_38_PRICING, verifiedAt: "2026-09-03", status: "verified" }, +``` + +The three suffix rows matter because usage rows can carry a wire id directly; the 3.6 block +(L151-153) is the precedent. + +### What must NOT be removed + +Every existing 3.5/3.6/3.7 row stays. Historical `usage.jsonl` rows still carry those ids, and +deleting a row silently zeroes the cost of requests the user already made. This rollout adds a +model; it retires nothing. + +## `src/providers/model-rename-migration.ts` — NO CHANGE, and why + +The migration exists for ids the vendor **took offline**. `001` proves 3.7 remains fully +supported and `002` proves CCA still serves it, so a `gemini-3.7-flash -> gemini-3.8-flash` +entry would rewrite a working saved selection out from under the user. The existing +3.6/3.5 to 3.7 entries stay untouched and keep working. + +`selectedModels` needs no migration for the same reason: a user who allowlisted +`gemini-3.7-flash` still gets a live model. + +## `src/oauth/index.ts` — NO CHANGE + +`OAUTH_RECONCILE_FIELDS` already refreshes `models`, `modelContextWindows`, +`modelInputModalities` and `modelReasoningEfforts` from the registry preset, so existing configs +pick up 3.8 on the next start. The `defaultModel` heal branch only fires when the stored default +is absent from the refreshed list; since 3.7 remains listed, an existing user's explicit 3.7 +default is preserved — which is the correct outcome. + +`isLegacyAntigravityStaticCatalog` (L1209) is a FROZEN v1 fingerprint that must keep naming +`gemini-3.6-flash`. Updating it would break the migration it exists to perform. + +## Tests + +- `tests/oauth-provider-reconcile.test.ts:82`: default becomes `gemini-3.8-flash`; L142's + `toHaveLength(6)` becomes 7 (audit blocker 3). +- **New case (audit blocker 4):** a config whose `defaultModel` is explicitly + `gemini-3.7-flash` must come OUT of `reconcileOAuthProviders` still holding that default, + while its capability maps refresh. The existing case starts from a retired 3.5 id and + therefore only exercises the stale-default HEALING branch; asserting 3.7 is still in `models` + does not prove the default survived. This is the activation scenario for the additive claim + in this doc — without it, "an existing 3.7 user keeps 3.7" is an untested assertion. +- New assertions near the existing price tests: an Antigravity 3.8 request resolves to the + `verified-derived` overlay rather than a `verified` bundled price (the activation scenario + for the omitted `cost` block). +- `tests/model-metadata-sync.test.ts` proves the regen is byte-synced. + +```bash +bun test tests/oauth-provider-reconcile.test.ts tests/model-metadata-sync.test.ts \ + tests/usage-summary.test.ts tests/usage-cost.test.ts +``` + +`tests/usage-cost.test.ts` is the owner of price resolution and was missing from the first +draft (audit blocker 3); it is where the `verified-derived`-wins assertion belongs. diff --git a/devlog/_plan/260903_gemini_38_rollout/030_wp3_peripheral_surfaces.md b/devlog/_plan/260903_gemini_38_rollout/030_wp3_peripheral_surfaces.md new file mode 100644 index 0000000000..f38909086d --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/030_wp3_peripheral_surfaces.md @@ -0,0 +1,122 @@ +# 030 — wp3: peripheral surfaces and docs + +The surfaces that *reference* the model rather than define it. Each one below is either +changed with its evidence, or explicitly not changed with its reason — no blanks +(c-5 requires exactly this). + +## CHANGE — `src/providers/registry.ts`, direct `google` provider (L1739) + +Google publishes `gemini-3.8-flash` on the Developer API (`001`), so the API-key surface gets it: + +```ts +models: ["gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"], +modelContextWindows: { ..., "gemini-3.8-flash": 1_048_576 }, +modelInputModalities: { ..., "gemini-3.8-flash": ["text", "image"] }, +modelReasoningEfforts: { ..., "gemini-3.8-flash": ["low", "medium", "high"] }, +``` + +Note the ladder here is `["low","medium","high"]` with NO `minimal`, unlike the neighbouring +3.5/3.6/3.7 rows which all list `minimal`. `001` proves `minimal` returns a validation error on +3.8. (The 3.7 row listing `minimal` is a pre-existing inconsistency with its own model page; +correcting it is out of scope for this unit and is recorded here as a follow-up observation.) + +**`defaultModel` stays `gemini-3.5-flash`.** Adding a model elsewhere must not silently change +an existing API-key user's default — the same rule the 3.6 rollout fixed as decision 5. + +**Activation test required (round-2 blocker 3).** Adding 3.8 to `modelReasoningEfforts` newly +arms the configured-ladder branch at `src/adapters/google.ts:782-790` for this model, and +`resolveDirectGeminiWireModelId` newly sees an id absent from `GEMINI_DIRECT_WIRE_RENAMES`. +Neither is covered by a registry-metadata assertion. Add a direct AI Studio request test: +the wire id is bare `gemini-3.8-flash` with no synthetic `-tiered` rename, and the selected +effort arrives as `generationConfig.thinkingConfig.thinkingLevel`. + +## CHANGE — `src/providers/free-directory.ts` (L85) + +Prepend `gemini-3.8-flash` to the `gemini` entry's `models` array. It is a directory listing of +what the provider serves; `001` proves 3.8 is served. + +**Also give that row its own `lastVerified: "2026-09-03"`** (audit blocker 6). The shared +`LAST_VERIFIED = "2026-07-23"` constant at L56 documents when each endpoint was checked; adding +2026-09-03 evidence under a July date makes the field lie. Do NOT bump the shared constant — +that would stamp a verification date on unrelated providers nobody re-checked. + +## CHANGE — `src/web-search/index.ts` (L26) + +```ts +const DEFAULT_GEMINI_SIDECAR_MODEL = "gemini-3.8-flash"; +``` + +The sidecar runs `google_search` grounding over the Antigravity transport, so its default should +track the Antigravity default. Verified safe by `002`: all three 3.8 tiers accept inference, and +wp1 gives the id a real effort ladder, so `reasoning` still maps to a tier. + +`tests/gemini-web-search.test.ts` asserts the resolved wire id. For 3.7 that was +`gemini-3.7-flash-tiered`; for 3.8 the low-effort call must resolve to `gemini-3.8-flash-low`. +That assertion difference is itself the proof the suffix-wire shape reached the sidecar path. + +## CHANGE — `src/adapters/cursor/effort-map.ts` and `catalog.ts` + +Cursor has NOT announced 3.8 (`001`). The repository has a documented precedent for exactly +this: `glm-5.3` at `effort-map.ts:60` is commented `260814 preemptive: glm-5.3 seeded ahead of +Cursor's lineup update`. Follow it exactly, including the comment style: + +```ts +// 260903 preemptive: gemini-3.8-flash seeded ahead of Cursor's lineup update. Google documents +// low/medium/high with no `minimal` for this generation, unlike 3.6. +"gemini-3.8-flash": ["low", "medium", "high"], +``` + +And in `catalog.ts` beside the 3.7 entry (L202): + +```ts +"gemini-3.8-flash": { + displayName: "Gemini 3.8 Flash", + window: CONTEXT_GEMINI, + defaultVariant: "regular", + variants: { regular: { levels: ["low", "medium", "high"] } }, +}, +``` + +This is a static seed, not a claim that Cursor serves it: the Cursor catalog is intersected with +the live `GetUsableModels` roster, so an unseeded model stays invisible until Cursor lists it. +If the reviewer judges the seed speculative, dropping it is an acceptable amendment — the +precedent makes it defensible, not mandatory. + +## CHANGE — `docs-site/` + +- `src/content/docs/guides/sidecars.md:30` — default model becomes `gemini-3.8-flash`. +- `src/content/docs/reference/configuration/providers.md` — the `directGeminiWireRenames` + description at L139 keeps its 3.7 example verbatim, because that IS the model with the + `-tiered` rename. Do not rewrite the example to 3.8; it would document a rename that does + not exist. +- Check translated locales for the same two strings and keep them from contradicting English. + +## NO CHANGE — with reasons + +| Surface | Reason | +|---|---| +| `src/adapters/google.ts` `GEMINI_DIRECT_WIRE_RENAMES` | Adding `gemini-3.8-flash -> gemini-3.8-flash-tiered` would invent a wire id no source proves. `002` shows CCA has no `-tiered` row for 3.8, and no AI Studio deployment is known to. | +| `src/adapters/client-fingerprint.ts` | Its 3.7 mention is a comment about UA-gated 404s, not a model list. | +| `src/providers/command-code-efforts.ts` | Keyed by what Command Code's live roster returns; no 3.8 row observed. | +| `src/providers/model-rename-migration.ts` | Nothing retired — see `020`. | +| `google-vertex` `defaultModel` | Frozen pending Vertex-specific evidence. `001` does prove the Agent Platform id, but this provider's default was deliberately frozen and moving it is a separate decision. | +| OrcaRouter / OpenRouter seeds | OpenRouter DOES publish `google/gemini-3.8-flash` (`001`), but seeding router catalogs is out of this unit's scope; recorded as a follow-up. | +| `tests/fixtures/commandcode-models.json` | A recorded upstream fixture; editing it would falsify a capture. | + +## Focused verification for this phase + +Stale exact assertions this phase must update (audit blocker 3): + +- `tests/google-hardening.test.ts:777` — exact `google?.models` array gains `gemini-3.8-flash`, + plus context-window/modality/effort assertions mirroring the 3.7 rows. Note its ladder + assertion must be `["low","medium","high"]` with no `minimal`. +- `tests/google-models-listing.test.ts:360` — exact discovered-id array. + +```bash +bun test tests/gemini-web-search.test.ts tests/cursor-effort-table.test.ts \ + tests/cursor-effort-suffix.test.ts tests/cursor-catalog.test.ts \ + tests/codex-catalog.test.ts tests/provider-registry-parity.test.ts \ + tests/google-hardening.test.ts tests/google-models-listing.test.ts \ + tests/sidecar-settings-web-search-gate.test.ts +bun run typecheck +``` diff --git a/devlog/_plan/260903_gemini_38_rollout/040_wp4_delivery.md b/devlog/_plan/260903_gemini_38_rollout/040_wp4_delivery.md new file mode 100644 index 0000000000..32c00b04f6 --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/040_wp4_delivery.md @@ -0,0 +1,70 @@ +# 040 — wp4: delivery + +## Branch and commits + +This worktree starts detached at `529639a57`. Adopt in place (WORKTREE-GUARD-01): + +```bash +git switch -c codex/gemini-3.8-flash-rollout +``` + +One commit per work-phase (DEV-GIT-COMMIT-01): the docs unit, then wp1, wp2, wp3. + +## The push constraint, stated exactly + +The user said `로컬스위트는 절대 돌리지 말고 no verify로 푸시하고`. The repository's pre-push +hook runs the full suite, which is precisely what is forbidden, so: + +```bash +git push --no-verify -u origin codex/gemini-3.8-flash-rollout +``` + +`--no-verify` bypasses the LOCAL hook only. It does not and cannot bypass branch protection: +`dev`, `main` and `preview` carry rulesets requiring a reviewed PR, so a direct push to `dev` +is rejected regardless. This is a feature branch push, which is allowed. + +## Pull request + +Target `dev` (never `main`). Fill all three template sections from +`.github/PULL_REQUEST_TEMPLATE.md`: Summary, Verification, Checklist. No GUI change, so no +screenshot is required — but the description must not mention `gui`, or `enforce-target` will +demand one. + +The Verification section lists the focused commands actually run and states plainly that the +repository-wide suite was not run locally by the maintainer's instruction, with CI as the gate. + +## CI evidence standard + +`gh pr checks --required` returning empty is NOT green evidence. Read the full current rollup +for the exact head SHA: + +```bash +HEAD_SHA=$(git rev-parse HEAD) +gh pr checks --watch +gh api repos/:owner/:repo/commits/$HEAD_SHA/check-runs --jq '.check_runs[] | "\(.name) \(.status) \(.conclusion)"' +``` + +A rollup for a stale SHA proves nothing about the head being merged. + +## Merge and landing proof + +The user pre-authorized the merge (`ci 보고 바로 머지해놔`), scoped to this PR after CI is read. +Squash-merge, then prove the merge actually landed rather than trusting the API response: + +```bash +git fetch origin dev +git merge-base --is-ancestor FETCH_HEAD && echo LANDED +``` + +## Post-merge runtime check (optional but cheap) + +The user's proxy runs from a source checkout on port 10100. After the merge, that checkout can +be refreshed and `ocx models live --provider google-antigravity --json` should show one +`gemini-3.8-flash` row with `reasoningEfforts: ["low","medium","high"]` instead of today's three +effortless rows. Do NOT restart the user's service without asking; report the command instead. + +## Terminal outcomes for this phase + +- `DONE` — merged with ancestry proof. +- `BLOCKED` — CI red for a cause outside this change, or protection refuses the merge. +- `NEEDS_HUMAN` — a reviewer raises a scope question only the maintainer can settle. diff --git a/devlog/_plan/260903_gemini_38_rollout/050_followups.md b/devlog/_plan/260903_gemini_38_rollout/050_followups.md new file mode 100644 index 0000000000..63bf53f03d --- /dev/null +++ b/devlog/_plan/260903_gemini_38_rollout/050_followups.md @@ -0,0 +1,27 @@ +# 050 — follow-ups deliberately out of this unit + +Recorded rather than silently dropped, so a later unit can pick them up with the evidence +already attached. + +1. **`ANTIGRAVITY_WIRE_MODELS` is dead data.** The audit confirmed no consumer outside its own + declaration; discovery never reads it. It reads like a source of truth and is not one. + Deleting it is a cleanup with its own review surface, not a line in a model rollout. + +2. ~~**The direct `google` 3.7 row advertises `minimal`.**~~ FOLDED into this PR after the + maintainer review asked (see `006`): the evidence is the same one 3.8 relies on, and the + line was already being edited here. 3.5 and 3.6 keep theirs. + +2b. **`gemini-3.5-flash` has no `modelInputModalities` entry** on the direct `google` provider, + even though it is that provider's `defaultModel`. Pre-existing and unrelated to this diff, + but a default model with no advertised modalities is worth its own evidence pass. + +3. **OpenRouter publishes `google/gemini-3.8-flash`** (`001`). Seeding router catalogs is out of + scope here, but the id is proven whenever that unit happens. + +4. **Vertex.** `001` proves the Agent Platform publisher id + `publishers/google/models/gemini-3.8-flash`. `google-vertex.defaultModel` was deliberately + frozen pending Vertex-specific evidence; unfreezing it is a separate decision. + +5. **`ANTIGRAVITY_SUFFIX_TIER_MODELS` and `gemini-3.1-pro`.** 3.1 Pro keeps emitting + `thinkingLevel` beside a suffix wire id for `low`. Its `high` rung (`gemini-pro-agent`) has + no suffix, so the set cannot simply include it; sorting out that asymmetry is its own task. diff --git a/devlog/_plan/260903_muse_release_train/000_plan.md b/devlog/_plan/260903_muse_release_train/000_plan.md new file mode 100644 index 0000000000..7891f4c6ff --- /dev/null +++ b/devlog/_plan/260903_muse_release_train/000_plan.md @@ -0,0 +1,77 @@ +# 260903 — Muse release train: regression review, provider mark, v2.41.0 + +## Why this unit exists + +`origin/dev` is 36 commits ahead of `origin/main`, which still carries +`v2.40.0` (published 2026-09-02). Three of those commits are the Meta/Muse +line that landed today: + +- `878f75417` (#3317) — Muse Spark 1.3 registered on the 1.2 spec. +- `ff1ac6b8c` (#3321) — the direct Meta Model API provider (`meta-model`). +- `1aa839aa8` (#3337) — the `meta-muse` provider importing the Muse Code CLI + credential behind a Terms-of-Service warning. + +The user asked for three things, in order: regression-review the 36-commit +delta against `main`, give Muse a provider mark in the dashboard, and run the +release through to a published Meta/Muse-carrying version. + +## Constraints that shape every phase + +- **No local full suite.** `bun run test` and a bare `bun test` are forbidden + for this unit. Verification is focused `bun test `, `bun run typecheck`, + and exact-SHA GitHub CI (`ci.yml` + `service-lifecycle.yml`). +- That constraint is load-bearing on the release path. `scripts/release.ts` + runs the whole suite in its preflight, so the helper cannot be used here. + The release therefore takes the manual path the helper would otherwise + automate: bump on the release branch, wait for both exact-SHA workflows, + then `gh workflow run release.yml` with `version`/`tag`/`expected-sha`. +- `--no-verify` pushes are authorized; PRs target `dev` and merge with admin + once CI is green. +- `main` and `preview` carry rulesets requiring a pull request. Promotion is + by PR, not by push. + +## Work phases + +| Phase | Doc | Deliverable | +|-------|-----|-------------| +| wp0 | this unit | roadmap + review method (docs only) | +| wp1 | `010_wp1_regression_review.md` | per-commit regression record for all 36 commits | +| wp2 | `020_wp2_muse_mark.md` | Meta/Muse SVG + `provider-icons.ts` wiring | +| wp3 | `030_wp3_preview_release.md` | `preview` dist-tag publish, exact-SHA proof | +| wp4 | `040_wp4_main_release.md` | `latest` dist-tag publish, ancestry proof | + +wp1 and wp2 are independent of each other and both gate wp3. wp4 consumes +wp3's published preview. + +## Review method (wp1) + +A 36-commit delta is too large to re-derive from scratch, and re-reading every +diff line would produce a document nobody checks. The review is risk-classed +instead, and the class decides what evidence is required: + +- **R0 docs-only** — `devlog/` or `docs-site/` only. Evidence: the diff touches + no runtime path. No test needed. +- **R1 scoped runtime** — one subsystem, covered by a focused test file that + already exists. Evidence: the focused test passes at the dev head. +- **R2 cross-cutting** — touches routing, the model catalog, release + automation, or a shared contract. Evidence: focused tests plus a read of the + seam the change crosses. +- **R3 credential/security** — auth, tokens, OAuth, keychain, workflow + permissions. Evidence: line-level read of the credential path plus + `privacy:scan`. + +The Muse commits are R2 (#3317, #3321) and R3 (#3337). + +Three more are R3, corrected after audit round 1 (`005`): `7ce0ba518` (#3262) +grants `contents: write` and `pull-requests: write` to a reusable-workflow +call, `7a529a2e8` (#3318) changes `pull_request_target` processing — a declared +trust boundary in `.github/AGENTS.md` — and `3c7c021ec` (#3296) touches +provider credential admission. A workflow-permission grant is a credential +change even when the diff reads like plumbing, which is the hole the first +draft of this table had. + +## What "done" means here + +`main` carries the reviewed dev SHA, npm `latest` resolves to the stable +version built from it, and `ocx` users installing fresh get Muse Spark 1.3 +plus both Meta providers with a real mark in the dashboard. diff --git a/devlog/_plan/260903_muse_release_train/005_audit_round1_synthesis.md b/devlog/_plan/260903_muse_release_train/005_audit_round1_synthesis.md new file mode 100644 index 0000000000..22124bef94 --- /dev/null +++ b/devlog/_plan/260903_muse_release_train/005_audit_round1_synthesis.md @@ -0,0 +1,84 @@ +# Audit round 1 — synthesis + +Reviewer: delegated read-only auditor (gpt-5.6-sol, high). Verdict: **FAIL**, +seven findings. Every one was re-derived against the tree before folding; the +outcome is six folded and one rebutted-with-a-carve-out. + +## 1. "ToS enforcement is bypassable" — REBUTTED as a release blocker, RECORDED as a known limit + +The reviewer is right about the mechanism and wrong about what it means for +this release. + +The mechanism, confirmed: `loginMetaMuse` emits its warning through the +optional `ctrl.n` progress callback (`src/oauth/meta-muse.ts:128`). The CLI's +own OAuth path wires that to `console.log` (`src/oauth/login-cli.ts:87`), so +`ocx login meta-muse` prints it. The management API's flow, by contrast, +installs `n: () => {}` (`src/oauth/index.ts:1720`) and drops it on the floor — +which means `POST /api/oauth/login` and `ocx account login meta-muse`, which +goes through that same endpoint, never surface the warning text. The GUI shows +`OAuthTosWarningModal` client-side, so the acknowledgement is enforced by the +browser, not by the server. + +Why it does not block: + +- **It is not a regression and not Muse-specific.** `n: () => {}` predates this + work by a long way, and `anthropic` and `google-antigravity` — the other two + `HIGH_RISK` ids in `gui/src/oauth-tos-risk.ts:10` — have carried exactly the + same client-side-only gate since `fbac9f05e`. Shipping v2.41.0 changes the + exposure for none of them. +- **The credential path itself is clean.** The reviewer looked for a leak and + found none: no Keychain stderr surfaced, no response bodies in errors, a fixed + public error vocabulary, atomic 0600 persistence. +- **The bypass requires the user's own admin token.** `/api/oauth/login` is + behind management auth. The actor who can call it is the account holder, who + is the only party the ToS warning protects, and who has already installed and + signed into the Muse Code CLI on that machine. + +What it is: a real server-side consent gap across all three high-risk +providers, worth its own unit. It is recorded here and in +`050_followups.md` rather than folded into a release cycle, because a +backend consent boundary is a behaviour change for `anthropic` and +`google-antigravity` users too, and that does not belong in a release train +the user asked to ship today. + +## 2-5. Release-path corrections — FOLDED + +All four are correct and all four are now in the phase docs: + +- **Version availability before the bump.** `scripts/release.ts:513` checks + unused-version and channel-forward ordering BEFORE mutating anything; the + workflow's own duplicate check at `release.yml:303` runs only after dispatch. + Doing this by hand means proving the version unused first, not discovering it + from a failed publish. Live state at audit time: `latest=2.40.0`, + `preview=2.40.0-preview.20260902`, `2.41.0` unused. +- **Exact-SHA is stricter than "CI passed".** `release-dispatch-guard.cjs:14` + requires a lowercase 40-char SHA, an allowed ref, a `workflow_dispatch` + event, and equality with `GITHUB_SHA`; `release.yml:222` requires a + successful **push-event** CI run on the release branch — PR CI does not + satisfy it. +- **`dev` already carries `2.41.0`.** `package.json:3`. The main bump in the + original 040 was a no-op step; promotion carries the version with it. The + post-release workflow is `dev-version-bump.yml`, and its PR moves `dev` to + `2.42.0`. +- **Publishing is OIDC Trusted Publishing.** `release.yml:119` (`id-token: + write`), `:153` (npm >= 11.5.1), `:285`. No `NPM_TOKEN`; verify provenance + and `gitHead` after publish. + +## 6. Risk classification — FOLDED + +`7ce0ba518` (#3262) grants `contents: write` + `pull-requests: write` +(`release.yml:67`) and `7a529a2e8` (#3318) changes `pull_request_target` +processing, a declared trust boundary (`.github/AGENTS.md:16`). Both move R2 -> +R3. `3c7c021ec` (#3296) touches provider credential admission and also gets R3. + +## 7. Icon wiring — FOLDED + +The set is `MASKED_PROVIDER_ICONS` (`gui/src/provider-icons.ts:188`), not +`MASKED_MARKS`; `020` named the client-side set by mistake. `meta.svg` carries +three gradients, so the masking question does not arise — the mark is colour and +is drawn as an image. Provenance goes in the asset README, and the two ids get +explicit assertions rather than relying on the generic wiring check. + +Reviewer's own non-blocking note, confirmed: no test enumerates every registry +provider's display name, and `tests/provider-workspace-data.test.ts` does not +need changing. diff --git a/devlog/_plan/260903_muse_release_train/010_wp1_regression_review.md b/devlog/_plan/260903_muse_release_train/010_wp1_regression_review.md new file mode 100644 index 0000000000..140b37598d --- /dev/null +++ b/devlog/_plan/260903_muse_release_train/010_wp1_regression_review.md @@ -0,0 +1,56 @@ +# wp1 — Regression review of the 36 dev-ahead-of-main commits + +## Input + +`git log --oneline origin/main..origin/dev` at the head recorded in +`000_plan.md`. Baseline is `v2.40.0`. + +## Method + +Each commit gets one row: SHA, PR, risk class (R0-R3 per `000_plan.md`), the +evidence actually checked, and a verdict of `clean`, `accepted`, or `blocker`. +A `blocker` must be fixed on `dev` before wp3 starts; an `accepted` row must +say why the residual risk is tolerable in a release. + +Evidence is gathered without the full suite: + +- `git show --stat ` for the touch set of every commit. +- For R1/R2, the focused test file that owns the subsystem, run individually. +- For R3, a line-level read of the credential handling plus + `bun run privacy:scan`. +- `bun run typecheck` once at the dev head covers the type-level seams that a + per-commit read would otherwise have to reason about by hand. + +## Special attention: the Meta/Muse line + +Three questions decide whether this release is safe to publish: + +1. **Does `meta-muse` ever write the imported credential anywhere a log or a + scan can see it?** #3337's follow-up (`81c1ebe8c` on the feature branch, + squashed into `1aa839aa8`) redacts scanned secrets and bounds the Keychain + read. Verify the redaction covers the error paths, not just the happy path. +2. **Can the ToS warning be bypassed?** The provider is deliberately marked + unsupported; the warning is the only thing standing between a user and an + unauthorized use of their Muse Code subscription. + + The verdict rule, so a later reader reaches the same decision this unit + did. A bypass is `UNSAFE` and blocks the release when it is EITHER of: + + - a **new** bypass introduced by a commit in this delta, or + - any path that **discloses the credential** (a log line, an error body, a + serialized config field). + + A bypass is **accepted** only when all three hold: it predates the delta, + it applies identically to the other `HIGH_RISK` providers rather than + singling out `meta-muse`, and it is recorded in `050_followups.md` with + the file:line evidence. That is exactly one case here — the client-side-only + acknowledgement on `POST /api/oauth/login` — and `005` §1 is why it + qualifies. Anything that does not meet all three is `UNSAFE`. +3. **Does Muse Spark 1.3 leak into a provider that cannot serve it?** #3317 + added 1.3 on the 1.2 spec across the resellers; the registry must not + advertise 1.3 on a provider whose upstream roster lacks it. + +## Output + +`011_review_ledger.md` — the per-commit table. Written in wp1's B phase, not +here. diff --git a/devlog/_plan/260903_muse_release_train/011_review_ledger.md b/devlog/_plan/260903_muse_release_train/011_review_ledger.md new file mode 100644 index 0000000000..94d4661b19 --- /dev/null +++ b/devlog/_plan/260903_muse_release_train/011_review_ledger.md @@ -0,0 +1,95 @@ +# wp1 — Per-commit regression review (origin/main..origin/dev) + +Baseline `v2.40.0` (`origin/main`). 36 commits. Risk classes and method: +`000_plan.md`, as corrected by audit round 1 (`005`). + +Focused suites run for this review, all at the dev head, none of them the full +suite (forbidden for this unit): + +| Batch | Files | Result | +|-------|-------|--------| +| Meta/Muse | `meta-muse-oauth`, `meta-model-api-provider`, `privacy-scan-meta-key`, `muse-spark-web-search-compat`, `opencode-go-muse-context`, `opencode-go-muse-vision`, `command-code-provider` | 97 pass / 0 fail | +| Combos + usage | `combos`, `kiro-pool-rank`, `server-combo-failover-e2e`, `usage-aggregate-cache`, `usage-ledger-scanner`, `usage-summary`, `api-key-attribution` | 274 pass / 0 fail | +| Cursor + catalog + CI | `cursor-catalog`, `cursor-claude-id`, `cursor-effort-rows`, `cursor-effort-table`, `cursor-display-names`, `cursor-discovery`, `codex-catalog`, `provider-config-batch-management`, `ci-workflows` | 500 pass / 0 fail | +| Responses + CLI + integrations | `responses-state`, `legacy-shell-compat`, `responses-custom-tool-repair`, `chat-completions-endpoint`, `claude-cli`, `cli-status-json`, `api-keys-routes`, `remote-catalog`, `client-connect`, `integrations-writer`, `grok-sync`, `codex-desired-state` | 498 pass / 0 fail | +| GUI marks | `provider-icons`, `provider-marks-assets`, `integration-marks` | 18 pass / 0 fail | + +Total 1387 focused assertions' worth of files, zero failures. Plus +`bun run typecheck` exit 0 and `bun run privacy:scan` passed at the dev head. + +## R3 — credential and workflow-permission changes + +| SHA | PR | What it does | Evidence | Verdict | +|-----|----|--------------|----------|---------| +| `1aa839aa8` | #3337 | `meta-muse` provider importing the Muse Code CLI credential | Line-level read of `src/oauth/meta-muse.ts`. The credential never reaches an error string: Keychain stderr is discarded, the `security` child is killed on timeout, a rejected key produces `HTTP ` with no body, and the format check refuses anything not matching the Meta key shape (see below the table). `refreshMetaMuseToken` deliberately does not re-read the Keychain, so a `muse login` with a different account cannot silently overwrite a stored slot. 27 tests. `privacy-scan-meta-key` covers the scanner. | clean | +| `7ce0ba518` | #3262 | grants `contents: write` + `pull-requests: write` to the `bump-dev-version` call | Full diff read: 8 added lines, all inside the one job. The grant equals what `dev-version-bump.yml`'s own job already declares — a reusable-workflow call cannot give the callee more than the caller holds, which is why both v2.40.0 dispatches died at `startup_failure`. No other job in the file gains anything, and the callee is a repository-local path, not a third-party action. | clean | +| `7a529a2e8` | #3318 | `missing_coauthor_credit` gate; changes `pull_request_target` processing | The new code runs in `enforce-pr-target.yml` and `pr-hygiene.yml`, both privileged contexts. It reads `pr.title`, `pr.body` and commit messages and passes them to `resolveReferencedAuthors`, which resolves them through the GitHub API — untrusted text is used as a lookup key, never interpolated into shell. `tests/ci-workflows.test.ts` (part of the 500-pass batch) asserts no dispatch input reaches shell source. Fail-open on lookup failure, capped at five per run. | clean | +| `3c7c021ec` | #3296 | atomic dashboard provider-editor save; provider field admission | `PROVIDER_CONFIG_FIELD_POLICY` in `src/server/auth-cors.ts` classifies every `OcxProviderConfig` field as `editor`, `redacted`, or `runtime`, with `satisfies Record` so a newly added field fails typecheck until classified. `apiKey` and `apiKeyPool` are `redacted`; MCP and desktop-executor blocks are redacted whole because both carry arbitrary env and headers. This is a tightening, not a loosening: it replaces an allowlist that had been inadequate. 356 lines of new tests in `provider-config-batch-management`. | clean | + +The key-shape check named in the `1aa839aa8` row, kept out of the table because +its two pipe characters are cell delimiters to a Markdown parser: + +``` +/^LLM\|\d+\|[A-Za-z0-9_-]{10,}$/ +``` + +## R2 — cross-cutting + +| SHA | PR | Seam crossed | Verdict | +|-----|----|--------------|---------| +| `878f75417` | #3317 | model catalog: Muse Spark 1.3 on the 1.2 spec across command-code and opencode-go | clean — 1.3 is registered only where the reseller actually serves it; `opencode-go` keeps only the contributor tier, matching its roster. Vision and context tests pin both. | +| `ff1ac6b8c` | #3321 | provider registry + pricing: the direct `meta-model` provider | clean — id chosen as `meta-model` specifically so it cannot capture the live `meta/` selector prefix at `router.ts`, and so it derives `META_MODEL_API_KEY` rather than the CLI's `META_API_KEY`. Parity test updated in the same commit. | +| `3d3c4fe26` | #3286 | model catalog across Antigravity, Google, sidecar | clean — closed out by its own devlog unit (`f0bbaaf6a`), catalog tests green. | +| `862e914c2` | #3274 | `/v1/models` row shape (`max_output_tokens`) | clean — contract tests updated across five files in the same commit. | +| `410a48a4f` | #3275 | Cursor Claude-id normalizer replacing three seeds | clean — 85 new assertions in `cursor-claude-id`, plus catalog and pricing tests. | +| `bc8ea072d` | #3273 | Cursor effort table read from the installed bundle; new `models-capabilities` input | clean — 117 assertions in `cursor-effort-table`, cached by bundle path, mtime and size, with a static fallback for a missing or malformed bundle. | +| `2ab9d9486` | #3276 | opt-in effort-variant rows; touches `server/index.ts`, `chat-completions.ts`, `claude-messages.ts`, `responses/core.ts` | clean — the widest seam in the Cursor group, and the one with the most new coverage: 314 assertions in `cursor-effort-rows`. Opt-in by config, so an operator who does not set it sees no row change. | +| `7ce713e8d` | #3277 | GUI Cursor tab shows effort-ladder provenance | clean — nine locales updated in the same commit and `locale-parity` extended, which is the check that would otherwise let a new string ship English-only. | +| `85d40ca35` | #3270 | usage aggregation rewritten to an incremental ledger scan | clean — the largest change in the delta (1570 lines in `usage/summary.ts`), and the one with the most new coverage: `usage-aggregate-cache` (301) and `usage-ledger-scanner` (498) are both new files. Management-API docs updated in the same commit. | +| `e9a5b0f13` | #3298 | combos fail over on provider-scoped quota caps; adds a `responses/core.ts` call site | clean — 51 new assertions in `combos`; the failover reads a cap it previously ignored, so the change can only widen the set of requests that survive. | +| `2e74a35d4` | #3302 | combo resolution skips exhausted provider quotas | clean — covered by `combos` plus a dedicated `server-combo-failover-e2e` scenario. | +| `6b2dfde11` | #3294 | shorter request-rate cooldowns; `Retry-After` on a combo 503 | clean — 53 new assertions; the 503 now carries the header a client needs to back off correctly, which is a strict improvement on an opaque 503. | +| `fd324dc88` | #3256 | Kiro reset-aligned cooldown without `Retry-After` | clean — 110 assertions in `kiro-pool-rank`; scoped to the Kiro pool's own ranking, and shares `combos/failover.ts` with the three rows above, all four verified together in the 274-pass batch. | +| `938c0136a` | #3246 | tool-bridge shape for `write_stdin` | clean — repair and undeclared-tool guards both extended. | +| `b3e205e99` | #3309 | integrations: hub clients routed through loopback | clean — a narrowing; three integration test files extended. | +| `ee24bab40` | #3269 | `service-lifecycle` triggers on `release.yml` | clean, and load-bearing for this very release: it is why a workflow-only change still trips the lifecycle gate. | +| `272ff6b11` | #3265 | moved `dev` to 2.41.0 after v2.40.0 | clean — this is the version the release train is about to publish. | + +## R1 — scoped runtime + +| SHA | PR | Subsystem | Evidence | Verdict | +|-----|----|-----------|----------|---------| +| `38f8a8164` | #3330 | Cursor picker keeps the `cursor/` slug for unbranded rows | `cursor-display-names` rewritten in the same commit; in the 500-pass batch | clean | +| `472c785c2` | #3308 | `ocx status` reports a reachable dashboard URL | `cli-status-json` +35 lines; in the 498-pass batch | clean | +| `906511f73` | #3310 | `connect` uses the catalog inactivity timeout | `remote-catalog` +50, `client-connect` +8; docs and skill page updated with it | clean | +| `eac662eb1` | #3307 | rotation creation time returned by the API-key route | `api-keys-routes` +40; a one-field addition to a response | clean | +| `4cf3e9187` | #3297 | liveness probes retried before `ocx claude` spawns a proxy | `claude-cli` +17; retry only, no new spawn path | clean | +| `34c9e9802` | #3289 | stops the background write storm on `responses-state.json` | `responses-state`; 9 lines in `src`, the rest devlog. A write-frequency reduction | clean | +| `b0a42ca2f` | #3254 | chat-native shares the transient send budget across recovery | `chat-completions-endpoint` +145 | clean | +| `fc08fc2f7` | #3290 | log panel no longer jitters as rows scroll in | GUI-only; `logs-auto-refresh` and `viewport-scroll-caps` extended | clean | +| `15b43e51c` | #3301 | provider-option E2E made hermetic | test-only; removes an external dependency from a test | clean | + +## R0 — docs only + +| SHA | PR | What | Verdict | +|-----|----|------|---------| +| `bb27c26be` | #3319 | contributor-credit unit closeout | clean — `devlog/` only | +| `af314b0a7` | #3311 | bug-drawdown campaign closeout | clean — `devlog/` only | +| `f0bbaaf6a` | #3292 | Gemini 3.8 rollout closeout | clean — `devlog/` only | +| `529639a57` | #3278 | Cursor Private Inference guide | clean — `docs-site/` only | +| `345e2175c` | #3272 | Cursor bundle effort-table roadmap | clean — `devlog/` only | +| `7424719ab` | #3267 | Windows CI repair and v2.40.0 outcome | clean — `devlog/` only | + +Each R0 diff was checked with `git show --stat` to confirm it touches no path +outside `devlog/` or `docs-site/`; nothing in the build, typecheck, or test +path reads from either. + +## Findings + +**No blockers.** One accepted residual, carried from audit round 1 (`005` §1) +and detailed in `050_followups.md`: the Terms-of-Service acknowledgement for +`HIGH_RISK` OAuth providers is enforced client-side, so `POST +/api/oauth/login` and `ocx account login` do not surface it. Accepted for this +release because it predates the delta, applies identically to `anthropic` and +`google-antigravity`, sits behind management auth, and involves no credential +disclosure. Publishing v2.41.0 does not change that exposure for anyone. diff --git a/devlog/_plan/260903_muse_release_train/020_wp2_muse_mark.md b/devlog/_plan/260903_muse_release_train/020_wp2_muse_mark.md new file mode 100644 index 0000000000..e9f775bcd2 --- /dev/null +++ b/devlog/_plan/260903_muse_release_train/020_wp2_muse_mark.md @@ -0,0 +1,74 @@ +# wp2 — A Meta/Muse mark for the provider catalog + +## Current state + +`gui/src/provider-icons.ts` maps a provider id to a file under +`gui/public/provider-icons/`. Two ids landed today with no entry: + +- `meta-model` — the direct Meta Model API provider. +- `meta-muse` — the Muse Code CLI credential import. + +Neither has a row in `PROVIDER_ICON_ALIASES` or `PROVIDER_DISPLAY_NAMES`, so +the dashboard renders them with the generic fallback and an unlabelled id. +Every other first-class provider in that file carries both. + +## Change + +1. Add `gui/public/provider-icons/meta.svg` — the Meta infinity mark, lifted + from the `aria-label="Meta symbol"` inline SVG that `dev.meta.ai` renders in + its own navigation header, read through a signed-in browser session. This is + the vendor's first-party mark on the vendor's own developer console, which is + the same provenance standard every other entry in the asset README meets. + Meta publishes no `favicon.svg` (`dev.meta.ai/favicon.svg` and + `/icon.svg` both 404; the site's declared icon is a 32x32 `.ico`), so the + rendered header mark is the best available vector. + + Normalization applied, and nothing else: the three gradient ids are renamed + from React's generated `_r_d_`/`_r_e_`/`_r_f_` to stable + `meta-mark-a`/`-b`/`-c` (a generated id collides when several documents are + inlined), the presentational `height`/`width`/`role`/`aria-label` are + dropped in favour of the `viewBox`, and `xmlns` is added so the file stands + alone. Every `d` attribute and every stop colour is verbatim. +2. Alias both ids to it: + + ```ts + "meta-model": "meta.svg", + "meta-muse": "meta.svg", + ``` + +3. Add display names: + + ```ts + "meta-model": "Meta Model API", + "meta-muse": "Muse Code", + ``` + + `meta-muse` is named for what the user recognizes — the Muse Code + subscription whose credential it imports — not for its config id. +4. The mark carries three linear gradients in Meta brand blue + (#0064E0 -> #0278F1), so it does NOT join `MASKED_PROVIDER_ICONS` + (`gui/src/provider-icons.ts:188`) — that set is for single-ink neutral + artwork that vanishes against one theme, and masking would flatten a + gradient to one ink. `gui/tests/provider-marks-assets.test.ts` enforces both + directions, so this is checked rather than asserted. + + (The first draft of this doc named `MASKED_MARKS`, which is the client-side + set in `gui/src/components/integration-marks.ts`. Audit round 1 caught it.) + +## Verification + +- `bun run typecheck` (the alias maps are typed `Record`; a + duplicate key is a type-level no-op, so the real check is the test below). +- `bun test tests/provider-icons.test.ts tests/provider-marks-assets.test.ts` + from `gui/`. The generic checks already cover a missing file and an unwired + committed asset; an explicit assertion pins the two new ids by intent, the + way the MiniMax/MiMo rows are pinned. +- Provenance recorded in `gui/public/provider-icons/README.md`. That file is + the only place a later reader can learn where a mark came from, and an + undocumented asset is indistinguishable from an invented one. +- `tests/provider-workspace-data.test.ts` needs no change: nothing enumerates + every registry provider's display name (confirmed in audit round 1). + +## Out of scope + +Re-theming the catalog, touching other marks, and any docs-site asset. diff --git a/devlog/_plan/260903_muse_release_train/021_meta_mark_screenshot.png b/devlog/_plan/260903_muse_release_train/021_meta_mark_screenshot.png new file mode 100644 index 0000000000..c0b3da6920 Binary files /dev/null and b/devlog/_plan/260903_muse_release_train/021_meta_mark_screenshot.png differ diff --git a/devlog/_plan/260903_muse_release_train/030_wp3_preview_release.md b/devlog/_plan/260903_muse_release_train/030_wp3_preview_release.md new file mode 100644 index 0000000000..f1cb0b9132 --- /dev/null +++ b/devlog/_plan/260903_muse_release_train/030_wp3_preview_release.md @@ -0,0 +1,98 @@ +# wp3 — Preview release (`preview` dist-tag) + +## Why the helper cannot run + +`scripts/release.ts` preflight runs `bun test --isolate tests` plus seven +isolated files. The user forbade the local suite for this unit, so invoking +the helper would violate the constraint before it reached the bump. The helper +is not broken; it is simply out of bounds here. + +## The manual path + +Everything the helper does after its preflight is reproducible by hand, and +each step keeps its own gate. Steps 0 and 4 were added after audit round 1 +(`005`); without them this path is strictly weaker than the helper it replaces. + +0. **Prove the version is available BEFORE mutating anything.** The helper does + this at `scripts/release.ts:513` — unused on npm, no existing tag or GitHub + release, and greater than what the channel currently carries. The workflow's + own duplicate check (`release.yml:303`) fires only after dispatch and never + checks channel ordering, so skipping this means learning about a collision + from a failed publish with the bump already pushed. + + Four checks, each of which must FAIL THE STEP rather than merely print. A + command that only retrieves data is not a gate: + + ```bash + V=2.41.0-preview.YYYYMMDD + # 1. the exact version is unpublished + npm view "@bitkyc08/opencodex@$V" version 2>/dev/null && { echo "published"; exit 1; } + # 2. no git tag + git ls-remote --tags origin "refs/tags/v$V" | grep -q . && { echo "tag exists"; exit 1; } + # 3. no GitHub release + gh release view "v$V" >/dev/null 2>&1 && { echo "release exists"; exit 1; } + # 4. it moves the CHANNEL forward + npm view @bitkyc08/opencodex dist-tags --json # compare against .preview + ``` + + Check 4 is the one with no automated equivalent anywhere in the workflow: + `release.yml` will happily publish a version that moves `preview` + BACKWARDS, because its only duplicate check is exact-version equality. Read + the current `preview` tag and confirm the new version sorts after it under + semver. +1. Open a promotion PR from a branch **pinned to the reviewed SHA** (not the + moving `dev` ref) into `preview`, and merge it with admin. `preview` is + protected by a ruleset requiring a reviewed pull request, so promotion is by + PR; #3260/#3261 and #3123/#3125 are the precedent. Expect `enforce-target` + to flag the base — a promotion PR is exactly the case that check is not + written for — and record the admin bypass rather than waiting for green. +2. `dev` already carries `2.41.0` (`package.json:3`), so the preview channel + needs the prerelease suffix and nothing else: bump to + `2.41.0-preview.` in a second PR onto `preview`. `release.ts` + enforces the `-preview.` infix; the workflow enforces `version` equals + `package.json`. +3. Record the release SHA (`preview` head after the bump merges) as the full + lowercase 40-character hash. `release-dispatch-guard.cjs:14` rejects a short + or upper-case SHA outright. +4. Wait for `ci.yml` AND `service-lifecycle.yml` to succeed on that exact SHA, + **as push-event runs on `preview`** — `release.yml:222` will not accept the + PR-event run that produced the same tree. The bump touches `package.json`, + which is a service-lifecycle trigger path, and `release.yml`'s service gate + requires an already-successful lifecycle run for the release SHA, so + dispatching early races it. +5. Re-read the LIVE remote head (`git ls-remote origin preview`) and confirm it + still equals the release SHA. The helper does this immediately before + dispatch for a reason: `workflow_dispatch` resolves a mutable branch. +6. `gh workflow run release.yml --ref preview -f version= -f tag=preview + -f expected-sha= -f dry-run=false`. +7. Watch the run; verify `npm view @bitkyc08/opencodex dist-tags --json` moves + `preview`, and that the GitHub prerelease tag resolves to the release SHA. + +## Publishing is tokenless + +There is no `NPM_TOKEN` to supply and none may be introduced. Publication runs +under OIDC Trusted Publishing: `id-token: write` (`release.yml:119`), npm +>= 11.5.1 (`:153`), and an npm Trusted Publisher binding for this repository and +workflow (`:285`). A failure there is a registry-side configuration problem, +not something to route around with a credential. `concurrency: group: release` +is shared with the stable publish, so the two channels serialize. + +## Failure handling + +If the dispatch fails after the bump is already pushed, do not re-bump. Re-run +the failed workflow once, confirm the remote SHA did not move, and re-dispatch +with the same `expected-sha`. The `validate-dispatch` job refuses a dispatch +whose `expected-sha` does not equal `GITHUB_SHA`, which is exactly the guard +that makes a re-dispatch safe. + +That reuse is for a TRANSIENT failure — a runner fault, a flaked job, a race +with the lifecycle gate. If the publish actually reached the registry, the +version is spent: npm forbids republishing it, so the recovery is a new +version, not a retry. Check `npm view` before deciding which case you are in. + +## Note on the automatic dev bump + +`release.yml` calls `dev-version-bump.yml` after a non-dry-run publish. For a +preview publish it usually returns `changed=false` because `dev` already +carries the stable core. Expect that, and do not treat the skipped bump PR as +a failure. diff --git a/devlog/_plan/260903_muse_release_train/040_wp4_main_release.md b/devlog/_plan/260903_muse_release_train/040_wp4_main_release.md new file mode 100644 index 0000000000..3ee01946c5 --- /dev/null +++ b/devlog/_plan/260903_muse_release_train/040_wp4_main_release.md @@ -0,0 +1,54 @@ +# wp4 — Main release (`latest` dist-tag) and ancestry proof + +## Sequence + +0. Run the same four blocking checks as wp3 step 0 against `2.41.0`, re-run + from scratch because the preview publish happened in between: the exact + version unpublished on npm, no `v2.41.0` tag, no GitHub release, and the + version moving the `latest` dist-tag FORWARD under semver. Each must fail + the step, not merely print. The channel-forward check matters as much here + as on preview: `release.yml` compares only for exact-version duplication, + so nothing in CI would stop `latest` being moved backwards. +1. Open a promotion PR from a branch **pinned to the reviewed SHA** into + `main`; `main` is protected the same way `preview` is. Merge with admin, + recording the `enforce-target` bypass. +2. **No bump is needed.** `dev` already carries `2.41.0` (`package.json:3`), so + the promotion brings the stable version with it. The original draft of this + doc prescribed a bump PR; audit round 1 established it would be a no-op that + `npm version` rejects as "Version not changed". +3. Wait for exact-SHA `ci.yml` and `service-lifecycle.yml` success on the + `main` head, as **push-event** runs (`release.yml:222`), then re-read + `git ls-remote origin main` immediately before dispatch. +4. `gh workflow run release.yml --ref main -f version=2.41.0 -f tag=latest + -f expected-sha= -f dry-run=false`. + +## Proof required before claiming DONE + +- `npm view @bitkyc08/opencodex dist-tags --json` shows `latest` at the + published stable version. +- The published version carries npm provenance and a `gitHead` matching the + release SHA. Publication is tokenless OIDC Trusted Publishing + (`release.yml:119`, `:153`, `:285`); provenance is the artifact-side proof + that the tarball came from this workflow on this repository. +- `gh release view v2.41.0` exists and its tag resolves to the release SHA. +- `git fetch origin main` FIRST, then + `git merge-base --is-ancestor FETCH_HEAD` exits 0, with + `FETCH_HEAD` confirmed equal to the `expected-sha` that was dispatched. + This is the check that distinguishes "main moved" from "main carries the work + that was reviewed" — a green release run proves neither by itself. The fetch + is not optional: `git ls-remote` reads the remote without updating + `origin/main`, so an ancestry test against the un-refreshed remote-tracking + ref can pass or fail on history that is minutes stale. +- The Meta work is actually in the published artifact, not merely in the tag. + Download the tarball and confirm all three: the `meta-model` provider entry, + the `meta-muse` provider entry, and `meta.svg` in the packaged GUI assets. + Checking only one of them lets a release pass with a missing alias or a + missing asset. A tag pointing at the right SHA and a tarball built from it + are separate facts. + +## After publish + +`dev-version-bump.yml` (called by `release.yml`'s `bump-dev-version` job) +opens a PR moving `dev` to `2.42.0`. Merge it so `dev` does not sit on an +already-published version — that stale state is what #3265 had to repair after +v2.40.0. diff --git a/devlog/_plan/260903_muse_release_train/050_followups.md b/devlog/_plan/260903_muse_release_train/050_followups.md new file mode 100644 index 0000000000..ccc4ef033e --- /dev/null +++ b/devlog/_plan/260903_muse_release_train/050_followups.md @@ -0,0 +1,33 @@ +# Follow-ups this unit deliberately does not do + +## Server-side consent for high-risk OAuth providers + +Audit round 1 (`005` §1) established that the Terms-of-Service acknowledgement +for `HIGH_RISK` providers is enforced in the browser, not at the API boundary: + +- `gui/src/oauth-tos-risk.ts:10` lists `anthropic`, `google-antigravity`, and + `meta-muse`, and `OAuthTosWarningModal` gates the GUI button. +- `POST /api/oauth/login` performs no acknowledgement check, and the controller + it builds installs `n: () => {}` (`src/oauth/index.ts:1720`), so even the + provider's own warning text is discarded on that path. +- `ocx account login ` posts to that endpoint + (`src/cli/account-auth.ts:142`), so it inherits the gap. The older + `ocx login ` path does print the warning, because + `src/oauth/login-cli.ts:87` wires `n` to `console.log`. + +This is pre-existing and provider-wide, not introduced by the Muse work, which +is why it is not a v2.41.0 blocker. It is still a real gap and should get its +own unit: move the acknowledgement to the backend so every entry point is +covered, with the acknowledgement recorded per provider rather than per browser +session. + +The design question that unit has to answer first: an acknowledgement gate on +`/api/oauth/login` changes behaviour for `anthropic` and `google-antigravity` +logins that work today, so it needs a migration story rather than a flag flip. + +## Muse subscription usage display + +`050_wp5_passive_muse_quota.md` in the `260903_muse_spark_plan_oauth` unit +records that Meta emits subscription window usage inside streaming responses +and that OpenCodex does not yet read it. The provider note says so plainly. +Unchanged by this release. 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..4f83666ccf --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/000_plan.md @@ -0,0 +1,160 @@ +# Meta Muse Spark: direct Model API provider + plan-credential question + +- Date: 2026-09-03 +- Session: `01a064b2-91b5-7272-b9ef-4db66bb46921` +- Work class: **C4** — wp4 raised it: a live credential read, an OAuth flow, a GUI consent gate, a privacy-scan rule, and billing metadata now move together. +- Status: **A (wp4)**. wp0 closed; wp1 merged as `ff1ac6b8c` (#3321); wp2 closed `NOOP` and is superseded (see below); wp4 in audit. + +## Loop spec + +- Archetype: satisfy-spec integration. wp2's open question resolved to a recorded negative, then reopened under owner authorization as wp4. +- 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 (wp1, done), and — under explicit owner authorization — reuse the Muse Code CLI credential behind a high-risk ToS warning (wp4). +- Non-goals: touching generated metadata, changing any `*-free` Zen id, retiring or redefaulting any model, altering the merged 1.3 work, wiring Meta's session-bound console GraphQL, and the passive quota cache (that is wp5). +- Authorization boundary: **wp1 issued no key and entered no billing detail.** wp4 exists only because the repository owner completed the Muse Code login and payment on his own account and instructed that it ship with a warning. No agent-initiated credential or billing action is in scope. +- 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 each of the three implementation PRs — wp1 (merged), wp4, wp5 — green on its exact head SHA and merged into `dev`. +- Memory artifact: this unit folder. +- Terminal outcomes: wp1 `DONE` (merged). wp2 `NOOP`, superseded by wp4. wp4 and wp5 target `DONE`. `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 — **merged** as `ff1ac6b8c` (#3321) | +| wp2 | `020_wp2_device_oauth.md` | closed `NOOP` on the evidence available at the time | none | +| wp4 | `003` + `040_wp4_muse_oauth_provider.md` | `meta-muse` OAuth provider, import-only, behind the high-risk ToS warning | PR 2, base `dev` | +| wp5 | `050_wp5_passive_muse_quota.md` | passive subscription-quota cache from the `response.subscription_usage` SSE event | PR 3, base `dev`, after wp4 | + +## wp4: the owner reopened wp2, and that is a different act + +`020` closed on the reasoning that proving a credential works is not the same as being +allowed to use it. **That reasoning is not withdrawn.** Its reopen conditions listed only +first-party vendor changes because they were written for the case where an *agent* would +be making the call. + +The repository owner has since completed the Muse Code login and the payment setup on his +own account and asked for this to ship with a warning. A user spending his own ToS risk +deliberately is not the same act as an agent spending it unilaterally, and opencodex +already models exactly that distinction — `gui/src/oauth-tos-risk.ts` carries +`anthropic` and `google-antigravity` in `HIGH_RISK` for the same reason. + +Measurements that became possible only after that login are in `003`. Two of them changed +the design: the OAuth `access_token` 401s while a sibling `api_key` works, so the +provider ships a static key rather than a refresh loop; and Meta reports subscription +window usage only as an SSE event on streaming turns, so no on-demand quota probe is +possible — reading it needs a passive cache, which is wp5. + +**Independent PRs, no stack (`DEV-STACK-01`).** wp1 merged as `ff1ac6b8c`. wp4 and wp5 +follow as separate PRs off `dev`: wp5 depends on wp4 in time (it needs the provider to +exist) but not in diff — it touches the streaming path and the quota cache, files wp4 +never opens — so stacking would impose a false merge order rather than aid review. +`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 `meta-model` rows in wp1 (64 → 66) and two `meta-muse` rows in wp4 (66 → 68) +- `docs-site/` English provider table (`src/AGENTS.md:29` requires it) +- `tests/` — a focused suite beside the existing provider tests +- `src/oauth/` — `meta-muse.ts` (NEW) and one `OAUTH_PROVIDERS` entry, in wp4 only. +- `gui/src/oauth-tos-risk.ts` + `gui/src/pages/Providers.tsx` — the high-risk warning and its reauth path (wp4). +- `scripts/privacy-scan.ts` — a detector for the measured `LLM|` key shape (wp4). +- `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` (wp1 only) — no API key is issued and no billing detail is entered by the agent; wp4 runs under the owner’s own completed login and payment, per the authorization boundary above. +4. `c4` — the plan-credential question is answered by working wiring or a recorded negative with the blocking evidence. Met first by `020`'s negative; **re-answered by wp4** as working wiring under owner authorization. +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`. +7. `c7` (wp4) — the `meta-muse` login imports the CLI credential, every GUI login path is gated behind the high-risk warning, both models resolve a price, and no credential value reaches a log, error, status object, or the repository. +8. `c8` (wp5) — the `response.subscription_usage` event is parsed through `normalizePercent`/`normalizeResetAt`, cached under the account that actually served the turn, and displayed with its observation time; no path issues an inference call to refresh a quota. 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..c12ad0ab02 --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/002_plan_credential_feasibility.md @@ -0,0 +1,125 @@ +# 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`), and is now +> superseded by `003`.** At the time of writing no login had completed and no credential +> existed on this machine. The owner has since logged in and authorized the work; `003` +> records what became measurable, including the finding that the OAuth access token does +> NOT authenticate the Model API while a sibling API key does. + +## 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/003_credential_and_quota_measurements.md b/devlog/_plan/260903_muse_spark_plan_oauth/003_credential_and_quota_measurements.md new file mode 100644 index 0000000000..a63480d8ca --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/003_credential_and_quota_measurements.md @@ -0,0 +1,224 @@ +# Measured: the Muse Code credential, and Meta's quota surface + +Research doc (000-range). No diffs here: the credential half is implemented by `040` +(wp4), the quota half by `050` (wp5). + +Everything below was measured on 2026-09-03 **after the repository owner completed the +Muse Code login and payment setup on his own account** and asked for this to ship. No +secret value is recorded here and none reaches any diff. + +## Supersedes, not retracts + +`002` concluded no reusable credential existed and `020` closed wp2 as `NOOP`. Both were +correct on their evidence, and the reasoning in `020` — that proving a credential works +is not the same as being allowed to use it — is **not** withdrawn. + +What changed is the decider. An agent must not spend a user's ToS risk on its own +initiative; a user may spend his own deliberately. `020`'s reopen conditions named only +first-party vendor changes because they were written for the first case. This is the +second. The repository already models it: `gui/src/oauth-tos-risk.ts` carries +`anthropic` and `google-antigravity` in `HIGH_RISK` for exactly this reason. + +## A. Where the credential actually lives + +`~/.config/muse/auth.json` (0600) contains **no secret**. It is a pointer: + +```json +{ "schema_version": 2, + "providers": { "meta": { + "mechanism": "oauth", "storage": "keychain", "obtained_via": "device_code", + "api_base_url": "https://api.meta.ai/v1", + "user_full_name": "…", "user_email": "…" } } } +``` + +The secret is a macOS Keychain generic-password item, service +`ai.meta.dev.credentials`, account `meta`, whose payload is: + +``` +{ secret_schema_version: int, + api_key: str(len=48, "LLM|"-prefixed), + access_token: str(len=282, opaque) } +``` + +**Key grammar, measured** (structure only, no value): the `api_key` is 48 characters in +three `|`-separated segments — `LLM` (3 alnum), a 16-digit id, and a 27-character +`[A-Za-z0-9_-]` tail. It matches `/LLM\|\d+\|[A-Za-z0-9_-]{10,}/` exactly. That is the +grammar the `privacy:scan` detector uses, so the rule is evidence-backed rather than a +guess at the vendor's format. + +**The third-party report was wrong about the exposure.** It claimed the key sits "in the +Keychain in plaintext so anyone can pull it". It is a normal Keychain item under the +user's own ACL — the same protection class Claude Code uses, which +`src/oauth/local-token-detect.ts` already reads. Not a plaintext file on disk. + +## B. Which half authenticates — the finding that shapes the provider + +| Credential | `GET https://api.meta.ai/v1/models` | +|---|---| +| `access_token` | **401** `invalid_api_key` | +| `api_key` | **200**, 7 models | + +The OAuth access token does **not** authenticate the Model API. The device flow's usable +output is the `api_key` stored beside it — the "automatically connected" Muse Code API +key the subscription docs describe (`001` §E). + +So there is no bearer refresh loop to implement. The artifact is a long-lived API key, +which is the shape `src/oauth/command-code.ts` already returns +(`expires: Number.MAX_SAFE_INTEGER`, `access === refresh`). + +## C. The live roster confirms the discovery risk was real + +``` +muse-spark-1.3-contributor, muse-voice-transcribe-1.0, muse-spark-1.3, +muse-image-1.0, muse-spark-1.2-contributor, muse-spark-1.2, muse-spark-1.1 +``` + +The #3321 A-gate reviewer flagged unfiltered discovery when we had no payload. We have +one now, and `muse-image-1.0` and `muse-voice-transcribe-1.0` are exactly the +non-Responses-agent rows he predicted. `liveModels` stays off. + +## D. The shipped effort ladder is confirmed against the live endpoint + +`POST /v1/responses`, `muse-spark-1.3`: + +| effort | result | +|---|---| +| `minimal` | 200 | +| `xhigh` | 200 | +| `max` | 400 — `unknown variant \`max\`, expected one of none, minimal, low, medium, high, xhigh` | +| `none` | 400 — `does not support "none" with this model` | + +`META_MUSE_REASONING_EFFORTS`, wired in #3321 from published spec alone, matches the live +API exactly. + +## E. Quota: the surface is in the stream, not at a URL + +**This section was wrong in its first draft and is corrected here.** The correction +matters more than the finding: I probed only non-streaming requests, concluded "no +machine-readable quota exists", and was disproved by a report that the Muse CLI's +`/quota` command renders instantly — which is only possible if the data already arrived +with the previous turn. + +### The finding: `response.subscription_usage` + +A **streaming** `POST /v1/responses` (`"stream": true`) emits one extra SSE event +alongside the ordinary `response.*` sequence. Measured on 2026-09-03: + +```json +{ "type": "response.subscription_usage", + "subscription": { + "tier": "27681393394859588", + "window": { "used_percent": 0, "resets_at": 1788431188, "window_duration_mins": 300 }, + "weekly": { "used_percent": 0, "resets_at": 1788739200 } } } +``` + +Full event list from that one turn: `response.created`, `response.in_progress`, +`response.output_item.added` ×2, `response.content_part.added`, +`response.output_text.delta`, `response.content_part.done`, +`response.output_item.done` ×2, **`response.subscription_usage`**, `response.completed`. + +This fits `ProviderQuota` in `src/providers/quota-types.ts` without a schema extension: +`window.used_percent` → `fiveHourPercent` (`window_duration_mins: 300` confirms the +5-hour window), `window.resets_at` → `fiveHourResetAt`, `weekly.used_percent` → +`weeklyPercent`, `weekly.resets_at` → `weeklyResetAt`. No new quota shape is needed. + +`tier` is an opaque numeric id here, not the human label the CLI prints, so it must not +be displayed raw. + +### What is still absent + +The rest of the original negative survives, and it constrains **how** the quota is +obtained rather than whether it exists. + +**Probed 17 plausible REST paths** with the working key — +`/v1/usage`, `/v1/billing`, `/v1/billing/credits`, `/v1/credits`, `/v1/account`, +`/v1/organization`, `/v1/organization/costs`, `/v1/me`, `/v1/whoami`, `/v1/limits`, +`/v1/rate_limits`, `/v1/quota`, `/v1/usage/costs`, `/v1/dashboard/billing/usage`, +`/v1/subscription`, `/v1/keys`, `/v1/api_keys` — **all 404**. + +**Response headers carry nothing, on both request shapes.** A 200 from `/v1/models`, a +200 from non-streaming `/v1/responses`, and a 200 from **streaming** `/v1/responses` all +return only `x-request-id`, `x-route: model-api-rust`, CORS, `Content-Type`, and +(streaming) `Cache-Control` + `Transfer-Encoding`. No `x-ratelimit-*`, no `retry-after`. + +Meta's docs publish `x-ratelimit-limit-tokens`, `x-ratelimit-remaining-tokens`, +`x-ratelimit-limit-requests`, `x-ratelimit-remaining-requests` and `Retry-After`. Three +measured request shapes carry none of them. They may appear only near a limit, or the +docs may be ahead of the deployment — either way **nothing may depend on them**, and a +parser that reads them when present must treat absence as normal. + +**The console does not use a public API.** A signed-in browser observation of +`dev.meta.ai` shows the usage and billing pages calling internal Relay GraphQL: + +| Surface | Path | Query | +|---|---|---| +| Usage | `POST /api/graphql/` | `LLMDCUsageQuery` (pinned `doc_id`) | +| API keys | `POST /api/graphql/` | `LLMDCAPIKeysQuery` (pinned `doc_id`) | +| Billing | `POST /api/billing/graphql/` | `BillingContextFactoryQuery`, `BiPSPaymentActivityViewQuery`, … | + +Those need `fb_dtsg`, `lsd`, session cookies and a pinned `doc_id` that rotates with +every Meta deploy. Wiring them would mean shipping a Facebook session scraper that breaks +without warning. **Out of scope** — and now unnecessary, since the SSE event carries the +same two windows the dashboard needs. + +What the docs do state, and what the provider can therefore say in prose: + +> Limits apply per team, not per API key. If you use multiple keys in one team, all +> requests, tokens, images, and audio minutes count toward the relevant shared quota. + +Defaults: Standard 3,000 RPM / 4M TPM; Contributor 100 RPM / 3M TPM. + +## F. What that means for multi-account + +Two consequences, and they cut in opposite directions. + +**Reactive 429 failover works with no new code.** `isGenericFailoverProvider` returns +true for any `authMode: "oauth"` provider outside `{openai, anthropic}`, and rotation +arms automatically once two usable accounts exist. A `meta-muse` OAuth provider inherits +it. The only obligation is that upstream exhaustion reaches the router **as HTTP 429** so +`generic-account-failover` sees it. + +**Quota display is possible, but only passively.** There is no endpoint to poll, so +nothing can be *probed* on demand: the quota arrives as a side effect of a streaming +turn. That is the same passive shape the Codex pool already uses for its +`x-codex-*-used-percent` headers — read off a real response, then cached. + +Two consequences for the implementation: + +- A `fetchMetaMuseQuota()`-style probe is **impossible**. Anything that would make + `ocx account refresh` or a dashboard button issue a fresh quota call cannot exist, + because obtaining one would mean spending a real inference turn. +- `supportsPerAccountQuota` must stay **false** regardless: that path calls + `fetchAccountQuota`, which is a probe. Per-account quota would need a + cache-read-only variant that does not exist today. + +So the honest scope for **wp5** is: parse the event when a turn produces one, cache it +under the serving account, and let the dashboard show what was last observed. wp4 ships +the credential only and surfaces no quota. + +And a trap worth recording: `fetchAccountQuota`'s fallback branch calls +`fetchAnthropicUsageQuota(token)` for any provider that is not `kiro` or +`google-antigravity`. **Adding `meta-muse` to the allowlist without a dedicated branch +would send a Meta bearer to Anthropic's endpoint.** Since Meta exposes no probe, the +correct action is to add nothing — but the hazard is documented here so a future +contributor does not "just extend the allowlist". + +Per-team quota is also the wrong shape for per-account ranking: two keys in one team +share one pool, so ranking accounts by headroom would be measuring the same number twice. +**But subscription windows are per-subscription**, and two different Muse Code accounts +hold two different subscriptions — so the SSE percentages ARE per-account even though the +RPM/TPM limits are per-team. Ranking on them would be sound; it is out of scope only +because the cache-read-only seam does not exist yet. + +## G. Method note + +The first version of §E asserted a negative from an incomplete search: I probed URLs and +headers, found nothing, and generalized. The disproof came from a behavioral observation +I had already been given and had not used — the CLI's `/quota` answers instantly, which +rules out an on-demand HTTP call and points at data arriving in-band. + +Same failure mode as `002` §G, where a docs-site search for "OAuth" returned nothing and +I concluded no flow existed until `muse login --help` disproved it in one command. Twice +now: **absence of evidence in the surface I happened to search is not evidence of +absence.** For a vendor claim, prefer a behavioral probe of the real client over an +inventory of guessed endpoints. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/004_muse_quota_emission_questions.md b/devlog/_plan/260903_muse_spark_plan_oauth/004_muse_quota_emission_questions.md new file mode 100644 index 0000000000..d0fa26a7ca --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/004_muse_quota_emission_questions.md @@ -0,0 +1,34 @@ +# Open questions on Muse subscription-usage emission + +Research doc (000-range), split out of `050` because unresolved research must not sit +inside an implementation phase (LEXICO-SPLIT-01). + +**None of these blocks wp5.** The parser is fail-soft by construction: a turn that emits +no event is normal, so every answer below only widens or narrows coverage. They are +recorded so a future contributor does not mistake partial coverage for a bug. + +## Q1 — Does the Contributor tier emit the event? + +Only `muse-spark-1.3` (standard) was observed on 2026-09-03. `muse-spark-1.3-contributor` +is a different billing tier and may or may not carry subscription windows. + +Resolvable with one streaming turn against the contributor id, comparing the event list. + +## Q2 — Does a pure pay-as-you-go account emit it? + +The field is named `subscription`, which suggests it appears only for accounts holding a +Muse Code subscription. If so, an account without one shows no quota — correct behavior, +not a defect, but the GUI must not present the absence as an error. + +Not resolvable on this machine: the only credential available belongs to a subscribed +account. + +## Q3 — Does the translated path preserve the event? **ANSWERED: no.** + +`src/adapters/openai-responses.ts` iterates `decodeServerSentEvents` and dispatches on +`payload.type` through a `switch` with no `response.subscription_usage` case, so a +translated turn drops it silently. + +That is not a bug to fix in the adapter — it is the reason `050` observes on the +passthrough path and treats translated coverage as an explicit, documented gap rather +than discovering it during Build. 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..d9ef17d5cf --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/020_wp2_device_oauth.md @@ -0,0 +1,98 @@ +# wp2 — Muse Code plan credential: CLOSED as a recorded negative + +> **Superseded by wp4 (`003`, `040`).** This close was correct on the evidence available +> and its reasoning is not retracted: proving a credential works is not the same as being +> allowed to use it. What changed is who decides. The repository owner completed the login +> and payment on his own account and asked for this to ship behind a warning — a user +> spending his own ToS risk, not an agent spending it for him. Read this doc as the +> record of why an agent would not have shipped it unprompted. + +**Outcome at the time: `NOOP`. No code shipped from this phase.** + +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/devlog/_plan/260903_muse_spark_plan_oauth/040_wp4_muse_oauth_provider.md b/devlog/_plan/260903_muse_spark_plan_oauth/040_wp4_muse_oauth_provider.md new file mode 100644 index 0000000000..b17445025c --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/040_wp4_muse_oauth_provider.md @@ -0,0 +1,295 @@ +# wp4 — `meta-muse` OAuth provider, import-only, behind a ToS warning + +Branch `codex/meta-muse-device-oauth`, base `dev` at `ff1ac6b8c`. One PR. + +Evidence: `003`. Authorization: the repository owner completed the login and payment +himself and asked for this to ship with a warning. + +Revised after A-gate rounds 1 (FAIL, 5) and 2 (FAIL, 5). Fixes are marked `[A1]`…`[B5]`. + +Round 2 also arrived with a **user-supplied disproof of my own research**: Meta *does* +expose quota, as a `response.subscription_usage` SSE event on streaming turns. My earlier +"no quota surface exists" came from non-streaming probes only. `003` §E is corrected and +§G records the method failure. What that enables is scoped at the end of this doc. + +## Shape + +**Import-only, macOS-only.** `[A2]` The first draft proposed spawning `muse login` and +polling for the credential file. That is unshippable for three reasons the reviewer +verified: the pointer file already exists, so "poll until it appears" returns instantly +with the *old* account on a force-login; `muse login` has no non-interactive mode, so a +spawned TUI can outlive cancellation; and the Keychain read is darwin-only, so on +Linux/Windows the spawn could succeed and the import still fail. + +So the provider reads an existing credential and never spawns anything. If none is +present it fails with instructions. + +## MODIFY `src/providers/registry.ts` + +One entry after `meta-model`, reusing every `META_MUSE_*` constant #3321 introduced +(no duplication): + +```ts + { + id: "meta-muse", + label: "Meta Muse Code (CLI credential)", + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + authKind: "oauth", + oauthId: "meta-muse", + defaultModel: "muse-spark-1.3", + models: META_MUSE_MODELS, + liveModels: false, // live roster carries image + voice rows (003 §C) + modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), + 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])), + note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. Meta reports subscription window usage inside streaming responses, but opencodex does not yet read or display it, and there is no endpoint to query it on demand. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", + }, +``` + +`label` is stated explicitly `[A5]`, and the GUI needs its own entry `[B4]`: account rows +render `OAUTH_LABELS` in `gui/src/pages/providers-shared.ts`, so a registry `label` alone +leaves the raw id `meta-muse` on screen. Add `"meta-muse": "Meta Muse Code (CLI)"` there. + +Three corrections to that `note`, from round 2: + +- **Billing is stated as unobservable, not settled** `[B3]`. The vendor text proves the + credential is CLI-scoped and that *separately created* keys are pay-as-you-go. It does + not prove how this CLI-minted key settles when replayed elsewhere, and `003` §E found + no billing surface to check. Asserting "bills pay-as-you-go" as fact would hand the + user false certainty about which balance is charged; "treat every call as billable" is + both honest and safe. +- **`META_MODEL_API_KEY`, not `MODEL_API_KEY`** `[B5]` — the exact env-name trap + CodeRabbit caught on #3321. Repeating Meta's own name would send a user to export a + variable opencodex never reads. +- The quota sentence states only what wp4 ships `[C2]`: that Meta emits the data and + opencodex does not yet surface it. Promising that opencodex can show the last observed + percentages would advertise wp5 work in wp4 documentation. + +## NEW `src/oauth/meta-muse.ts` + +``` +MUSE_POINTER = ~/.config/muse/auth.json +KEYCHAIN_SVC = "ai.meta.dev.credentials" +KEYCHAIN_ACCT = "meta" +``` + +`loginMetaMuse(ctrl)`: + +1. `process.platform !== "darwin"` → throw naming the limitation. `[A2]` +2. Read the pointer. Require `providers.meta.mechanism === "oauth"` and + `storage === "keychain"`; a different `storage` means a shape we have not measured, so + refuse rather than guess. `[A1]` +3. `security find-generic-password -s -a -w` — same mechanism as + `readClaudeKeychain` in `local-token-detect.ts`, 5s timeout, stderr piped. +4. Parse; take `api_key`. Reject anything that is not `LLM|`-prefixed. **Never** + `access_token` — it 401s (`003` §B). +5. `sanitizeApiKeyValue()` from `src/providers/api-keys.ts`. `[A1]` +6. Validate live: `GET /v1/models` must return 200. +7. Return `{ access: key, refresh: key, expires: Number.MAX_SAFE_INTEGER, + email: normalizedEmail, source: "local-cli" }`. + +`email`, **not** `accountId` `[A1]` — `src/oauth/index.ts` masks `email` for display, and +`store.ts` already falls back to `email` for slot identity, so the masking path is kept +and multi-account identity still works. + +`refreshMetaMuseToken(token)` `[B2]` returns the supplied token unchanged with +`Number.MAX_SAFE_INTEGER`, exactly as `refreshCommandCodeToken` does. It must **not** +re-import from the Keychain: generic refresh writes its result into the slot being +refreshed, so if the user switched Muse accounts in between, a different identity would +silently overwrite the existing slot. Only an explicit login may import. + +The validation fetch is bounded `[B2]`, and the guard matters `[C1]`: +`OAuthController.signal` is OPTIONAL (`src/oauth/types.ts`) and the CLI controller in +`login-cli.ts` supplies none, so `AbortSignal.any([ctrl.signal, ...])` throws a +`TypeError` before the fetch — every `ocx login meta-muse` would fail immediately after +printing its warning. Use the exact shape `command-code.ts:49` already uses: + +```ts +signal: ctrl.signal + ? AbortSignal.any([ctrl.signal, AbortSignal.timeout(10_000)]) + : AbortSignal.timeout(10_000), +``` + +so a stalled `/v1/models` cannot hang a login and cancellation is honored when offered. +Tests cover a controller with a signal, one without, an aborted signal, and a timeout. The reader, platform check, pointer path and `fetch` are +injected so tests stay deterministic and never touch the real Keychain. + +### The warning must reach the CLI too `[B1]` + +`src/oauth/login-cli.ts` calls `runLogin` and never reads the registry `note`, so +`ocx login meta-muse` would import a restricted credential in silence. It does pass +`onProgress: m => console.log(...)`. + +So `loginMetaMuse` emits the full warning through `ctrl.onProgress` **before** touching +the pointer or the Keychain: the CLI-scope restriction, that settlement is unobservable +and calls should be treated as billable, that the key is copied into opencodex's auth +store, and that `meta-model` is the supported path. The GUI ignores progress text because +it already shows the modal. A focused test asserts the warning precedes credential access. + +Every failure path throws a message naming what to do — install the CLI, run +`muse login`, retry — and **never includes the credential**. + +### The key IS persisted, and the plan must say so `[A1]` + +The first draft implied read-only access to Meta's store. That was wrong: +`runLogin` → `store.ts` writes `access` and `refresh` into `~/.opencodex/auth.json` +(0600, dir 0700), exactly as every other OAuth provider does. The doc now states it, the +note tells the user, and `privacy:scan` is extended below so the key shape is detectable +if it ever escapes into a tracked file. + +## MODIFY `src/oauth/index.ts` + +```ts + "meta-muse": { + login: ctrl => loginMetaMuse(ctrl), + refresh: refreshMetaMuseToken, + providerConfig: oauthConfig("meta-muse"), + defaultModel: oauthDefaultModel("meta-muse"), + // Static API key scoped by Meta to its own CLI. Never generate unattended traffic + // on it — same posture as anthropic, for the same reason. + defaultRefreshPolicy: "disabled", + }, +``` + +## MODIFY `scripts/privacy-scan.ts` `[A1]` + +Its `token-looking` pattern matches `sk-`, `ghp_`, and JWTs — **not** `LLM|`. Add a +detector for `/LLM\|\d+\|[A-Za-z0-9_-]{10,}/` so a leaked Meta key is caught by the gate +this plan names as its protection. + +That grammar is **measured, not guessed** `[B5]`: the real key is 48 chars in three +`|`-separated segments — `LLM`, a 16-digit id, a 27-char `[A-Za-z0-9_-]` tail — and the +pattern was verified against it (`003` §A). The `\d+` segment is the part a guess would +have gotten wrong. + +`scanFile` is private and the script runs on import, so a test cannot call it `[C4]`. +Without a seam the regression degrades into re-declaring the same regex inside the test, +which stays green even if the production detector is deleted. + +So extract an import-safe `export function scanText(file: string, text: string): Finding[]` +that `scanFile` then calls, and have `tests/privacy-scan-meta-key.test.ts` exercise **that +exact function**. The canary is assembled at runtime (`"LLM" + "|" + digits + "|" + tail`) +so the fixture is not itself a secret-shaped literal. Drive the test red once by removing +the detector, to prove it is not vacuous. + +## MODIFY `src/usage/expected-prices.ts` + `tests/usage-cost.test.ts` `[A4]` + +`cost.ts` resolves overlays by exact provider id, so `meta-muse` rows do not inherit +`meta-model`'s and both models currently resolve to `null`. A provider whose whole +warning is "this bills pay-as-you-go" must not report zero cost. + +Extract the two `Cost4` tuples and the source string #3321 introduced into named +constants, reuse them for both providers, add two `meta-muse` rows, and move the pinned +count 66 → 68 with lookup assertions for both new ids. + +## MODIFY the GUI warning path `[A3]` + +Adding `"meta-muse"` to `HIGH_RISK` is necessary and **not sufficient**. Verified: +ordinary login goes through `requestLoginOAuth` (which checks `oauthTosRisk`), but +`onReauth` calls `loginOAuth` **directly** — so a user who already logged in can refresh +the risky credential without ever seeing the warning. + +1. `gui/src/oauth-tos-risk.ts`: add `"meta-muse"` to `HIGH_RISK` — `high`, not + `elevated`, because Meta restricts it in writing. +2. `gui/src/pages/Providers.tsx`: route `onReauth` through a warning-aware path, + carrying `accountId` in the pending state so acknowledgement continues the *same* + operation rather than a fresh login. +3. `gui/src/pages/providers-shared.ts`: add the `OAUTH_LABELS` entry, or the account row + renders the raw id. +4. The executable regression goes in **`gui/tests/oauth-tos-warning-gate.test.tsx`**, not + the root suite `[B4]`: React and `happy-dom` are `gui` dependencies and the root + `tests/` tree cannot render components. It asserts login, add-account and reauth each + call login zero times before acknowledgement and exactly once after. The root + `tests/oauth-tos-warning.test.ts` keeps its map-level assertion for `"meta-muse"`. + +CLI login (`ocx login meta-muse`) is outside the GUI warning map. Its warning surface is +`loginMetaMuse`'s `ctrl.onProgress` emission, which fires before any credential is read; +the registry `note` is duplicate persistent disclosure shown in the picker, not the CLI +gate. + +## MODIFY `tests/provider-registry-parity.test.ts` + +Add `meta-muse` to whichever roster enumerates OAuth providers, in registry order. + +## NEW `tests/meta-muse-oauth.test.ts` + +Registry shape and `oauthId`; the reused ladder, window, modalities and identity wire +map; `liveModels === false`; `meta/muse-spark-1.3` still routes to `command-code` with +all three Meta-adjacent providers configured; the note carries the CLI-scope, +treat-as-billable, auth-store and `META_MODEL_API_KEY` disclosures; +`defaultRefreshPolicy === "disabled"`; `supportsPerAccountQuota("meta-muse") === false` +[B4]; refresh returns its input unchanged and performs no Keychain read [B2]. + +Importer, against an **injected reader** — never the real Keychain, never the network: +non-darwin refuses; missing pointer refuses; `storage !== "keychain"` refuses; malformed +JSON refuses; a payload with only `access_token` refuses; a valid payload yields +`email` set and `accountId` unset; a synthetic canary key appears in **no** thrown +message, log line, or returned status object. `[A1]` + +## MODIFY `docs-site/src/content/docs/guides/providers.md` + +English only. A `meta-muse` section stating: macOS plus the Muse Code CLI signed in; the +key is imported and copied into OpenCodex’s auth store; Meta scopes that credential to its +own CLI so this is an unsupported use; settlement is not observable from the API, so +treat every call as billable; opencodex shows no quota for this provider and cannot +refresh one on demand; and `meta-model` with your own `META_MODEL_API_KEY` is the +supported path. + +The docs copy must match the registry note exactly on those three points `[C2]` — no +pay-as-you-go settlement claim, no quota-display promise, and the correct env var. + +## Quota and multi-account + +From `003` §E-F, as corrected by the SSE finding: + +- **Reactive 429 failover: free.** `isGenericFailoverProvider` arms for any OAuth + provider outside `{openai, anthropic}` once two usable accounts exist. `meta-muse` + inherits it with no new code. The only obligation is that upstream exhaustion reaches + the router as HTTP 429. +- **Quota display: possible, passively — and OUT OF SCOPE for this PR.** The + `response.subscription_usage` event fits `ProviderQuota` + (`fiveHourPercent` / `fiveHourResetAt` / `weeklyPercent` / `weeklyResetAt`) without a + schema extension — though not as a literal copy `[C5]`: `updatedAt` is generated + locally, percentages and Unix-second resets go through `normalizePercent` / + `normalizeResetAt`, `tier` is dropped, `window_duration_mins === 300` must be checked + before the five-hour slot is assigned, either window may be absent, and a turn with no + event at all is normal rather than an error. But there is no endpoint to poll: the value arrives only as a + side effect of a real streaming turn, so it needs a passive read-and-cache seam rather + than the probe-shaped `maybeFetchProviderQuota` dispatch every other provider uses. + That touches the streaming path, the quota cache and account attribution — a distinct + unit. It is registered as **wp5** with its own diff-level document + (`050_wp5_passive_muse_quota.md`) `[C3]`, since a declared work-phase without one + violates DIFFLEVEL-ROADMAP-01. Folding it into a credential PR would make both harder + to review. +- **`supportsPerAccountQuota` stays false**, and a test asserts it `[B4]`. That path calls + `fetchAccountQuota`, whose fallback branch sends any non-Kiro/non-Antigravity bearer to + `fetchAnthropicUsageQuota`. Flipping the allowlist without a dedicated branch would ship + a Meta key to Anthropic; the assertion locks that guard. +- Subscription windows are per-subscription, so they WOULD be sound for per-account + ranking in wp5. The RPM/TPM limits are per-team and would not be. + + +## Verification + +```bash +bun test tests/meta-muse-oauth.test.ts tests/meta-model-api-provider.test.ts \ + tests/oauth-tos-warning.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 +bun run lint:gui +bun test gui/tests/oauth-tos-warning-gate.test.tsx +cd gui && bun run build # gui/AGENTS.md requires this for GUI changes +cd docs-site && bun install --frozen-lockfile && bun run build +``` + +No repository-wide suite. No test may read the real Keychain or reach the network. + +## Terminal outcome + +`DONE` when the PR is green at its exact head SHA and merged, login imports the CLI +credential on macOS, every GUI login path is gated behind the high-risk warning, and both +models resolve a price. diff --git a/devlog/_plan/260903_muse_spark_plan_oauth/050_wp5_passive_muse_quota.md b/devlog/_plan/260903_muse_spark_plan_oauth/050_wp5_passive_muse_quota.md new file mode 100644 index 0000000000..460b280316 --- /dev/null +++ b/devlog/_plan/260903_muse_spark_plan_oauth/050_wp5_passive_muse_quota.md @@ -0,0 +1,158 @@ +# wp5 — passive Muse subscription quota + +Own PR, base `dev`, **after wp4 lands** (it needs the `meta-muse` provider to exist). +Branch: `codex/meta-muse-passive-quota`. + +Research and unresolved questions live in `003` §E and `004`. This document is +implementation only. + +## Why this is a separate phase + +Every other provider's quota is **probe-shaped**: `maybeFetchProviderQuota` dispatches to +a function that issues an HTTP request and returns a `ProviderQuota`. Meta has no such +endpoint (`003` §E). Its quota arrives as an SSE event on streaming turns, so obtaining a +fresh value would mean spending a real inference turn. + +That inverts the seam, and the inversion is the whole phase: writes come from the +streaming path, reads are cache-only, and "refresh" does not exist. + +## Decisions taken here, so Build does not have to make them + +| Question | Decision | +|---|---| +| Where to observe | `createSseInspector` in `src/server/relay.ts`, which already parses every passthrough SSE frame | +| Translated path | **Not covered.** `openai-responses.ts`'s switch drops unknown types (`004` Q3). Documented gap, not a silent one | +| Which account | the account that **served** the turn, read after failover may have moved it | +| `supportsPerAccountQuota` | **stays false.** A new cache-only accessor is added instead — see below | +| Refresh semantics | none; `ocx account refresh meta-muse` must not issue an inference call | + +### Why `supportsPerAccountQuota` stays false + +That predicate gates `fetchAccountQuota`, whose fallback branch sends any +non-Kiro/non-Antigravity bearer to `fetchAnthropicUsageQuota` — flipping it without a +dedicated branch ships a Meta key to Anthropic. But even *with* a branch it is the wrong +predicate: it means "this provider can be probed", and Meta cannot. + +So the flag stays false and a second, honest predicate is added: +`hasPassiveAccountQuota(provider)`, true for `meta-muse`, which the read path consults +for cached rows without ever reaching a probe. + +## NEW `src/providers/muse-subscription-usage.ts` + +```ts +/** The event Meta emits on streaming turns. Shape from 003 §E, measured 2026-09-03. */ +export function parseMuseSubscriptionUsage(payload: unknown): ProviderQuota | null; +``` + +Rules, all mandatory (`[C5]`): + +| Source | Target | Rule | +|---|---|---| +| `subscription.window.used_percent` | `fiveHourPercent` | `normalizePercent`; assign **only** if `window_duration_mins === 300` | +| `subscription.window.resets_at` | `fiveHourResetAt` | `normalizeResetAt` (Unix seconds) | +| `subscription.weekly.used_percent` | `weeklyPercent` | `normalizePercent` | +| `subscription.weekly.resets_at` | `weeklyResetAt` | `normalizeResetAt` | +| — | `updatedAt` | `Date.now()`, never from the payload | +| `subscription.tier` | — | **dropped**: an opaque numeric id, not the label the CLI prints | + +Returns `null` — never throws — when the payload is not an object, carries no +`subscription`, or yields no usable window. A `window_duration_mins` other than `300` +goes to `customWindows` with its duration as the label rather than being forced into the +five-hour slot. Either window may be absent independently. + +## MODIFY `src/server/relay.ts` + +Add one optional handler to `SseInspectorHandlers`: + +```ts + /** Fires for a `response.subscription_usage` frame. Meta-only today. */ + onSubscriptionUsage?(payload: unknown): void; +``` + +`createSseInspector` already decodes every frame; this adds a type check and a call. No +behavior changes when the handler is absent, which is every other provider. + +## MODIFY `src/server/responses/core.ts` + +At the passthrough inspector construction, pass `onSubscriptionUsage` **only** when the +resolved provider is `meta-muse`. The handler: + +1. `parseMuseSubscriptionUsage(payload)`; bail on `null`. +2. Resolve the serving account: `genericFailoverAccountId` if failover moved it, else the + account resolved at dispatch. Attribution to the dispatch-time account would be wrong + precisely when it matters most. +3. `recordPassiveAccountQuota("meta-muse", accountId, quota)`. + +## MODIFY `src/providers/quota.ts` + +```ts +/** Providers whose per-account quota is observed passively, never probed. */ +export function hasPassiveAccountQuota(provider: string): boolean { + return provider === "meta-muse"; +} + +/** Write a quota observed in-band. Generation-fenced, like the probe writers. */ +export function recordPassiveAccountQuota(provider: string, accountId: string, quota: ProviderQuota): void; +``` + +`recordPassiveAccountQuota` mirrors the existing probe writers at `quota.ts:1380`, with +one correction the A-gate caught: capturing the generation immediately before the write +cannot see a config or account change that happened EARLIER in the turn, which is exactly +the case that matters. So the CALLER captures `captureConfigGeneration()` when it resolves +the serving credential and passes it in, and the writer discards if the generation moved +since. Then write +`accountQuotaCache.set(accountCacheKey(provider, accountId), { ts: Date.now(), quota })`, +then `persistAccountQuotaCache()` so a restart keeps the last observation. + +The read path gains `hasPassiveAccountQuota` alongside `supportsPerAccountQuota` so +cached Meta rows are served, and **no** dispatch branch is added to +`maybeFetchProviderQuota` — there is nothing to fetch. + +## MODIFY `src/server/management/oauth-account-routes.ts` + +The `quota=1` enrichment returns cached rows for a passive provider and never triggers a +probe. When no observation exists yet, the row is absent rather than an error: a user who +has not run a streaming turn has no quota, which is correct. + +## MODIFY `gui/src/hooks/useProviderAccountPools.ts` + the account row + +Render the observation time with the percentages — "5h 12% · observed 14m ago". A passive +value can be arbitrarily old and must not be presented as live. Absent quota renders +nothing, not a zero bar. + +## Tests + +`tests/muse-subscription-usage.test.ts` — parser, fixture-driven: +the measured payload; `window_duration_mins: 600` → `customWindows`, not +`fiveHourPercent`; weekly-only; window-only; `used_percent: 150` CLAMPED to 100 by +`normalizePercent` (quota-wire clamps rather than rejects - assert the clamp); missing `subscription`; non-object; `tier` never surfaced; +`updatedAt` local. + +`tests/muse-passive-quota-cache.test.ts` — `recordPassiveAccountQuota` writes under the +serving account key; a generation bump discards the write; the row persists and rehydrates; +`hasPassiveAccountQuota("meta-muse")` true while `supportsPerAccountQuota("meta-muse")` +stays **false** (the exfiltration guard from wp4 must survive this phase). + +`tests/relay-sse-subscription-usage.test.ts` — the inspector invokes the handler for a +recorded transcript containing the event, does not invoke it for one without, and is +unaffected when the handler is absent. + +No live call, no real Keychain, in any test. + +## Verification + +```bash +bun test tests/muse-subscription-usage.test.ts tests/muse-passive-quota-cache.test.ts \ + tests/relay-sse-subscription-usage.test.ts tests/meta-muse-oauth.test.ts +bun run test:changed +bun x tsc --noEmit +bun run privacy:scan +bun run lint:gui +cd gui && bun run build +``` + +## Terminal outcome + +`DONE` when a streaming `meta-muse` turn populates the account's 5-hour and weekly +percentages, the dashboard shows them with their observation age, a restart preserves the +last observation, and no code path issues an inference call to refresh a quota. diff --git a/docs-site/src/content/docs/guides/cursor-private-inference.md b/docs-site/src/content/docs/guides/cursor-private-inference.md index e3b98e27dc..5b21698637 100644 --- a/docs-site/src/content/docs/guides/cursor-private-inference.md +++ b/docs-site/src/content/docs/guides/cursor-private-inference.md @@ -34,6 +34,26 @@ Read this section first; it is the part people miss. and leave "Import data from existing Cursor installation" unchecked on first run unless you want your settings copied. +## Identify the installed build + +Both builds are named "Cursor" in the Dock and share a bundle id, so check `product.json`: + +| Platform | product.json | +|---|---| +| macOS | `/Applications/Cursor Private Inference.app/Contents/Resources/app/product.json` | +| Windows | `%LOCALAPPDATA%\\Programs\\cursor-private-inference\\resources\\app\\product.json` | +| Linux | `/resources/app/product.json` (an AppImage must be extracted first) | + +`nameLong` is `"Cursor Private Inference"` for the local-agent build and `"Cursor"` for the +regular one; `version` is the build (3.18.25 at the time of writing). The dashboard's +Integrations > Cursor card runs the same check and lists what it found. Local mode is switched +on inside the workbench bundle, not in `product.json`, so there is no flag to flip: if +`nameLong` says regular Cursor, that install cannot reach a loopback gateway. + +The agent loop that talks to the gateway lives in one file under the same install root, +`extensions/cursor-agent-exec/dist/main.js`. opencodex reads it (read-only, bounded) to learn +Cursor's reasoning-effort table; see "Models and reasoning effort". + ## Configure the gateway opencodex needs to be running (`ocx service status`). Then either of these works; both @@ -54,9 +74,17 @@ rows you want. ```text CURSOR_LOCAL_AGENT_BASE_URL=http://127.0.0.1:10100/v1 CURSOR_LOCAL_AGENT_API_KEY=opencodex-loopback -CURSOR_LOCAL_AGENT_HEADERS= # optional, extra headers as key=value pairs +CURSOR_LOCAL_AGENT_HEADERS= # optional, newline-separated "Header-Name: value" lines ``` +`CURSOR_LOCAL_AGENT_HEADERS` rejects `User-Agent` and unresolved `{...}` placeholders; +`{gitOrgRepo}` and `{gitBranch}` are expanded. + +Precedence, highest first: per-model credentials → the gateway saved in Settings → +`CURSOR_LOCAL_AGENT_*` → `ANTHROPIC_BASE_URL` / `ANTHROPIC_AUTH_TOKEN` (compatibility +fallback). The environment does not override a saved gateway; clear it in Settings first if you +intend to switch through the environment. + Cursor Private Inference is a GUI app, so an interactive shell profile is not enough on its own; the variable has to be in the environment of whatever launches the app. @@ -103,8 +131,13 @@ a **Reasoning** control: 1. opencodex must advertise capabilities on the row (`api_types` plus a `capabilities` object). It does, from v2.41. Older proxies show the models but no effort control. -2. The model id, after stripping everything up to the last `/`, must match Cursor's own - effort table. Cursor decides the ladder, not opencodex: +2. The model id, after stripping everything up to the last `/` and any `@…` suffix, must + match Cursor's own effort table. That table is compiled into the app + (`extensions/cursor-agent-exec/dist/main.js`); opencodex reads it from the detected install + so the dashboard prediction follows a Cursor update, and the card says which build it read + or "static mirror" when none was found. Cursor decides the ladder, not opencodex, and no + `/v1/models` field can add a model to that table. The matrix below is the 3.18.25 snapshot + the static mirror carries: | Model id (after the last `/`) | Ladder Cursor shows | Wire field | |---|---|---| @@ -117,9 +150,24 @@ a **Reasoning** control: | anything else, including `claude-fable-5-1`, `kimi-k3` | no control | — | So `anthropic/claude-opus-5` works, and opencodex's `max`/`ultra` tiers for GPT-5.6 are not -reachable from this picker. For a model with no control, set a default in opencodex instead -(`modelDefaultReasoningEfforts` on the provider); that default applies when Cursor sends no -effort. +reachable from this picker. + +### Models with no control + +`anthropic/claude-fable-5-1`, `cursor/kimi-k3`, and anything else outside the table get no +Reasoning control, and Cursor logs one line per such id when the gateway advertises +`supports_reasoning`: "Local provider advertises reasoning support for a model with no +hardcoded Bottlerocket effort family". Two ways to still choose an effort: + +- **Effort rows** (`cursorEffortRows: true` in the opencodex config, default off): the gateway + publishes one picker entry per effort for table-less models, such as + `anthropic/claude-fable-5-1--high` or `cursor/kimi-k3--max`, and routes each to the base + model with that effort applied. Models Cursor already renders get no extra rows, and an exact + known model id always wins over the `--` suffix. Press Refresh model list after + turning it on. The dashboard card counts the rows it published per model. Picking a row is + an explicit choice, so its effort also wins over an `ocx-effort` directive in the request. +- **A fixed default** (`modelDefaultReasoningEfforts` on the provider): applies when Cursor + sends no effort. ### "Max" is two different things @@ -137,6 +185,13 @@ and the GPT-5.6 entry stops at Extra High. Because opencodex advertises `responses` in `api_types`, this build sends agent turns to `/v1/responses` with `reasoning.effort`, not to `/v1/chat/completions`. +That wire choice has a side effect for Claude rows: Cursor sends Claude effort only as +`output_config.effort` on the Anthropic Messages wire, so with a `/v1` Base URL a Claude row +that does show a control still runs at the provider default. A Base URL ending in `/messages` +reverses it: Claude effort is sent and OpenAI-family effort is dropped. One gateway entry cannot +serve both families; effort rows (above) side-step this because opencodex applies the effort +itself. + ## Verify `ocx observe logs` shows the turns as `inboundProtocol: responses` with `admissionKind: loopback`. @@ -145,6 +200,6 @@ Because opencodex advertises `responses` in `api_types`, this build sends agent |---|---| | 401 from the gateway | the API Key does not match `OPENCODEX_API_AUTH_TOKEN`; for a loopback bind without API auth any value works | | picker is empty | opencodex is not running, or the Base URL is missing `/v1`; press Refresh model list after fixing | -| models listed but no Reasoning control | opencodex older than v2.41, or the model id is not in the table above | -| a schema change is not picked up | Cursor caches `/models` per Base URL string; restart the app, or temporarily save a different spelling of the URL (`localhost` vs `127.0.0.1`) and refresh | +| models listed but no Reasoning control | opencodex older than v2.41, or the id is not in Cursor's table (the dashboard marks it —); turn on `cursorEffortRows` or set a provider default | +| a schema change is not picked up | Cursor caches `/models` per Base URL string with no expiry; Refresh model list re-reads it, otherwise restart the app or temporarily save a different spelling of the URL (`localhost` vs `127.0.0.1`) | | 23k-token first turn | expected; that is Cursor's local system prompt | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 3a3276c602..73f7f20620 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -343,6 +343,8 @@ 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` | +| Meta Muse Code (CLI credential) | `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 +439,45 @@ 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`. + +**Meta Muse Code (`meta-muse`).** If you already use the Muse Code CLI, this imports the +API key it stored after `muse login` instead of asking you to provision a second one. +macOS only — the CLI keeps that key in the macOS Keychain, and no other platform's +storage has been verified. OpenCodex never launches the CLI: if no credential is present +it tells you to run `muse login` yourself. + +**Read this before enabling it.** Meta scopes that credential to the Muse Code CLI, so +using it here is an *unsupported* path. Meta does not authorize subscription coverage +outside its own client, how these calls settle is not observable from the API, and you +should treat every call as billable against your account. The imported key is copied into +OpenCodex's auth store (`~/.opencodex/auth.json`, mode 0600) like every other OAuth +credential. The dashboard shows a Terms-of-Service warning before the first login and +before any reauthentication — the same treatment Anthropic and Google Antigravity get. + +Meta reports subscription window usage inside streaming responses, but OpenCodex does not +yet read or display it, and there is no endpoint to query it on demand, so this provider +shows no quota. Rate limits apply per team, not per key. + +For a supported setup, use `meta-model` above with your own key. + **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/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 0606129f39..d0c79d272e 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -27,7 +27,7 @@ a missing credential produces no sidecar plan and the request takes the normal r | Backend | Runs | Credential | Notes | | --- | --- | --- | --- | | `xai` | Grok hosted `web_search` (+ opt-in `x_search`) on `api.x.ai` Responses | Stored Grok OAuth (`ocx login xai`) | `webSearchSidecar.xSearch` enables X search with `allowedXHandles`/`excludedXHandles` (max 20, mutually exclusive) and ISO `fromDate`/`toDate`. Default model `grok-4.6`. | -| `gemini` | `google_search` grounding on the Antigravity transport | Stored Antigravity OAuth with a discovered project (`ocx login google-antigravity`) | Default model `gemini-3.7-flash`; reasoning maps to the tiered thinking level. | +| `gemini` | `google_search` grounding on the Antigravity transport | Stored Antigravity OAuth with a discovered project (`ocx login google-antigravity`) | Default model `gemini-3.8-flash`; reasoning selects the matching tier. | | `exa` | Exa Search API (non-LLM result digest) | `webSearchSidecar.exaApiKey` | The key is write-only through the management API (never echoed, redacted from logs). No sidecar model applies. | ## Web-search sidecar diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 46d34a84e5..3f3e286863 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -486,4 +486,4 @@ publishes them to npm. ## Remote Hub client lifecycle -Use `ocx connect --pairing-code-stdin`, `ocx connect status`, `ocx sync`, and `ocx connect rotate --pairing-code-stdin`. `ocx disconnect` restores local state offline and does not revoke the hub key. While connected only, `ocx connect revoke --admin-token-stdin` revokes the persisted `apiKeyId`; after disconnect use the hub's **Integrations → API Keys** page. Secrets are stdin-only and never belong in argv. +Use `ocx connect --pairing-code-stdin`, `ocx connect status`, `ocx sync`, and `ocx connect rotate --pairing-code-stdin`. The initial catalog download fails after five seconds without incoming bytes, but active transfers may run longer; use `--catalog-timeout ` (1–120) to override that inactivity window. `ocx disconnect` restores local state offline and does not revoke the hub key. While connected only, `ocx connect revoke --admin-token-stdin` revokes the persisted `apiKeyId`; after disconnect use the hub's **Integrations → API Keys** page. Secrets are stdin-only and never belong in argv. diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 29e0c59053..fb28e8055a 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -48,6 +48,15 @@ Aliases are optional short request names. They never change the native model id Aliases match case-insensitively. A model alias works as `or/opus` or, when globally unique, bare `opus`; an ambiguous bare alias reports its qualified candidates. Codex model pickers show the qualified alias while preserving the canonical `provider/model` routing id. A provider's `defaultAliases` value overrides `defaultModelAliases`. Built-ins are skipped when multiple models in one provider match the same pattern. +### Cursor effort rows + +`cursorEffortRows` is an optional boolean and defaults to `false`. When enabled, the raw OpenAI-style +`/v1/models` list adds `--` selectors for reasoning-capable models that Cursor Private +Inference does not match in its installed effort table. Selecting a generated row routes the base model +and applies that row's effort; models Cursor already recognizes receive no variants. The flag reserves a +terminal `--` suffix for generated selectors, except when the complete value is already +a known configured model id. Cursor may require a model-list refresh or restart after this setting changes. + Valid values in `config.json` override built-in defaults. Missing optional fields use the defaults documented on the domain pages. `OPENCODEX_HOME` takes precedence over the default configuration directory. Fields that accept an environment reference, such as `apiKey: "${PROVIDER_API_KEY}"`, diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index ffc26dbc90..716636bfdc 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -124,7 +124,7 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | `GET /api/debug/usage-logs` | Read bounded usage-debug entries | — | | `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — | | `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — | -| `GET /api/usage` | Summarize usage by range and client surface; Codex responses also include an `accounts` breakdown keyed by stable non-PII log labels | Returns an `error: "read_failed"` summary if storage cannot be read | +| `GET /api/usage` | Stream the complete usage ledger into compact aggregates, then incrementally fold verified appends; summarize by range and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | Returns an `error: "read_failed"` summary if storage cannot be read | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | | `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | @@ -135,6 +135,25 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable | +`GET /api/usage` reads `~/.opencodex/usage.jsonl` from the beginning through the current ledger +snapshot on a cold start. It processes fixed 1 MiB chunks and retains compact aggregate state rather +than every normalized request row. Later refreshes validate the previous line boundary and fold only +newly appended complete rows. Concurrent callers share the same refresh. Range and surface predicates +are applied to the complete aggregate, so the former read-byte window and parsed-row cap cannot omit +an earlier file prefix from 7-day, 30-day, or all-history totals. `managementUsageMaxReadBytes` remains +accepted for compatibility with bounded legacy readers, but changing it no longer expands or reduces +the history summarized by this endpoint. + +The runtime ledger is append-only. Replacing or truncating it, or changing local pricing/time-zone +inputs, triggers a complete rebuild. If you manually edit an older row in place while the proxy is +running, restart the proxy (or replace the file) before relying on the new total; incremental refreshes +verify the append boundary, not every previously aggregated byte. + +The response still includes `historyTruncated`, `truncatedPrefixBytes`, `entriesTruncated`, and +`entriesDropped` so older clients can consume the same wire shape. A successful whole-ledger scan +reports `false`, `0`, `false`, and `0`, respectively. These are legacy compatibility fields, not a +signal that the endpoint read only a configured-size tail. + For `GET /api/usage?range=30d&surface=codex`, `accounts` contains one row per observed Codex pool label. Each row reports `accountLogLabel`, token totals, `usageCoverageRatio`, and an optional `estimatedCostUsd` based on the currently configured display pricing. Active user `modelCosts` diff --git a/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md b/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md index e233e7bae0..a62ce0e80d 100644 --- a/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md +++ b/docs-site/src/content/docs/troubleshooting/disk-usage-temp-files.md @@ -63,6 +63,12 @@ still owner-only. A fresh process rewrites an identical snapshot once, and a fil whose contents or permissions changed underneath the proxy is rewritten through the hardening path rather than left alone. +Each ordinary background cadence performs at most one full atomic rewrite. If the +continuation cache changes while that write is in progress, opencodex schedules one +follow-up on the normal delayed cadence instead of rewriting the whole snapshot again +immediately. Graceful shutdown keeps its bounded retry behavior after in-flight +requests have drained so the final snapshot can catch up before the process exits. + Together these keep the write rate roughly flat as the cache grows, instead of re-serializing and replacing the whole file every two seconds. diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index 43b1e1a92e..1fc7c57857 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -222,3 +222,27 @@ neighbouring brand's, is a misattribution that outlives the commit. `zhipu-bigmodel` and `zhipu-bigmodel-coding` share `zai.svg`: Z.AI and BigModel are the same company, and the mainland console publishes only the wordmark. + +## Meta (2026-09-03) + +- `meta.svg` — the `aria-label="Meta symbol"` inline SVG that `dev.meta.ai` + renders in its own navigation header, read 2026-09-03 through a signed-in + browser session. Meta publishes no square vector at the conventional paths: + `dev.meta.ai/favicon.svg`, `/icon.svg` and `/logo.svg` all 404, and the + site's declared icon is a 32x32 `.ico` on `static.xx.fbcdn.net`. The rendered + header mark is therefore the first-party vector, taken from the developer + console the two providers actually belong to. + + Path data and gradient stops are verbatim. Three normalizations: React's + generated gradient ids (`_r_d_`, `_r_e_`, `_r_f_`) become + `meta-mark-a/-b/-c`, because a generated id collides when several marks are + inlined into one document — the same reason `minimax.svg` renamed its + `未命名的渐变_6`; the presentational `height`/`width`/`role`/`aria-label` + are dropped in favour of the `viewBox`; and `xmlns` is added so the file + stands alone. + + Wired to both `meta-model` (the direct Meta Model API provider) and + `meta-muse` (the Muse Code credential import). One brand, two credentials — + the same shape as the three Alibaba ids sharing `alibaba-color.svg`. + **Not masked:** three linear gradients in Meta brand blue + (#0064E0 -> #0278F1), and masking flattens a gradient to a single ink. diff --git a/gui/public/provider-icons/meta.svg b/gui/public/provider-icons/meta.svg new file mode 100644 index 0000000000..59d5570e57 --- /dev/null +++ b/gui/public/provider-icons/meta.svg @@ -0,0 +1 @@ + diff --git a/gui/src/hooks/useJsonConfigEditor.ts b/gui/src/hooks/useJsonConfigEditor.ts index c53a17a496..f27110ab90 100644 --- a/gui/src/hooks/useJsonConfigEditor.ts +++ b/gui/src/hooks/useJsonConfigEditor.ts @@ -3,7 +3,33 @@ import { useCallback, useEffect, useRef, useState } from "react"; export interface Config { port: number; defaultProvider: string; - providers: Record; + providers: Record & { adapter: string; baseUrl: string; hasApiKey?: boolean; hasHeaders?: boolean; xaiResponsesOptInState?: boolean | "mixed" }>; +} + +const PROVIDER_EDITOR_DERIVED_FIELDS = [ + "hasApiKey", + "hasHeaders", + "xaiResponsesOptInState", +] as const; + +type ProviderEditorConfig = { + defaultProvider: string; + providers: Record>; +}; + +const PROVIDER_EDITOR_DERIVED_FIELD_SET = new Set(PROVIDER_EDITOR_DERIVED_FIELDS); + +function projectProviderEditorConfig(config: Config): ProviderEditorConfig { + return { + defaultProvider: config.defaultProvider, + providers: Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => { + const projected: Record = {}; + for (const [field, value] of Object.entries(provider)) { + if (!PROVIDER_EDITOR_DERIVED_FIELD_SET.has(field)) projected[field] = structuredClone(value); + } + return [name, projected]; + })), + }; } export function useJsonConfigEditor(deps: { @@ -25,17 +51,25 @@ export function useJsonConfigEditor(deps: { const jsonEditorOpenRef = useRef(false); useEffect(() => { - if (config && !jsonEditorOpenRef.current) setDraft(JSON.stringify(config, null, 2)); + if (config && !jsonEditorOpenRef.current) setDraft(JSON.stringify(projectProviderEditorConfig(config), null, 2)); }, [config]); const saveConfig = useCallback(async (): Promise => { setJsonSaving(true); + let parsed: unknown; + try { + parsed = JSON.parse(draft); + } catch { + notify(t("prov.invalidJson"), false); + setJsonSaving(false); + return false; + } try { - const parsed = JSON.parse(draft); - const res = await fetch(`${apiBase}/api/config`, { + const baseline = JSON.parse(jsonBaseline) as unknown; + const res = await fetch(`${apiBase}/api/providers`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(parsed), + body: JSON.stringify({ baseline, next: parsed }), }); if (!res.ok) { const data = await res.json().catch(() => ({})) as { error?: string }; @@ -53,15 +87,15 @@ export function useJsonConfigEditor(deps: { onSaved(); return true; } catch { - notify(t("prov.invalidJson"), false); + notify(t("prov.saveFailed"), false); return false; } finally { setJsonSaving(false); } - }, [apiBase, draft, fetchConfig, fetchProviderQuotas, notify, onSaved, t]); + }, [apiBase, draft, fetchConfig, fetchProviderQuotas, jsonBaseline, notify, onSaved, t]); const openJsonEditor = useCallback(() => { - const baseline = config ? JSON.stringify(config, null, 2) : draft; + const baseline = config ? JSON.stringify(projectProviderEditorConfig(config), null, 2) : draft; setJsonBaseline(baseline); setDraft(baseline); setJsonLeaveOpen(false); @@ -73,7 +107,7 @@ export function useJsonConfigEditor(deps: { setJsonLeaveOpen(false); setJsonEditorOpen(false); jsonEditorOpenRef.current = false; - const baseline = config ? JSON.stringify(config, null, 2) : jsonBaseline; + const baseline = config ? JSON.stringify(projectProviderEditorConfig(config), null, 2) : jsonBaseline; setJsonBaseline(baseline); setDraft(baseline); }, [config, jsonBaseline]); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 888bfac8ea..f0eb85fcee 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2391,6 +2391,16 @@ export const de: Record = { "integrations.cursor.neverSeen": "Seit dem Start des Proxys ist keine Anfrage von Cursor eingegangen. Klicken Sie nach dem Speichern des Gateways in Cursor auf Refresh model list.", "integrations.cursor.models": "Was Cursor anzeigt", "integrations.cursor.modelsHint": "Cursor wählt die Reasoning-Abstufung anhand seiner eigenen Modelltabelle aus, daher kann opencodex sie nur vorhersagen. Die Kontextspalte zeigt das Standardfenster und das optionale Fenster (Cursors Max Mode).", + "integrations.cursor.ladderFromBundle": "Reasoning-Abstufungen wurden aus dem installierten Cursor-Private-Inference-Bundle {version} gelesen. Cursor legt sie fest; opencodex gibt nur dessen Tabelle wieder.", + "integrations.cursor.ladderFromStatic": "Reasoning-Abstufungen sind ein statischer Spiegel von Cursor 3.18.25 (kein lesbares Private-Inference-Bundle gefunden). Die Kontextspalte zeigt das Standardfenster und das optionale Fenster.", + "integrations.cursor.unknownVersion": "unbekannte Version", + "integrations.cursor.noControl": "—", + "integrations.cursor.singleWindow": "ein Fenster", + "integrations.cursor.noControlTitle": "Diese ID steht nicht in Cursors eingebauter Effort-Tabelle, daher zeigt Cursor keine Reasoning-Steuerung an.", + "integrations.cursor.effortRowsOne": "1 Effort-Zeile veröffentlicht", + "integrations.cursor.effortRowsMany": "{n} Effort-Zeilen veröffentlicht", + "integrations.cursor.effortRowsOff": "keine Effort-Zeilen", + "integrations.cursor.tableLessHint": "Mit — markierte Zeilen erhalten in Cursor keine Reasoning-Steuerung. Aktivieren Sie cursorEffortRows, um pro Effort einen Picker-Eintrag (id--effort) zu veröffentlichen, oder setzen Sie modelDefaultReasoningEfforts beim Provider für einen festen Standard.", "integrations.cursor.colModel": "Modell", "integrations.cursor.colReasoning": "Reasoning-Aufwand", "integrations.cursor.colContext": "Kontext", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0be94fe83b..8c49a81248 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1567,6 +1567,16 @@ export const en = { "integrations.cursor.neverSeen": "No request from Cursor since the proxy started. After saving the gateway, press Refresh model list in Cursor.", "integrations.cursor.models": "What Cursor will show", "integrations.cursor.modelsHint": "Cursor picks the Reasoning ladder from its own model table, so opencodex can only predict it. Context lists the default and the opt-in window (Cursor's Max Mode).", + "integrations.cursor.ladderFromBundle": "Reasoning ladders read from the installed Cursor Private Inference {version} bundle. Cursor decides them; opencodex only reports its table.", + "integrations.cursor.ladderFromStatic": "Reasoning ladders are a static mirror of Cursor 3.18.25 (no readable Private Inference bundle was found). Context lists the default and the opt-in window.", + "integrations.cursor.unknownVersion": "unknown version", + "integrations.cursor.noControl": "—", + "integrations.cursor.singleWindow": "single window", + "integrations.cursor.noControlTitle": "This id is not in Cursor's built-in effort table, so Cursor shows no Reasoning control.", + "integrations.cursor.effortRowsOne": "1 effort row published", + "integrations.cursor.effortRowsMany": "{n} effort rows published", + "integrations.cursor.effortRowsOff": "no effort rows", + "integrations.cursor.tableLessHint": "Rows marked — get no Reasoning control in Cursor. Turn on cursorEffortRows to publish one picker entry per effort (id--effort), or set modelDefaultReasoningEfforts on the provider for a fixed default.", "integrations.cursor.colModel": "Model", "integrations.cursor.colReasoning": "Reasoning", "integrations.cursor.colContext": "Context", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index ee0ffe0fde..a05872c155 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2378,6 +2378,16 @@ export const fr: Record = { "integrations.cursor.neverSeen": "Aucune requête de Cursor depuis le démarrage du proxy. Après avoir enregistré la passerelle, cliquez sur Refresh model list dans Cursor.", "integrations.cursor.models": "Ce que Cursor affichera", "integrations.cursor.modelsHint": "Cursor sélectionne le niveau de raisonnement dans sa propre table de modèles ; opencodex ne peut donc que le prévoir. La colonne Contexte indique la fenêtre par défaut et celle disponible en option (le Max Mode de Cursor).", + "integrations.cursor.ladderFromBundle": "Les niveaux de raisonnement sont lus dans le bundle Cursor Private Inference {version} installé. Cursor les décide ; opencodex ne fait que rapporter sa table.", + "integrations.cursor.ladderFromStatic": "Les niveaux de raisonnement sont un miroir statique de Cursor 3.18.25 (aucun bundle Private Inference lisible trouvé). La colonne Contexte indique la fenêtre par défaut et la fenêtre optionnelle.", + "integrations.cursor.unknownVersion": "version inconnue", + "integrations.cursor.noControl": "—", + "integrations.cursor.singleWindow": "fenêtre unique", + "integrations.cursor.noControlTitle": "Cet identifiant n'est pas dans la table d'effort intégrée de Cursor, donc Cursor n'affiche aucun contrôle de raisonnement.", + "integrations.cursor.effortRowsOne": "1 ligne d'effort publiée", + "integrations.cursor.effortRowsMany": "{n} lignes d'effort publiées", + "integrations.cursor.effortRowsOff": "aucune ligne d'effort", + "integrations.cursor.tableLessHint": "Les lignes marquées — n'ont pas de contrôle de raisonnement dans Cursor. Activez cursorEffortRows pour publier une entrée du sélecteur par effort (id--effort), ou définissez modelDefaultReasoningEfforts sur le fournisseur pour une valeur fixe.", "integrations.cursor.colModel": "Modèle", "integrations.cursor.colReasoning": "Raisonnement", "integrations.cursor.colContext": "Contexte", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 9d3dba4588..ce861c5c47 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2412,6 +2412,16 @@ export const ja: Record = { "integrations.cursor.neverSeen": "プロキシの起動後、Cursor からのリクエストはありません。ゲートウェイを保存したら、Cursor で Refresh model list を押してください。", "integrations.cursor.models": "Cursor に表示される内容", "integrations.cursor.modelsHint": "Cursor は独自のモデルテーブルから推論レベルの段階を決めるため、opencodex が示せるのは予測のみです。コンテキスト欄にはデフォルトとオプトインのウィンドウ(Cursor の Max Mode)を表示します。", + "integrations.cursor.ladderFromBundle": "推論レベルの段階は、インストール済みの Cursor Private Inference {version} バンドルから読み取りました。決めるのは Cursor で、opencodex はその表を表示するだけです。", + "integrations.cursor.ladderFromStatic": "推論レベルの段階は Cursor 3.18.25 の静的ミラーです(読み取れる Private Inference のバンドルが見つかりません)。コンテキスト欄はデフォルトとオプトインのウィンドウを示します。", + "integrations.cursor.unknownVersion": "バージョン不明", + "integrations.cursor.noControl": "—", + "integrations.cursor.singleWindow": "単一ウィンドウ", + "integrations.cursor.noControlTitle": "この ID は Cursor 内蔵の effort 表にないため、Cursor は推論コントロールを表示しません。", + "integrations.cursor.effortRowsOne": "effort 行を 1 件公開", + "integrations.cursor.effortRowsMany": "effort 行を {n} 件公開", + "integrations.cursor.effortRowsOff": "effort 行なし", + "integrations.cursor.tableLessHint": "— の行は Cursor で推論コントロールが使えません。cursorEffortRows を有効にすると effort ごとにピッカー項目(id--effort)を公開できます。固定の既定値はプロバイダーの modelDefaultReasoningEfforts で設定します。", "integrations.cursor.colModel": "モデル", "integrations.cursor.colReasoning": "推論", "integrations.cursor.colContext": "コンテキスト", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 71ebdd8fa5..bf7fe01e24 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2413,6 +2413,16 @@ export const ko: Record = { "integrations.cursor.neverSeen": "프록시 시작 후 Cursor 요청이 없습니다. 게이트웨이 저장 후 Cursor에서 Refresh model list를 누르세요.", "integrations.cursor.models": "Cursor에 표시될 항목", "integrations.cursor.modelsHint": "Reasoning 사다리는 Cursor 자체 모델 표가 정하므로 opencodex는 예측만 합니다. Context는 기본 창과 옵트인 창(Cursor의 Max Mode)입니다.", + "integrations.cursor.ladderFromBundle": "Reasoning 사다리는 설치된 Cursor Private Inference {version} 번들에서 읽었습니다. 사다리는 Cursor가 정하고 opencodex는 그 표를 보여줄 뿐입니다.", + "integrations.cursor.ladderFromStatic": "Reasoning 사다리는 Cursor 3.18.25의 정적 미러입니다(읽을 수 있는 Private Inference 번들을 찾지 못함). Context는 기본 창과 옵트인 창입니다.", + "integrations.cursor.unknownVersion": "버전 미상", + "integrations.cursor.noControl": "—", + "integrations.cursor.singleWindow": "단일 창", + "integrations.cursor.noControlTitle": "이 id는 Cursor 내장 effort 표에 없어서 Cursor가 Reasoning 컨트롤을 보여주지 않습니다.", + "integrations.cursor.effortRowsOne": "effort 행 1개 게시됨", + "integrations.cursor.effortRowsMany": "effort 행 {n}개 게시됨", + "integrations.cursor.effortRowsOff": "effort 행 없음", + "integrations.cursor.tableLessHint": "—로 표시된 행은 Cursor에서 Reasoning 컨트롤이 없습니다. cursorEffortRows를 켜면 effort마다 picker 항목(id--effort)을 하나씩 게시하고, 고정 기본값은 provider의 modelDefaultReasoningEfforts로 정합니다.", "integrations.cursor.colModel": "모델", "integrations.cursor.colReasoning": "추론", "integrations.cursor.colContext": "컨텍스트", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 3cb6f8e10a..19ebbf68b2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2414,6 +2414,16 @@ export const ru: Record = { "integrations.cursor.neverSeen": "Запросов от Cursor не было с момента запуска прокси. После сохранения параметров шлюза нажмите Refresh model list в Cursor.", "integrations.cursor.models": "Что будет отображаться в Cursor", "integrations.cursor.modelsHint": "Cursor выбирает шкалу уровней рассуждений из собственной таблицы моделей, поэтому opencodex может только предсказать её. В столбце «Контекст» указаны окно по умолчанию и дополнительное окно, доступное при включении Max Mode в Cursor.", + "integrations.cursor.ladderFromBundle": "Уровни рассуждения прочитаны из установленного бандла Cursor Private Inference {version}. Их определяет Cursor; opencodex лишь показывает его таблицу.", + "integrations.cursor.ladderFromStatic": "Уровни рассуждения — статическая копия Cursor 3.18.25 (читаемый бандл Private Inference не найден). Столбец «Контекст» показывает окно по умолчанию и опциональное окно.", + "integrations.cursor.unknownVersion": "версия неизвестна", + "integrations.cursor.noControl": "—", + "integrations.cursor.singleWindow": "одно окно", + "integrations.cursor.noControlTitle": "Этого id нет во встроенной таблице усилий Cursor, поэтому Cursor не показывает управление рассуждением.", + "integrations.cursor.effortRowsOne": "опубликована 1 строка усилия", + "integrations.cursor.effortRowsMany": "опубликовано строк усилия: {n}", + "integrations.cursor.effortRowsOff": "строк усилия нет", + "integrations.cursor.tableLessHint": "Строки с — не получают управление рассуждением в Cursor. Включите cursorEffortRows, чтобы публиковать по одной записи выбора на каждое усилие (id--effort), или задайте modelDefaultReasoningEfforts у провайдера для фиксированного значения.", "integrations.cursor.colModel": "Модель", "integrations.cursor.colReasoning": "Рассуждения", "integrations.cursor.colContext": "Контекст", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 2befb8ef27..21c70d54fd 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2414,6 +2414,16 @@ export const tr: Record = { "integrations.cursor.neverSeen": "Proxy başlatıldığından beri Cursor'dan istek alınmadı. Ağ geçidini kaydettikten sonra Cursor'da Refresh model list düğmesine basın.", "integrations.cursor.models": "Cursor'da gösterilecekler", "integrations.cursor.modelsHint": "Cursor, akıl yürütme kademesini kendi model tablosundan seçtiği için opencodex bunu yalnızca tahmin edebilir. Bağlam sütunu varsayılan pencereyi ve isteğe bağlı pencereyi (Cursor'ın Max Mode'u) listeler.", + "integrations.cursor.ladderFromBundle": "Akıl yürütme kademeleri yüklü Cursor Private Inference {version} paketinden okundu. Bunlara Cursor karar verir; opencodex yalnızca tablosunu gösterir.", + "integrations.cursor.ladderFromStatic": "Akıl yürütme kademeleri Cursor 3.18.25'in statik bir kopyasıdır (okunabilir bir Private Inference paketi bulunamadı). Bağlam sütunu varsayılan ve isteğe bağlı pencereyi gösterir.", + "integrations.cursor.unknownVersion": "bilinmeyen sürüm", + "integrations.cursor.noControl": "—", + "integrations.cursor.singleWindow": "tek pencere", + "integrations.cursor.noControlTitle": "Bu kimlik Cursor'ın yerleşik çaba tablosunda yok, bu yüzden Cursor akıl yürütme denetimi göstermez.", + "integrations.cursor.effortRowsOne": "1 çaba satırı yayımlandı", + "integrations.cursor.effortRowsMany": "{n} çaba satırı yayımlandı", + "integrations.cursor.effortRowsOff": "çaba satırı yok", + "integrations.cursor.tableLessHint": "— ile işaretli satırlar Cursor'da akıl yürütme denetimi almaz. Her çaba için bir seçici girdisi (id--effort) yayımlamak üzere cursorEffortRows'u açın veya sabit bir varsayılan için sağlayıcıda modelDefaultReasoningEfforts ayarlayın.", "integrations.cursor.colModel": "Model", "integrations.cursor.colReasoning": "Akıl yürütme", "integrations.cursor.colContext": "Bağlam", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 19d9e4321c..588b09cac0 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2376,6 +2376,16 @@ export const zhTW: Record = { "integrations.cursor.neverSeen": "代理啟動後尚未收到 Cursor 的請求。儲存閘道後,請在 Cursor 按 Refresh model list。", "integrations.cursor.models": "Cursor 會顯示的內容", "integrations.cursor.modelsHint": "Cursor 從自己的模型表決定 Reasoning 階梯,opencodex 只能預測。Context 列出預設與可選的視窗(Cursor 的 Max Mode)。", + "integrations.cursor.ladderFromBundle": "Reasoning 階梯讀取自已安裝的 Cursor Private Inference {version} bundle。階梯由 Cursor 決定,opencodex 只是呈現它的表。", + "integrations.cursor.ladderFromStatic": "Reasoning 階梯是 Cursor 3.18.25 的靜態鏡像(找不到可讀取的 Private Inference bundle)。Context 欄列出預設與可選的視窗。", + "integrations.cursor.unknownVersion": "版本不明", + "integrations.cursor.noControl": "—", + "integrations.cursor.singleWindow": "單一視窗", + "integrations.cursor.noControlTitle": "此 id 不在 Cursor 內建的 effort 表中,因此 Cursor 不會顯示 Reasoning 控制項。", + "integrations.cursor.effortRowsOne": "已發布 1 個 effort 列", + "integrations.cursor.effortRowsMany": "已發布 {n} 個 effort 列", + "integrations.cursor.effortRowsOff": "沒有 effort 列", + "integrations.cursor.tableLessHint": "標為 — 的列在 Cursor 中沒有 Reasoning 控制項。開啟 cursorEffortRows 可為每個 effort 發布一個選擇器項目(id--effort),或在 provider 上設定 modelDefaultReasoningEfforts 作為固定預設值。", "integrations.cursor.colModel": "模型", "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5f3d36b848..136c60b254 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2412,6 +2412,16 @@ export const zh: Record = { "integrations.cursor.neverSeen": "代理启动后尚未收到 Cursor 的请求。保存网关设置后,请在 Cursor 中点击 Refresh model list。", "integrations.cursor.models": "Cursor 将显示的内容", "integrations.cursor.modelsHint": "Cursor 会从自身的模型表中选择推理层级,因此 opencodex 只能进行预测。“上下文”列会列出默认窗口和可选窗口(Cursor 的 Max Mode)。", + "integrations.cursor.ladderFromBundle": "推理档位读取自已安装的 Cursor Private Inference {version} 包。档位由 Cursor 决定,opencodex 只是展示它的表。", + "integrations.cursor.ladderFromStatic": "推理档位是 Cursor 3.18.25 的静态镜像(未找到可读取的 Private Inference 包)。上下文列显示默认窗口和可选窗口。", + "integrations.cursor.unknownVersion": "未知版本", + "integrations.cursor.noControl": "—", + "integrations.cursor.singleWindow": "单一窗口", + "integrations.cursor.noControlTitle": "此 id 不在 Cursor 内置的 effort 表中,因此 Cursor 不显示推理控件。", + "integrations.cursor.effortRowsOne": "已发布 1 个 effort 行", + "integrations.cursor.effortRowsMany": "已发布 {n} 个 effort 行", + "integrations.cursor.effortRowsOff": "无 effort 行", + "integrations.cursor.tableLessHint": "标为 — 的行在 Cursor 中没有推理控件。开启 cursorEffortRows 可为每个 effort 发布一个选择器条目(id--effort),或在提供商上设置 modelDefaultReasoningEfforts 作为固定默认值。", "integrations.cursor.colModel": "模型", "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", diff --git a/gui/src/oauth-tos-risk.ts b/gui/src/oauth-tos-risk.ts index d894d1f121..8d87db55d5 100644 --- a/gui/src/oauth-tos-risk.ts +++ b/gui/src/oauth-tos-risk.ts @@ -7,7 +7,7 @@ */ export type OAuthTosRiskLevel = "high" | "elevated"; -const HIGH_RISK = new Set(["anthropic", "google-antigravity"]); +const HIGH_RISK = new Set(["anthropic", "google-antigravity", "meta-muse"]); const ELEVATED_RISK = new Set(["github-copilot", "cursor"]); export function oauthTosRisk(providerId: string): OAuthTosRiskLevel | null { diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 07aec2b12a..384231f698 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -523,8 +523,12 @@ export default function Logs({ apiBase }: { apiBase: string }) { const rowVirtualizer = useVirtualizer({ count: filteredLogs.length, getScrollElement: () => scrollContainerRef.current, - estimateSize: () => 44, + estimateSize: () => 92, overscan: 15, + getItemKey: index => { + const log = filteredLogs[filteredLogs.length - 1 - index]!; + return log.requestId ?? `${log.timestamp}:${log.model}:${log.provider}`; + }, }); const virtualRows = rowVirtualizer.getVirtualItems(); const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0; @@ -713,6 +717,18 @@ export default function Logs({ apiBase }: { apiBase: string }) { <>
+ + + + + + + + + + + + @@ -739,7 +755,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { const when = formatLogDateParts(log.timestamp, localeTag, serverTimeZone); return ( diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 158497a36c..7bb9009418 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -43,7 +43,12 @@ export default function Providers({ apiBase }: { apiBase: string }) { /** ChatGPT/Codex login from Add Provider → Accounts (uses /api/codex-auth, not /api/oauth). */ const [codexLoginOpen, setCodexLoginOpen] = useState(false); const [modelsRefreshToken, setModelsRefreshToken] = useState(0); - const [oauthTosPending, setOauthTosPending] = useState<{ provider: string; addAccount: boolean } | null>(null); + // `accountId` rides along so acknowledging the warning continues the SAME operation. + // Without it, a reauth that reached the modal would resume as a plain login and target + // the active account instead of the one the user clicked. + const [oauthTosPending, setOauthTosPending] = useState< + { provider: string; addAccount: boolean; accountId?: string } | null + >(null); /** Bumped after OAuth login so ProviderDetails switches to the Accounts tab. */ const [accountsFocus, setAccountsFocus] = useState<{ token: number; provider: string | null }>({ token: 0, @@ -227,13 +232,20 @@ export default function Providers({ apiBase }: { apiBase: string }) { refreshCodexAccount: () => codexPool.load(true), }); - const requestLoginOAuth = (provider: string, addAccount = false) => { + /** + * The single warning-aware entry point for every OAuth login. + * + * Reauthentication used to call `loginOAuth` directly, so a user who had already logged + * in could refresh a high-risk credential without ever seeing the ToS modal — the map + * gated the first login and nothing after it. + */ + const requestLoginOAuth = (provider: string, addAccount = false, accountId?: string) => { if (busy === provider) return; if (oauthTosRisk(provider)) { - setOauthTosPending({ provider, addAccount }); + setOauthTosPending({ provider, addAccount, ...(accountId ? { accountId } : {}) }); return; } - void loginOAuth(provider, addAccount); + void loginOAuth(provider, addAccount, accountId); }; if (!config) { @@ -367,7 +379,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onLogin: requestLoginOAuth, onCancelLogin: cancelLoginOAuth, onLogout: logoutOAuth, - onReauth: (provider, accountId) => loginOAuth(provider, true, accountId), + onReauth: (provider, accountId) => requestLoginOAuth(provider, true, accountId), onSwitchAccount: switchAccount, onRemoveAccount: removeAccount, onRetryAccounts: async provider => { await fetchAccountSets([provider]); }, @@ -441,7 +453,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const pending = oauthTosPending; if (!pending) return; setOauthTosPending(null); - void loginOAuth(pending.provider, pending.addAccount); + void loginOAuth(pending.provider, pending.addAccount, pending.accountId); }} /> diff --git a/gui/src/pages/integrations/CursorIntegrationPage.tsx b/gui/src/pages/integrations/CursorIntegrationPage.tsx index 1036dc624b..48beef70b5 100644 --- a/gui/src/pages/integrations/CursorIntegrationPage.tsx +++ b/gui/src/pages/integrations/CursorIntegrationPage.tsx @@ -139,7 +139,13 @@ export default function CursorIntegrationPage({ apiBase, active }: { apiBase: st

{t("integrations.cursor.models")}

-

{t("integrations.cursor.modelsHint")}

+

+ {status.effortTable.source === "bundle" + ? t("integrations.cursor.ladderFromBundle", { + version: status.effortTable.version ?? t("integrations.cursor.unknownVersion"), + }) + : t("integrations.cursor.ladderFromStatic")} +

{t("logs.col.time")}
@@ -152,12 +158,41 @@ export default function CursorIntegrationPage({ apiBase, active }: { apiBase: st {status.models.map(model => ( - - + + ))}
{model.id}{model.reasoning ? model.reasoning.join(" · ") : "—"}{model.context ? `${formatTokens(model.context.defaultWindow, locale)} · ${formatTokens(model.context.longWindow, locale)}` : "—"} + {model.reasoning + ? model.reasoning.join(" · ") + : ( + <> + + {t("integrations.cursor.noControl")} + + {model.effortRows.length > 0 + ? ( + + {t( + model.effortRows.length === 1 + ? "integrations.cursor.effortRowsOne" + : "integrations.cursor.effortRowsMany", + { n: model.effortRows.length }, + )} + + ) + : {t("integrations.cursor.effortRowsOff")}} + + )} + {model.context ? `${formatTokens(model.context.defaultWindow, locale)} · ${formatTokens(model.context.longWindow, locale)}` : t("integrations.cursor.singleWindow")}
+ {status.models.some(model => model.tableLess) && ( +

{t("integrations.cursor.tableLessHint")}

+ )}

diff --git a/gui/src/pages/integrations/cursor-api.ts b/gui/src/pages/integrations/cursor-api.ts index f3f23a3538..d1eddcf263 100644 --- a/gui/src/pages/integrations/cursor-api.ts +++ b/gui/src/pages/integrations/cursor-api.ts @@ -12,6 +12,9 @@ export interface CursorSeen { export interface CursorModelExpectation { id: string; reasoning: string[] | null; + family: string | null; + tableLess: boolean; + effortRows: string[]; context: { defaultWindow: number; longWindow: number } | null; } @@ -20,6 +23,7 @@ export interface CursorIntegrationStatus { regularCursor: { installed: boolean; path: string | null }; gateway: { baseUrl: string; apiKeyMode: "credential" | "placeholder"; placeholder: string }; lastSeen: CursorSeen | null; + effortTable: { source: "bundle" | "static"; version: string | null; families: number | null }; models: CursorModelExpectation[]; guideUrl: string; } diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index 3b8607dc75..2efc654c40 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -49,6 +49,7 @@ const OAUTH_LABELS: Record = { xai: "xAI (Grok)", anthropic: "Anthropic (Claude)", kimi: "Kimi (Moonshot)", + "meta-muse": "Meta Muse Code (CLI)", "google-antigravity": "Google Antigravity", "github-copilot": "GitHub Copilot", cursor: "Cursor", diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 9e2c4cda3a..6f84950ce1 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -270,7 +270,7 @@ export function useDashboardData(apiBase: string) { (signal) => fetchDashboardUsage(apiBase, signal), // 30d usage is documented ~5s cold; this shared key has four subscribers, so // every one of them carries the same raised deadline (mount-order independent). - { enabled: overviewReady, deadlineMs: 60_000 }, + { enabled: overviewReady, pollMs: 60_000, deadlineMs: 60_000 }, ); const diagnosticsPoll = useKeyedClientResource( diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index 3192862648..b99cbacd8c 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -27,6 +27,8 @@ const PROVIDER_ICON_ALIASES: Record = { "kimi-code": "kimi-color.svg", kiro: "kiro-color.svg", "lm-studio": "lm-studio-color.svg", + "meta-model": "meta.svg", + "meta-muse": "meta.svg", mistral: "mistral-color.svg", minimax: "minimax.svg", "minimax-cn": "minimax.svg", @@ -121,6 +123,8 @@ const PROVIDER_DISPLAY_NAMES: Record = { "opencode-zen": "OpenCode Zen", mistral: "Mistral", groq: "Groq", + "meta-model": "Meta Model API", + "meta-muse": "Muse Code", alibaba: "Alibaba Coding Plan", "alibaba-token-plan": "Alibaba Token Plan", "alibaba-token-plan-intl": "Alibaba Token Plan (Intl)", diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css index 8b9d958059..b0c438f3a0 100644 --- a/gui/src/styles-integrations.css +++ b/gui/src/styles-integrations.css @@ -176,3 +176,4 @@ .cursor-model-table { width: 100%; border-collapse: collapse; font-size: var(--text-caption); } .cursor-model-table th, .cursor-model-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; } .cursor-model-table th { font-weight: var(--weight-semibold); color: var(--muted); } +.cursor-effort-rows { margin-left: .5rem; font-size: .85em; } diff --git a/gui/src/styles.css b/gui/src/styles.css index 38880aa0aa..1d321fc843 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1992,7 +1992,18 @@ dialog.modal-overlay::backdrop { table.logs-table { width: 100%; min-width: 1100px; -} + table-layout: fixed; +} +.logs-table col.logs-col-time { width: 12%; } +.logs-table col.logs-col-tokens { width: 9%; } +.logs-table col.logs-col-rate { width: 7%; } +.logs-table col.logs-col-cost { width: 8%; } +.logs-table col.logs-col-model { width: 15%; } +.logs-table col.logs-col-effort { width: 9%; } +.logs-table col.logs-col-provider { width: 13%; } +.logs-table col.logs-col-status { width: 8%; } +.logs-table col.logs-col-request { width: 11%; } +.logs-table col.logs-col-duration { width: 8%; } .log-col-rate { min-width: 7ch; white-space: nowrap; } .log-col-cost { min-width: 10ch; white-space: nowrap; } .log-status-cell { display: inline-flex; flex-direction: column; align-items: flex-start; gap: var(--space-0-5); min-width: 7ch; line-height: var(--leading-tight); } @@ -2050,6 +2061,8 @@ table.logs-table { .logs-table-wrap { overflow-y: auto; + overflow-anchor: none; + scrollbar-gutter: stable; /* `dvh`, not `vh`: static `vh` resolves against the LARGE viewport, so on mobile the cap is computed for a viewport taller than the one the user can see and the last rows sit under the browser chrome. The rest of the shell already moved to `100dvh` diff --git a/gui/tests/cursor-integration-page.test.tsx b/gui/tests/cursor-integration-page.test.tsx index fdaabf9d16..1cb160518b 100644 --- a/gui/tests/cursor-integration-page.test.tsx +++ b/gui/tests/cursor-integration-page.test.tsx @@ -33,9 +33,10 @@ function payload(overrides: Partial = {}): CursorIntegr regularCursor: { installed: true, path: "/Applications/Cursor.app" }, gateway: { baseUrl: "http://127.0.0.1:10100/v1", apiKeyMode: "placeholder", placeholder: "opencodex" }, lastSeen: null, + effortTable: { source: "bundle", version: "3.18.25", families: 16 }, models: [ - { id: "gpt-5.6-sol", reasoning: ["low", "medium", "high", "xhigh"], context: { defaultWindow: 272_000, longWindow: 922_000 } }, - { id: "kimi/k3", reasoning: null, context: null }, + { id: "gpt-5.6-sol", reasoning: ["low", "medium", "high", "xhigh"], family: "gpt-5.6", tableLess: false, effortRows: [], context: { defaultWindow: 272_000, longWindow: 922_000 } }, + { id: "kimi/k3", reasoning: null, family: null, tableLess: true, effortRows: [], context: null }, ], guideUrl: "https://example.invalid/guides/cursor-private-inference/", ...overrides, @@ -230,6 +231,42 @@ test("the model table shows the reasoning ladder and both context windows", asyn expect(rows[0]).toContain("922K"); expect(rows[1]).toContain("kimi/k3"); expect(rows[1]).toContain("—"); + expect(rows[1]).toContain("single window"); +}); + +test("the ladder provenance names the installed bundle, and table-less rows get the hint", async () => { + await mount(); + const text = textOf(); + expect(text).toContain("installed Cursor Private Inference 3.18.25 bundle"); + expect(text).toContain("no effort rows"); + expect(container.querySelector("[data-cursor-tableless-hint]")).not.toBeNull(); + const marker = container.querySelector(".cursor-no-control"); + expect(marker?.getAttribute("aria-label")).toContain("not in Cursor's built-in effort table"); +}); + +test("the static mirror is named when no bundle was read, and effort rows are counted", async () => { + statusResponse = () => json(payload({ + effortTable: { source: "static", version: null, families: null }, + models: [ + { id: "gpt-5.6-sol", reasoning: ["low", "medium", "high", "xhigh"], family: null, tableLess: false, effortRows: [], context: null }, + { id: "anthropic/claude-fable-5-1", reasoning: null, family: null, tableLess: true, effortRows: ["anthropic/claude-fable-5-1--low", "anthropic/claude-fable-5-1--high"], context: null }, + { id: "cursor/kimi-k3", reasoning: null, family: null, tableLess: true, effortRows: ["cursor/kimi-k3--max"], context: null }, + ], + })); + await mount(); + const text = textOf(); + expect(text).toContain("static mirror of Cursor 3.18.25"); + expect(text).toContain("2 effort rows published"); + expect(text).toContain("1 effort row published"); +}); + +test("without a table-less row the hint paragraph is absent", async () => { + statusResponse = () => json(payload({ + models: [{ id: "gpt-5.6-sol", reasoning: ["low", "medium", "high", "xhigh"], family: "gpt-5.6", tableLess: false, effortRows: [], context: null }], + })); + await mount(); + expect(container.querySelector("[data-cursor-tableless-hint]")).toBeNull(); + expect(textOf()).not.toContain("no effort rows"); }); test("a failed read is an error notice, never a fake 'not installed'", async () => { diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index c411168daf..6b7ec2c6a6 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -53,7 +53,7 @@ test("Dashboard usage polling cannot delay core health and settings", async () = expect(hook).toContain("fetchDashboardUsage(apiBase, signal)"); expect(hook).toContain("fetchDashboardSidecars"); expect(hook).toContain("fetchDashboardOverview"); - expect(hook).not.toMatch(/usageSummary30dResourceKey\(apiBase\)[\s\S]*pollMs: 60_000/); + expect(hook).toMatch(/usageSummary30dResourceKey\(apiBase\)[\s\S]*pollMs: 60_000/); }); test("Dashboard interactive controls load independently of health/providers", async () => { diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index f1c3511e95..037b03671a 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -15,6 +15,7 @@ const PLACEHOLDER_RE = /\{([a-zA-Z0-9_]+)\}/g; const INTENTIONAL_ENGLISH = new Set([ // Units, symbols, protocol values, machine labels, and product names. + "integrations.cursor.noControl", "uptime.hour", "uptime.second", // "auto" is the same word in French, and it labels a machine-derived alias source rather diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index c6bd877265..8a718e2651 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -30,6 +30,8 @@ async function readDict(locale: string): Promise> { // gap. Anything *not* on this list that ships an English-identical value is treated as a stale // placeholder and fails the build. const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ + // A bare em dash: the "no Reasoning control" marker is a symbol, not copy. + "integrations.cursor.noControl", // API protocol/endpoint names "api.chatCompletionsEndpoint", "api.messagesEndpoint", @@ -204,6 +206,8 @@ test("every locale key set matches the English source", async () => { const CURSOR_KEEP_ENGLISH: ReadonlySet = new Set([ "integrations.tab.cursor", "integrations.cursor.title", + // Em-dash marker, identical in every locale. + "integrations.cursor.noControl", "integrations.cursor.privateInference", "integrations.cursor.baseUrl", // "API Key" is the literal field name in Cursor's gateway form. diff --git a/gui/tests/logs-auto-refresh.test.tsx b/gui/tests/logs-auto-refresh.test.tsx index 758eeb14fa..5be3656b0d 100644 --- a/gui/tests/logs-auto-refresh.test.tsx +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -162,6 +162,33 @@ function expectTableLoaded(container: HTMLElement, model: string): void { expect(container.textContent).toContain(model); } +test("Logs: renders the ordered ten-column layout schema", async () => { + globalThis.fetch = (async (input) => { + if (!String(input).includes("/api/logs")) return new Response(null, { status: 404 }); + return jsonResponse([sampleLog]); + }) as typeof fetch; + + const { root, container } = await mountLogs(); + await flushMicrotasks(); + + const colgroup = container.querySelector(".logs-table > colgroup"); + expect(colgroup).not.toBeNull(); + expect([...colgroup!.children].map(column => column.className)).toEqual([ + "logs-col-time", + "logs-col-tokens", + "logs-col-rate", + "logs-col-cost", + "logs-col-model", + "logs-col-effort", + "logs-col-provider", + "logs-col-status", + "logs-col-request", + "logs-col-duration", + ]); + + await act(async () => { root.unmount(); }); +}); + test("Logs: initial failure shows error; silent failure keeps it; retry then recovers", async () => { const calls: string[] = []; let mode: "fail" | "ok" = "fail"; diff --git a/gui/tests/oauth-tos-warning-gate.test.tsx b/gui/tests/oauth-tos-warning-gate.test.tsx new file mode 100644 index 0000000000..e2dca17ac5 --- /dev/null +++ b/gui/tests/oauth-tos-warning-gate.test.tsx @@ -0,0 +1,121 @@ +/** + * The ToS warning must gate EVERY OAuth login path, not just the first one. + * + * The root suite's seam test greps source text, and it passed for months while + * reauthentication called `loginOAuth` directly — so a user who had already logged in + * could refresh a high-risk credential without ever seeing the modal. Source-string + * assertions cannot catch that; this exercises the real decision function instead. + * + * It mirrors `requestLoginOAuth` in `Providers.tsx`: same risk lookup, same pending + * state, same continuation. If that function stops consulting `oauthTosRisk`, or drops + * `accountId` from the pending state, the corresponding case here fails. + */ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { oauthTosRisk } from "../src/oauth-tos-risk"; + +/** GUI tests run with `gui/` as cwd, so resolve page paths relative to this file. */ +const PROVIDERS_PAGE = join(import.meta.dir, "..", "src", "pages", "Providers.tsx"); + +interface Pending { + provider: string; + addAccount: boolean; + accountId?: string; +} + +/** A standalone model of the component's gate, exercised without mounting the page. */ +function createGate() { + const logins: Array<{ provider: string; addAccount: boolean; accountId?: string }> = []; + let pending: Pending | null = null; + + const loginOAuth = (provider: string, addAccount = false, accountId?: string) => { + logins.push({ provider, addAccount, ...(accountId ? { accountId } : {}) }); + }; + + const requestLoginOAuth = (provider: string, addAccount = false, accountId?: string) => { + if (oauthTosRisk(provider)) { + pending = { provider, addAccount, ...(accountId ? { accountId } : {}) }; + return; + } + loginOAuth(provider, addAccount, accountId); + }; + + const acknowledge = () => { + const p = pending; + if (!p) return; + pending = null; + loginOAuth(p.provider, p.addAccount, p.accountId); + }; + + return { logins, requestLoginOAuth, acknowledge, cancel: () => { pending = null; }, pending: () => pending }; +} + +describe("meta-muse sits in the high-risk map", () => { + test("is flagged high, like the other vendor-restricted subscription logins", () => { + expect(oauthTosRisk("meta-muse")).toBe("high"); + expect(oauthTosRisk("META-MUSE")).toBe("high"); + }); + + test("the supported key provider is NOT flagged", () => { + // meta-model uses the user's own key on a documented endpoint: no ToS risk to warn about. + expect(oauthTosRisk("meta-model")).toBeNull(); + }); +}); + +describe("every login path is gated for a high-risk provider", () => { + for (const [label, invoke] of [ + ["plain login", (g: ReturnType) => g.requestLoginOAuth("meta-muse")], + ["add account", (g: ReturnType) => g.requestLoginOAuth("meta-muse", true)], + ["reauthentication", (g: ReturnType) => g.requestLoginOAuth("meta-muse", true, "acct-1")], + ] as const) { + test(`${label}: no login before acknowledgement, exactly one after`, () => { + const gate = createGate(); + invoke(gate); + expect(gate.logins).toHaveLength(0); + expect(gate.pending()).not.toBeNull(); + + gate.acknowledge(); + expect(gate.logins).toHaveLength(1); + }); + + test(`${label}: cancelling never logs in`, () => { + const gate = createGate(); + invoke(gate); + gate.cancel(); + gate.acknowledge(); + expect(gate.logins).toHaveLength(0); + }); + } + + /* + * Without accountId in the pending state, acknowledging a reauth resumes as a plain + * add-account login and targets the wrong account. + */ + test("reauthentication continues the SAME operation after acknowledgement", () => { + const gate = createGate(); + gate.requestLoginOAuth("meta-muse", true, "acct-42"); + gate.acknowledge(); + expect(gate.logins[0]).toEqual({ provider: "meta-muse", addAccount: true, accountId: "acct-42" }); + }); + + test("an unflagged provider is not gated at all", () => { + const gate = createGate(); + gate.requestLoginOAuth("kimi"); + expect(gate.logins).toHaveLength(1); + expect(gate.pending()).toBeNull(); + }); +}); + +describe("the page wires reauthentication through the gate", () => { + test("onReauth calls requestLoginOAuth, not loginOAuth", async () => { + const page = await Bun.file(PROVIDERS_PAGE).text(); + const onReauth = page.slice(page.indexOf("onReauth:"), page.indexOf("onReauth:") + 120); + expect(onReauth).toContain("requestLoginOAuth"); + expect(onReauth).not.toContain("loginOAuth(provider"); + }); + + test("the pending state carries accountId through to the continuation", async () => { + const page = await Bun.file(PROVIDERS_PAGE).text(); + expect(page).toContain("pending.accountId"); + }); +}); diff --git a/gui/tests/provider-icons.test.ts b/gui/tests/provider-icons.test.ts index 5a0495c453..b0609255b8 100644 --- a/gui/tests/provider-icons.test.ts +++ b/gui/tests/provider-icons.test.ts @@ -74,3 +74,21 @@ test("the MiniMax and Xiaomi MiMo provider ids resolve to their brand's mark", ( // The precedent that makes the two above consistent rather than novel. expect(providerIconSrc("mimo-free")).toBe("/provider-icons/xiaomi-color.svg"); }); + +/* + * One brand, two credentials. + * + * `meta-model` is Meta's own pay-as-you-go Model API and `meta-muse` imports the + * Muse Code CLI's credential. They are separate providers with separate billing + * and separate ToS risk, but they are the same company's mark -- the same shape + * as the three Alibaba plan ids sharing one asset. + * + * Pinned explicitly rather than left to the generic wiring check above, because + * that check only fires when an asset named after the id is already committed. + * Neither id is `meta`, so a dropped alias row here would restore the fallback + * tile silently. + */ +test("both Meta provider ids resolve to the Meta mark", () => { + expect(providerIconSrc("meta-model")).toBe("/provider-icons/meta.svg"); + expect(providerIconSrc("meta-muse")).toBe("/provider-icons/meta.svg"); +}); diff --git a/gui/tests/use-json-config-editor.test.tsx b/gui/tests/use-json-config-editor.test.tsx new file mode 100644 index 0000000000..787db615e1 --- /dev/null +++ b/gui/tests/use-json-config-editor.test.tsx @@ -0,0 +1,176 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { useJsonConfigEditor, type Config } from "../src/hooks/useJsonConfigEditor"; + +const originalFetch = globalThis.fetch; +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; +const originalNavigator = globalThis.navigator; + +const config: Config = { + port: 10100, + defaultProvider: "alpha", + providers: { + alpha: { + adapter: "openai-chat", + baseUrl: "https://alpha.example.test/v1", + defaultModel: "alpha-old", + modelContextWindows: { "alpha-old": 131_072 }, + modelReasoningEfforts: { "alpha-old": ["low", "high"] }, + noVisionModels: ["alpha-old"], + allowPrivateNetwork: true, + hasApiKey: true, + hasHeaders: true, + note: "derived registry note", + }, + beta: { + adapter: "anthropic", + baseUrl: "https://beta.example.test/v1", + hasApiKey: false, + }, + }, +} as Config; + +type Editor = ReturnType; +type RequestRecord = { url: string; method: string; body: unknown }; + +let testWindow: Window; +let host: HTMLElement; +let root: Root | null; +let editor: Editor | null; +let requests: RequestRecord[]; +let responseFactory: () => Promise; +let configRefreshes: number; +let quotaRefreshes: number; +let savedCallbacks: number; +let notifications: Array<{ message: string; ok?: boolean }>; + +function Harness() { + editor = useJsonConfigEditor({ + apiBase: "/editor", + config, + notify: (message, ok) => { notifications.push({ message, ok }); }, + fetchConfig: async () => { configRefreshes += 1; }, + fetchProviderQuotas: async () => { quotaRefreshes += 1; }, + onSaved: () => { savedCallbacks += 1; }, + t: key => key, + }); + return null; +} + +async function mountHook(): Promise { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render(); + }); +} + +beforeEach(() => { + testWindow = new Window({ url: "http://localhost" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + host = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(host as never); + root = null; + editor = null; + requests = []; + configRefreshes = 0; + quotaRefreshes = 0; + savedCallbacks = 0; + notifications = []; + responseFactory = async () => Response.json({ success: true }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: typeof init?.body === "string" ? JSON.parse(init.body) : init?.body, + }); + return responseFactory(); + }) as typeof fetch; +}); + +afterEach(async () => { + if (root) await act(async () => { root?.unmount(); }); + await testWindow.happyDOM?.close?.(); + Object.defineProperties(globalThis, { + document: { configurable: true, value: originalDocument }, + window: { configurable: true, value: originalWindow }, + navigator: { configurable: true, value: originalNavigator }, + }); + globalThis.fetch = originalFetch; +}); + +test("Save sends one atomic provider PUT with baseline and next, then refreshes", async () => { + await mountHook(); + await act(async () => { editor!.openJsonEditor(); }); + + const baseline = { + defaultProvider: "alpha", + providers: { + alpha: { + adapter: "openai-chat", + baseUrl: "https://alpha.example.test/v1", + defaultModel: "alpha-old", + modelContextWindows: { "alpha-old": 131_072 }, + modelReasoningEfforts: { "alpha-old": ["low", "high"] }, + noVisionModels: ["alpha-old"], + allowPrivateNetwork: true, + note: "derived registry note", + }, + beta: { + adapter: "anthropic", + baseUrl: "https://beta.example.test/v1", + }, + }, + }; + expect(JSON.parse(editor!.draft)).toEqual(baseline); + + const next = structuredClone(baseline); + next.defaultProvider = "beta"; + next.providers.alpha.defaultModel = "alpha-new"; + await act(async () => { editor!.setDraft(JSON.stringify(next, null, 2)); }); + + let saved = false; + await act(async () => { saved = await editor!.saveConfig(); }); + + expect(saved).toBe(true); + expect(requests).toEqual([{ + url: "/editor/api/providers", + method: "PUT", + body: { baseline, next }, + }]); + expect(requests.some(request => request.url.endsWith("/api/config") && request.method === "PUT")).toBe(false); + expect(requests.some(request => ["POST", "PATCH", "DELETE"].includes(request.method))).toBe(false); + expect(configRefreshes).toBe(1); + expect(quotaRefreshes).toBe(1); + expect(savedCallbacks).toBe(1); +}); + +test("parse failures stay distinct from server failures and failed saves do not refresh", async () => { + await mountHook(); + await act(async () => { editor!.openJsonEditor(); }); + await act(async () => { editor!.setDraft("{bad json"); }); + + await act(async () => { expect(await editor!.saveConfig()).toBe(false); }); + expect(requests).toHaveLength(0); + expect(notifications.at(-1)).toEqual({ message: "prov.invalidJson", ok: false }); + + await act(async () => { editor!.restoreJsonEditor(); }); + responseFactory = async () => Response.json({ error: "stale baseline" }, { status: 409 }); + await act(async () => { expect(await editor!.saveConfig()).toBe(false); }); + + expect(notifications.at(-1)).toEqual({ message: "stale baseline", ok: false }); + responseFactory = async () => { throw new Error("network down"); }; + await act(async () => { expect(await editor!.saveConfig()).toBe(false); }); + expect(notifications.at(-1)).toEqual({ message: "prov.saveFailed", ok: false }); + expect(configRefreshes).toBe(0); + expect(quotaRefreshes).toBe(0); + expect(savedCallbacks).toBe(0); +}); diff --git a/gui/tests/viewport-scroll-caps.test.ts b/gui/tests/viewport-scroll-caps.test.ts index 78d822ee62..a77c09c48b 100644 --- a/gui/tests/viewport-scroll-caps.test.ts +++ b/gui/tests/viewport-scroll-caps.test.ts @@ -32,6 +32,35 @@ test("the log table caps its scroll height against the dynamic viewport", async expect(wrap).not.toMatch(/max-height:\s*calc\(\s*100vh\s*-/); }); +test("the virtualized log table keeps a fixed ten-column layout", async () => { + const css = withoutComments(await Bun.file(cssUrl).text()); + const columns = [ + ["time", 12], + ["tokens", 9], + ["rate", 7], + ["cost", 8], + ["model", 15], + ["effort", 9], + ["provider", 13], + ["status", 8], + ["request", 11], + ["duration", 8], + ] as const; + + expect(effectiveDeclaration(css, "table.logs-table", "table-layout")).toBe("fixed"); + + const widths = columns.map(([column, expectedWidth]) => { + const width = effectiveDeclaration(css, `.logs-table col.logs-col-${column}`, "width"); + expect(width).toBe(`${expectedWidth}%`); + return Number(width.slice(0, -1)); + }); + expect(widths).toHaveLength(10); + expect(widths.reduce((total, width) => total + width, 0)).toBe(100); + + expect(effectiveDeclaration(css, ".logs-table-wrap", "overflow-anchor")).toBe("none"); + expect(effectiveDeclaration(css, ".logs-table-wrap", "scrollbar-gutter")).toBe("stable"); +}); + test("the toast width cap outranks the later .notice rule", async () => { const css = withoutComments(await Bun.file(cssUrl).text()); diff --git a/package.json b/package.json index e3bc6b777a..78ad559e54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.40.0", + "version": "2.41.0-preview.20260903", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/scripts/model-metadata.source.json b/scripts/model-metadata.source.json index 654e324285..8cc73ca1c3 100644 --- a/scripts/model-metadata.source.json +++ b/scripts/model-metadata.source.json @@ -12062,6 +12062,25 @@ "maxLevel": "high" } }, + "gemini-3.8-flash": { + "id": "gemini-3.8-flash", + "name": "Gemini 3.8 Flash", + "api": "google-generative-ai", + "provider": "google", + "baseUrl": "https://generativelanguage.googleapis.com/v1beta", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 1048576, + "maxTokens": 65536, + "thinking": { + "mode": "google-level", + "minLevel": "low", + "maxLevel": "high" + } + }, "gemini-flash-latest": { "id": "gemini-flash-latest", "name": "Gemini Flash Latest", diff --git a/scripts/privacy-scan.ts b/scripts/privacy-scan.ts index f6dbcc6618..47bb733779 100644 --- a/scripts/privacy-scan.ts +++ b/scripts/privacy-scan.ts @@ -184,8 +184,14 @@ function addFindingsForPattern( } } -function scanFile(file: string): Finding[] { - const text = readFileSync(file, "utf-8"); +/** + * Scan already-read text. + * + * Split out of `scanFile` so a test can exercise the REAL detectors. This module runs its + * scan on import, so a test that cannot call a function ends up re-declaring the patterns + * instead — and then stays green even if a detector here is deleted. + */ +export function scanText(file: string, text: string): Finding[] { const findings: Finding[] = []; addFindingsForPattern( findings, @@ -221,9 +227,35 @@ function scanFile(file: string): Finding[] { /\b(?:sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9_]{20,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})\b/g, match => isAllowedTokenLooking(file, match[0]), ); + /* + * Meta Model API keys. The pattern above does not match them: the measured shape is + * `LLM|<16 digits>|<27 chars>`, verified against a real key's grammar (never its value). + * The `meta-muse` provider imports one of these, so a leak has to be detectable here. + */ + addFindingsForPattern( + findings, + file, + text, + "meta-api-key", + /\bLLM\|\d+\|[A-Za-z0-9_-]{10,}\b/g, + match => isAllowedTokenLooking(file, match[0]), + ); return findings; } +function scanFile(file: string): Finding[] { + return scanText(file, readFileSync(file, "utf-8")); +} + +/** + * Finding kinds whose matched text is itself a secret. + * + * A home path or an email is context a reviewer needs in the failure message. A bearer + * token or an API key is the very thing the scan exists to keep out of a readable + * artifact, so the report names where it is instead of what it is. + */ +const REDACTED_FINDING_KINDS = new Set(["bearer-token", "token-looking", "meta-api-key"]); + const findings = gitLsFiles() .filter(existsSync) .filter(shouldScan) @@ -232,7 +264,13 @@ const findings = gitLsFiles() if (findings.length > 0) { console.error("Privacy scan failed:"); for (const finding of findings) { - console.error(`${finding.file}:${finding.line} ${finding.kind}: ${finding.value}`); + // A credential finding must not be echoed: this output goes to stderr and into CI + // logs, so printing the match would copy a leaked secret from one place it should + // not be into another — and CI logs are far more widely readable than a diff. + // The location and kind are enough to find it; the value is one `git show` away + // for whoever is fixing it. + const shown = REDACTED_FINDING_KINDS.has(finding.kind) ? "" : finding.value; + console.error(`${finding.file}:${finding.line} ${finding.kind}: ${shown}`); } process.exit(1); } diff --git a/scripts/test.ts b/scripts/test.ts index 3e34655175..529967c56e 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -400,6 +400,10 @@ async function runTestLane( [TEST_RUN_ID_ENV]: runId, [TEST_RUN_LOCK_PATH_ENV]: inheritedLock?.lockPath, [TEST_RUN_LOCK_TOKEN_ENV]: inheritedLock?.ownerToken, + // Lanes run many files in parallel, so a test that shortened a PRODUCT timing budget + // (not its own test timeout) needs headroom for process startup on a busy machine. + // See tests/helpers/ci-watchdog.ts `isolationBudgetMs`. + OCX_TEST_FULL_SUITE: "1", }); const startedAt = Date.now(); let interrupted: NodeJS.Signals | null = null; diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 9951247a6f..46b846d443 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -87,7 +87,8 @@ Connect flags: `--clients codex,claude` (which client configs to point at the hu `--management-url ` (when management lives at a different address), `--management-transport direct|relay` (`relay` tunnels management over the data connection when no management port can be opened), `--no-sync` (connect without pulling -the catalog). +the catalog), and `--catalog-timeout ` (1–120 seconds of catalog-transfer +inactivity before failing; arriving bytes reset the deadline). `ocx gui pair` refuses an origin that is not in `hub.managementPublicOrigin` or `corsAllowOrigins`. Grants are single-use, expire in five minutes, are origin-bound, @@ -160,4 +161,3 @@ Do not work around these. Each one means unwinding would damage state that new one is issued, or a client that has not yet been updated is stranded. - *"Why is there no remote UI on my machine?"* Expected — `runtimeRole` is not `hub`. - *"Can I pass the pairing code as an argument?"* No. Credentials are stdin-only by design. - diff --git a/src/adapters/cursor/catalog.ts b/src/adapters/cursor/catalog.ts index d355708736..f32249f051 100644 --- a/src/adapters/cursor/catalog.ts +++ b/src/adapters/cursor/catalog.ts @@ -1,3 +1,9 @@ +import { + composeCursorClaudeWireId, + normalizeCursorClaudeId, + type NormalizedCursorClaudeId, +} from "./claude-id"; + /** * Cursor umbrella catalog — the single source of truth for cursor model * identities (devlog 260828_cursor_umbrella_catalog). @@ -120,12 +126,8 @@ export const CURSOR_CAPABILITIES: Record = { thinking: { levels: FULL, order: T }, }, }, - // 260902 preemptive: Claude Fable 5.1 seeded ahead of Cursor's lineup update, mirroring - // claude-fable-5 (same 1M window and full effort ladder). Cursor has spelled Claude ids - // both Anthropic-style (`claude-opus-4-7`, thinking-then-effort) and version-first - // (`claude-4.6-opus`, effort-then-thinking), so all three plausible spellings are seeded; - // the live GetUsableModels filter drops whichever the roster does not expose. Collapse to - // the one real spelling once it is observed. + // Claude Fable 5.1 has one canonical capability row. Saved aliases and the live roster's + // exact spelling are normalized and round-tripped at the adapter boundary. "claude-fable-5-1": { displayName: "Claude Fable 5.1", window: CONTEXT_1M, @@ -135,24 +137,6 @@ export const CURSOR_CAPABILITIES: Record = { thinking: { levels: FULL, order: T }, }, }, - "claude-fable-5.1": { - displayName: "Claude Fable 5.1", - window: CONTEXT_1M, - defaultVariant: "thinking", - variants: { - regular: { levels: FULL }, - thinking: { levels: FULL, order: T }, - }, - }, - "claude-5.1-fable": { - displayName: "Claude Fable 5.1", - window: CONTEXT_1M, - defaultVariant: "thinking", - variants: { - regular: { levels: FULL }, - thinking: { levels: FULL, order: E }, - }, - }, "claude-sonnet-5": { displayName: "Claude Sonnet 5", window: CONTEXT_1M, @@ -221,6 +205,12 @@ export const CURSOR_CAPABILITIES: Record = { defaultVariant: "regular", variants: { regular: { levels: ["low", "medium", "high"] } }, }, + "gemini-3.8-flash": { + displayName: "Gemini 3.8 Flash", + window: CONTEXT_GEMINI, + defaultVariant: "regular", + variants: { regular: { levels: ["low", "medium", "high"] } }, + }, "kimi-k3": { displayName: "Kimi K3", window: CONTEXT_1M, @@ -382,13 +372,26 @@ const REAL_1M_WIRE_IDS: ReadonlySet = new Set(["claude-4-sonnet-1m"]); export function parseCursorVariantId(rawId: string): ParsedCursorVariantId { const id = rawId.trim(); + if (REAL_1M_WIRE_IDS.has(id)) { + return { baseId: id, kind: "regular", ultra: false, known: false }; + } + const claude = normalizeCursorClaudeId(id); + if (claude && CURSOR_CAPABILITIES[claude.canonicalBaseId]) { + const explicitVariant = claude.thinking || claude.fast || claude.level !== undefined; + return { + baseId: claude.canonicalBaseId, + kind: explicitVariant + ? claude.thinking ? (claude.fast ? "thinkingFast" : "thinking") : claude.fast ? "fast" : "regular" + : defaultKindFor(claude.canonicalBaseId), + ...(claude.level ? { level: claude.level } : {}), + ultra: false, + known: true, + }; + } // 1. Exact base identity. if (CURSOR_CAPABILITIES[id]) { return { baseId: id, kind: defaultKindFor(id), ultra: false, known: true }; } - if (REAL_1M_WIRE_IDS.has(id)) { - return { baseId: id, kind: "regular", ultra: false, known: false }; - } // 2. cursor- wire prefix (regular grok wire forms). if (id.startsWith("cursor-")) { const inner = parseCursorVariantId(id.slice("cursor-".length)); @@ -537,17 +540,32 @@ export interface CursorResolvedSelection { readonly known: boolean; } +type CursorLiveClaudeWireIdentity = Pick; + /** * Compose a variant's flattened wire id, reproducing the legacy effort-map * order rules exactly (thinking-then-effort / effort-then-thinking / bare; * fast marker terminal; wrong order is ERROR_BAD_MODEL_NAME on the wire). */ -function composeWireId(baseId: string, kind: CursorVariantKind, effort: string | undefined): string { +function composeWireId( + baseId: string, + kind: CursorVariantKind, + effort: string | undefined, + claudeIdentity?: CursorLiveClaudeWireIdentity, +): string { const capability = CURSOR_CAPABILITIES[baseId]; const spec = capability?.variants[kind]; if (!capability || !spec) return baseId; const thinking = kind === "thinking" || kind === "thinkingFast"; const fast = kind === "fast" || kind === "thinkingFast"; + if (claudeIdentity) { + return composeCursorClaudeWireId(claudeIdentity, { + thinking, + fast, + effort, + bareThinking: spec.order === "bare", + }); + } if (thinking) { const order = spec.order ?? "thinking-then-effort"; if (order === "bare" || effort === undefined) return `${baseId}-thinking`; @@ -587,7 +605,12 @@ export function resolveCursorSelection( } const requested = parsed.level ?? reasoning; const effort = cursorVariantEffort(spec, requested); - const canonicalId = composeWireId(parsed.baseId, kind, effort); + const requestedClaude = normalizeCursorClaudeId(pickedId); + const claudeIdentity = liveCursorClaudeWireIdentities.get(parsed.baseId) + ?? (requestedClaude + ? { sourceBaseId: requestedClaude.sourceBaseId, spelling: requestedClaude.spelling } + : undefined); + const canonicalId = composeWireId(parsed.baseId, kind, effort, claudeIdentity); const wireId = capability.wirePrefix && kind === "regular" ? `${capability.wirePrefix}${canonicalId}` : canonicalId; @@ -604,6 +627,25 @@ export function resolveCursorSelection( * arrives — never from window size (devlog 260828 blocker-4 fold). */ let liveCursorMaxModeBases: ReadonlySet = new Set(); +let liveCursorClaudeWireIdentities: ReadonlyMap = new Map(); + +export function recordLiveCursorClaudeModels(liveIds: readonly string[]): void { + const next = new Map(); + for (const rawId of liveIds) { + const n = normalizeCursorClaudeId(rawId.startsWith("cursor-") ? rawId.slice(7) : rawId); + if (!n || !CURSOR_CAPABILITIES[n.canonicalBaseId]) continue; + if (!next.has(n.canonicalBaseId)) next.set(n.canonicalBaseId, { sourceBaseId: n.sourceBaseId, spelling: n.spelling }); + } + liveCursorClaudeWireIdentities = next; +} + +export function liveCursorClaudeWireIdentitiesForTests(): ReadonlyMap { + return liveCursorClaudeWireIdentities; +} + +export function resetLiveCursorClaudeWireIdentitiesForTests(): void { + liveCursorClaudeWireIdentities = new Map(); +} export function recordLiveCursorMaxModeModels(liveIds: readonly string[]): void { const bases = new Set(); diff --git a/src/adapters/cursor/claude-id.ts b/src/adapters/cursor/claude-id.ts new file mode 100644 index 0000000000..9394f6ab97 --- /dev/null +++ b/src/adapters/cursor/claude-id.ts @@ -0,0 +1,76 @@ +export type CursorClaudeSpelling = "anthropic" | "version-first"; + +export interface NormalizedCursorClaudeId { + /** The sole key used by CURSOR_CAPABILITIES and pricing metadata. */ + canonicalBaseId: string; + /** Exact input stem, preserving `5-1` versus `5.1` for wire round-trips. */ + sourceBaseId: string; + spelling: CursorClaudeSpelling; + thinking: boolean; + fast: boolean; + level?: string; +} + +const CLAUDE_LEVELS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "extra-high"]); + +/** Existing picker bases whose canonical key stays version-first (saved configs). */ +const VERSION_FIRST_CANONICAL_BASES = new Set([ + "claude-4.5-haiku", "claude-4.5-opus", "claude-4.6-opus", "claude-4.5-sonnet", "claude-4.6-sonnet", "claude-4-sonnet", +]); + +function parseClaudeBase(raw: string): { canonicalBaseId: string; sourceBaseId: string; spelling: CursorClaudeSpelling } | undefined { + const anthropic = /^claude-(fable|haiku|opus|sonnet)-(\d+(?:[.-]\d+)*)$/.exec(raw); + if (anthropic) { + const family = anthropic[1]!; + const version = anthropic[2]!.replaceAll(".", "-"); + const versionFirst = `claude-${version.replaceAll("-", ".")}-${family}`; + return { + canonicalBaseId: VERSION_FIRST_CANONICAL_BASES.has(versionFirst) ? versionFirst : `claude-${family}-${version}`, + sourceBaseId: raw, + spelling: "anthropic", + }; + } + const versionFirst = /^claude-(\d+(?:\.\d+)*)-(fable|haiku|opus|sonnet)$/.exec(raw); + if (!versionFirst) return undefined; + const sourceBaseId = `claude-${versionFirst[1]!}-${versionFirst[2]!}`; + return { + canonicalBaseId: VERSION_FIRST_CANONICAL_BASES.has(sourceBaseId) ? sourceBaseId : `claude-${versionFirst[2]!}-${versionFirst[1]!.replaceAll(".", "-")}`, + sourceBaseId, + spelling: "version-first", + }; +} + +export function normalizeCursorClaudeId(raw: string): NormalizedCursorClaudeId | undefined { + const id = raw.trim().toLowerCase(); + const patterns: ReadonlyArray { base: string; thinking: boolean; fast: boolean; level?: string }]> = [ + [/^(.*)-thinking-([a-z-]+)-fast$/, m => ({ base: m[1]!, thinking: true, fast: true, level: m[2]! })], + [/^(.*)-([a-z-]+)-thinking-fast$/, m => ({ base: m[1]!, thinking: true, fast: true, level: m[2]! })], + [/^(.*)-thinking-([a-z-]+)$/, m => ({ base: m[1]!, thinking: true, fast: false, level: m[2]! })], + [/^(.*)-([a-z-]+)-thinking$/, m => ({ base: m[1]!, thinking: true, fast: false, level: m[2]! })], + [/^(.*)-([a-z-]+)-fast$/, m => ({ base: m[1]!, thinking: false, fast: true, level: m[2]! })], + [/^(.*)-thinking-fast$/, m => ({ base: m[1]!, thinking: true, fast: true })], + [/^(.*)-thinking$/, m => ({ base: m[1]!, thinking: true, fast: false })], + [/^(.*)-fast$/, m => ({ base: m[1]!, thinking: false, fast: true })], + ]; + for (const [pattern, dims] of patterns) { + const match = pattern.exec(id); + if (!match) continue; + const parsed = dims(match); + if (parsed.level && !CLAUDE_LEVELS.has(parsed.level)) continue; + const base = parseClaudeBase(parsed.base); + if (base) return { ...base, ...parsed, sourceBaseId: base.sourceBaseId }; + } + const base = parseClaudeBase(id); + return base ? { ...base, thinking: false, fast: false } : undefined; +} + +export function composeCursorClaudeWireId( + identity: Pick, + options: { thinking: boolean; fast: boolean; effort?: string; bareThinking?: boolean }, +): string { + const { sourceBaseId: base, spelling } = identity; + const fast = options.fast ? "-fast" : ""; + if (!options.thinking) return options.effort ? `${base}-${options.effort}${fast}` : `${base}${fast}`; + if (options.bareThinking || !options.effort) return `${base}-thinking${fast}`; + return spelling === "version-first" ? `${base}-${options.effort}-thinking${fast}` : `${base}-thinking-${options.effort}${fast}`; +} diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index faf9214c8f..87dd47e923 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -332,14 +332,27 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM ...CURSOR_REAL_ID_EXCEPTIONS, ]); -/** Picker labels for every seeded row, for providers.cursor.modelDisplayNames. */ +/** + * Picker labels for providers.cursor.modelDisplayNames. + * + * Only labels that carry Cursor's own product name ("Cursor Grok 4.6") are published. Every + * other row keeps the routed `cursor/` slug that the rest of the picker uses, so a Cursor + * row reads like its siblings from other providers instead of an unprefixed marketing name. + * #3222 labeled every row and that dropped the `cursor/` prefix from the picker. + */ export function cursorModelDisplayNames(): Record { - return Object.fromEntries([ + const labels: (readonly [string, string])[] = [ ...CURSOR_ROUTER_MODEL_IDS.map(id => [id, CURSOR_ROUTER_DISPLAY_NAMES[id] ?? id] as const), ...cursorUmbrellaRows().map(row => [row.id, row.displayName] as const), ...CURSOR_PRODUCT_MODELS.map(model => [model.id, model.displayName] as const), ...CURSOR_REAL_ID_EXCEPTIONS.map(model => [model.id, model.displayName] as const), - ]); + ]; + return Object.fromEntries(labels.filter(([, label]) => isCursorBrandedLabel(label))); +} + +/** A label Cursor itself brands with its name, e.g. "Cursor Grok 4.6". */ +export function isCursorBrandedLabel(label: string): boolean { + return /^cursor\b/i.test(label.trim()); } export function cursorModelIds(models: readonly CursorModelInfo[] = CURSOR_STATIC_MODELS): string[] { diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index 371e2f40be..2f1bcb8fc9 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -1,3 +1,5 @@ +import { composeCursorClaudeWireId, normalizeCursorClaudeId } from "./claude-id"; + /** * Per-model Cursor reasoning-effort mapping. * @@ -23,11 +25,8 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { // max is always the top tier (canonical order: low < medium < high < xhigh < max), confirmed // against Anthropic's effort ladder docs and Cursor's live model lineup. "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], - // 260902 preemptive: Fable 5.1 seeded ahead of Cursor's lineup update (mirrors fable-5) under - // the three spellings Cursor has used for Claude ids. + // Fable 5.1 aliases normalize onto this sole capability ladder. "claude-fable-5-1": ["low", "medium", "high", "xhigh", "max"], - "claude-fable-5.1": ["low", "medium", "high", "xhigh", "max"], - "claude-5.1-fable": ["low", "medium", "high", "xhigh", "max"], "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], // Opus Fast tiers from the 260822 GetUsableModels dump (devlog .../300): the wire // exposes {base-without-fast}-{effort}-fast only; suffix derivation at the bottom of @@ -43,6 +42,10 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { // listing it here is also what admits the suffix into CANONICAL_EFFORT_SUFFIXES below. "gemini-3.6-flash": ["minimal", "low", "medium", "high"], "gemini-3.7-flash": ["low", "medium", "high"], + // 260903 preemptive: gemini-3.8-flash seeded ahead of Cursor's lineup update, the same way + // glm-5.3 was. Google documents low/medium/high with no `minimal` for this generation, + // unlike 3.6. The seed is inert until Cursor's live roster lists the id. + "gemini-3.8-flash": ["low", "medium", "high"], // Explicit-thinking variants (260825 live roster). Tiers are the rungs the wire actually // lists for each family, which is not always the same set the non-thinking id carries: // 4.6-opus thinks only at high/max, 4.5-opus only at high, 4.6-sonnet only at medium. @@ -55,8 +58,6 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { "claude-sonnet-5-thinking": ["low", "medium", "high", "xhigh", "max"], "claude-fable-5-thinking": ["low", "medium", "high", "xhigh", "max"], "claude-fable-5-1-thinking": ["low", "medium", "high", "xhigh", "max"], - "claude-fable-5.1-thinking": ["low", "medium", "high", "xhigh", "max"], - "claude-5.1-fable-thinking": ["low", "medium", "high", "xhigh", "max"], "claude-4.6-opus-thinking": ["high", "max"], "claude-4.5-opus-thinking": ["high"], "claude-4.6-sonnet-thinking": ["medium"], @@ -122,8 +123,6 @@ const CURSOR_THINKING_FAMILIES: Readonly tierSet.has(effort)); @@ -196,7 +201,7 @@ export function cursorModelEffortLadder(baseModelId: string): string[] | undefin /** Base models known to carry a reasoning-effort suffix (everything else is sent bare). */ export function cursorModelHasEffortTiers(baseModelId: string): boolean { - return (CURSOR_MODEL_EFFORT_TIERS[baseModelId]?.length ?? 0) > 0; + return (CURSOR_MODEL_EFFORT_TIERS[cursorEffortLookupId(baseModelId)]?.length ?? 0) > 0; } /** @@ -205,7 +210,17 @@ export function cursorModelHasEffortTiers(baseModelId: string): boolean { * and send the base model plus requested_model parameters instead. */ export function cursorWireModelIdWithEffort(baseModelId: string, effortSuffix: string): string { - const thinking = CURSOR_THINKING_FAMILIES[baseModelId]; + const lookupId = cursorEffortLookupId(baseModelId); + const thinking = CURSOR_THINKING_FAMILIES[lookupId]; + const claude = normalizeCursorClaudeId(baseModelId); + if (claude) { + return composeCursorClaudeWireId(claude, { + thinking: claude.thinking, + fast: claude.fast, + effort: effortSuffix, + bareThinking: thinking?.order === "bare", + }); + } if (thinking) { const { source, order } = thinking; // Cursor writes the thinking marker on either side of the effort depending on family diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 01189a5938..7af5bbdbf1 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -30,7 +30,7 @@ import { clearAntigravityReplay, observeAntigravityReplay, } from "./google-antigravity-replay"; -import { resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; +import { canonicalAntigravityUsageModel, resolveAntigravityEffortWireModel } from "../providers/antigravity-models"; import { googleVertexLocationConfigError } from "../providers/google-vertex-location"; import { forgetThoughtSignatureForReplay, lookupReplayThoughtSignature } from "../responses/thought-signature-replay"; import { @@ -57,6 +57,43 @@ const GOOGLE_BREVITY_INSTRUCTION = [ const ANTIGRAVITY_REJECTED_CLAUDE_SDK_PARAGRAPH = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; +/** + * CCA Flash generations that reject the Claude-Agent identity paragraph. + * + * Membership is probe-established per generation, never assumed: 3.7 and 3.8 both answer + * 429 RESOURCE_EXHAUSTED when this paragraph survives into `systemInstruction`, and 200 with + * it stripped — same account, seconds apart. A policy rejection wearing a quota error's + * clothing sends users hunting a quota problem that does not exist, so a new generation is + * added here only after the probe, and never dropped on the assumption that Google fixed it. + */ +const ANTIGRAVITY_CLAUDE_SDK_PARAGRAPH_REJECTORS = new Set([ + "gemini-3.7-flash", + "gemini-3.8-flash", +]); + +/** + * Whether CCA rejects the Claude-Agent identity paragraph for this request. + * + * Judged on the ROUTED WIRE id, not the selector, because three different selectors reach the + * same rejecting generation: + * + * - the collapsed base (`gemini-3.8-flash`); + * - a raw suffix id (`gemini-3.8-flash-high`), which the picker publishes whenever discovery + * returns a PARTIAL ladder; + * - a RETIRED id (`gemini-3.6-flash`), which rule 0 redirects onto `gemini-3.7-flash-tiered`. + * + * That last one is why a selector-keyed test is not enough: retired ids deliberately keep their + * own identity for usage accounting, so they never canonicalize into the generation they + * actually call. A saved 3.6 selection was probed at 429 with the paragraph intact for exactly + * this reason. Matching on the wire id also means a future generation is covered by naming its + * wire spelling once, rather than every selector that can reach it. + */ +function rejectsClaudeSdkParagraph(modelId: string, wireModelId: string): boolean { + const canonicalWire = canonicalAntigravityUsageModel(wireModelId.replace(/-tiered$/, "")); + return ANTIGRAVITY_CLAUDE_SDK_PARAGRAPH_REJECTORS.has(canonicalWire) + || ANTIGRAVITY_CLAUDE_SDK_PARAGRAPH_REJECTORS.has(canonicalAntigravityUsageModel(modelId)); +} + function stripAntigravityRejectedClaudeSdkParagraph(systemText: string): string { return systemText .split("\n\n") @@ -748,7 +785,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // AI Studio's `-tiered` spelling is wire-only; CCA aliases may migrate to another generation. const identityModelId = provider.googleMode === "cloud-code-assist" ? routedModelId : parsed.modelId; const stripRejectedClaudeSdkParagraph = provider.googleMode === "cloud-code-assist" - && parsed.modelId === "gemini-3.7-flash"; + && rejectsClaudeSdkParagraph(parsed.modelId, routedModelId); const { systemInstruction, contents, replayedCallIds } = messagesToGeminiFormat( parsed, identityModelId, diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 781bbef414..8e23b383f3 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1965,6 +1965,18 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { return changed ? next : body; } +/** + * Muse Spark ids whose Responses gateway refuses `search_content_types` on a plain + * `web_search` tool. Membership, not equality: 1.3 shipped 2026-09-02 as the + * same-shaped successor to 1.2 on the same Zen wire, and an equality check would + * have let a Codex-emitted `web_search` + `search_content_types` body reach the + * gateway and come back 400 for every request the moment 1.3 was selected. + */ +const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ + "muse-spark-1.3-contributor", + "muse-spark-1.2-contributor", +]); + /** * OpenCode Zen / Go Muse Spark Responses gateway refuses `search_content_types` * on a plain `web_search` tool (400) but accepts it on `web_search_preview`; a @@ -1976,7 +1988,8 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { */ function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown { if (!isPlainObject(body)) return body; - if (typeof modelId !== "string" || modelId.trim().toLowerCase() !== "muse-spark-1.2-contributor") return body; + if (typeof modelId !== "string") return body; + if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body; const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { let changed = false; diff --git a/src/cli/claude.ts b/src/cli/claude.ts index 48bc8a8fd0..a9e64fc1af 100644 --- a/src/cli/claude.ts +++ b/src/cli/claude.ts @@ -354,8 +354,17 @@ export function readConnectedClaudeContextWindows(path = DEFAULT_CATALOG_PATH): } } -async function ensureProxyForClaude(): Promise { - const live = await findLiveProxy(); +export type ClaudeProxyEnsureDeps = { + findLiveProxy?: typeof findLiveProxy; +}; + +export async function ensureProxyForClaude(deps: ClaudeProxyEnsureDeps = {}): Promise { + // A proxy that has only just bound can miss a single probe while its event loop + // is still settling startup work — the same just-started race the stop paths + // already retry for (#764, SERVICE_STOP_LIVENESS). Only the attempts budget is + // borrowed here; the probe timeout remains DEFAULT_PROBE_TIMEOUT_MS (750 ms). + // Without this, `ocx claude` can spawn a second proxy while the first is serving. + const live = await (deps.findLiveProxy ?? findLiveProxy)({ attempts: 3 }); if (live) return live.port; const cfgPort = loadConfig().port; const pinPort = typeof cfgPort === "number" && cfgPort > 0 ? cfgPort : 10100; diff --git a/src/cli/connect.ts b/src/cli/connect.ts index 8cc3b6dc4b..752d43a7e1 100644 --- a/src/cli/connect.ts +++ b/src/cli/connect.ts @@ -17,6 +17,7 @@ import { rejectArgs, runCliAction, takeFlag, + takeIntegerOption, takeOption, type RuntimeApiDeps, } from "./runtime-api"; @@ -25,7 +26,7 @@ export const CONNECT_USAGE = `Usage: ocx connect [--management-url ] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] - [--no-sync] + [--catalog-timeout ] [--no-sync] ocx connect status [--json] ocx connect rotate (--pairing-code-stdin | --admin-token-stdin) [--json] @@ -147,6 +148,10 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise { if (!serverUrl || serverUrl.startsWith("--")) throw new CliUsageError("hub URL is required", CONNECT_USAGE); const managementUrl = takeOption(args, "--management-url"); const clients = parseClients(takeOption(args, "--clients")); + const catalogTimeoutSeconds = takeIntegerOption(args, "--catalog-timeout", { min: 1 }); + if (catalogTimeoutSeconds !== undefined && catalogTimeoutSeconds > 120) { + throw new CliUsageError("--catalog-timeout must be an integer between 1 and 120", CONNECT_USAGE); + } const managementTransport = takeOption(args, "--management-transport") ?? "direct"; if (managementTransport !== "direct" && managementTransport !== "relay") { throw new CliUsageError("--management-transport must be direct or relay", CONNECT_USAGE); @@ -167,6 +172,7 @@ async function runConnect(argv: string[], deps: RuntimeApiDeps): Promise { selectedClients: clients, managementTransport, noSync, + ...(catalogTimeoutSeconds === undefined ? {} : { catalogTimeoutMs: catalogTimeoutSeconds * 1_000 }), }, { fetchImpl: deps.fetchImpl }); console.log(`Connected to ${connection.serverUrl} as key ${connection.apiKeyId}.`); } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 36bbf9a771..5d6cd4391c 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -88,7 +88,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "ensure", usage: "ocx ensure", summary: "Ensure the proxy is running and Codex config/cache are current." }, { name: "connect", - usage: "ocx connect [--management-url ] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--no-sync]", + usage: "ocx connect [--management-url ] (--pairing-code-stdin | --admin-token-stdin) [--clients codex,claude] [--management-transport direct|relay] [--catalog-timeout ] [--no-sync]", summary: "Connect this machine to a remote OpenCodex hub without persisting the one-time authority.", details: [ "Status: ocx connect status [--json]", diff --git a/src/cli/status.ts b/src/cli/status.ts index e3120b074d..1a0d5685e7 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -120,20 +120,35 @@ export type ListenTarget = { dashboardUrl: string; }; +type StatusListenConfig = Pick; + +function statusDashboardUrl(config: StatusListenConfig, hostname: string | undefined, port: number): string { + const managementOrigin = config.runtimeRole === "hub" ? config.hub?.managementPublicOrigin : undefined; + if (managementOrigin) return managementOrigin.endsWith("/") ? managementOrigin : `${managementOrigin}/`; + + const reachableHostname = probeHostname(hostname); + const dashboardHostname = reachableHostname === "127.0.0.1" + || reachableHostname === "[::1]" + || reachableHostname.toLowerCase() === "localhost" + ? "localhost" + : reachableHostname; + return `http://${dashboardHostname}:${port}/`; +} + export function selectListenTarget( - config: Pick, + config: StatusListenConfig, pid: number | null, runtimePort: RuntimePortState | null, ): ListenTarget { const currentRuntimePort = pid && runtimePort?.pid === pid ? runtimePort : null; const port = currentRuntimePort ? currentRuntimePort.port : config.port ?? 10100; - const hostname = currentRuntimePort ? currentRuntimePort.hostname : config.hostname; + const hostname = currentRuntimePort?.hostname ?? config.hostname; return { port, hostname, source: currentRuntimePort ? "runtime" : "config", healthUrl: `http://${probeHostname(hostname)}:${port}/healthz`, - dashboardUrl: `http://localhost:${port}/`, + dashboardUrl: statusDashboardUrl(config, hostname, port), }; } @@ -341,7 +356,7 @@ export async function collectStatus(): Promise { hostname: live.hostname, source: live.source, healthUrl: `http://${probeHostname(live.hostname)}:${live.port}/healthz`, - dashboardUrl: `http://localhost:${live.port}/`, + dashboardUrl: statusDashboardUrl(config, live.hostname, live.port), } : selectListenTarget(config, pidFile, pidFile ? readRuntimePort(pidFile) : null); // findLiveProxy already identity-probed /healthz; avoid a second fetch that can race. diff --git a/src/client/connect.ts b/src/client/connect.ts index cc990ae15a..a9f0d9881a 100644 --- a/src/client/connect.ts +++ b/src/client/connect.ts @@ -71,6 +71,7 @@ export interface ConnectOptions { selectedClients: OcxConnectedClientId[]; managementTransport: "direct" | "relay"; noSync?: boolean; + catalogTimeoutMs?: number; } export interface ClientConnectDeps { @@ -408,7 +409,10 @@ export async function connectClient( const persisted = writeServiceApiTokenFile(issued.key); tokenFingerprint = persisted.fingerprint; - const catalog = await downloadClientCatalog(serverUrl, issued.key, { fetchImpl: deps.fetchImpl }); + const catalog = await downloadClientCatalog(serverUrl, issued.key, { + fetchImpl: deps.fetchImpl, + timeoutMs: options.catalogTimeoutMs, + }); atomicWriteFile(DEFAULT_CATALOG_PATH, catalog.body); writtenCatalogFingerprint = sha256(catalog.body); diff --git a/src/client/hub-client.ts b/src/client/hub-client.ts index 524d9292ef..6f0a9e7c07 100644 --- a/src/client/hub-client.ts +++ b/src/client/hub-client.ts @@ -1,5 +1,6 @@ import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download"; import { readBoundedResponseBytes } from "../lib/bounded-body"; +import { clearableDeadline } from "../lib/abort"; /** * A pairing grant may cross loopback or authenticated HTTPS, and nothing else. @@ -84,13 +85,17 @@ async function fetchBounded( url: string, init: RequestInit, timeoutMs: number | undefined, + timeoutScope: "request" | "headers" = "request", ): Promise { + const timeout = safeTimeout(timeoutMs); + const headerDeadline = timeoutScope === "headers" ? clearableDeadline(timeout) : null; try { const response = await fetchImpl(url, { ...init, redirect: "manual", - signal: AbortSignal.timeout(safeTimeout(timeoutMs)), + signal: headerDeadline?.signal ?? AbortSignal.timeout(timeout), }); + headerDeadline?.clear(); if (response.status >= 300 && response.status < 400 && response.status !== 304) { throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status); } @@ -98,15 +103,24 @@ async function fetchBounded( } catch (error) { if (error instanceof HubClientError) throw error; throw new HubClientError("unreachable", "Hub request did not complete", undefined, { cause: error }); + } finally { + headerDeadline?.clear(); } } -async function boundedText(response: Response, maxBytes: number): Promise { +async function boundedText( + response: Response, + maxBytes: number, + options: { inactivityTimeoutMs?: number } = {}, +): Promise { const declared = Number(response.headers.get("content-length") ?? "0"); if (Number.isFinite(declared) && declared > maxBytes) { throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status); } - const result = await readBoundedResponseBytes(response, { maxBytes }); + const result = await readBoundedResponseBytes(response, { + maxBytes, + ...(options.inactivityTimeoutMs === undefined ? {} : { inactivityTimeoutMs: options.inactivityTimeoutMs }), + }); if (result.oversized) { throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status); } @@ -422,7 +436,7 @@ export async function downloadClientCatalog( const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/catalog`, { method: "GET", headers, - }, options.timeoutMs); + }, options.timeoutMs, "headers"); if (response.status === 304) { throw new HubClientError("catalog_unexpected_304", "Hub answered 304 to an unconditional catalog request", 304); } @@ -434,7 +448,17 @@ export async function downloadClientCatalog( try { await response.body?.cancel(); } catch { /* best effort */ } throw new HubClientError("catalog_content_type_invalid", "Hub catalog response was not JSON", response.status); } - const body = await boundedText(response, options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES); + let body: string; + try { + body = await boundedText(response, options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES, { + inactivityTimeoutMs: safeTimeout(options.timeoutMs), + }); + } catch (error) { + if (error instanceof DOMException && error.name === "TimeoutError") { + throw new HubClientError("unreachable", "Hub catalog download stalled", undefined, { cause: error }); + } + throw error; + } const parsed = parseJson(body, "catalog_invalid"); validateRemoteCatalog(parsed); const keyId = response.headers.get("x-opencodex-key-id")?.trim() || undefined; diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 50d6a201f1..eb2c29aa9d 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -22,7 +22,7 @@ import { homedir } from "node:os"; import { existsSync, readFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; -import { shouldInjectApiAuthHeader } from "../codex/inject"; +import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject"; import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize"; import { providerCodexAccountMode } from "../providers/registry"; import { canonicalizeReasoningEfforts, sanitizeCodexReasoningEfforts } from "../reasoning-effort"; @@ -301,7 +301,17 @@ export function ompModelsConfigPath(env: OpencodeLaunchEnv = process.env, home: } /** Compose the OpenAI-compatible proxy base URL from a live probe result. */ -export function opencodeProxyBaseUrl(port: number, hostname?: string): string { +export function opencodeProxyBaseUrl( + port: number, + hostname?: string, + config?: Pick, +): string { + if (config?.unauthenticatedLoopbackListener?.enabled) { + return standaloneCodexRoutingTarget(port, { + hostname, + unauthenticatedLoopbackListener: config.unauthenticatedLoopbackListener, + }).baseUrl; + } return `http://${probeHostname(hostname)}:${port}/v1`; } diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index fe73d48263..e32d5216a5 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -2,7 +2,7 @@ // Public surface preserved exactly; importers keep using "src/codex/catalog". export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; -export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_CONTEXT_WINDOW, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeContextLimits, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiContextTier, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, type NativeContextLimits, type NativeContextLimitsInput } from "./catalog/metadata"; +export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_CONTEXT_WINDOW, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_GPT56_OPT_IN_CONTEXT_WINDOW, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeContextLimits, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiContextTier, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, type NativeContextLimits, type NativeContextLimitsInput } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index e43fddbf1d..f97b8295fc 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -162,6 +162,12 @@ export function deriveComboCatalogModel( contextWindow, ...members.map(member => member.maxInputTokens ?? member.contextWindow!), ); + const knownMaxOutputTokens = members + .map(member => member.maxOutputTokens) + .filter((value): value is number => typeof value === "number" && value > 0); + const maxOutputTokens = knownMaxOutputTokens.length === members.length + ? Math.min(...knownMaxOutputTokens) + : undefined; const autoCompactTokenLimit = Math.min( ...members.map(member => clampAutoCompactTokenLimit( member.contextWindow!, @@ -180,6 +186,7 @@ export function deriveComboCatalogModel( owned_by: COMBO_NAMESPACE, contextWindow, maxInputTokens, + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), autoCompactTokenLimit, ...(hasLimitingContextCapMetadata ? { contextCapped } : {}), inputModalities, @@ -320,6 +327,7 @@ export function normalizedOpenAiApiSignature(model: CatalogModel): string { id: model.id, contextWindow: model.contextWindow ?? null, maxInputTokens: model.maxInputTokens ?? null, + maxOutputTokens: model.maxOutputTokens ?? null, autoCompactTokenLimit: model.autoCompactTokenLimit ?? null, inputModalities: [...new Set(model.inputModalities ?? [])].sort(), reasoningEfforts: [...new Set(model.reasoningEfforts ?? [])].sort(), diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index f0bbe6348c..6b134934a9 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -271,6 +271,11 @@ export function nativeOpenAiContextWindow(slug: string, limits?: NativeContextLi return narrowToLimits(raw, slug, limits); } +export function nativeOpenAiMaxOutputTokens(slug: string): number | undefined { + const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug); + return positiveInt(getModelMetadata("openai", sourceSlug)?.maxTokens); +} + /** * Long-context tier for a native slug as a (default, long) pair, for clients that let the user * pick a window per request (Cursor's local-agent "Context" selector). The pair is the family's diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index de195b9abc..d9ec821646 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -112,6 +112,8 @@ export interface CatalogModel { defaultReasoningEffort?: string; contextWindow?: number; maxInputTokens?: number; + /** Model-scoped output-token ceiling; omitted when no authoritative value is known. */ + maxOutputTokens?: number; /** Soft client compaction threshold; hard context/input limits remain authoritative. */ autoCompactTokenLimit?: number; contextCap?: number; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 66b7f63a88..f48edcd03c 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -49,7 +49,7 @@ import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; -import { recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, @@ -81,7 +81,7 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; import type { CatalogModel } from "./parsing"; -import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; +import { disabledNativeSlugs, hasComboTargets, isNativeOpenAiCapabilityAliasModel, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; @@ -163,6 +163,7 @@ interface CapturedProviderGather { readonly policy: CatalogProviderDiscoveryPolicySnapshot; readonly request: CapturedModelsRequest; readonly fastPolicyAuthority: FastPolicyAuthority; + readonly metadataModelIdCaseFold: boolean; readonly observedAuth?: ModelsAuthResolution; /** * Configured model ids this provider must keep even when live discovery omits @@ -379,6 +380,7 @@ function captureTrustedOpenAiApiPolicy( models: entry.models, ...(entry.modelContextWindows ? { modelContextWindows: entry.modelContextWindows } : {}), ...(entry.modelMaxInputTokens ? { modelMaxInputTokens: entry.modelMaxInputTokens } : {}), + ...(entry.virtualModels ? { virtualModels: entry.virtualModels } : {}), ...(entry.modelInputModalities ? { modelInputModalities: entry.modelInputModalities } : {}), ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: entry.modelReasoningEfforts } : {}), }); @@ -422,6 +424,7 @@ function captureProviderGather( registryTransportMatch, configured, ); + const metadataModelIdCaseFold = shouldCaseFoldMetadataModelId(name); const observedAuth = authResolver.kind === "observed" && provider.authMode !== "forward" && provider.liveModels !== false @@ -458,6 +461,7 @@ function captureProviderGather( policy, request, fastPolicyAuthority, + metadataModelIdCaseFold, ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}), ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0 ? { retainConfiguredModelIds } @@ -579,6 +583,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco ctx: prov.contextWindow ?? null, ctxW: prov.modelContextWindows ?? null, maxIn: prov.modelMaxInputTokens ?? null, + maxOut: prov.modelMaxOutputTokens ?? null, autoCompact: prov.modelAutoCompactTokenLimits ?? null, inMod: prov.modelInputModalities ?? null, re: prov.modelReasoningEfforts ?? null, @@ -645,6 +650,44 @@ export function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): n return typeof configured === "number" && configured > 0 ? configured : undefined; } +function generatedMaxOutputTokens( + providerName: string, + id: string, + metadataId = id, + metadataModelIdCaseFold?: boolean, +): number | undefined { + const metadataProvider = providerName === OPENAI_API_PROVIDER_ID || providerName === OPENAI_CODEX_PROVIDER_ID + ? "openai" + : resolveMetadataProvider(providerName); + if (!metadataProvider) return undefined; + const metadata = getModelMetadata(metadataProvider, metadataId) + ?? ((metadataModelIdCaseFold ?? (providerName === OPENAI_API_PROVIDER_ID || providerName === OPENAI_CODEX_PROVIDER_ID + ? false + : shouldCaseFoldMetadataModelId(providerName))) + ? getModelMetadataCaseInsensitive(metadataProvider, metadataId) + : undefined); + return positiveSafeInteger(metadata?.maxTokens); +} + +function routedMaxOutputTokens( + providerName: string, + provider: OcxProviderConfig, + model: CatalogModel, + metadataId = model.id, + metadataModelIdCaseFold?: boolean, +): number | undefined { + const discovered = positiveSafeInteger(model.maxOutputTokens); + const generated = generatedMaxOutputTokens(providerName, model.id, metadataId, metadataModelIdCaseFold); + const configured = positiveSafeInteger( + modelRecordValue(provider.modelMaxOutputTokens, model.id), + ); + const authoritative = discovered ?? generated; + if (configured === undefined) return authoritative; + return authoritative === undefined + ? configured + : Math.min(authoritative, configured); +} + export function configuredAutoCompactTokenLimit( prov: OcxProviderConfig | undefined, id: string, @@ -679,10 +722,17 @@ function configuredVerbositySupport(name: string, prov: OcxProviderConfig | unde return prov.supportsVerbosity; } -export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel { +export function applyProviderConfigHints( + name: string, + prov: OcxProviderConfig, + model: CatalogModel, + providerCap?: number, + metadataModelIdCaseFold?: boolean, +): CatalogModel { const displayName = configuredModelDisplayName(prov, model.id); const configuredCap = configuredContextWindow(prov, model.id); const configuredMaxInput = configuredMaxInputTokens(prov, model.id); + const maxOutputTokens = routedMaxOutputTokens(name, prov, model, model.id, metadataModelIdCaseFold); const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id); let inputModalities = configuredInputModalities(prov, model.id); // The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time @@ -727,6 +777,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, : configuredMaxInput, } : {}), + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), ...(typeof supportsVerbosity === "boolean" ? { supportsVerbosity } : {}), @@ -769,14 +820,26 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, }; } -export function catalogHintsFromProviderConfig(name: string, prov: OcxProviderConfig, id: string, contextCap?: number): Partial { - const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap); +export function catalogHintsFromProviderConfig( + name: string, + prov: OcxProviderConfig, + id: string, + contextCap?: number, + metadataModelIdCaseFold?: boolean, +): Partial { + const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold); const { provider: _provider, id: _id, ...hints } = hinted; return hints; } -export function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, models: CatalogModel[], contextCap?: number): CatalogModel[] { - return models.map(model => applyProviderConfigHints(name, prov, model, contextCap)); +export function applyConfigHintsToCachedModels( + name: string, + prov: OcxProviderConfig, + models: CatalogModel[], + contextCap?: number, + metadataModelIdCaseFold?: boolean, +): CatalogModel[] { + return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold)); } @@ -793,6 +856,7 @@ interface ComboCatalogMemberFallback { readonly contextWindow?: number; /** Input ceiling when it is lower than the window (native GPT-5.6: 922k under 1.05M). */ readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; readonly autoCompactTokenLimit?: number; readonly inputModalities?: readonly string[]; readonly reasoningEfforts?: readonly string[]; @@ -814,6 +878,7 @@ export function resolveComboCatalogMember( providers: ReadonlyMap, contextCap?: number, fallback?: ComboCatalogMemberFallback, + metadataModelIdCaseFold?: boolean, ): CatalogModel | undefined { const existing = memberByKey.get(targetKey(target)); const prov = providers.get(target.provider); @@ -827,6 +892,10 @@ export function resolveComboCatalogMember( : undefined; const addMaxInput = fallback !== undefined && contextWindow !== undefined && !(typeof member.maxInputTokens === "number" && member.maxInputTokens > 0); + const addMaxOutput = fallback !== undefined + && typeof fallback.maxOutputTokens === "number" + && fallback.maxOutputTokens > 0 + && !(typeof member.maxOutputTokens === "number" && member.maxOutputTokens > 0); const effectiveMaxInput = addMaxInput ? Math.min(fallback?.maxInputTokens ?? contextWindow!, contextWindow!) : member.maxInputTokens; @@ -840,12 +909,13 @@ export function resolveComboCatalogMember( && fallback?.inputModalities !== undefined; const addReasoning = member.reasoningEfforts === undefined && fallback?.reasoningEfforts !== undefined; - if (!addMaxInput && !adjustAutoCompact && !addModalities && !addReasoning) return member; + if (!addMaxInput && !addMaxOutput && !adjustAutoCompact && !addModalities && !addReasoning) return member; return { ...member, // Never claim a larger input budget than the window, and prefer the model's own // measured ceiling when the fallback carries one. ...(addMaxInput ? { maxInputTokens: effectiveMaxInput } : {}), + ...(addMaxOutput ? { maxOutputTokens: fallback!.maxOutputTokens } : {}), ...(adjustAutoCompact && autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), ...(addModalities ? { inputModalities: [...fallback!.inputModalities!] } : {}), ...(addReasoning ? { reasoningEfforts: [...fallback!.reasoningEfforts!] } : {}), @@ -882,7 +952,7 @@ export function resolveComboCatalogMember( provider: target.provider, }; const hinted = prov - ? applyProviderConfigHints(target.provider, prov, base, contextCap) + ? applyProviderConfigHints(target.provider, prov, base, contextCap, metadataModelIdCaseFold) : base; const hintedContext = typeof hinted.contextWindow === "number" && hinted.contextWindow > 0 ? hinted.contextWindow @@ -923,6 +993,8 @@ export function resolveComboCatalogMember( ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined) ?? base.reasoningEfforts ?? (fallback?.reasoningEfforts ? [...fallback.reasoningEfforts] : undefined); + const maxOutputTokens = positiveSafeInteger(hinted.maxOutputTokens, base.maxOutputTokens) + ?? (existing || prov ? positiveSafeInteger(fallback?.maxOutputTokens) : undefined); // The model's own measured input ceiling still applies when discovery gave us nothing: // GPT-5.6 advertises a 1.05M window but refuses input past 922k. const effectiveMaxInput = knownMaxInput ?? fallbackMaxInput; @@ -950,6 +1022,7 @@ export function resolveComboCatalogMember( ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), contextWindow, maxInputTokens, + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}), }; @@ -1233,6 +1306,12 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid plainRecord(item.meta)?.n_ctx_train, ); const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens); + const maxOutputTokens = positiveSafeInteger( + capabilityRecord?.max_output_tokens, + limits?.max_output_tokens, + metadata?.max_output_tokens, + item.max_output_tokens, + ); // Some OpenAI-compatible catalogs expose the selectable ladder under // `reasoning_parameters.efforts` instead of the older `reasoning_efforts` key. // Treat both as model metadata: otherwise a valid upstream capability disappears @@ -1260,6 +1339,7 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid return { ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}), + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), ...(inputModalities ? { inputModalities } : {}), ...(capabilities ? { capabilities } : {}), @@ -1305,7 +1385,7 @@ async function fetchProviderModelsWithAuth( contextCap: number | undefined, resolveAuth: ModelsAuthResolver, ): Promise { - const { name, provider: prov, discovery, request } = captured; + const { name, provider: prov, discovery, request, metadataModelIdCaseFold } = captured; const observed = ( models: CatalogModel[], state: CatalogGatherProviderModelOutcome["state"], @@ -1330,7 +1410,7 @@ async function fetchProviderModelsWithAuth( const configured: CatalogModel[] = configuredIds.map(id => ({ id, provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold), })); const withConfiguredRetention = ( models: CatalogModel[], @@ -1345,6 +1425,7 @@ async function fetchProviderModelsWithAuth( contextCap, seedVertexDefault, retainComboTargets: options?.retainComboTargets, + metadataModelIdCaseFold, }); if ( options?.warnDrops === true @@ -1383,7 +1464,7 @@ async function fetchProviderModelsWithAuth( : [{ id: prov.defaultModel, provider: name, - ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap), + ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, metadataModelIdCaseFold), }]; const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined; const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => ( @@ -1400,7 +1481,7 @@ async function fetchProviderModelsWithAuth( const cachedCursor = getFreshCached(name, ttlMs); if (cachedCursor) { return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor)), + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold)), "authoritative", ); } @@ -1408,7 +1489,7 @@ async function fetchProviderModelsWithAuth( const cooling = getStaleCached(name); return observed( withConfiguredRetention( - cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured, + cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold) : configured, ), "degraded", ); @@ -1422,9 +1503,6 @@ async function fetchProviderModelsWithAuth( }); if (liveResult.ok) { const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); - // Live Max-Mode evidence feeds the umbrella resolver's ultra gate - // (devlog 260828_cursor_umbrella_catalog; union with static evidence). - recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); const result = available.length > 0 ? available : configured; // Cache the discovery-filtered roster without combo retention so a later // gather can re-apply the current capture's retain set on read. @@ -1432,6 +1510,13 @@ async function fetchProviderModelsWithAuth( if (!setCached(name, forCache, Date.now(), cacheGeneration)) { return observed(withConfiguredRetention(configured), "degraded"); } + // Publish roster-derived state only for a discovery the cache accepted: a stale + // in-flight capture (generation revoked by a credential/config change) must not + // overwrite the spelling or Max-Mode evidence of the newer one. + recordLiveCursorClaudeModels(liveResult.models); + // Live Max-Mode evidence feeds the umbrella resolver's ultra gate + // (devlog 260828_cursor_umbrella_catalog; union with static evidence). + recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); markProviderDiscoveryOk(name, liveResult.models.length); return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); } @@ -1445,7 +1530,7 @@ async function fetchProviderModelsWithAuth( const staleCursor = getStaleCached(name); return observed( withConfiguredRetention( - staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured, + staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold) : configured, ), "degraded", ); @@ -1463,7 +1548,7 @@ async function fetchProviderModelsWithAuth( if (fresh) { return observed( withConfiguredRetention( - withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)), + withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold)), ), "authoritative", ); // dedups Codex's frequent /v1/models polling within the TTL @@ -1475,7 +1560,7 @@ async function fetchProviderModelsWithAuth( return observed( withConfiguredRetention( stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold)) : failedDiscoveryConfigured, ), "degraded", @@ -1515,7 +1600,7 @@ async function fetchProviderModelsWithAuth( return { models: withConfiguredRetention( stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold)) : failedDiscoveryConfigured, ), fallback: stale ? "stale" : "configured", @@ -1592,7 +1677,7 @@ async function fetchProviderModelsWithAuth( reasoningEfforts: [], ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), - }, contextCap)); + }, contextCap, metadataModelIdCaseFold)); const forCache = withConfiguredRetention(live, { retainComboTargets: false }); if (!setCached(name, forCache, Date.now(), cacheGeneration)) { return observed(withConfiguredRetention(configured), "degraded"); @@ -1655,7 +1740,7 @@ async function fetchProviderModelsWithAuth( provider: name, ...(ownedBy ? { owned_by: ownedBy } : {}), ...discoveredHints, - }, contextCap); + }, contextCap, metadataModelIdCaseFold); }) .filter(m => shouldExposeProviderModel(name, m.id)); // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into @@ -1752,6 +1837,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { contextCap?: number; seedVertexDefault?: boolean; retainComboTargets?: boolean; + metadataModelIdCaseFold?: boolean; }): { models: CatalogModel[]; droppedConfiguredIds: string[] } { const { name, @@ -1761,6 +1847,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { contextCap, seedVertexDefault, retainComboTargets = true, + metadataModelIdCaseFold, } = opts; const out = [...opts.models]; const present = new Set(out.map(model => model.id)); @@ -1769,7 +1856,7 @@ export function mergeConfiguredModelsIntoLiveCatalog(opts: { if (present.has(candidate.id)) continue; const dated = out.find(live => isDatedVariantId(live.id, candidate.id)); if (dated) { - out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap)); + out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold)); present.add(candidate.id); continue; } @@ -1955,7 +2042,16 @@ async function gatherRoutedModelsUncached( config, capture.openAiApiPolicy, ); - const all = augmentRoutedModelsWithMetadata(apiAugmented, activeProviders.map(provider => provider.name), config.providers, config) + const metadataModelIdCaseFoldByProvider = new Map( + activeProviders.map(provider => [provider.name, provider.metadataModelIdCaseFold]), + ); + const all = augmentRoutedModelsWithMetadata( + apiAugmented, + activeProviders.map(provider => provider.name), + config.providers, + config, + metadataModelIdCaseFoldByProvider, + ) // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog // intentionally mirrors Cursor's public model table, including Gemini image preview, so the // exposure decision goes through shouldExposeRoutedModel (single choke point). @@ -2009,6 +2105,9 @@ async function gatherRoutedModelsUncached( // stay separate fields because routed/API rows of the same family run a wider window. // Falls back to the window for slugs with no separate ceiling. maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextCap) ?? contextWindow, contextWindow), + ...(nativeOpenAiMaxOutputTokens(slug) !== undefined + ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(slug) } + : {}), autoCompactTokenLimit: nativeOpenAiAutoCompactTokenLimit(slug, openaiContextCap), inputModalities: nativeInputModalities(slug), reasoningEfforts: nativeReasoningEfforts(slug), @@ -2042,6 +2141,9 @@ async function gatherRoutedModelsUncached( ? { contextWindow: nativeContextWindow, ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: nativeAliasMaxInput } : {}), + ...(nativeOpenAiMaxOutputTokens(combo.alias) !== undefined + ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(combo.alias) } + : {}), ...(nativeAliasAutoCompact !== undefined ? { autoCompactTokenLimit: nativeAliasAutoCompact } : {}), inputModalities: nativeInputModalities(combo.alias), reasoningEfforts: nativeReasoningEfforts(combo.alias), @@ -2054,6 +2156,7 @@ async function gatherRoutedModelsUncached( enrichedByName, providerContextCap(config, target.provider), nativeAliasFallback, + metadataModelIdCaseFoldByProvider.get(target.provider), )) .filter((member): member is CatalogModel => member !== undefined); const derived = deriveComboCatalogModel(id, combo, members); @@ -2105,6 +2208,9 @@ async function gatherRoutedModelsUncached( const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias ? nativeOpenAiMaxInputTokens(cm.modelId, customNativeLimits) : undefined; + const nativeAliasMaxOutputTokens = codexForwardNativeCapabilityAlias + ? nativeOpenAiMaxOutputTokens(cm.modelId) + : undefined; const configuredMaxInput = rawProvider ? configuredMaxInputTokens(rawProvider, cm.modelId) : undefined; @@ -2116,6 +2222,13 @@ async function gatherRoutedModelsUncached( ...(customContextWindow !== undefined ? [customContextWindow] : []), ) : undefined; + const customMaxOutputTokens = rawProvider + ? routedMaxOutputTokens(cm.provider, rawProvider, { + id: cm.modelId, + provider: cm.provider, + ...(nativeAliasMaxOutputTokens !== undefined ? { maxOutputTokens: nativeAliasMaxOutputTokens } : {}), + }, cm.modelId, metadataModelIdCaseFoldByProvider.get(cm.provider)) + : nativeAliasMaxOutputTokens; const configuredAutoCompact = configuredAutoCompactTokenLimit(rawProvider, cm.modelId); const customAutoCompactTokenLimit = codexForwardNativeCapabilityAlias ? nativeOpenAiAutoCompactTokenLimit(cm.modelId, customNativeLimits) @@ -2142,6 +2255,7 @@ async function gatherRoutedModelsUncached( : codexForwardNativeCapabilityAlias ? { displayName: "Daybreak Blue" } : {}), ...(customContextWindow !== undefined ? { contextWindow: customContextWindow } : {}), ...(customMaxInputTokens !== undefined ? { maxInputTokens: customMaxInputTokens } : {}), + ...(customMaxOutputTokens !== undefined ? { maxOutputTokens: customMaxOutputTokens } : {}), ...(customAutoCompactTokenLimit !== undefined ? { autoCompactTokenLimit: customAutoCompactTokenLimit } : {}), ...(cm.inputModalities ? { inputModalities: cm.inputModalities } @@ -2193,10 +2307,16 @@ async function gatherRoutedModelsUncached( const mergedMaxInput = mergedMaxInputCandidates.length > 0 ? Math.min(...mergedMaxInputCandidates) : undefined; + const mergedMaxOutputCandidates = [base.maxOutputTokens, replaced?.maxOutputTokens] + .filter((value): value is number => typeof value === "number" && value > 0); + const mergedMaxOutput = mergedMaxOutputCandidates.length > 0 + ? Math.min(...mergedMaxOutputCandidates) + : undefined; const merged: CatalogModel = replaced ? { ...base, ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}), ...(mergedMaxInput !== undefined ? { maxInputTokens: mergedMaxInput } : {}), + ...(mergedMaxOutput !== undefined ? { maxOutputTokens: mergedMaxOutput } : {}), ...(base.autoCompactTokenLimit === undefined && replaced.autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit: replaced.autoCompactTokenLimit } : {}), @@ -2331,12 +2451,19 @@ function augmentRoutedModelsWithCapturedOpenAiApiRows( const autoCompactTokenLimit = contextWindow !== undefined && configuredAutoCompact !== undefined ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, configuredAutoCompact) : undefined; + const maxOutputTokens = routedMaxOutputTokens( + OPENAI_API_PROVIDER_ID, + configured, + existingById.get(id) ?? { provider: OPENAI_API_PROVIDER_ID, id }, + policy.virtualModels?.[id]?.wireModelId ?? id, + ); return { provider: OPENAI_API_PROVIDER_ID, id, owned_by: OPENAI_API_PROVIDER_ID, ...(contextWindow ? { contextWindow } : {}), ...(maxInputTokens ? { maxInputTokens } : {}), + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), ...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}), ...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}), @@ -2366,6 +2493,7 @@ export function augmentRoutedModelsWithMetadata( providerNames: string[], providers?: Record, caps?: Pick, + metadataModelIdCaseFoldByProvider?: ReadonlyMap, ): CatalogModel[] { const out = [...models]; const seen = new Set(out.map(m => `${m.provider}/${m.id}`)); @@ -2384,11 +2512,20 @@ export function augmentRoutedModelsWithMetadata( id: meta.id, owned_by: provider, ...(typeof meta.contextWindow === "number" && meta.contextWindow > 0 ? { contextWindow: meta.contextWindow } : {}), + ...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 ? { maxOutputTokens: meta.maxTokens } : {}), ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}), }; out.push({ ...model, - ...(providers?.[provider] ? applyProviderConfigHints(provider, providers[provider], model, contextCap) : {}), + ...(providers?.[provider] + ? applyProviderConfigHints( + provider, + providers[provider], + model, + contextCap, + metadataModelIdCaseFoldByProvider?.get(provider), + ) + : {}), }); } } diff --git a/src/codex/convergence-types.ts b/src/codex/convergence-types.ts index c5518a862d..5254285ab4 100644 --- a/src/codex/convergence-types.ts +++ b/src/codex/convergence-types.ts @@ -429,6 +429,7 @@ export interface CatalogTrustedOpenAiApiPolicySnapshot { readonly models?: readonly string[]; readonly modelContextWindows?: Readonly>; readonly modelMaxInputTokens?: Readonly>; + readonly virtualModels?: Readonly>>; readonly modelInputModalities?: Readonly>; readonly modelReasoningEfforts?: Readonly>; } diff --git a/src/codex/desired-state.ts b/src/codex/desired-state.ts index b750592403..d8ca52a84c 100644 --- a/src/codex/desired-state.ts +++ b/src/codex/desired-state.ts @@ -71,15 +71,25 @@ export function codexIntegrationEnabled(config: Pick): boolean { +type LocalClientSyncConfig = Pick< + OcxConfig, + "clientIntegrations" | "runtimeRole" | "unauthenticatedLoopbackListener" +>; + +function localClientSyncAllowed(config: LocalClientSyncConfig): boolean { + return config.runtimeRole !== "hub" + || config.unauthenticatedLoopbackListener?.enabled === true; +} + +export function shouldSyncCodexOnStart(config: LocalClientSyncConfig): boolean { // A hub is a server for OTHER machines: it must not rewrite its own host's // Codex/Claude/Grok client configs on startup (interview decision Q6, and the // first clisu-oracle dogfood boot proved the failure mode — the hub marked // /readyz failed because it tried to run the full local client sync). - // "Hub is also a client" stays possible by explicitly enabling integrations - // later; the ROLE alone never injects. - if (config.runtimeRole === "hub") return false; - return codexIntegrationEnabled(config); + // A hub can be a local client only through its explicitly enabled loopback + // listener. The public hub bind remains outside this gate and still requires + // admission; an explicit client OFF continues to win. + return localClientSyncAllowed(config) && codexIntegrationEnabled(config); } /** @@ -189,7 +199,7 @@ export function setClaudeDesktopIntegrationEnabled(enabled: boolean): CodexDesir */ export async function syncCodexOnStartIfEnabled( port: number, - config: Pick, + config: LocalClientSyncConfig, sync: CodexStartupSync = defaultStartupSync, readinessGate?: ReadinessGate, ): Promise<{ ran: boolean; catalogWritten: boolean; cacheSynced: boolean }> { @@ -232,9 +242,6 @@ async function defaultStartupSync(port: number): Promise): boolean { - // Same hub rule as shouldSyncCodexOnStart: the hub role never rewrites its - // host's client configs on startup. - if (config.runtimeRole === "hub") return false; - return grokIntegrationEnabled(config); +export function shouldSyncGrokOnStart(config: LocalClientSyncConfig): boolean { + return localClientSyncAllowed(config) && grokIntegrationEnabled(config); } diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 97bd1f5928..ae0c044be2 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -13,6 +13,28 @@ interface TargetCooldown { const DEFAULT_COOLDOWN_MS = 60_000; const MAX_COOLDOWN_MS = 10 * 60_000; +/** Short cooldown for request-rate 429s (for example provider code 1302) that omit Retry-After. */ +export const COMBO_REQUEST_RATE_COOLDOWN_MS = 5_000; + +const QUOTA_LIMIT_CODES = new Set([ + "1308", + "1310", + "1316", + "1317", + "1318", + "1319", + "1320", + "1321", + "insufficient_quota", +]); +const TRANSIENT_REQUEST_RATE_CODES = new Set(["1302", "1305"]); +const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; +const RFC850_DATE_RE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/i; +const ASCTIME_DATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d{2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/i; +const HTTP_MONTH_INDEX: Record = { + jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, + jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, +}; /** Map<`${comboId}\0${provider/model}`, TargetCooldown> */ const targetCooldowns = new Map(); @@ -26,22 +48,92 @@ function cooldownMapKey( return `${comboId}\0${targetKey(target)}`; } +function parseUtcDateParts( + year: number, + monthName: string, + day: number, + hour: number, + minute: number, + second: number, +): number | undefined { + const month = HTTP_MONTH_INDEX[monthName.toLowerCase()]; + if (month === undefined) return undefined; + const timestamp = Date.UTC(year, month, day, hour, minute, second); + const parsed = new Date(timestamp); + return parsed.getUTCFullYear() === year + && parsed.getUTCMonth() === month + && parsed.getUTCDate() === day + && parsed.getUTCHours() === hour + && parsed.getUTCMinutes() === minute + && parsed.getUTCSeconds() === second + ? timestamp + : undefined; +} + +function parseHttpDate(value: string, now: number): number | undefined { + const imf = IMF_FIXDATE_RE.exec(value); + if (imf) { + return parseUtcDateParts( + Number(imf[3]), imf[2]!, Number(imf[1]), + Number(imf[4]), Number(imf[5]), Number(imf[6]), + ); + } + const rfc850 = RFC850_DATE_RE.exec(value); + if (rfc850) { + const current = new Date(now); + const currentYear = current.getUTCFullYear(); + const month = HTTP_MONTH_INDEX[rfc850[2]!.toLowerCase()]; + if (month === undefined) return undefined; + let year = Math.floor(currentYear / 100) * 100 + Number(rfc850[3]); + const yearDelta = year - currentYear; + const candidateTimeOfYear = Date.UTC( + 2000, month, Number(rfc850[1]), + Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]), + ); + const currentTimeOfYear = Date.UTC( + 2000, current.getUTCMonth(), current.getUTCDate(), + current.getUTCHours(), current.getUTCMinutes(), current.getUTCSeconds(), + current.getUTCMilliseconds(), + ); + if (yearDelta < -50 || (yearDelta === -50 && candidateTimeOfYear < currentTimeOfYear)) { + year += 100; + } else if (yearDelta > 50 || (yearDelta === 50 && candidateTimeOfYear > currentTimeOfYear)) { + year -= 100; + } + return parseUtcDateParts( + year, rfc850[2]!, Number(rfc850[1]), + Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]), + ); + } + const asctime = ASCTIME_DATE_RE.exec(value); + if (!asctime) return undefined; + return parseUtcDateParts( + Number(asctime[6]), asctime[1]!, Number(asctime[2]), + Number(asctime[3]), Number(asctime[4]), Number(asctime[5]), + ); +} + export function parseRetryAfterMs( value: string | null | undefined, now = Date.now(), + options?: { preserveImmediate?: boolean }, ): number | undefined { const text = value?.trim(); if (!text) return undefined; if (/^\d+(?:\.\d+)?$/.test(text)) { const seconds = Number(text); - if (Number.isFinite(seconds) && seconds > 0) { + if ( + Number.isFinite(seconds) + && (seconds > 0 || (options?.preserveImmediate && seconds === 0)) + ) { return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS); } } - const timestamp = Date.parse(text); - if (!Number.isFinite(timestamp)) return undefined; + const timestamp = parseHttpDate(text, now); + if (timestamp === undefined) return undefined; const delay = timestamp - now; - return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined; + if (delay > 0) return Math.min(delay, MAX_COOLDOWN_MS); + return options?.preserveImmediate ? 1 : undefined; } export function isComboTargetInCooldown( @@ -59,10 +151,59 @@ export function isComboTargetInCooldown( return true; } +export function isTransientRequestRateLimit(input: { + status?: number; + code?: string | null; + message?: string; +}): boolean { + if (isProviderScopedQuotaCap(input.status, input.message ?? "", input.code)) return false; + const code = (input.code ?? "").trim().toLowerCase().replaceAll("-", "_"); + if (QUOTA_LIMIT_CODES.has(code)) return false; + if (TRANSIENT_REQUEST_RATE_CODES.has(code)) return true; + const text = (input.message ?? "").toLowerCase(); + if ( + text.includes("usage limit reached") + || text.includes("insufficient_quota") + || text.includes("quota exhausted") + ) { + return false; + } + return text.includes("rate limit reached for requests"); +} + +export function remainingComboCooldownMs(comboId: string, now = Date.now()): number | undefined { + const prefix = `${comboId}\0`; + let soonest: number | undefined; + for (const [key, cooldown] of targetCooldowns) { + if (!key.startsWith(prefix)) continue; + const remaining = cooldown.cooldownUntil - now; + if (remaining <= 0) { + targetCooldowns.delete(key); + continue; + } + if (soonest === undefined || remaining < soonest) soonest = remaining; + } + return soonest; +} + +export function comboCooldownRetryAfterSeconds(comboId: string, now = Date.now()): string | undefined { + const remainingMs = remainingComboCooldownMs(comboId, now); + if (remainingMs === undefined) return undefined; + return String(Math.max(1, Math.ceil(remainingMs / 1000))); +} + export function coolComboTarget( comboId: string, target: Pick, - options?: { retryAfter?: string | null; now?: number; cooldownMs?: number; writerGeneration?: number }, + options?: { + retryAfter?: string | null; + now?: number; + cooldownMs?: number; + writerGeneration?: number; + status?: number; + code?: string | null; + message?: string; + }, ): void { const now = options?.now ?? Date.now(); const writerGeneration = options?.writerGeneration ?? captureConfigGeneration(); @@ -70,7 +211,11 @@ export function coolComboTarget( if (writerGeneration < lastReconciledGeneration && !liveComboTargets.has(ownerKey)) return; const cooldownMs = options?.cooldownMs ?? parseRetryAfterMs(options?.retryAfter, now) - ?? DEFAULT_COOLDOWN_MS; + ?? (isTransientRequestRateLimit({ + status: options?.status, + code: options?.code, + message: options?.message, + }) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS); targetCooldowns.set(cooldownMapKey(comboId, target), { cooldownUntil: now + Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS), }); @@ -108,6 +253,37 @@ export function clearComboTargetCooldowns(comboId?: string): void { } export type ComboFailureDecision = "hop" | "stop"; +export type ComboFailureCooldownScope = "target" | "provider"; + +function normalizedFailureCode(code?: string | null): string { + return code?.trim().toLowerCase().replaceAll("-", "_") ?? ""; +} + +function isProviderScopedQuotaCap( + status: number | undefined, + message: string, + code?: string | null, +): boolean { + const normalizedCode = normalizedFailureCode(code); + const text = message.toLowerCase(); + if ( + status === 429 + && (normalizedCode === "gousagelimiterror" || text.includes("monthly usage limit reached")) + ) { + return true; + } + return normalizedCode === "free_rate_limited" + || text.includes("err_free_prompt_cap") + || (text.includes("free tier") && text.includes("single request")); +} + +export function comboFailureCooldownScope( + status: number, + message: string, + options?: { code?: string | null }, +): ComboFailureCooldownScope { + return isProviderScopedQuotaCap(status, message, options?.code) ? "provider" : "target"; +} function isModelLifecycleGone( status: number, @@ -168,6 +344,9 @@ export function comboFailureDecision( if (options?.code === "input_admission_refused" || error.code === "input_admission_refused") { return "hop"; } + if (isProviderScopedQuotaCap(status, message, options?.code || error.code)) { + return "hop"; + } if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) { return "stop"; } diff --git a/src/combos/index.ts b/src/combos/index.ts index 502e210dc6..982f87c9e1 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -31,11 +31,17 @@ export { } from "./resolve"; export { clearComboTargetCooldowns, + comboCooldownRetryAfterSeconds, + COMBO_REQUEST_RATE_COOLDOWN_MS, coolComboTarget, isComboTargetInCooldown, + isTransientRequestRateLimit, parseRetryAfterMs, + remainingComboCooldownMs, comboFailureDecision, + comboFailureCooldownScope, type ComboFailureDecision, + type ComboFailureCooldownScope, } from "./failover"; export { comboIdFromRawBody, diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index 4dc5cc0298..56d7dd8fd1 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -1,6 +1,7 @@ import type { OcxComboTarget, OcxConfig } from "../types"; import { getCachedProviderQuota } from "../providers/quota-routing-cache"; -import { coolComboTarget, isComboTargetInCooldown } from "./failover"; +import type { ProviderQuota } from "../providers/quota-types"; +import { coolComboTarget, isComboTargetInCooldown, type ComboFailureCooldownScope } from "./failover"; import { quotaResetRemainingMs } from "./reset-window"; import { getCombo, resolveComboId, targetKey } from "./types"; import type { NormalizedComboConfig } from "./types"; @@ -58,6 +59,28 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget): bool && config.providers[target.provider]?.disabled !== true; } +function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean { + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 100) return false; + return typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt > now; +} + +export function cachedProviderQuotaIsExhausted( + quota: ProviderQuota | null, + now = Date.now(), +): boolean { + if (!quota) return false; + if (quotaWindowExhausted(quota.fiveHourPercent, quota.fiveHourResetAt, now)) return true; + if (quotaWindowExhausted(quota.weeklyPercent, quota.weeklyResetAt, now)) return true; + if (quotaWindowExhausted(quota.monthlyPercent, quota.monthlyResetAt, now)) return true; + if (quota.customWindows?.some(window => quotaWindowExhausted(window.percent, window.resetAt, now))) return true; + if (quota.creditsUsd?.unlimited !== true + && typeof quota.creditsUsd?.percent === "number" + && Number.isFinite(quota.creditsUsd.percent) + && quota.creditsUsd.percent >= 100 + && quota.creditsUsd.remaining <= 0) return true; + return false; +} + function smoothWeightedIndex( targets: Required[], state: SelectionState, @@ -121,14 +144,17 @@ export function pickComboTarget( options: { exclude?: Iterable; eligible?: (target: Required) => boolean; + now?: number; } = {}, ): ComboPick | null { const writerGeneration = captureConfigGeneration(); const combo = getCombo(config, comboId); if (!combo) throw new UnknownComboError(comboId); const excluded = new Set(options.exclude ?? []); + const now = options.now ?? Date.now(); const eligible = (target: Required): boolean => targetProviderIsUsable(config, target) + && !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now) && !excluded.has(targetKey(target)) && (options.eligible?.(target) ?? true); @@ -187,7 +213,7 @@ export function pickComboTarget( } } } else if (combo.strategy === "reset-window") { - targetIndex = resetWindowIndex(combo.targets, eligible); + targetIndex = resetWindowIndex(combo.targets, eligible, now); } else { targetIndex = combo.targets.findIndex(eligible); } @@ -250,15 +276,26 @@ export function advanceComboAfterFailure( retryAfter?: string | null; now?: number; eligible?: (target: Required) => boolean; + cooldownScope?: ComboFailureCooldownScope; + status?: number; + code?: string | null; + message?: string; } = {}, ): ComboPick | null { noteComboFailure(pick.comboId, pick.target, pick.writerGeneration); - coolComboTarget(pick.comboId, pick.target, { - ...options, - writerGeneration: pick.writerGeneration, - }); + const combo = getCombo(config, pick.comboId); + const cooldownTargets = options.cooldownScope === "provider" && combo + ? combo.targets.filter(target => target.provider === pick.target.provider) + : [pick.target]; + for (const target of cooldownTargets) { + coolComboTarget(pick.comboId, target, { + ...options, + writerGeneration: pick.writerGeneration, + }); + } return pickComboTarget(config, pick.comboId, { exclude: pick.attempted, + now: options.now, eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now) && (options.eligible?.(target) ?? true), }); diff --git a/src/config.ts b/src/config.ts index 0641f772eb..6cd87ef29f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1010,7 +1010,9 @@ const configSchema = z.object({ // A malformed present client block must remain diagnosable from raw config and // fail closed through src/client/state.ts; unrelated provider state still loads. client: clientConnectionSchema.optional().catch(undefined), - managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024), + managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024).describe( + "Deprecated compatibility limit for bounded legacy usage readers; GET /api/usage always aggregates the complete ledger", + ), // Invalid hand edits disable only this opt-in circuit. Live writes remain strict. upstreamHostCircuitThreshold: z.number().int() .min(0) @@ -1046,6 +1048,8 @@ const configSchema = z.object({ providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), defaultModelAliases: z.boolean().optional(), + // Malformed hand edits disable this opt-in projection without rejecting providers. + cursorEffortRows: z.boolean().optional().catch(false), // Future versions remain opaque through passthrough-compatible whole-config saves. // Only version 1 grants deletion authority in the rebase path. configRebaseProvenance: z.unknown().optional(), diff --git a/src/generated/model-metadata.ts b/src/generated/model-metadata.ts index dea2a49baf..73b9dee5d5 100644 --- a/src/generated/model-metadata.ts +++ b/src/generated/model-metadata.ts @@ -42,7 +42,7 @@ const DATA: Record = { "azure-openai": [["gpt-4.1",1047576,32768,"text,image",0,null,2,8,0.5,0],["gpt-4o",128000,16384,"text,image",0,null,2.5,10,1.25,0],["gpt-4o-mini",128000,16384,"text,image",0,null,0.15,0.6,0.075,0],["o3",200000,100000,"text,image",1,null,2,8,0.5,0],["o3-mini",200000,100000,"text",1,null,1.1,4.4,0.55,0]], "cerebras": [["gemma-4-31b",131072,40960,"text,image",1,null,0.99,1.49,0,0],["gpt-oss-120b",131072,40960,"text",1,null,0.35,0.75,0,0],["llama3.1-8b",32000,8000,"text",0,null,0.1,0.1,0,0],["qwen-3-235b-a22b-instruct-2507",131000,32000,"text",0,null,0.6,1.2,0,0],["qwen-3-coder-480b",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.6",131072,32768,"text",0,null,0,0,0,0],["zai-glm-4.7",131072,40960,"text",1,null,2.25,2.75,2.25,0]], "deepseek": [["deepseek-v4-flash",1048576,384000,"text",1,null,0.14,0.28,0.0028,0],["deepseek-v4-pro",1048576,384000,"text",1,null,0.435,0.87,0.003625,0]], - "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]], + "google": [["deep-research-max-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["deep-research-preview-04-2026",131072,65536,"text,image",1,null,2,12,0.2,0],["gemini-1.5-flash",1000000,8192,"text,image",0,null,0.075,0.3,0.01875,0],["gemini-1.5-flash-8b",1000000,8192,"text,image",0,null,0.0375,0.15,0.01,0],["gemini-1.5-pro",1000000,8192,"text,image",0,null,1.25,5,0.3125,0],["gemini-2.0-flash",1048576,8192,"text,image",0,null,0.1,0.4,0.025,0],["gemini-2.0-flash-lite",1048576,8192,"text,image",0,null,0.075,0.3,0,0],["gemini-2.5-computer-use-preview-10-2025",131072,65536,"text,image",1,null,1.25,10,0,0],["gemini-2.5-flash",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-2.5-flash-lite",1048576,65536,"text,image",1,null,0.1,0.4,0.01,0],["gemini-2.5-flash-lite-preview-06-17",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-lite-preview-09-2025",1048576,65536,"text,image",1,null,0.1,0.4,0.025,0],["gemini-2.5-flash-preview-04-17",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-05-20",1048576,65536,"text,image",1,null,0.15,0.6,0.0375,0],["gemini-2.5-flash-preview-09-2025",1048576,65536,"text,image",1,null,0.3,2.5,0.075,0],["gemini-2.5-pro",1048576,65536,"text,image",1,null,1.25,10,0.125,0],["gemini-2.5-pro-preview-05-06",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-2.5-pro-preview-06-05",1048576,65536,"text,image",1,null,1.25,10,0.31,0],["gemini-3-flash-preview",1048576,65536,"text,image",1,null,0.5,3,0.05,0],["gemini-3-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-flash-lite",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-lite-image",65536,65536,"text,image",1,null,0.25,30,0,0],["gemini-3.1-flash-lite-preview",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-3.1-flash-live-preview",131072,65536,"text,image",1,null,0.75,4.5,0,0],["gemini-3.1-pro-preview",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.1-pro-preview-customtools",1048576,65536,"text,image",1,null,2,12,0.2,0],["gemini-3.5-flash",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-3.5-flash-lite",1048576,65536,"text,image",1,null,0.3,2.5,0.03,0],["gemini-3.6-flash",1048576,65536,"text,image",1,null,1.5,7.5,0.15,0],["gemini-3.7-flash",1048576,65536,"text,image",1],["gemini-3.8-flash",1048576,65536,"text,image",1],["gemini-flash-latest",1048576,65536,"text,image",1,null,1.5,9,0.15,0],["gemini-flash-lite-latest",1048576,65536,"text,image",1,null,0.25,1.5,0.025,0],["gemini-live-2.5-flash",128000,8000,"text,image",1,null,0.5,2,0,0],["gemini-live-2.5-flash-preview-native-audio",131072,65536,"text",1,null,0.5,2,0,0],["gemini-robotics-er-1.6-preview",131072,65536,"text,image",1,null,1,5,0,0],["gemma-3-27b-it",131072,8192,"text,image",0,null,0,0,0,0],["gemma-4-26b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-26b-a4b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-26b-it",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b",256000,8192,"text,image",1,null,0,0,0,0],["gemma-4-31b-it",262144,32768,"text,image",1,null,0,0,0,0],["gemma-4-E2B-it",131072,8192,"text,image",1,null,0,0,0,0],["gemma-4-E4B-it",131072,8192,"text,image",1,null,0,0,0,0]], "minimax": [["MiniMax-M2",196608,128000,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.1",204800,131072,"text",1,null,0.3,1.2,0,0],["MiniMax-M2.5",204800,131072,"text",1,null,0.3,1.2,0.03,0.375],["MiniMax-M2.5-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["MiniMax-M2.5-lightning",204800,32000,"text",1,null,0.3,2.4,0,0],["MiniMax-M2.7",204800,131072,"text",1,null,0.3,1.2,0.06,0.375],["MiniMax-M2.7-highspeed",204800,131072,"text",1,null,0.6,2.4,0.06,0.375],["minimax-m3",512000,128000,"text,image",1,null,0.6,2.4,0.12,0],["MiniMax-M3",1000000,128000,"text,image,video",1,null,0.3,1.2,0.06,0]], "mistral": [["codestral-latest",256000,4096,"text",0,null,0.3,0.9,0,0],["devstral-2512",262144,262144,"text",0,null,0.4,2,0,0],["devstral-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-medium-2507",128000,128000,"text",0,null,0.4,2,0,0],["devstral-medium-latest",262144,262144,"text",0,null,0.4,2,0,0],["devstral-small-2505",128000,128000,"text",0,null,0.1,0.3,0,0],["devstral-small-2507",128000,128000,"text",0,null,0.1,0.3,0,0],["labs-devstral-small-2512",256000,256000,"text,image",0,null,0,0,0,0],["magistral-medium-latest",128000,16384,"text",1,null,2,5,0,0],["magistral-small",128000,128000,"text",1,null,0.5,1.5,0,0],["ministral-3b-latest",128000,128000,"text",0,null,0.04,0.04,0,0],["ministral-8b-latest",128000,128000,"text",0,null,0.1,0.1,0,0],["mistral-large-2411",131072,16384,"text",0,null,2,6,0,0],["mistral-large-2512",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-large-latest",262144,262144,"text,image",0,null,0.5,1.5,0,0],["mistral-medium-2505",131072,131072,"text,image",0,null,0.4,2,0,0],["mistral-medium-2508",262144,262144,"text,image",0,null,0.4,2,0,0],["mistral-medium-2604",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-medium-latest",262144,262144,"text,image",1,null,1.5,7.5,0,0],["mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["mistral-small-2506",128000,16384,"text,image",0,null,0.1,0.3,0,0],["mistral-small-2603",256000,256000,"text,image",1,null,0.15,0.6,0,0],["mistral-small-latest",256000,256000,"text,image",1,null,0.15,0.6,0,0],["open-mistral-7b",8000,8000,"text",0,null,0.25,0.25,0,0],["open-mistral-nemo",128000,128000,"text",0,null,0.15,0.15,0,0],["open-mixtral-8x22b",64000,64000,"text",0,null,2,6,0,0],["open-mixtral-8x7b",32000,32000,"text",0,null,0.7,0.7,0,0],["pixtral-12b",128000,128000,"text,image",0,null,0.15,0.15,0,0],["pixtral-large-latest",128000,128000,"text,image",0,null,2,6,0,0]], "moonshot": [["kimi-k2.5",262144,65536,"text,image",1,null,0,0,0,0]], diff --git a/src/grok/sync.ts b/src/grok/sync.ts index 561df07dbf..59fc1992b7 100644 --- a/src/grok/sync.ts +++ b/src/grok/sync.ts @@ -7,6 +7,7 @@ * Deps are injectable (mirrors src/codex/sync.ts) so tests can run without a live proxy. */ import type { CatalogModel } from "../codex/catalog"; +import { standaloneCodexRoutingTarget } from "../codex/inject"; import type { OcxConfig } from "../types"; import { projectGrokCatalog } from "./catalog"; import { injectGrokConfig, type GrokInjectResult } from "./inject"; @@ -47,8 +48,15 @@ export async function syncGrokConfig( // Pass the FULL list plus the exclusion set: the writer allocates aliases over // everything and emits only what is switched on, so a model's alias never depends on // its neighbours' switches. Absent/empty selection keeps today's behaviour exactly. - return deps.injectGrokConfig(port, projection.models, { - ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}), + const target = standaloneCodexRoutingTarget(port, { + hostname: opts.hostname ?? config.hostname, + unauthenticatedLoopbackListener: config.unauthenticatedLoopbackListener, + }); + const targetUrl = new URL(target.baseUrl); + return deps.injectGrokConfig(Number(targetUrl.port), projection.models, { + hostname: target.requiresAdmissionToken + ? (opts.hostname ?? config.hostname) + : targetUrl.hostname, ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}), excluded: new Set(config.grokExcludedModels ?? []), catalogModelIds: projection.catalogModelIds, diff --git a/src/integrations/cursor-effort-table.ts b/src/integrations/cursor-effort-table.ts new file mode 100644 index 0000000000..bca6a53c30 --- /dev/null +++ b/src/integrations/cursor-effort-table.ts @@ -0,0 +1,143 @@ +/** + * Cursor's local-agent effort table, read from the installed bundle. + * + * Cursor Private Inference decides which model rows get a Reasoning control from a table + * compiled into extensions/cursor-agent-exec/dist/main.js, not from the gateway's + * reasoning_effort list (devlog 260902_cursor_bundle_effort_table/000). Reading that table + * from the install the dashboard already detects lets the prediction follow a Cursor update + * instead of a hand-copied mirror. Read-only, size-bounded, cached by (path, mtime, size); + * any parse failure yields null so the caller falls back to the static mirror. + */ +import { readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import type { CursorInstall } from "./cursor-detect"; + +export interface CursorEffortFamily { + id: string; + pattern: RegExp; + /** [] = family matched but Cursor shows no control. */ + ladder: readonly string[]; + param?: "reasoning_effort" | "output_config.effort"; + defaultValue?: string; + outputCap?: number; + requiresReasoningCapability: boolean; +} + +export interface CursorBareGpt5Rule { + pattern: RegExp; + ladder: readonly string[]; + defaultValue: string; +} + +export interface CursorEffortTable { + families: readonly CursorEffortFamily[]; + /** The bare gpt-5 / gpt-5.x rule that runs when no family matched. */ + bareGpt5: CursorBareGpt5Rule | null; + version: string | null; + bundlePath: string; +} + +const BUNDLE_MAX_BYTES = 32 * 1024 * 1024; + +/** Bundle path under the install root cursor-detect reports. */ +export function cursorAgentBundlePath(install: Pick, platform: string = process.platform): string { + const tail = ["extensions", "cursor-agent-exec", "dist", "main.js"]; + return platform === "darwin" + ? join(install.path, "Contents", "Resources", "app", ...tail) + : join(install.path, "resources", "app", ...tail); +} + +/** + * Parse the family table out of the minified source: + * const w={param:"reasoning_effort",values:[...],defaultValue:"medium"}; + * ...const T={...},k={...},S={...},b=[{id:"...",matches:e=>/.../u.test(e),effort:k,outputCap:128e3},...]; + * Identifier names are minifier-assigned, so binding is by structure: every + * ={param:"...",values:[...],defaultValue:"..."} is an effort constant, and a family's + * effort: is either such an identifier or an inline object. + */ +export function parseCursorEffortTable(source: string): Omit | null { + const constants = new Map(); + const constRe = /(?:const |,)([A-Za-z_$][\w$]*)=\{param:"(reasoning_effort|output_config\.effort)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/gu; + for (const m of source.matchAll(constRe)) { + constants.set(m[1]!, { param: m[2]!, values: splitStrings(m[3]!), defaultValue: m[4]! }); + } + const tableStart = source.indexOf('=[{id:"anthropic-'); + if (tableStart === -1) return null; + const tableEnd = source.indexOf("];", tableStart); + if (tableEnd === -1) return null; + const body = source.slice(tableStart + 2, tableEnd + 1); + const entryRe = /\{id:"([^"]+)",matches:e=>\/((?:\\\/|[^/])+)\/([a-z]*)\.test\(e\)((?:,(?:effort:(?:[A-Za-z_$][\w$]*|\{[^}]*\})|outputCap:[\de.]+|effortRequiresReasoningCapability:!0))*)\}/gu; + const families: CursorEffortFamily[] = []; + // Every "{id:" opener in the window must be consumed by entryRe. A build that adds a + // property to one family would otherwise drop that family silently and the caller would + // report a bundle-sourced "no control" for it instead of falling back to the mirror. + const openers = body.split('{id:"').length - 1; + for (const m of body.matchAll(entryRe)) { + let pattern: RegExp; + try { pattern = new RegExp(m[2]!, m[3]!); } catch { return null; } + const tail = m[4]!; + const effortRef = /effort:([A-Za-z_$][\w$]*)(?:,|$)/u.exec(tail)?.[1]; + const inline = /effort:\{param:"([^"]+)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/u.exec(tail); + const effort = inline + ? { param: inline[1]!, values: splitStrings(inline[2]!), defaultValue: inline[3]! } + : effortRef ? constants.get(effortRef) : undefined; + if (effortRef && !inline && !effort) return null; // unknown constant: structure changed + const cap = /outputCap:([\de.]+)/u.exec(tail)?.[1]; + families.push({ + id: m[1]!, + pattern, + ladder: effort?.values ?? [], + ...(effort ? { param: effort.param as CursorEffortFamily["param"], defaultValue: effort.defaultValue } : {}), + ...(cap ? { outputCap: Number(cap) } : {}), + requiresReasoningCapability: tail.includes("effortRequiresReasoningCapability:!0"), + }); + } + if (families.length === 0 || families.length !== openers) return null; + // The tested variable and the returned constant are minifier-assigned names; bind by shape. + const bareRe = /if\(\/(\^gpt-5[^/]+)\/([a-z]*)\.test\([A-Za-z_$][\w$]*\)\)return ([A-Za-z_$][\w$]*)\}/u.exec(source); + const bareConst = bareRe ? constants.get(bareRe[3]!) : undefined; + let bareGpt5: CursorBareGpt5Rule | null = null; + if (bareRe && bareConst) { + let pattern: RegExp; + try { pattern = new RegExp(bareRe[1]!, bareRe[2]!); } catch { return null; } + bareGpt5 = { pattern, ladder: bareConst.values, defaultValue: bareConst.defaultValue }; + } + return { families, bareGpt5 }; +} + +function splitStrings(list: string): string[] { + return [...list.matchAll(/"([^"]+)"/gu)].map(m => m[1]!); +} + +export interface CursorEffortTableDeps { + platform: string; + stat(path: string): { mtimeMs: number; size: number } | null; + readText(path: string): string | null; +} + +export function realCursorEffortTableDeps(): CursorEffortTableDeps { + return { + platform: process.platform, + stat: path => { try { const s = statSync(path); return { mtimeMs: s.mtimeMs, size: s.size }; } catch { return null; } }, + readText: path => { try { return readFileSync(path, "utf8"); } catch { return null; } }, + }; +} + +let cache: { key: string; table: CursorEffortTable | null } | null = null; + +/** Table from the Private Inference install, else null (caller falls back to the static mirror). */ +export function loadCursorEffortTable(install: CursorInstall | undefined, deps: CursorEffortTableDeps = realCursorEffortTableDeps()): CursorEffortTable | null { + if (!install) return null; + const bundlePath = cursorAgentBundlePath(install, deps.platform); + const st = deps.stat(bundlePath); + if (!st || st.size > BUNDLE_MAX_BYTES) return null; + const key = `${bundlePath}|${st.mtimeMs}|${st.size}`; + if (cache?.key === key) return cache.table; + const text = deps.readText(bundlePath); + const parsed = text ? parseCursorEffortTable(text) : null; + const table = parsed ? { ...parsed, version: install.version, bundlePath } : null; + cache = { key, table }; + return table; +} + +export function resetCursorEffortTableCacheForTests(): void { cache = null; } diff --git a/src/integrations/state.ts b/src/integrations/state.ts index f4eb12cadf..71dd93a71d 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -369,7 +369,7 @@ export function exportContextOf(input: { * loopback, and every client we write into deserves the same answer the * export command already gives. */ - baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname), + baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname, input.config), models: input.models, config: input.config, }; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 422aa680ea..4aa0944c80 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -12,7 +12,7 @@ import { homedir } from "node:os"; import { dirname } from "node:path"; import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; -import { isLoopbackHostname } from "../codex/inject"; +import { shouldInjectApiAuthHeader } from "../codex/inject"; import type { OcxConfig } from "../types"; import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { @@ -290,7 +290,7 @@ function applyOrRefreshIntegration( if (io.statKind(detectDir) !== "dir") { return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`); } - if (isLoopbackOnly(clientId) && !isLoopbackHostname(input.config.hostname)) { + if (isLoopbackOnly(clientId) && shouldInjectApiAuthHeader(input.config)) { return refuse(clientId, "non_loopback", classified.state, `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`); } diff --git a/src/lib/app-owned-memory-stores.ts b/src/lib/app-owned-memory-stores.ts index 032be3ff0b..a4c3dbad43 100644 --- a/src/lib/app-owned-memory-stores.ts +++ b/src/lib/app-owned-memory-stores.ts @@ -38,6 +38,10 @@ import { discardRetainedUsageSnapshot, retainedUsageSnapshotStats, } from "../usage/log"; +import { + discardRetainedUsageAggregate, + usageAggregateRetainedStats, +} from "../server/management/usage-aggregate-cache"; import { cursorBlobRetainedStoreSnapshot, evictOldestCursorBlobForBudget, @@ -61,18 +65,33 @@ function ringSnapshot(metrics: { entries: number; bytes: number; oldestAt: numbe }; } -/** The retained usage tail is a single all-or-nothing entry: evicting it drops the whole tail. */ +/** Legacy parsed tail and streaming aggregate share one stable public store id. */ function usageSnapshotRetainedStoreSnapshot(): RetainedStoreSnapshot { - const stats = retainedUsageSnapshotStats(); + const legacy = retainedUsageSnapshotStats(); + const aggregate = usageAggregateRetainedStats(); + const oldest = [legacy.oldestAt, aggregate.oldestAt] + .filter((value): value is number => value !== null) + .sort((a, b) => a - b)[0] ?? null; return { - count: stats.count, - bytes: stats.bytes, - evictableBytes: stats.bytes, - pinnedBytes: 0, - oldestAt: stats.oldestAt, + count: legacy.count + aggregate.count, + bytes: legacy.bytes + aggregate.bytes, + evictableBytes: legacy.bytes + aggregate.evictableBytes, + pinnedBytes: aggregate.pinnedBytes, + oldestAt: oldest, }; } +function evictOldestUsageSnapshot(): number { + const legacy = retainedUsageSnapshotStats(); + const aggregate = usageAggregateRetainedStats(); + if (legacy.bytes > 0 + && (aggregate.evictableBytes === 0 + || (legacy.oldestAt ?? Number.POSITIVE_INFINITY) <= (aggregate.oldestAt ?? Number.POSITIVE_INFINITY))) { + return discardRetainedUsageSnapshot(); + } + return discardRetainedUsageAggregate(); +} + function providerDebugSnapshot(): RetainedStoreSnapshot { return ringSnapshot(debugBufferMetrics()); } @@ -154,7 +173,7 @@ export const APP_OWNED_RETAINED_STORE_REGISTRATIONS = [ id: "usage_snapshot", category: "caches", snapshot: usageSnapshotRetainedStoreSnapshot, - evictOldest: discardRetainedUsageSnapshot, + evictOldest: evictOldestUsageSnapshot, }, { id: "cursor_blobs", diff --git a/src/lib/bounded-body.ts b/src/lib/bounded-body.ts index 909c925646..4016a0a753 100644 --- a/src/lib/bounded-body.ts +++ b/src/lib/bounded-body.ts @@ -1,3 +1,5 @@ +import { idleDeadline } from "./abort"; + /** Maximum number of response-body bytes that may be retained for an error. */ export const BOUNDED_BODY_MAX_BYTES = 65_536; @@ -49,6 +51,8 @@ export interface BoundedBytesOptions { signal?: AbortSignal; /** Maximum number of raw bytes retained from the response body. */ maxBytes: number; + /** Deadline between non-empty raw chunks. Omitted means no body-read deadline. */ + inactivityTimeoutMs?: number; } export interface BoundedBytesResult { @@ -136,6 +140,15 @@ export async function readBoundedResponseBytes( let retainedBytes = 0; let mustCancel = false; let cancelReason: unknown; + const inactivityReason = new DOMException("Response body stalled", "TimeoutError"); + let rejectForInactivity: ((reason: unknown) => void) | undefined; + const inactive = new Promise((_resolve, reject) => { + rejectForInactivity = reject; + }); + const inactivity = options.inactivityTimeoutMs === undefined + ? null + : idleDeadline(options.inactivityTimeoutMs, () => rejectForInactivity?.(inactivityReason)); + inactivity?.reset(); let rejectForAbort: ((reason: unknown) => void) | undefined; const aborted = new Promise((_resolve, reject) => { @@ -151,7 +164,7 @@ export async function readBoundedResponseBytes( const read = reader.read(); // Observe a late read rejection when abort/cancellation wins the race. void read.catch(() => undefined); - const outcome = await Promise.race([read, aborted]); + const outcome = await Promise.race([read, aborted, inactive]); if (signal?.aborted) { mustCancel = true; cancelReason = signal.reason; @@ -163,6 +176,7 @@ export async function readBoundedResponseBytes( return { bytes: retained.subarray(0, retainedBytes), oversized: false }; } if (!value || value.byteLength === 0) continue; + inactivity?.reset(); if (value.byteLength > maxBytes - retainedBytes) { mustCancel = true; @@ -187,6 +201,7 @@ export async function readBoundedResponseBytes( cancelReason = error; throw error; } finally { + inactivity?.cancel(); signal?.removeEventListener("abort", onAbort); if (mustCancel) cancelWithoutWaiting(reader, cancelReason); try { diff --git a/src/oauth/generic-account-failover.ts b/src/oauth/generic-account-failover.ts index a36974a629..785b9f7bfa 100644 --- a/src/oauth/generic-account-failover.ts +++ b/src/oauth/generic-account-failover.ts @@ -202,11 +202,11 @@ export function rotateGenericOAuthAccountOn429( // A single stored account has nowhere to go; rotating to itself would just replay the 429. if (!set || set.accounts.length < 2) return null; - const parsed = parseRetryAfterMs(retryAfterHeader, now); + const parsed = parseRetryAfterMs(retryAfterHeader, now, { preserveImmediate: true }); // An account whose allowance is provably spent gets a reset-aligned cooldown instead of // the default minute: retrying it every 60s until the window rolls over is pure waste. // A Retry-After from upstream still wins — it is the server's own instruction. - const exhausted = parsed === null ? exhaustedCooldownMs(providerName, failedAccountId, now) : null; + const exhausted = parsed === undefined ? exhaustedCooldownMs(providerName, failedAccountId, now) : null; const cooldownMs = exhausted ?? Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS); health.set(healthKey(providerName, failedAccountId), { cooldownUntil: now + cooldownMs, diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 92bc74b3fa..868c4f9b99 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -39,6 +39,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity" import { loginCursor, refreshCursorToken } from "./cursor"; import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot"; import { loginCommandCode, refreshCommandCodeToken } from "./command-code"; +import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse"; import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys"; @@ -228,6 +229,16 @@ export const OAUTH_PROVIDERS: Record = { providerConfig: oauthConfig("kimi"), defaultModel: oauthDefaultModel("kimi"), }, + "meta-muse": { + login: ctrl => loginMetaMuse(ctrl), + refresh: refreshMetaMuseToken, + providerConfig: oauthConfig("meta-muse"), + defaultModel: oauthDefaultModel("meta-muse"), + // Static API key that Meta scopes to its own CLI. Never generate unattended traffic + // on it — same posture as anthropic, for the same reason: the vendor restricts use + // outside its own client, so every exchange stays attributable to a user action. + defaultRefreshPolicy: "disabled", + }, nous: { // Nous Portal device-grant login (RFC 8628) against portal.nousresearch.com. // The access token is the per-request inference JWT (scope inference:invoke). diff --git a/src/oauth/meta-muse.ts b/src/oauth/meta-muse.ts new file mode 100644 index 0000000000..6839be42b3 --- /dev/null +++ b/src/oauth/meta-muse.ts @@ -0,0 +1,235 @@ +/** + * Meta Muse Code credential import. + * + * The Muse Code CLI signs in through a browser device-approval flow and stores the + * result in two places: `~/.config/muse/auth.json` is a POINTER carrying no secret, and + * the secret itself lives in the macOS Keychain under service + * `ai.meta.dev.credentials`, account `meta`. + * + * Two measured facts shape this module (devlog/_plan/260903_muse_spark_plan_oauth/003): + * + * 1. The Keychain payload holds BOTH an `access_token` and an `api_key`, and only the + * `api_key` authenticates the Model API — the OAuth access token returns 401 + * `invalid_api_key`. So this is a static-key credential, not a refreshable one. + * 2. Meta scopes that credential to the Muse Code CLI in writing. Reusing it here is an + * UNSUPPORTED path the repository owner opted into deliberately, which is why the + * warning below fires before anything is read and why the provider sits in the GUI's + * HIGH_RISK ToS map. + * + * This module never spawns the CLI. A login that finds no credential explains what to + * run rather than running it: `muse login` is interactive with no machine-readable mode, + * so a spawned child could outlive cancellation, and polling for the pointer file would + * be satisfied instantly by the one already on disk — reimporting the OLD account on a + * force-login. + */ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { sanitizeApiKeyValue } from "../providers/api-keys"; +import type { OAuthController, OAuthCredentials } from "./types"; + +const MUSE_POINTER_PATH = join(homedir(), ".config", "muse", "auth.json"); +const KEYCHAIN_SERVICE = "ai.meta.dev.credentials"; +const KEYCHAIN_ACCOUNT = "meta"; +const MODELS_URL = "https://api.meta.ai/v1/models"; +const VALIDATE_TIMEOUT_MS = 10_000; +const KEYCHAIN_TIMEOUT_MS = 5_000; + +/** + * Shown BEFORE any credential is read. + * + * `login-cli.ts` passes `onProgress` straight to `console.log` and never reads the + * registry note, so this is the CLI's only warning surface. The GUI ignores it because + * `OAuthTosWarningModal` has already been acknowledged by then. + */ +const CONSENT_WARNING = [ + "Meta scopes the Muse Code credential to the Muse Code CLI.", + "Using it here is UNSUPPORTED: Meta does not authorize subscription coverage outside its own CLI,", + "how these calls settle is not observable from the API, and you should treat every call as billable.", + "The imported key is copied into OpenCodex's auth store (~/.opencodex/auth.json, 0600).", + "Supported alternative: the meta-model provider with your own key (META_MODEL_API_KEY).", +].join(" "); + +/** The Keychain payload. `access_token` is deliberately unused — it 401s (003 §B). */ +interface MuseKeychainSecret { + api_key?: unknown; + access_token?: unknown; +} + +interface MusePointer { + providers?: { meta?: { mechanism?: unknown; storage?: unknown; user_email?: unknown } }; +} + +/** Injected so tests never touch the real Keychain, filesystem, platform, or network. */ +export interface MuseImportDeps { + platform?: string; + readPointer?: () => Promise; + readKeychain?: (signal?: AbortSignal) => Promise; + fetchImpl?: typeof fetch; +} + +async function defaultReadPointer(): Promise { + try { + return await Bun.file(MUSE_POINTER_PATH).text(); + } catch { + return null; + } +} + +/** + * `security` can block indefinitely — the Keychain may raise an interactive approval + * prompt, and on a headless or locked machine nobody answers it. Without a deadline the + * login would hang before the validation timeout below is even created, so the bound + * lives here rather than only around the fetch. + */ +async function defaultReadKeychain(signal?: AbortSignal): Promise { + const deadline = signal + ? AbortSignal.any([signal, AbortSignal.timeout(KEYCHAIN_TIMEOUT_MS)]) + : AbortSignal.timeout(KEYCHAIN_TIMEOUT_MS); + let proc: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined; + try { + proc = Bun.spawn( + ["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"], + { stdout: "pipe", stderr: "pipe" }, + ); + const child = proc; + const finished = Promise.all([new Response(child.stdout).text(), child.exited]); + const timedOut = new Promise((resolve) => { + if (deadline.aborted) { resolve(null); return; } + deadline.addEventListener("abort", () => resolve(null), { once: true }); + }); + const settled = await Promise.race([finished, timedOut]); + if (settled === null) return null; + const [out, code] = settled; + if (code !== 0) return null; + const trimmed = out.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } finally { + // A prompt still on screen keeps the child alive after the race resolves. + if (proc && proc.exitCode === null) { try { proc.kill(); } catch { /* already gone */ } } + } +} + +const INSTALL_HINT = + "Install it from https://dev.meta.ai/install.sh, run `muse login`, then retry."; + +function normalizedEmail(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim().toLowerCase(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Import the credential the Muse Code CLI already holds. + * + * Every refusal names what the user should do. None of them includes the credential. + */ +export async function loginMetaMuse( + ctrl: OAuthController = {}, + deps: MuseImportDeps = {}, +): Promise { + // Before ANY read: the CLI has no other warning surface. + ctrl.onProgress?.(CONSENT_WARNING); + + const platform = deps.platform ?? process.platform; + if (platform !== "darwin") { + throw new Error( + "Meta Muse Code login is macOS-only: the CLI stores its credential in the macOS Keychain, " + + "and no other platform's storage has been verified. Use the meta-model provider with your own key instead.", + ); + } + + const pointerRaw = await (deps.readPointer ?? defaultReadPointer)(); + if (pointerRaw === null) { + throw new Error(`Muse Code CLI credential not found at ${MUSE_POINTER_PATH}. ${INSTALL_HINT}`); + } + + let pointer: MusePointer; + try { + pointer = JSON.parse(pointerRaw) as MusePointer; + } catch { + throw new Error(`Muse Code credential file at ${MUSE_POINTER_PATH} is not valid JSON. Run \`muse login\` to rewrite it.`); + } + + const meta = pointer.providers?.meta; + if (!meta || meta.mechanism !== "oauth") { + throw new Error("The Muse Code credential file has no signed-in Meta account. Run `muse login`, then retry."); + } + // A different storage backend is a shape we have not measured; refuse rather than guess. + if (meta.storage !== "keychain") { + throw new Error( + `Muse Code stored its credential with an unsupported backend (${String(meta.storage)}); only the macOS Keychain is verified.`, + ); + } + + const secretRaw = await (deps.readKeychain ?? defaultReadKeychain)(ctrl.signal); + if (secretRaw === null) { + throw new Error( + "Could not read the Muse Code credential from the macOS Keychain within 5s. Approve the Keychain prompt, or run `muse login` again.", + ); + } + + let secret: MuseKeychainSecret; + try { + secret = JSON.parse(secretRaw) as MuseKeychainSecret; + } catch { + throw new Error("The Muse Code Keychain entry is not valid JSON. Run `muse login` to rewrite it."); + } + + // access_token is present but 401s against the Model API (003 §B) — never fall back to it. + const apiKey = sanitizeApiKeyValue(secret.api_key); + if (!apiKey) { + throw new Error("The Muse Code Keychain entry carries no usable API key. Run `muse login` again."); + } + if (!/^LLM\|\d+\|[A-Za-z0-9_-]{10,}$/.test(apiKey)) { + throw new Error("The Muse Code credential is not in the expected Meta API key format. Run `muse login` again."); + } + + ctrl.onProgress?.("Validating the imported Meta credential…"); + const fetchImpl = deps.fetchImpl ?? fetch; + // ctrl.signal is OPTIONAL and the CLI controller supplies none: AbortSignal.any([undefined]) + // throws a TypeError, which would fail every CLI login right after the warning printed. + const signal = ctrl.signal + ? AbortSignal.any([ctrl.signal, AbortSignal.timeout(VALIDATE_TIMEOUT_MS)]) + : AbortSignal.timeout(VALIDATE_TIMEOUT_MS); + let response: Response; + try { + response = await fetchImpl(MODELS_URL, { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal, + }); + } catch (error) { + if (ctrl.signal?.aborted) throw ctrl.signal.reason ?? new DOMException("Meta Muse login aborted", "AbortError"); + throw new Error(`Could not reach the Meta Model API to validate the credential: ${(error as Error).message}`); + } + if (!response.ok) { + throw new Error( + `The Muse Code credential was rejected by the Meta Model API (HTTP ${response.status}). Run \`muse login\` again.`, + ); + } + + return { + access: apiKey, + // Static key: there is nothing to exchange, so refresh carries the same value. + refresh: apiKey, + expires: Number.MAX_SAFE_INTEGER, + // `email`, not `accountId`: the account list masks email for display, and store.ts + // already falls back to it for slot identity, so multi-account still works. + ...(normalizedEmail(meta.user_email) ? { email: normalizedEmail(meta.user_email) } : {}), + source: "local-cli", + }; +} + +/** + * Static-key refresh, exactly like Command Code's. + * + * This deliberately does NOT re-read the Keychain. Generic refresh writes its result into + * the slot being refreshed, so if the user ran `muse login` with a DIFFERENT account in + * between, a re-import would silently overwrite one stored identity with another. Only an + * explicit login may import. + */ +export async function refreshMetaMuseToken(apiKey: string): Promise { + if (!apiKey) throw new Error("Meta Muse Code API key missing; run `ocx login meta-muse`"); + return { access: apiKey, refresh: apiKey, expires: Number.MAX_SAFE_INTEGER, source: "local-cli" }; +} diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index 9b9045cb9b..9b01c69cd1 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -13,14 +13,27 @@ import { isModelCacheGenerationCurrent } from "../codex/model-cache"; // ── Wire IDs (what CCA :fetchAvailableModels returns) ── /** Current Antigravity Flash generation. */ -const GEMINI_FLASH_CURRENT = "gemini-3.7-flash"; +const GEMINI_FLASH_CURRENT = "gemini-3.8-flash"; /** - * Wire ID that CCA actually accepts for the current Flash generation. - * Google renamed the model to include a `-tiered` suffix; the picker-visible - * ID stays `gemini-3.7-flash` (stripped by `pickerModelIdForDiscoveredWireId`). + * Previous Flash generation — still served, still picker-visible. + * + * 3.6 vanished from CCA the moment 3.7 shipped, which is why RETIRED_FLASH_TIERS exists. 3.8 + * did not do that: Google documents 3.7 Flash as "remains fully supported", and a 2026-09-03 + * :fetchAvailableModels call returns 3.8, 3.7 AND 3.6 wire ids together. Retiring 3.7 here + * would strand a model the backend is actively serving. + */ +const GEMINI_FLASH_PREVIOUS = "gemini-3.7-flash"; + +/** + * Wire ID that CCA accepts for the RETIRED-tier redirect target (currently 3.7). + * + * Google renamed 3.7 to carry a `-tiered` suffix; the picker-visible ID stays + * `gemini-3.7-flash` (stripped by `pickerModelIdForDiscoveredWireId`). This constant is named + * for its ROLE, not for the current generation: 3.8 is current and has no `-tiered` id, so a + * name like GEMINI_FLASH_WIRE_ID would now point readers at the wrong model. */ -const GEMINI_FLASH_WIRE_ID = "gemini-3.7-flash-tiered"; +const GEMINI_RETIRED_FLASH_TARGET_WIRE_ID = "gemini-3.7-flash-tiered"; /** * Retired Flash ids → the reasoning tier they used to encode. @@ -60,6 +73,9 @@ const ANTIGRAVITY_WIRE_MODELS = [ ]; const ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID: Record = { + "gemini-3.8-flash-low": "gemini-3.8-flash", + "gemini-3.8-flash-medium": "gemini-3.8-flash", + "gemini-3.8-flash-high": "gemini-3.8-flash", "gemini-3.1-pro-low": "gemini-3.1-pro", "gemini-pro-agent": "gemini-3.1-pro", }; @@ -143,6 +159,9 @@ function collapsesIntoKnownPickerModel(candidateId: string): boolean { // Gemini models: effort → wire model suffix (official agy UI pattern). // Claude Opus: effort → thinkingConfig.thinkingLevel (CLIProxyAPI proven pattern). export const ANTIGRAVITY_MODEL_EFFORTS: Record = { + // No `minimal`: Google documents it as an error for this generation, and CCA exposes only + // the three tiers. + "gemini-3.8-flash": ["low", "medium", "high"], "gemini-3.7-flash": ["low", "medium", "high"], "gemini-3.1-pro": ["low", "high"], "claude-sonnet-4-6": ["low", "medium", "high", "max"], @@ -151,12 +170,32 @@ export const ANTIGRAVITY_MODEL_EFFORTS: Record = { // ── Effort → wire model map for Gemini base models ── const ANTIGRAVITY_EFFORT_WIRE_MAP: Record> = { + // 3.8 publishes one wire id per tier and no `-tiered` row, so its efforts ride the suffix. + // This is the 3.6 shape, not the 3.7 one. + "gemini-3.8-flash": { + low: "gemini-3.8-flash-low", + medium: "gemini-3.8-flash-medium", + high: "gemini-3.8-flash-high", + }, "gemini-3.1-pro": { low: "gemini-3.1-pro-low", high: "gemini-pro-agent", }, }; +/** + * Base models whose every effort maps to a wire id that ALREADY encodes the tier. + * + * Sending `thinkingLevel` beside such a suffix states the effort twice, and CCA does not reject + * the contradiction — a `-low` wire id paired with `HIGH` returns 200, so the tier that actually + * ran becomes unknowable from the response. Membership also makes static resolution + * byte-identical to the discovery path, which never emits a thinking level. + * + * `gemini-3.1-pro` is deliberately absent: its `high` rung is `gemini-pro-agent`, which carries + * no tier suffix, so there the level is the only thing naming the effort. + */ +const ANTIGRAVITY_SUFFIX_TIER_MODELS = new Set(["gemini-3.8-flash"]); + function completeDiscoveredEffortWireModelIds( pickerId: string, available: ReadonlyMap>, @@ -178,6 +217,8 @@ function completeDiscoveredEffortWireModelIds( // ── Default effort per Gemini base model ── const ANTIGRAVITY_DEFAULT_EFFORT: Record = { + // Google's documented thinking_level default, and the tier CCA marks `recommended`. + "gemini-3.8-flash": "medium", "gemini-3.1-pro": "high", }; @@ -198,7 +239,7 @@ const ANTIGRAVITY_THINKING_LEVELS = new Set(["low", "medium", "high"]); * Models not listed here use themselves as the wire ID. */ const ANTIGRAVITY_PICKER_TO_WIRE: Record = { - "gemini-3.7-flash": GEMINI_FLASH_WIRE_ID, + "gemini-3.7-flash": GEMINI_RETIRED_FLASH_TARGET_WIRE_ID, }; /** Map a picker-visible base model to its CCA wire ID. Identity when no mapping exists. */ @@ -230,7 +271,7 @@ const ANTIGRAVITY_COMPATIBILITY_MODEL_ALIASES: Record = { // because `parseAntigravityAvailableModels` uses THIS map to keep a stale CCA // payload from republishing a dead wire id as a picker row. ...Object.fromEntries( - Object.keys(RETIRED_FLASH_TIERS).map(retired => [retired, GEMINI_FLASH_WIRE_ID]), + Object.keys(RETIRED_FLASH_TIERS).map(retired => [retired, GEMINI_RETIRED_FLASH_TARGET_WIRE_ID]), ), }; @@ -242,6 +283,7 @@ export const ANTIGRAVITY_MODEL_ALIASES: Record = { // Picker-visible: collapsed base models only. export const ANTIGRAVITY_MODELS = [ GEMINI_FLASH_CURRENT, + GEMINI_FLASH_PREVIOUS, "gemini-3.1-pro", "gemini-3.1-flash-image", "claude-sonnet-4-6", @@ -255,6 +297,9 @@ function isKnownAntigravityPickerModelId(value: string): boolean { // Context windows from the upstream `:fetchAvailableModels` maxTokens per model. const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record = { + "gemini-3.8-flash-low": 1_048_576, + "gemini-3.8-flash-medium": 1_048_576, + "gemini-3.8-flash-high": 1_048_576, "gemini-3.7-flash-tiered": 1_048_576, "gemini-3.1-pro-low": 1_048_576, "gemini-pro-agent": 1_048_576, @@ -266,6 +311,7 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record = { export const ANTIGRAVITY_MODEL_CONTEXT_WINDOWS: Record = { // Collapsed base IDs — explicit entries for the picker. + "gemini-3.8-flash": 1_048_576, "gemini-3.7-flash": 1_048_576, "gemini-3.1-pro": 1_048_576, // Wire IDs and aliases via derivation. @@ -283,6 +329,7 @@ export const ANTIGRAVITY_MODEL_INPUT_MODALITIES: Record = { // carries only text and image parts (`OcxImageContent`, src/types.ts) and the Codex // catalog normalizes `input_modalities` against a closed enum. Advertising a modality // the wire cannot carry would be a promise we break at request time. + "gemini-3.8-flash": ["text", "image"], "gemini-3.7-flash": ["text", "image"], "gemini-3.1-pro": ["text", "image"], "gemini-3.1-flash-image": ["text", "image"], @@ -609,13 +656,15 @@ export function resolveAntigravityEffortWireModel( }; } - // Rule 0: retired Flash id — Google has taken the wire id offline, so route to the - // current generation and carry the tier the retired id encoded. This runs BEFORE the - // suffix check because those ids are aliases, and rule 1 would drop the tier. + // Rule 0: retired Flash id — Google has taken the wire id offline, so route to the 3.7 + // redirect target and carry the tier the retired id encoded. (3.7, not "the current + // generation": 3.8 is current but these ids were retired onto 3.7, which is still served.) + // This runs BEFORE the suffix check because those ids are aliases, and rule 1 would drop + // the tier. const retiredTier = retiredAntigravityFlashTier(modelId); if (retiredTier) { return { - wireModelId: GEMINI_FLASH_WIRE_ID, + wireModelId: GEMINI_RETIRED_FLASH_TARGET_WIRE_ID, thinkingLevel: effort ? resolveAntigravityThinkingLevel(effort) ?? retiredTier : retiredTier, }; } @@ -638,8 +687,17 @@ export function resolveAntigravityEffortWireModel( // Rule 2/3: mapped Gemini base model. const effortMap = ANTIGRAVITY_EFFORT_WIRE_MAP[modelId]; if (effortMap) { - if (effort && effort in effortMap) { - return { wireModelId: effortMap[effort]!, thinkingLevel: effort }; + const suffixTiered = ANTIGRAVITY_SUFFIX_TIER_MODELS.has(modelId); + // Normalize FIRST for suffix-tiered models. The discovery path clamps max/xhigh/ultra to + // `high` before its own lookup, so a static path that skipped the clamp answered `medium` + // for the same request: one input, two tiers, decided by whether discovery happened to run. + const requested = suffixTiered && effort + ? resolveAntigravityThinkingLevel(effort) ?? effort + : effort; + if (requested && requested in effortMap) { + const wireModelId = effortMap[requested]!; + // The suffix already names the tier; see ANTIGRAVITY_SUFFIX_TIER_MODELS. + return suffixTiered ? { wireModelId } : { wireModelId, thinkingLevel: requested }; } const defaultEffort = ANTIGRAVITY_DEFAULT_EFFORT[modelId]!; return { wireModelId: effortMap[defaultEffort]! }; diff --git a/src/providers/command-code-efforts.ts b/src/providers/command-code-efforts.ts index b5bfa93b37..621eb330bc 100644 --- a/src/providers/command-code-efforts.ts +++ b/src/providers/command-code-efforts.ts @@ -103,6 +103,21 @@ const COMMAND_CODE_MODEL_EFFORTS = { // 2026-08-13: direct upstream POST with low/medium/high/xhigh/max all 200, // ultra 400; reasoningTokens differentiated 114..253; proxy previously stripped // the field so effort changes had no effect). + // + // 1.3 shipped 2026-09-02 as the same-shaped successor to 1.2 (Command Code + // publishes meta/muse-spark-1.3 and meta/muse-spark-1.3-contributor alongside + // the 1.2 pair, and Zen serves muse-spark-1.3-contributor over the same + // /responses wire). It carries the 1.2 ladder because it IS the 1.2 spec: the + // upstream ladder statement is per-family, and a narrower guess here would + // strip an effort the gateway accepts. Additive — 1.2 and 1.1 stay live. + "meta/muse-spark-1.3": { + efforts: ["low", "medium", "high", "xhigh", "max"], + profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.3", + }, + "meta/muse-spark-1.3-contributor": { + efforts: ["low", "medium", "high", "xhigh", "max"], + profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.3-contributor", + }, "meta/muse-spark-1.2": { efforts: ["low", "medium", "high", "xhigh", "max"], profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.2", diff --git a/src/providers/free-directory.ts b/src/providers/free-directory.ts index 2e935c40e4..ab6e9b2389 100644 --- a/src/providers/free-directory.ts +++ b/src/providers/free-directory.ts @@ -82,7 +82,10 @@ const CONNECTABLE: Record = { "cloudflare-ai": openAi("https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", "https://dash.cloudflare.com/?to=/:account/ai/workers-ai", { supportLevel: "supported", verification: "official", documentationUrl: "https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/", discovery: "static", liveModels: false, models: ["@cf/meta/llama-3.3-70b-instruct-fp8-fast", "@cf/qwen/qwq-32b"] }), cohere: openAi("https://api.cohere.com/compatibility/v1", "https://dashboard.cohere.com/api-keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.cohere.com/reference/list-models", modelsUrl: "https://api.cohere.com/compatibility/v1/models" }), friendliai: openAi("https://api.friendli.ai/serverless/v1", "https://suite.friendli.ai", { modelsUrl: "https://api.friendli.ai/serverless/v1/models" }), - gemini: { baseUrl: "https://generativelanguage.googleapis.com", dashboardUrl: "https://aistudio.google.com/apikey", adapter: "google", authKind: "key", supportLevel: "supported", verification: "official", documentationUrl: "https://ai.google.dev/api/models", lastVerified: LAST_VERIFIED, discovery: "live", liveModels: true, googleMode: "ai-studio", models: ["gemini-3.7-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"] }, + // `lastVerified` is row-specific here: the model list was re-checked against ai.google.dev + // on 2026-09-03 when 3.8 was added. Bumping the shared LAST_VERIFIED instead would stamp + // that date on every other provider row, none of which was re-checked. + gemini: { baseUrl: "https://generativelanguage.googleapis.com", dashboardUrl: "https://aistudio.google.com/apikey", adapter: "google", authKind: "key", supportLevel: "supported", verification: "official", documentationUrl: "https://ai.google.dev/api/models", lastVerified: "2026-09-03", discovery: "live", liveModels: true, googleMode: "ai-studio", models: ["gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview"] }, "github-models": openAi("https://models.github.ai/inference", "https://github.com/settings/tokens", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.github.com/en/github-models/prototyping-with-ai-models", discovery: "static", liveModels: false, models: ["openai/gpt-4.1", "meta/llama-4-scout-17b-16e-instruct"] }), groq: openAi("https://api.groq.com/openai/v1", "https://console.groq.com/keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://console.groq.com/docs/api-reference#models" }), hackclub: openAi("https://ai.hackclub.com/proxy/v1", "https://ai.hackclub.com", { modelsUrl: "https://ai.hackclub.com/proxy/v1/models" }), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 3f71a1a8a7..cc692902d3 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 @@ -552,6 +578,8 @@ const COMMAND_CODE_IMAGE_MODELS = [ "gpt-5.6-sol", "MiniMaxAI/MiniMax-M3", "moonshotai/Kimi-K3", + "meta/muse-spark-1.3", + "meta/muse-spark-1.3-contributor", "meta/muse-spark-1.2", "meta/muse-spark-1.2-contributor", ] as const; @@ -1446,6 +1474,74 @@ 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.", + }, + /* [Decision Log] + - 목적과 의도: Let an operator who already signed the Muse Code CLI in reach Muse Spark with that credential, instead of provisioning a second key. + - 기존 구현 및 제약 조건: The CLI stores a pointer at ~/.config/muse/auth.json and the secret in the macOS Keychain (ai.meta.dev.credentials/meta). Measured: the OAuth access_token 401s on /v1/models while the sibling api_key returns 200, so the usable artifact is a static key, not a refreshable token. + - 검토한 주요 대안: spawn `muse login` and poll; reimplement Meta's device grant; treat it as a second key preset; ship nothing. + - 선택한 방식: an import-only, macOS-only OAuth provider that reads the existing credential, validates it once, and never spawns or reimplements anything. + - 다른 대안 대신 이 방식을 선택한 이유: `muse login` has no non-interactive mode, so a spawned child could outlive cancellation, and polling for the pointer file is satisfied instantly by the one already on disk — reimporting the OLD account on a force-login. Reimplementing the grant would mean guessing a client id the vendor does not publish. + - 장점, 단점 및 영향: no new credential to provision, and the id is distinct from meta-model so neither pool contaminates the other. Meta scopes this credential to its own CLI, so the provider carries a HIGH_RISK ToS warning, a CLI-side warning before any read, and a note that says plainly what is unsupported. + */ + { + id: "meta-muse", + label: "Meta Muse Code (CLI credential)", + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + authKind: "oauth", + oauthId: "meta-muse", + dashboardUrl: "https://dev.meta.ai", + defaultModel: "muse-spark-1.3", + models: META_MUSE_MODELS, + // Same reason as meta-model: the authenticated roster carries muse-image-1.0 and + // muse-voice-transcribe-1.0, which this Responses-agent provider cannot drive. + liveModels: false, + modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), + 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])), + note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported key is copied into OpenCodex's auth store. Meta reports subscription window usage inside streaming responses, but OpenCodex does not yet read or display it, and there is no endpoint to query it on demand. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", + }, { id: "umans", label: "Umans AI Coding Plan", @@ -1487,25 +1583,33 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ - 다른 대안 대신 이 방식을 선택한 이유: OpenCode Go documents sibling models on Chat or Anthropic endpoints, and an exact registry default preserves both those routes and explicit opt-out precedence. - 장점, 단점 및 영향: Each listed model reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. */ - modelWireDefaults: { "gpt-5.6-luna": "openai-responses", "muse-spark-1.2-contributor": "openai-responses" }, + modelWireDefaults: { + "gpt-5.6-luna": "openai-responses", + "muse-spark-1.3-contributor": "openai-responses", + "muse-spark-1.2-contributor": "openai-responses", + }, modelContextWindows: { "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW, // The DeepSeek vision preview id is metadata-only here: the Go roster is // discovered live, so it applies the moment the gateway serves the id. [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, - // Muse Spark 1.2 Contributor serves a 1,048,576-token (1M) context window over + // Muse Spark Contributor serves a 1,048,576-token (1M) context window over // /responses on Zen Go, matching its 1.1 sibling (Meta developer docs, verified 2026-08-28). // Without this declaration the catalog falls back to 128k, capping real usable context. + // 1.3 ships the same window as 1.2 and is served from the same Zen Go roster. + "muse-spark-1.3-contributor": 1_048_576, "muse-spark-1.2-contributor": 1_048_576, }, modelInputModalities: { "kimi-k3": ["text", "image"], // Experimental DeepSeek vision preview — expected to merge into deepseek-v4-flash later. [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], - // Muse Spark 1.2 Contributor is natively multimodal on Zen Go: it accepts input_image + // Muse Spark Contributor is natively multimodal on Zen Go: it accepts input_image // parts over /responses (probed 2026-08-26). Without this declaration the catalog // advertises it text-only and the Codex app blocks image attachments client-side with // "This model does not support image inputs" before the request ever reaches the proxy. + // 1.3 is the same-shaped successor and Command Code documents it as multimodal. + "muse-spark-1.3-contributor": ["text", "image"], "muse-spark-1.2-contributor": ["text", "image"], }, modelReasoningEfforts: { @@ -1736,13 +1840,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // devlog/_plan/260710_provider_hardening/001_research_frontier.md. { id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, - dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3.7-flash"], - modelContextWindows: { "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576, "gemini-3.7-flash": 1_048_576 }, - modelInputModalities: { "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"], "gemini-3.7-flash": ["text", "image"] }, + dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-3.8-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3.7-flash"], + modelContextWindows: { "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576, "gemini-3.7-flash": 1_048_576 }, + modelInputModalities: { "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"], "gemini-3.7-flash": ["text", "image"] }, modelReasoningEfforts: { + // 3.7 and 3.8 omit `minimal`: Google documents it as a validation error on both model + // pages, so advertising it hands the user a rung the API rejects. 3.5/3.6 keep theirs — + // their pages still list it, and this unit has no evidence to change them. + "gemini-3.8-flash": ["low", "medium", "high"], + "gemini-3.7-flash": ["low", "medium", "high"], "gemini-3.6-flash": ["minimal", "low", "medium", "high"], "gemini-3.5-flash": ["minimal", "low", "medium", "high"], - "gemini-3.7-flash": ["minimal", "low", "medium", "high"], "gemini-3.1-pro-preview": ["low", "medium", "high"], }, jawcodeBundle: "google", extraMetadataAliases: ["gemini"], @@ -1750,7 +1858,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API // evidence from ai.google.dev does not establish Vertex publisher availability. { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, - { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.7-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, + { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] }, { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, diff --git a/src/responses/code-mode-helper-compat.ts b/src/responses/code-mode-helper-compat.ts index a9140f8f30..627f201b6e 100644 --- a/src/responses/code-mode-helper-compat.ts +++ b/src/responses/code-mode-helper-compat.ts @@ -21,7 +21,7 @@ function unwrapPatchInput(value: string): string { * Convert a nested Code Mode helper call into unified-exec JavaScript. * * Parsed values are serialized as data, never interpolated as source, so command and patch text - * cannot escape the generated call. Invalid structured shell payloads are also passed as data so + * cannot escape the generated call. Invalid structured helper payloads are also passed as data so * nested-tool validation can reject them without evaluating provider text as JavaScript. */ export function compileCodeModeHelperInput(argumentsText: unknown, toolName: string): string { @@ -46,5 +46,8 @@ export function compileCodeModeHelperInput(argumentsText: unknown, toolName: str args.cmd = args.command; delete args.command; } + if (toolName === "write_stdin") { + return `const result = await tools.write_stdin(${JSON.stringify(args)});\ntext(result);`; + } return `const result = await tools.exec_command(${JSON.stringify(args)});\ntext(result);`; } diff --git a/src/responses/state.ts b/src/responses/state.ts index b95a1fa2c6..6d8c6a3a96 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -1469,14 +1469,14 @@ function ensureLoaded(): void { type SnapshotWriteOutcome = "stable" | "unstable" | "failed"; -async function writeBoundedSnapshot(path: string): Promise { +async function writeBoundedSnapshot(path: string, attemptLimit: number): Promise { // Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612). const previous = persistGate; let release!: () => void; persistGate = new Promise(resolve => { release = resolve; }); await previous; try { - for (let attempt = 0; attempt < MAX_SNAPSHOT_REWRITE_ATTEMPTS; attempt += 1) { + for (let attempt = 0; attempt < attemptLimit; attempt += 1) { const revision = stateRevision; const entries: Array<[string, unknown]> = []; let total = 0; @@ -1579,12 +1579,13 @@ async function persistNow(path: string, awaitFollowUp = false): Promise { persistTimer = null; } pendingPersistPath = null; - let outcome = await writeBoundedSnapshot(path); + const attemptLimit = awaitFollowUp ? MAX_SNAPSHOT_REWRITE_ATTEMPTS : 1; + let outcome = await writeBoundedSnapshot(path, attemptLimit); if (outcome === "unstable" && awaitFollowUp) { if (persistTimer) clearTimeout(persistTimer); persistTimer = null; pendingPersistPath = null; - outcome = await writeBoundedSnapshot(path); + outcome = await writeBoundedSnapshot(path, attemptLimit); } if (outcome === "stable") drainPendingSpillUnlinks(); else if (outcome === "unstable" && !awaitFollowUp) schedulePersistAt(path, true); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 10516ca31c..8da10d5f44 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -749,73 +749,258 @@ export function copyIfDefined( if (value !== undefined) out[key as string] = value as unknown; } +/** + * Exhaustive provider-field policy shared by dashboard redaction and editor + * admission. `satisfies Record` makes a newly added + * provider field fail typecheck until it is deliberately classified. + * + * `editor` fields are user-authored, `redacted` fields may contain credentials, + * and `runtime` fields are observations/limits that must never become editor write + * authority. MCP and desktop executor blocks are redacted as a whole because both + * contain arbitrary environment variables and/or headers. + */ +type ProviderConfigFieldPolicy = "editor" | "redacted" | "runtime"; + +const PROVIDER_CONFIG_FIELD_POLICY = { + alias: "editor", + modelAliases: "editor", + modelDisplayNames: "editor", + defaultAliases: "editor", + adapter: "editor", + codexToolMode: "editor", + requestPacing: "editor", + mcpMaxTools: "editor", + mcpMaxSchemaBytes: "editor", + mcpMaxResultBytes: "editor", + modelAdapters: "editor", + fastWire: "editor", + baseUrl: "editor", + responsesPath: "editor", + commandCodeVersion: "editor", + statelessResponses: "editor", + requiresAdjacentResponsesToolResults: "editor", + annotateEmptyToolOutputs: "editor", + supportsServiceTier: "editor", + modelSupportsServiceTier: "editor", + preserveResponsesReasoningContent: "editor", + decodesNativeCompactionBlobs: "editor", + allowPrivateNetwork: "editor", + upstreamHttpVersion: "editor", + upstreamWebsocket: "editor", + directGeminiWireRenames: "editor", + disabled: "editor", + codexAccountMode: "editor", + apiKey: "redacted", + apiKeyTransport: "editor", + apiKeyPool: "redacted", + defaultModel: "editor", + models: "editor", + liveModels: "editor", + selectedModels: "editor", + retainModels: "editor", + newModelPolicy: "editor", + modelPreset: "editor", + contextWindow: "editor", + modelContextWindows: "editor", + modelInputModalities: "editor", + modelMaxInputTokens: "runtime", + modelAutoCompactTokenLimits: "editor", + defaultMaxOutputTokens: "editor", + modelMaxOutputTokens: "editor", + modelCosts: "editor", + headers: "redacted", + openRouterRouting: "editor", + modelOpenRouterRouting: "editor", + vercelGatewayRouting: "editor", + modelVercelGatewayRouting: "editor", + authMode: "editor", + oauthAccountFailover: "editor", + keyOptional: "editor", + freeTier: "editor", + note: "editor", + modelSuffixBracketStrip: "editor", + refreshPolicy: "editor", + reasoningEfforts: "editor", + modelReasoningEfforts: "editor", + modelDefaultReasoningEfforts: "editor", + modelSupportsReasoningSummaries: "editor", + modelSupportsVerbosity: "editor", + supportsVerbosity: "editor", + modelReasoningSummaryDelivery: "editor", + modelPreferHostedTools: "editor", + supportsOpenAiWebSearchToolFields: "editor", + xaiResponsesXSearch: "editor", + supportsResponsesCustomTools: "editor", + responsesSnapshotRepair: "editor", + reasoningEffortMap: "editor", + modelReasoningEffortMap: "editor", + reasoningWireFormat: "editor", + noReasoningModels: "editor", + noTemperatureModels: "editor", + noTopPModels: "editor", + noPenaltyModels: "editor", + noStructuredOutputModels: "editor", + omitReasoningEffortWithToolsModels: "editor", + parallelToolCalls: "editor", + pinParallelToolCallsFalse: "editor", + terminalContinuationGuard: "editor", + openaiChatEofTolerance: "editor", + promptCacheKey: "editor", + chatServiceTier: "editor", + responsesItemIdRepair: "editor", + autoToolChoiceOnlyModels: "editor", + preserveReasoningContentModels: "editor", + requiresReasoningPlaceholderModels: "editor", + retryOn429: "editor", + transientRetryOn5xx: "editor", + reasoningSplitModels: "editor", + reasoningDetailsModels: "editor", + thinkingToggleModels: "editor", + thinkingBudgetModels: "editor", + escapeBuiltinToolNames: "editor", + anthropicEofTolerance: "editor", + noVisionModels: "editor", + googleMode: "editor", + project: "editor", + location: "editor", + mcpServers: "redacted", + desktopExecutor: "redacted", + unsafeAllowNativeLocalExec: "editor", + nativeLocalExec: "editor", +} as const satisfies Record; + +type ProviderFieldWithPolicy = { + [Field in keyof typeof PROVIDER_CONFIG_FIELD_POLICY]: + typeof PROVIDER_CONFIG_FIELD_POLICY[Field] extends Policy ? Field : never; +}[keyof typeof PROVIDER_CONFIG_FIELD_POLICY]; + +type RedactedProviderField = ProviderFieldWithPolicy<"redacted">; +type RuntimeProviderField = ProviderFieldWithPolicy<"runtime">; +export const REDACTED_PROVIDER_FIELDS = Object.freeze(Object.entries(PROVIDER_CONFIG_FIELD_POLICY) + .filter(([, policy]) => policy === "redacted") + .map(([field]) => field as RedactedProviderField)); +const RUNTIME_PROVIDER_FIELDS = Object.freeze(Object.entries(PROVIDER_CONFIG_FIELD_POLICY) + .filter(([, policy]) => policy === "runtime") + .map(([field]) => field as RuntimeProviderField)); + +const PROVIDER_EDITOR_DERIVED_FIELDS = [ + ...RUNTIME_PROVIDER_FIELDS, + ...FORBIDDEN_PROVIDER_RUNTIME_FIELDS, + "fetch", + "hasApiKey", + "hasHeaders", + "xaiResponsesOptInState", +] as const; + +export const PROVIDER_EDITOR_DENIED_FIELDS = [ + ...REDACTED_PROVIDER_FIELDS, + ...PROVIDER_EDITOR_DERIVED_FIELDS, +] as const; + +export type ProviderEditorProviderDTO = Omit + & Record; + +export interface ProviderEditorConfigDTO { + defaultProvider: string; + providers: Record; +} + +export type ProviderEditorConfigParseResult = + | { ok: true; value: ProviderEditorConfigDTO } + | { ok: false; error: string; code: "invalid_provider_editor_body" | "invalid_provider_editor_field" }; + +const PROVIDER_EDITOR_DENIED_FIELD_SET = new Set(PROVIDER_EDITOR_DENIED_FIELDS); +const PROVIDER_CONFIG_FIELD_SET = new Set(Object.keys(PROVIDER_CONFIG_FIELD_POLICY)); + +function isPlainDataRecord(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * Project one provider through the same redaction path used by both the public + * config DTO and the raw editor. Persisted unknown fields remain on disk but are + * not exposed until OcxProviderConfig classifies them as editor-safe. + */ +function providerEditorProviderDTO(name: string, provider: OcxProviderConfig): ProviderEditorProviderDTO { + const dto = Object.fromEntries(Object.entries(provider) + .filter(([field]) => PROVIDER_CONFIG_FIELD_SET.has(field) && !PROVIDER_EDITOR_DENIED_FIELD_SET.has(field)) + .map(([field, value]) => [field, structuredClone(value)])) as Record; + dto.baseUrl = publicProviderBaseUrl(provider.baseUrl); + const modelCosts = sanitizeModelCostsForDisplay(provider.modelCosts); + if (modelCosts) dto.modelCosts = modelCosts; + else delete dto.modelCosts; + + const registryNote = (providerMatchesRegistryTransport(name, provider) + ? getProviderRegistryEntry(name) + : registryEntryForProviderDestination(provider))?.note; + if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote; + const codexAccountMode = providerCodexAccountMode(name, provider); + if (codexAccountMode) dto.codexAccountMode = codexAccountMode; + return dto as ProviderEditorProviderDTO; +} + +/** The complete non-secret provider shape the raw GUI editor may round-trip. */ +export function providerEditorConfigDTO(config: OcxConfig): ProviderEditorConfigDTO { + const providers: Record = Object.create(null); + for (const [name, provider] of Object.entries(config.providers)) { + providers[name] = providerEditorProviderDTO(name, provider); + } + return { defaultProvider: config.defaultProvider, providers }; +} + +/** Parse an editor snapshot; unknown, redacted, and derived fields fail closed. */ +export function parseProviderEditorConfigDTO(value: unknown): ProviderEditorConfigParseResult { + if (!isPlainDataRecord(value)) { + return { ok: false, error: "provider editor config must be a plain object", code: "invalid_provider_editor_body" }; + } + const rootKeys = Object.keys(value); + if (rootKeys.length !== 2 || !Object.hasOwn(value, "defaultProvider") || !Object.hasOwn(value, "providers")) { + return { ok: false, error: "provider editor config must contain only defaultProvider and providers", code: "invalid_provider_editor_body" }; + } + if (typeof value.defaultProvider !== "string" || value.defaultProvider.trim() === "") { + return { ok: false, error: "defaultProvider must be a non-empty string", code: "invalid_provider_editor_body" }; + } + if (!isPlainDataRecord(value.providers)) { + return { ok: false, error: "providers must be a plain object", code: "invalid_provider_editor_body" }; + } + + const providers: Record = Object.create(null); + for (const [name, provider] of Object.entries(value.providers)) { + if (!isPlainDataRecord(provider)) { + return { ok: false, error: `provider ${JSON.stringify(redactSecretString(name))} must be a plain object`, code: "invalid_provider_editor_body" }; + } + const deniedField = Object.keys(provider).find(field => + !PROVIDER_CONFIG_FIELD_SET.has(field) || PROVIDER_EDITOR_DENIED_FIELD_SET.has(field)); + if (deniedField) { + return { + ok: false, + error: `provider ${JSON.stringify(redactSecretString(name))} contains non-editable field ${JSON.stringify(redactSecretString(deniedField))}`, + code: "invalid_provider_editor_field", + }; + } + providers[name] = structuredClone(provider) as ProviderEditorProviderDTO; + } + return { + ok: true, + value: { defaultProvider: value.defaultProvider, providers }, + }; +} + /** Public dashboard DTO for config.json: provider entries with secrets stripped and documented fields exposed (including `modelCosts`). */ export function safeConfigDTO(config: OcxConfig): unknown { + const editor = providerEditorConfigDTO(config); const providers: Record> = {}; for (const [name, provider] of Object.entries(config.providers)) { const dto: Record = { - adapter: provider.adapter, - baseUrl: publicProviderBaseUrl(provider.baseUrl), + ...editor.providers[name], hasApiKey: !!provider.apiKey, hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0, }; if (name === "xai") { dto.xaiResponsesOptInState = xaiResponsesOptInState(provider); } - for (const key of [ - "defaultModel", - "alias", - "modelAliases", - "defaultAliases", - "disabled", - "allowPrivateNetwork", - "authMode", - "apiKeyTransport", - "keyOptional", - "freeTier", - "liveModels", - "requestPacing", - "models", - "contextWindow", - "modelContextWindows", - "modelAutoCompactTokenLimits", - "defaultMaxOutputTokens", - "modelMaxOutputTokens", - "openRouterRouting", - "modelOpenRouterRouting", - "vercelGatewayRouting", - "modelVercelGatewayRouting", - "reasoningEfforts", - "modelReasoningEfforts", - "reasoningWireFormat", - "noVisionModels", - "noReasoningModels", - "noTemperatureModels", - "noTopPModels", - "noPenaltyModels", - "noStructuredOutputModels", - "retainModels", - "omitReasoningEffortWithToolsModels", - "upstreamHttpVersion", - "autoToolChoiceOnlyModels", - "preserveReasoningContentModels", - "requiresReasoningPlaceholderModels", - "escapeBuiltinToolNames", - ] as const) { - copyIfDefined(dto, provider, key); - } - const modelCosts = sanitizeModelCostsForDisplay(provider.modelCosts); - if (modelCosts) dto.modelCosts = modelCosts; - // Resolve the note by DESTINATION, not by name. A preset saved under a custom name is - // still pointed at the same vendor route, and a usage restriction the user needs to see - // must not disappear because the row was renamed. Prefer the same-name entry so an - // unrenamed provider keeps its exact registry note. - const registryNote = (providerMatchesRegistryTransport(name, provider) - ? getProviderRegistryEntry(name) - : registryEntryForProviderDestination(provider))?.note; - if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote; - const codexAccountMode = providerCodexAccountMode(name, provider); - if (codexAccountMode) dto.codexAccountMode = codexAccountMode; providers[name] = dto; } return { diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index db084df490..cb3ddbb4d2 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -46,6 +46,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { handleNativeChatCompletions, isNativeChatRouteEligible } from "./chat-native"; +import { parseRequestEffortRowId } from "./effort-row"; type Rec = Record; @@ -104,6 +105,8 @@ async function handleChatCompletionsWithBudget( } const requestedModel = chatBody.model as string; + const effortRow = parseRequestEffortRowId(requestedModel, config); + if (effortRow) chatBody.model = effortRow.baseId; const stream = chatBody.stream === true; // Best-effort Grok attribution: the managed fence stamps this header on every model // it registers (extra_headers, sent verbatim by upstream Grok). Dashboard usage @@ -113,7 +116,7 @@ async function handleChatCompletionsWithBudget( let settledRoute: ReturnType | null = null; let chatNativeRoute: ReturnType | null = null; try { - const route = routeModel(config, requestedModel, evidenceFromBody(chatBody)); + const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody)); // Settle the wire once so every branch below reads the adapter this model will // actually use, not the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat"); @@ -133,7 +136,7 @@ async function handleChatCompletionsWithBudget( if (chatBody.tools !== undefined) parts.push(JSON.stringify(chatBody.tools)); logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel)); } - if (isNativeChatRouteEligible(route, chatBody)) chatNativeRoute = route; + if (!effortRow && isNativeChatRouteEligible(route, chatBody)) chatNativeRoute = route; } catch (err) { if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; @@ -162,6 +165,12 @@ async function handleChatCompletionsWithBudget( // Validate the full Chat boundary after routing. Native Chat keeps `chatBody` as // its wire source; this Responses projection is used only by the fallback path. internalBody = chatCompletionsToResponsesBody(chatBody); + if (effortRow) { + internalBody.reasoning = { + ...(isRec(internalBody.reasoning) ? internalBody.reasoning : {}), + effort: effortRow.effort, + }; + } } catch (err) { const overflow = isTranslatorBudgetExceededError(err); const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500; diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index a05e7dd289..32f3daea8a 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -204,12 +204,24 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error"); } + // One inbound request owns one transient send allowance. Capture the policy before any + // key rotation so recovery cannot replace the ceiling along with the active credential. + const requestTransientPolicy = transientRetryPolicyFor(activeProvider); + let transientSendsUsed = 0; + const remainingTransientSends = (): number => requestTransientPolicy + ? Math.max(0, requestTransientPolicy.attempts - transientSendsUsed) + : Number.POSITIVE_INFINITY; + const transientSendAvailable = (): boolean => remainingTransientSends() > 0; + const send = async (request: AdapterRequest, recovery?: "rate-limit-429" | "key-429"): Promise => { try { // #2643: opted-in key-auth openai-chat providers retry pre-stream transient statuses on // the native chat lane too; everyone else keeps reset-only semantics. - const transientPolicy = transientRetryPolicyFor(activeProvider); - const fetchWithPolicy = transientPolicy ? fetchWithTransientRetry : fetchWithResetRetry; + const remaining = remainingTransientSends(); + if (requestTransientPolicy && remaining <= 0) { + throw new Error("native Chat transient send budget exhausted before recovery dispatch"); + } + const fetchWithPolicy = requestTransientPolicy ? fetchWithTransientRetry : fetchWithResetRetry; return await fetchWithPolicy( (transportRecovery?: UpstreamSendRecovery) => { noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery); @@ -232,7 +244,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio { abortSignal: upstream.signal, label: safeHostLabel(request.url), - ...(transientPolicy ? { attempts: transientPolicy.attempts } : {}), + ...(requestTransientPolicy + ? { + attempts: remaining, + onSendsConsumed: (sends: number) => { transientSendsUsed += Math.max(0, sends); }, + } + : {}), }, ); } finally { @@ -245,7 +262,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio response = await send(activeRequest); const retryPolicy = rateLimitRetryPolicyFor(activeProvider); let retries = 0; - while (response.status === 429 && retryPolicy && retries < retryPolicy.attempts) { + while ( + response.status === 429 + && retryPolicy + && retries < retryPolicy.attempts + && transientSendAvailable() + ) { retries += 1; for await (const _ of prepareSameTarget429Wait({ body: response.body, @@ -263,6 +285,10 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio promptCacheKey: typeof options.chatBody.prompt_cache_key === "string" ? options.chatBody.prompt_cache_key : undefined, }); if (!rotated) break; + // Rotation also records the failed key's cooldown and persists the next healthy key. + // Keep that bookkeeping when this request has spent its final send, but preserve the + // terminal 429 body and do not dispatch with the replacement credential. + if (!transientSendAvailable()) break; try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } activeProvider = rotated; activeAdapter = createOpenAIChatAdapter(activeProvider); diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 8425395b18..476ac34bc0 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -50,6 +50,10 @@ import { isTranslatorBudgetExceededError, type TranslatorBudget, } from "../lib/translator-budget"; +import { + parseRequestEffortRowId, + type ParsedEffortRowId, +} from "./effort-row"; type Rec = Record; @@ -600,7 +604,9 @@ async function handleClaudeMessagesWithBudget( let anthropicBody: unknown; let internalBody: Rec; let cacheKeySource: ClaudeCacheKeySource = null; - let effortOverride: ReturnType = null; + let effortOverride: string | null = null; + let effortRow: ParsedEffortRowId | null = null; + let requestedModel = ""; try { anthropicBody = await readAnthropicBody(req, translatorBudget); // Defensive [1m] strip (devlog 138): clients normally remove the context-variant @@ -620,6 +626,14 @@ async function handleClaudeMessagesWithBudget( effortOverride = extractOcxEffortDirective(anthropicBody); } } + if (isRec(anthropicBody) && typeof anthropicBody.model === "string") { + requestedModel = anthropicBody.model; + effortRow = parseRequestEffortRowId(requestedModel, config); + if (effortRow) { + anthropicBody.model = effortRow.baseId; + effortOverride = effortRow.effort; + } + } // Debug capture (opt-in allowlist scalars) BEFORE the passthrough branch so // native, routed, and disabled-alias paths are all observable (devlog 130 B1). captureClaudeInbound( @@ -643,7 +657,7 @@ async function handleClaudeMessagesWithBudget( ); if (claudeConversationId) logCtx.conversationId = claudeConversationId; } - if (isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { + if (!effortRow && isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } if (isRec(anthropicBody) && effortOverride) { @@ -669,7 +683,7 @@ async function handleClaudeMessagesWithBudget( ); } - const requestedModel = (anthropicBody as Rec).model as string; + if (!requestedModel) requestedModel = (anthropicBody as Rec).model as string; const stream = internalBody.stream === true; // Routed adapters only support streamed turns; always stream internally and fold // the translated Anthropic SSE into a message JSON for non-streaming clients. diff --git a/src/server/effort-row.ts b/src/server/effort-row.ts new file mode 100644 index 0000000000..03471059d1 --- /dev/null +++ b/src/server/effort-row.ts @@ -0,0 +1,131 @@ +import { comboModelId, comboPublicModelId } from "../combos/types"; +import { detectCursorInstalls } from "../integrations/cursor-detect"; +import { + loadCursorEffortTable, + type CursorEffortTable, +} from "../integrations/cursor-effort-table"; +import { + canonicalizeReasoningEfforts, + isDeclaredReasoningEffort, +} from "../reasoning-effort"; +import { knownModelIdsForProvider } from "../router"; +import { policyModelId, policyPublicModelId } from "../routing/profile"; +import type { OcxConfig } from "../types"; +import { routedSlug } from "../providers/slug-codec"; +import { predictCursorEffort } from "./models-capabilities"; + +const EFFORT_ROW_SEPARATOR = "--"; + +export interface ParsedEffortRowId { + baseId: string; + effort: string; +} + +export type EffortRowKnownIds = ReadonlySet | ((id: string) => boolean); + +export interface EffortRowOptions { + knownIds?: EffortRowKnownIds; + table?: CursorEffortTable | null; + supportsReasoning?: boolean; +} + +function isKnownId(knownIds: EffortRowKnownIds | undefined, id: string): boolean { + return typeof knownIds === "function" ? knownIds(id) : knownIds?.has(id) === true; +} + +export function effortRowId(baseId: string, effort: string): string { + return `${baseId}${EFFORT_ROW_SEPARATOR}${effort}`; +} + +/** + * Exact configured/public ids that must beat the synthetic terminal-suffix grammar. + * This is request-local because live-model cache contents can change while the server runs. + */ +export function knownEffortRowIds(config: OcxConfig): Set { + const ids = new Set(); + for (const [providerName, provider] of Object.entries(config.providers)) { + const known = knownModelIdsForProvider(providerName, provider, config); + const namespaces = [providerName, provider.alias].filter((value): value is string => ( + typeof value === "string" && value.length > 0 + )); + for (const id of known) { + ids.add(id); + ids.add(routedSlug(providerName, id)); + for (const namespace of namespaces) ids.add(`${namespace}/${id}`); + } + for (const alias of Object.values(provider.modelAliases ?? {})) { + ids.add(alias); + for (const namespace of namespaces) ids.add(`${namespace}/${alias}`); + } + } + for (const [id, combo] of Object.entries(config.combos ?? {})) { + ids.add(comboModelId(id)); + ids.add(comboPublicModelId(id, combo)); + } + for (const [id, profile] of Object.entries(config.routingProfiles ?? {})) { + ids.add(policyModelId(id)); + ids.add(policyPublicModelId(id, profile)); + } + return ids; +} + +/** Resolve the installed Private Inference effort table once for the current request. */ +export function loadDetectedCursorEffortTable(): CursorEffortTable | null { + const privateInference = detectCursorInstalls().find(install => install.build === "private-inference"); + return loadCursorEffortTable(privateInference); +} + +export function parseEffortRowId( + id: string, + config: Pick, + options: EffortRowOptions = {}, +): ParsedEffortRowId | null { + if (config.cursorEffortRows !== true || isKnownId(options.knownIds, id)) return null; + + const separator = id.lastIndexOf(EFFORT_ROW_SEPARATOR); + if (separator <= 0) return null; + const baseId = id.slice(0, separator); + const effort = id.slice(separator + EFFORT_ROW_SEPARATOR.length); + // "none" is never published as a row (discovery filters it), so it is never accepted either. + if (effort === "none" || !isDeclaredReasoningEffort(effort)) return null; + if (predictCursorEffort(baseId, options.table ?? null, options.supportsReasoning).ladder !== null) { + return null; + } + return { baseId, effort }; +} + +/** Parse one ingress selector against the current config and installed Cursor table. */ +export function parseRequestEffortRowId(id: string, config: OcxConfig): ParsedEffortRowId | null { + if (config.cursorEffortRows !== true) return null; + // Ordinary ids carry no separator; bail before the known-id scan and install detection so + // the flag costs nothing on the request path for models that are not effort rows. + if (id.lastIndexOf(EFFORT_ROW_SEPARATOR) <= 0) return null; + return parseEffortRowId(id, config, { + knownIds: knownEffortRowIds(config), + table: loadDetectedCursorEffortTable(), + }); +} + +export function expandCursorEffortRow( + row: T, + efforts: readonly string[] | undefined, + config: Pick, + options: EffortRowOptions = {}, +): T[] { + if (config.cursorEffortRows !== true) return [row]; + + const supported = canonicalizeReasoningEfforts( + (efforts ?? []).filter(effort => effort !== "none" && isDeclaredReasoningEffort(effort)), + ); + const supportsReasoning = options.supportsReasoning ?? supported.length > 0; + if (predictCursorEffort(row.id, options.table ?? null, supportsReasoning).ladder !== null) { + return [row]; + } + return [ + row, + ...supported + .map(effort => effortRowId(row.id, effort)) + .filter(id => !isKnownId(options.knownIds, id)) + .map(id => ({ ...row, id })), + ]; +} diff --git a/src/server/index.ts b/src/server/index.ts index eab0ba1d7f..92495a8b58 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -227,6 +227,9 @@ import { detectInstall } from "../update/index"; import { readyProtocolMetadata } from "../remote/protocol"; import { modelCapabilityFields } from "./models-capabilities"; import { recordCursorSeen } from "../integrations/cursor-seen"; +import { detectCursorInstalls } from "../integrations/cursor-detect"; +import { loadCursorEffortTable } from "../integrations/cursor-effort-table"; +import { expandCursorEffortRow, knownEffortRowIds } from "./effort-row"; export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024; const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0; @@ -1354,7 +1357,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server install.build === "private-inference") + : undefined; + const cursorEffortTable = effortRowsEnabled + ? (deps.managementApi?.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference) + : null; + const expandedNativeModelRow = (id: string, metadataId = id) => { + const reasoningEfforts = nativeReasoningEfforts(metadataId); + return expandCursorEffortRow(nativeModelRow(id, metadataId), reasoningEfforts, config, { + knownIds: effortRowKnownIds, + table: cursorEffortTable, + supportsReasoning: reasoningEfforts.length > 0, + }); + }; + const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { + // Same rule as the anthropic branch: with the global fast switch on, a client + // that has no Fast toggle is offered the fast identity directly. An operator + // alias is an explicit decision and still wins. + const fastModelId = cursorFastIdForListing?.(m.id, m.provider); + const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`; + const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId); + const provider = config.providers[m.provider]; + const effective = provider + ? (await import("../providers/default-aliases")).effectiveModelAliases( + config, + provider, + knownModelIdsForProvider(m.provider, provider, config), + ).get(m.id) + : undefined; + const row = { + id: publicId, + object: "model", + created: 0, + // This endpoint is an OpenAI-compatible inbound contract. Some clients use + // owned_by as an adapter selector, so a virtual combo must name that wire + // adapter rather than the internal catalog authority marker. + owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider), + ...(isCombo ? { is_combo: true } : {}), + ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}), + ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), + ...modelCapabilityFields({ + reasoningEfforts: m.reasoningEfforts, + // contextWindow is already the post-cap effective value; contextCap is the raw + // operator knob and over-reports models whose real window sits below it. + contextWindow: m.contextWindow, + maxOutputTokens: m.maxOutputTokens, + inputModalities: m.inputModalities, + }), + }; + return expandCursorEffortRow(row, m.reasoningEfforts, config, { + knownIds: effortRowKnownIds, + table: cursorEffortTable, + supportsReasoning: (m.reasoningEfforts ?? []).length > 0, + }); + })); const data = [ - ...visibleNatives.map(id => nativeModelRow(id)), - ...visibleAccountNatives.map(({ id, metadataId }) => nativeModelRow(id, metadataId)), - ...await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => { - // Same rule as the anthropic branch: with the global fast switch on, a client - // that has no Fast toggle is offered the fast identity directly. An operator - // alias is an explicit decision and still wins. - const fastModelId = cursorFastIdForListing?.(m.id, m.provider); - const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`; - const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId); - const provider = config.providers[m.provider]; - const effective = provider - ? (await import("../providers/default-aliases")).effectiveModelAliases( - config, - provider, - knownModelIdsForProvider(m.provider, provider, config), - ).get(m.id) - : undefined; - return { - id: publicId, - object: "model", - created: 0, - // This endpoint is an OpenAI-compatible inbound contract. Some clients use - // owned_by as an adapter selector, so a virtual combo must name that wire - // adapter rather than the internal catalog authority marker. - owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider), - ...(isCombo ? { is_combo: true } : {}), - ...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}), - ...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort), - ...modelCapabilityFields({ - reasoningEfforts: m.reasoningEfforts, - // contextWindow is already the post-cap effective value; contextCap is the raw - // operator knob and over-reports models whose real window sits below it. - contextWindow: m.contextWindow, - inputModalities: m.inputModalities, - }), - }; - })), + ...visibleNatives.flatMap(id => expandedNativeModelRow(id)), + ...visibleAccountNatives.flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)), + ...routedRows.flat(), ]; return jsonResponse({ object: "list", data }, 200, req, policy); } diff --git a/src/server/management/api-key-rotation.ts b/src/server/management/api-key-rotation.ts index 982a0c337c..8aaf07188c 100644 --- a/src/server/management/api-key-rotation.ts +++ b/src/server/management/api-key-rotation.ts @@ -7,6 +7,7 @@ export type ApiKeyRotationStart = { id: string; name: string; key: string; + createdAt: string; rotationId: string; expiresAt: string; }; @@ -44,7 +45,7 @@ export function startApiKeyRotation( const createdAt = new Date(now).toISOString(); const expiresAt = new Date(now + API_KEY_ROTATION_TTL_MS).toISOString(); entry.pendingRotation = { id: rotationId, key, createdAt, expiresAt }; - return { id: entry.id, name: entry.name, key, rotationId, expiresAt }; + return { id: entry.id, name: entry.name, key, createdAt, rotationId, expiresAt }; } export function commitApiKeyRotation( diff --git a/src/server/management/api-key-usage.ts b/src/server/management/api-key-usage.ts index 61519aeb95..6a8664dee2 100644 --- a/src/server/management/api-key-usage.ts +++ b/src/server/management/api-key-usage.ts @@ -1,9 +1,9 @@ import { currentUsageLogRevision, - readUsageSnapshotForManagement, usageLogIdentityKey, type PersistedUsageEntry, } from "../../usage/log"; +import { scanUsageLedgerCooperatively } from "../../usage/ledger-scanner"; /** * Per-key usage as the API tab renders it. @@ -29,6 +29,11 @@ export interface ApiKeyUsageSnapshot { attributionSince?: string; } +export interface ApiKeyUsageAccumulator { + add(entry: PersistedUsageEntry): void; + snapshot(): ApiKeyUsageSnapshot; +} + const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; /** @@ -58,6 +63,21 @@ export function rollupApiKeyUsage( configuredIds: string[], now: number = Date.now(), ): ApiKeyUsageSnapshot { + const accumulator = createApiKeyUsageAccumulator(configuredIds, now); + for (const entry of entries) accumulator.add(entry); + return accumulator.snapshot(); +} + +/** + * Constant-memory fold for API-key attribution while the usage ledger streams. + * + * Only configured IDs are retained, so a hand-edited ledger containing an + * unbounded set of arbitrary `apiKeyId` values cannot grow this accumulator. + */ +export function createApiKeyUsageAccumulator( + configuredIds: string[], + now: number = Date.now(), +): ApiKeyUsageAccumulator { const duplicated = new Set(); const seen = new Set(); for (const id of configuredIds) { @@ -69,37 +89,40 @@ export function rollupApiKeyUsage( let attributionSince: number | undefined; const cutoff = now - SEVEN_DAYS_MS; - for (const entry of entries) { - if (!entry.admissionKind) continue; - const timestamp = usableTimestamp(entry.timestamp); - if (timestamp !== null && (attributionSince === undefined || timestamp < attributionSince)) { - attributionSince = timestamp; - } - if (entry.admissionKind !== "configured" || !entry.apiKeyId) continue; - - const bucket = totals.get(entry.apiKeyId) ?? { requests7d: 0, totalRequests: 0 }; - // The request happened even if its clock reading is unusable, so it still - // counts toward the total; only the time-based fields are skipped. - bucket.totalRequests += 1; - if (timestamp !== null) { - if (timestamp >= cutoff) bucket.requests7d += 1; - const iso = new Date(timestamp).toISOString(); - if (!bucket.lastUsedAt || iso > bucket.lastUsedAt) bucket.lastUsedAt = iso; - } - totals.set(entry.apiKeyId, bucket); - } - - const rollup = new Map(); - for (const id of configuredIds) { - if (duplicated.has(id)) { - rollup.set(id, { ambiguous: true }); - continue; - } - rollup.set(id, totals.get(id) ?? { requests7d: 0, totalRequests: 0 }); - } return { - rollup, - ...(attributionSince !== undefined ? { attributionSince: new Date(attributionSince).toISOString() } : {}), + add(entry) { + if (!entry.admissionKind) return; + const timestamp = usableTimestamp(entry.timestamp); + if (timestamp !== null && (attributionSince === undefined || timestamp < attributionSince)) { + attributionSince = timestamp; + } + if (entry.admissionKind !== "configured" || !entry.apiKeyId || !seen.has(entry.apiKeyId)) return; + + const bucket = totals.get(entry.apiKeyId) ?? { requests7d: 0, totalRequests: 0 }; + // The request happened even if its clock reading is unusable, so it still + // counts toward the total; only the time-based fields are skipped. + bucket.totalRequests += 1; + if (timestamp !== null) { + if (timestamp >= cutoff) bucket.requests7d += 1; + const iso = new Date(timestamp).toISOString(); + if (!bucket.lastUsedAt || iso > bucket.lastUsedAt) bucket.lastUsedAt = iso; + } + totals.set(entry.apiKeyId, bucket); + }, + snapshot() { + const rollup = new Map(); + for (const id of configuredIds) { + if (duplicated.has(id)) { + rollup.set(id, { ambiguous: true }); + continue; + } + rollup.set(id, totals.get(id) ?? { requests7d: 0, totalRequests: 0 }); + } + return { + rollup, + ...(attributionSince !== undefined ? { attributionSince: new Date(attributionSince).toISOString() } : {}), + }; + }, }; } @@ -111,6 +134,7 @@ export function rollupApiKeyUsage( * caching it costs nothing; a new row changes the revision and invalidates it. */ let rollupCache: { revisionKey: string; expiresAt: number; lastSeenSize?: number; snapshot: ApiKeyUsageSnapshot } | null = null; +const rollupFlights = new Map>(); /** * The rollup is a function of the log AND of the clock: a request ages out of @@ -127,6 +151,7 @@ const ROLLUP_CACHE_TTL_MS = 60_000; /** Test seam: the cache is module state and would otherwise leak between cases. */ export function clearApiKeyUsageCacheForTests(): void { rollupCache = null; + rollupFlights.clear(); } /** @@ -159,6 +184,25 @@ export function cacheApiKeyUsageFromSnapshot( return rolled; } +/** Seed the API-key cache from the accumulator already fed by `/api/usage`. */ +export function cacheApiKeyUsageFromRollup( + snapshot: ApiKeyUsageSnapshot, + configuredIds: string[], + identityKey: string, + lastSeenSize: number, + maxReadBytes: number | undefined, + now: number = Date.now(), +): ApiKeyUsageSnapshot { + const idsKey = JSON.stringify([configuredIds, maxReadBytes]); + rollupCache = { + revisionKey: `${identityKey}|${idsKey}`, + expiresAt: now + ROLLUP_CACHE_TTL_MS, + lastSeenSize, + snapshot, + }; + return snapshot; +} + export async function readApiKeyUsageRollup(configuredIds: string[], maxReadBytes?: number): Promise { // JSON rather than a joined string: ids are only validated as non-empty // strings, so `["a\0b","c"]` and `["a","b\0c"]` join to the same value and one @@ -173,18 +217,28 @@ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadByte return rollupCache.snapshot; } - const snapshot = await readUsageSnapshotForManagement(maxReadBytes); - const rolled = { - ...rollupApiKeyUsage(snapshot.entries, configuredIds, now), - ...(snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated ? { historyTruncated: true as const } : {}), - }; - rollupCache = { - revisionKey: `${usageLogIdentityKey(snapshot.revision)}|${idsKey}`, - expiresAt: now + ROLLUP_CACHE_TTL_MS, - lastSeenSize: snapshot.revision?.size ?? 0, - snapshot: rolled, - }; - return rolled; + const existing = rollupFlights.get(idsKey); + if (existing) return await existing; + + const flight = (async (): Promise => { + const accumulator = createApiKeyUsageAccumulator(configuredIds, now); + const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); + if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); + return cacheApiKeyUsageFromRollup( + accumulator.snapshot(), + configuredIds, + usageLogIdentityKey(scan.revision), + scan.revision?.size ?? 0, + maxReadBytes, + now, + ); + })(); + rollupFlights.set(idsKey, flight); + try { + return await flight; + } finally { + if (rollupFlights.get(idsKey) === flight) rollupFlights.delete(idsKey); + } } catch { const rollup = new Map(); for (const id of configuredIds) rollup.set(id, { requests7d: 0, totalRequests: 0 }); diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 49aedf4990..13c922e575 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -11,6 +11,8 @@ import type { injectGrokConfig } from "../../grok/inject"; import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p"; import type { probeClaudeDesktopPolicy } from "../../claude/desktop-policy"; import type { RuntimePortState } from "../../config/process-state"; +import type { CursorInstall } from "../../integrations/cursor-detect"; +import type { CursorEffortTable } from "../../integrations/cursor-effort-table"; import type { CatalogDisposition, ConvergeCodex } from "../../codex/convergence-types"; import type { performCodexRestart, @@ -58,6 +60,7 @@ export interface ManagementApiDeps { * on the developer's real runtime state file. */ readRuntimePort?: (pid: number) => RuntimePortState | null; + loadCursorEffortTable?: (install: CursorInstall | undefined) => CursorEffortTable | null; clearThreadAccountMap?: () => void; clearProviderQuotaCache?: () => void; primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise | void; diff --git a/src/server/management/cursor-integration-routes.ts b/src/server/management/cursor-integration-routes.ts index c7a263f804..46f39209f9 100644 --- a/src/server/management/cursor-integration-routes.ts +++ b/src/server/management/cursor-integration-routes.ts @@ -9,12 +9,14 @@ * started — plus which active models will show Cursor's Reasoning and Context controls. */ import { readRuntimePort } from "../../config/process-state"; -import { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextTier, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs } from "../../codex/catalog"; +import { filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextTier, nativeReasoningEfforts, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs } from "../../codex/catalog"; import { cursorLastSeen, type CursorSeen } from "../../integrations/cursor-seen"; import { detectCursorInstalls, type CursorInstall } from "../../integrations/cursor-detect"; +import { loadCursorEffortTable } from "../../integrations/cursor-effort-table"; import { configuredApiAuthToken, isApiAuthRequired, jsonResponse } from "../auth-cors"; import { fetchAllModels } from "../management-api"; -import { cursorEffortFamily } from "../models-capabilities"; +import { predictCursorEffort } from "../models-capabilities"; +import { expandCursorEffortRow, knownEffortRowIds } from "../effort-row"; import type { ManagementContext } from "./context"; export const CURSOR_GATEWAY_PLACEHOLDER_KEY = "opencodex-loopback"; @@ -25,9 +27,13 @@ export interface CursorIntegrationStatus { regularCursor: { installed: boolean; path: string | null }; gateway: { baseUrl: string; apiKeyMode: "credential" | "placeholder"; placeholder: string }; lastSeen: CursorSeen | null; + effortTable: { source: "bundle" | "static"; version: string | null; families: number | null }; models: Array<{ id: string; reasoning: string[] | null; + family: string | null; + tableLess: boolean; + effortRows: string[]; context: { defaultWindow: number; longWindow: number } | null; }>; guideUrl: string; @@ -58,18 +64,40 @@ export async function buildCursorIntegrationStatus( // Same visibility rules as the raw /v1/models list Cursor will read: disabled models and // provider allowlists drop out here too, or the prediction shows rows Cursor never gets. const goModels = filterCatalogVisibleModels(await fetchAllModels(config), config); - const ids = [ - ...visibleNativeSlugs(config), - ...uniqueCatalogModelsForRawPublicList(goModels).map(model => model.alias ?? `${model.provider}/${model.id}`), + // supportsReasoning mirrors what the /v1/models row advertises (a non-empty ladder); the + // gemini family withholds its control when it is false. + const ids: Array<{ id: string; supportsReasoning: boolean; reasoningEfforts: readonly string[] }> = [ + ...visibleNativeSlugs(config).map(id => { + const reasoningEfforts = nativeReasoningEfforts(id); + return { id, supportsReasoning: reasoningEfforts.length > 0, reasoningEfforts }; + }), + ...uniqueCatalogModelsForRawPublicList(goModels).map(model => ({ + id: model.alias ?? `${model.provider}/${model.id}`, + supportsReasoning: (model.reasoningEfforts ?? []).length > 0, + reasoningEfforts: model.reasoningEfforts ?? [], + })), ]; - const models = ids.map(id => { + const table = (deps.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference); + const effortRowKnownIds = config.cursorEffortRows === true ? knownEffortRowIds(config) : undefined; + const models = ids.map(({ id, supportsReasoning, reasoningEfforts }) => { const tier = nativeOpenAiContextTier(id, limits); + const predicted = predictCursorEffort(id, table, supportsReasoning); return { id, - reasoning: cursorEffortFamily(id), + reasoning: predicted.ladder, + family: predicted.family, + tableLess: predicted.ladder === null, + effortRows: expandCursorEffortRow({ id }, reasoningEfforts, config, { + knownIds: effortRowKnownIds, + table, + supportsReasoning, + }).slice(1).map(row => row.id), context: tier ? { defaultWindow: tier.defaultWindow, longWindow: tier.longWindow } : null, }; }); + const effortTable = table + ? { source: "bundle" as const, version: table.version, families: table.families.length } + : { source: "static" as const, version: null, families: null }; return { privateInference: { @@ -84,6 +112,7 @@ export async function buildCursorIntegrationStatus( placeholder: CURSOR_GATEWAY_PLACEHOLDER_KEY, }, lastSeen: cursorLastSeen(), + effortTable, models, guideUrl: CURSOR_GUIDE_URL, }; diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 03d8f96f59..5d909283f2 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -47,13 +47,11 @@ import { } from "../../storage/policy-job"; import { currentUsageLogRevision, - readUsageSnapshotForManagement, usageLogIdentityKey, usageLogRevisionKey, - type PersistedUsageEntry, } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; -import { USAGE_RANGES, USAGE_SURFACES, parseRange, parseUsageSurface, projectUsageSummary, rangeWindow, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary"; +import { USAGE_RANGES, USAGE_SURFACES, parseRange, parseUsageSurface, rangeWindow, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary"; import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; import { getDebugLogEntries } from "../../lib/debug-log-buffer"; @@ -83,7 +81,7 @@ import { getUsageSummaryCacheEntry, setUsageSummaryCacheEntry, } from "./usage-summary-cache"; -import { cacheApiKeyUsageFromSnapshot } from "./api-key-usage"; +import { getFilteredUsageAggregate, getUsageAggregate } from "./usage-aggregate-cache"; function nextLocalMidnight(now: number): number { const next = new Date(now); @@ -92,7 +90,6 @@ function nextLocalMidnight(now: number): number { } function usageSummaryExpiresAt( - _entries: PersistedUsageEntry[], _range: UsageRange, _surface: UsageSurface, now: number, @@ -105,29 +102,6 @@ function refreshedUsageSummary end) end = at; - } - return { start, end }; -} - export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; @@ -195,18 +169,16 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise(summary: T, entries?: PersistedUsageEntry[]) => - projectUsageSummary(summary, filter, entries); - const filterRequested = Boolean(filter.provider ?? filter.model ?? filter.apiKeyId); + const filterRequested = [filter.provider, filter.model, filter.apiKeyId] + .some(value => typeof value === "string" && value.trim() !== ""); const now = Date.now(); try { const cacheKey = `${range}:${surface}`; @@ -214,52 +186,68 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise= cached.lastSeenSize) { return jsonResponse(refreshedUsageSummary(cached.summary, range, now)); } if (cached && !filterRequested) discardUsageSummaryCacheEntry(cacheKey); - // Capture the overlay version BEFORE reading/computing: the cache entry - // must be stamped with the version the summary was priced under. Reading - // it again at stamp time could cache an old-price summary as current, - // and the next request would then accept stale pricing for the whole - // cache lifetime. - const overlayVersion = userCostOverlayVersion(); - const snapshot = await readUsageSnapshotForManagement(effectiveReadLimit); + if (filterRequested) { + const filteredAggregate = await getFilteredUsageAggregate(filter); + const accumulator = filteredAggregate.accumulator; + return jsonResponse({ + ...accumulator.summarize(range, now, surface), + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + snapshotWindowStart: accumulator.snapshotWindow.start, + snapshotWindowEnd: accumulator.snapshotWindow.end, + }); + } + + const configuredApiKeyIds = (config.apiKeys ?? []).map(key => key.id); + const aggregate = await getUsageAggregate({ + now, + configuredApiKeyIds, + managementUsageMaxReadBytes: effectiveReadLimit, + }); + const baseAccumulator = aggregate.accumulator; const revisionReadAt = Date.now(); - const window = snapshotWindow(snapshot.entries); - const summary = { - ...summarizeUsage(snapshot.entries, range, now, surface), - historyTruncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated, - truncatedPrefixBytes: snapshot.truncatedPrefixBytes, - entriesTruncated: snapshot.entriesTruncated, - entriesDropped: snapshot.entriesDropped, - snapshotWindowStart: window.start, - snapshotWindowEnd: window.end, + const freshUntil = now + 60_000; + const snapshotIdentity = `${usageLogIdentityKey(aggregate.revision)}\0${effectiveReadLimit}`; + const revisionKey = `${usageLogRevisionKey(aggregate.revision)}\0${effectiveReadLimit}`; + const lastSeenSize = aggregate.revision?.size ?? 0; + const baseReadMetadata = { + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + snapshotWindowStart: baseAccumulator.snapshotWindow.start, + snapshotWindowEnd: baseAccumulator.snapshotWindow.end, + } as const; + const requestedSummary = { + ...baseAccumulator.summarize(range, now, surface), + ...baseReadMetadata, }; - if (userCostOverlayVersion() !== overlayVersion) { - // The overlay changed while the summary was being computed, so this - // summary may mix old and new prices. Serve it uncached: the next - // request recomputes against the settled overlay instead of caching a - // mixed-price entry under either version. - return jsonResponse(project(summary, snapshot.entries)); + const currentOverlayVersion = userCostOverlayVersion(); + const currentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (currentOverlayVersion !== aggregate.overlayVersion + || currentTimeZone !== aggregate.timeZone) { + // The aggregate is internally consistent, but an input changed after + // its scan. Serve it uncached and let the next request rebuild. + return jsonResponse(requestedSummary); } - const freshUntil = now + 60_000; - const snapshotIdentity = `${usageLogIdentityKey(snapshot.revision)}\0${effectiveReadLimit}`; - const revisionKey = `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`; - const lastSeenSize = snapshot.revision?.size ?? 0; // Derived from the canonical constants rather than re-listed: a subset // literal type-checks perfectly happily, so a range added to the union // and forgotten here would never be warmed and never invalidated @@ -268,21 +256,19 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise key.id), - usageLogIdentityKey(snapshot.revision), - snapshot.revision?.size ?? 0, - snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated, - effectiveReadLimit, - now, - ); - return jsonResponse(project(summary, snapshot.entries)); + return jsonResponse(requestedSummary); } catch { return jsonResponse({ range, diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 2eedb1d5e6..8b9f8d0dd4 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; +import { isDeepStrictEqual } from "node:util"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { clearGatherRoutedModelsInflight } from "../../codex/catalog/provider-fetch"; @@ -11,6 +12,7 @@ import { isValidProviderName, modelDisplayNamesConfigError, multiAgentGuidanceEnabled, + mutatePersistedConfig, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, providerBaseUrlConfigError, @@ -19,6 +21,7 @@ import { readConfigAdmissionSnapshot, saveConfigPreservingClaudeCode, upstreamHttpVersionConfigError, + validateConfigCandidate, withConfigMutationLockSync, } from "../../config"; import { @@ -77,7 +80,17 @@ import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; +import { + isAllowedRequestOrigin, + jsonResponse, + parseProviderEditorConfigDTO, + providerEditorConfigDTO, + providerManagementConfigError, + publicProviderBaseUrl, + safeConfigDTO, + type ProviderEditorConfigDTO, + type ProviderEditorProviderDTO, +} from "../auth-cors"; import { providerServiceTierConfigError } from "./provider-capability-config"; import { providerEmptyToolOutputConfigError } from "../../config/provider-validation"; import { applySystemEnvToggle } from "../system-env"; @@ -86,10 +99,12 @@ import { LOCAL_PROVIDER_RELOAD_PATH, } from "../../lib/local-provider-reload-contract"; import { refreshUserCostOverlays } from "../../usage/user-cost-overlays"; +import { redactSecretString } from "../../lib/redact"; import { XAI_RESPONSES_OPT_IN_MODELS, xaiResponsesOptInState, } from "../../providers/xai-responses-opt-in"; +import { dropProviderCustomModels } from "../../providers/provider-id-rewrite"; import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; @@ -163,6 +178,116 @@ function restorePersistedAliasOverlays(target: OcxProviderConfig, existing: OcxP } } +type ProviderEditorCandidateResult = + | { ok: true; config: OcxConfig; removedProviders: string[] } + | { ok: false; status: 400 | 409; error: string; code: string }; + +type ProviderEditorMutationValue = ProviderEditorCandidateResult; + +function mergeProviderEditorRow( + persisted: OcxProviderConfig | undefined, + baseline: ProviderEditorProviderDTO | undefined, + next: ProviderEditorProviderDTO, +): OcxProviderConfig { + const merged = structuredClone(persisted ?? {}) as Record; + const fields = new Set([...Object.keys(baseline ?? {}), ...Object.keys(next)]); + for (const field of fields) { + const baselineHasField = baseline !== undefined && Object.hasOwn(baseline, field); + const nextHasField = Object.hasOwn(next, field); + if ( + baselineHasField === nextHasField + && (!baselineHasField || isDeepStrictEqual(baseline[field], next[field])) + ) { + continue; + } + if (nextHasField) merged[field] = structuredClone(next[field]); + else delete merged[field]; + } + return merged as unknown as OcxProviderConfig; +} + +/** Build and validate a complete candidate without mutating the caller's snapshot. */ +function providerEditorCandidate( + persisted: OcxConfig, + baseline: ProviderEditorConfigDTO, + next: ProviderEditorConfigDTO, +): ProviderEditorCandidateResult { + const candidate = structuredClone(persisted); + const removedProviders = Object.keys(persisted.providers) + .filter(name => !Object.hasOwn(next.providers, name)); + + for (const name of removedProviders) { + const dependentCombos = Object.entries(persisted.combos ?? {}) + .filter(([, combo]) => combo.targets.some(target => target.provider === name)) + .map(([id]) => id) + .sort((a, b) => a.localeCompare(b)); + if (dependentCombos.length > 0) { + return { + ok: false, + status: 409, + error: `cannot delete provider ${JSON.stringify(redactSecretString(name))} while combos depend on it`, + code: "provider_has_dependent_combos", + }; + } + } + + const providers: Record = Object.create(null); + for (const [name, publicProvider] of Object.entries(next.providers)) { + if (!isValidProviderName(name)) { + return { + ok: false, + status: 400, + error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key", + code: "invalid_provider_name", + }; + } + const namespaceCollision = codexAccountNamespaceProviderCollisionError(candidate.codexAccountNamespaces, name); + if (namespaceCollision) return { ok: false, status: 409, error: namespaceCollision, code: "provider_namespace_conflict" }; + const merged = mergeProviderEditorRow(persisted.providers[name], baseline.providers[name], publicProvider); + const transportCandidate = providerTransportValidationCandidate(merged as unknown as Record); + const providerError = providerManagementConfigError(name, transportCandidate) + ?? providerEmptyToolOutputConfigError(name, transportCandidate) + ?? providerServiceTierConfigError(name, transportCandidate); + if (providerError) return { ok: false, status: 400, error: providerError, code: "invalid_provider" }; + providers[name] = merged; + } + + const defaultProvider = next.defaultProvider.trim(); + const selectedDefault = providers[defaultProvider]; + if (!selectedDefault) { + return { ok: false, status: 400, error: "defaultProvider must name a configured provider", code: "invalid_default_provider" }; + } + if (selectedDefault.disabled === true) { + return { ok: false, status: 400, error: "defaultProvider cannot be disabled", code: "default_provider_disabled" }; + } + + candidate.defaultProvider = defaultProvider; + candidate.providers = providers; + for (const name of removedProviders) { + dropProviderCustomModels(candidate, name); + setProviderContextCap(candidate, name, false); + } + const validated = validateConfigCandidate(candidate); + if (!validated.ok) { + return { ok: false, status: 400, error: validated.error, code: "invalid_provider_editor_config" }; + } + return { ok: true, config: candidate, removedProviders }; +} + +function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): void { + live.defaultProvider = persisted.defaultProvider; + for (const name of Object.keys(live.providers)) { + if (!Object.hasOwn(persisted.providers, name)) delete live.providers[name]; + } + for (const [name, provider] of Object.entries(persisted.providers)) { + live.providers[name] = structuredClone(provider); + } + if (persisted.customModels === undefined) delete live.customModels; + else live.customModels = structuredClone(persisted.customModels); + if (persisted.providerContextCaps === undefined) delete live.providerContextCaps; + else live.providerContextCaps = structuredClone(persisted.providerContextCaps); +} + /** * Apply the recognized PATCH field mask onto a provider copy. The caller runs this once * for validation and again inside the config mutation lock against the newest provider, @@ -649,6 +774,98 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise(persisted => { + if (!isDeepStrictEqual(providerEditorConfigDTO(persisted), baselineResult.value)) { + return { + changed: false, + value: { + ok: false, + status: 409, + error: "provider editor baseline is stale", + code: "stale_provider_editor_baseline", + }, + }; + } + const candidate = providerEditorCandidate(persisted, baselineResult.value, nextResult.value); + if (!candidate.ok) return { changed: false, value: candidate }; + const changed = !isDeepStrictEqual(providerEditorConfigDTO(persisted), nextResult.value); + if (!changed) return { changed: false, value: candidate }; + + persisted.defaultProvider = candidate.config.defaultProvider; + persisted.providers = structuredClone(candidate.config.providers); + for (const name of candidate.removedProviders) { + dropProviderCustomModels(persisted, name); + setProviderContextCap(persisted, name, false); + } + return { + changed: true, + value: { + ok: true, + config: structuredClone(persisted), + removedProviders: candidate.removedProviders, + }, + }; + }); + if (outcome.status === "unavailable") { + const code = outcome.reason === "conflict" ? "provider_config_conflict" : "provider_config_unavailable"; + return jsonResponse({ error: "provider config changed before it could be saved", code }, 409); + } + if (!outcome.value.ok) { + return jsonResponse({ error: outcome.value.error, code: outcome.value.code }, outcome.value.status); + } + + adoptProviderEditorCandidate(config, outcome.value.config); + reconcileLiveStateStores(); + refreshUserCostOverlays(outcome.value.config); + clearGatherRoutedModelsInflight(); + (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)(); + clearAccountQuotaCache(); + clearKeyCooldowns(); + clearModelCache(); + (deps.clearThreadAccountMap ?? clearThreadAccountMap)(); + const catalogRefresh = await convergeCodexCatalog(); + return jsonResponse({ success: true, catalogRefresh }); + } + // Add (or overwrite) a single provider. Merges into the live in-memory config and // persists — existing providers' real keys are never round-tripped (unlike PUT /api/config, // which would re-save the masked keys from GET). Live routing picks it up immediately. diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 95c72f5021..9a52cc9d79 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -273,6 +273,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true }, { method: "POST", path: "/api/providers", module: "server/management/provider-routes", mutates: true }, { method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true }, + { method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md" } }, { method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true }, // server/management/request-history-routes { method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false }, diff --git a/src/server/management/usage-aggregate-cache.ts b/src/server/management/usage-aggregate-cache.ts new file mode 100644 index 0000000000..5e65c26ad7 --- /dev/null +++ b/src/server/management/usage-aggregate-cache.ts @@ -0,0 +1,464 @@ +import { enforceAppOwnedMemoryBudget } from "../../lib/app-owned-memory"; +import { + currentUsageLogRevision, + usageLogIdentityKey, + usageLogRevisionKey, + type UsageLogRevision, +} from "../../usage/log"; +import { + scanUsageLedgerCooperatively, + UsageLedgerRebuildRequiredError, +} from "../../usage/ledger-scanner"; +import { + createUsageSummaryAccumulator, + type UsageSummaryAccumulator, +} from "../../usage/summary"; +import { userCostOverlayVersion } from "../../usage/user-cost-overlays"; + +import { + cacheApiKeyUsageFromRollup, + createApiKeyUsageAccumulator, +} from "./api-key-usage"; + +interface RetainedUsageAggregate { + accumulator: UsageSummaryAccumulator; + revision: UsageLogRevision | null; + identityKey: string; + revisionKey: string; + processedThroughBytes: number; + processedThroughDigest: string; + overlayVersion: number; + timeZone: string; + retainedAt: number; +} + +export interface UsageAggregateResult { + accumulator: UsageSummaryAccumulator; + revision: UsageLogRevision | null; + processedThroughBytes: number; + overlayVersion: number; + timeZone: string; + update: "unchanged" | "append" | "rebuild"; +} + +export interface UsageAggregateOptions { + now?: number; + configuredApiKeyIds?: string[]; + managementUsageMaxReadBytes?: number; +} + +export interface UsageAggregateRetainedStats { + count: number; + bytes: number; + evictableBytes: number; + pinnedBytes: number; + oldestAt: number | null; +} + +const MAX_REBUILD_ATTEMPTS = 2; +const MAX_RETAINED_FILTERED_AGGREGATES = 4; + +let retainedAggregate: RetainedUsageAggregate | null = null; +const pinnedAggregates = new Set(); +let baseFlight: Promise | null = null; +const filteredFlights = new Map>(); +const retainedFilteredAggregates = new Map(); + +function currentTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +function resultFrom( + state: RetainedUsageAggregate, + update: UsageAggregateResult["update"], +): UsageAggregateResult { + return { + accumulator: state.accumulator, + revision: state.revision, + processedThroughBytes: state.processedThroughBytes, + overlayVersion: state.overlayVersion, + timeZone: state.timeZone, + update, + }; +} + +function publishRetainedAggregate(state: RetainedUsageAggregate): UsageAggregateResult { + retainedAggregate = state; + // The budget may evict the state immediately. The request that built it still + // owns the returned accumulator and can finish this response safely. + enforceAppOwnedMemoryBudget(); + return resultFrom(state, "rebuild"); +} + +function makeRetainedAggregate( + accumulator: UsageSummaryAccumulator, + scan: Awaited>, + overlayVersion: number, + timeZone: string, +): RetainedUsageAggregate { + return { + accumulator, + revision: scan.revision, + identityKey: usageLogIdentityKey(scan.revision), + revisionKey: usageLogRevisionKey(scan.revision), + processedThroughBytes: scan.processedThroughBytes, + processedThroughDigest: scan.processedThroughDigest, + overlayVersion, + timeZone, + retainedAt: Date.now(), + }; +} + +async function rebuildAggregate(options: UsageAggregateOptions): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_REBUILD_ATTEMPTS; attempt += 1) { + const overlayVersion = userCostOverlayVersion(); + const timeZone = currentTimeZone(); + const accumulator = createUsageSummaryAccumulator({ mode: "row-unique" }); + const apiKeyAccumulator = options.configuredApiKeyIds + ? createApiKeyUsageAccumulator(options.configuredApiKeyIds, options.now) + : null; + try { + const scan = await scanUsageLedgerCooperatively({ + onEntry(entry) { + accumulator.add(entry); + apiKeyAccumulator?.add(entry); + }, + }); + if (scan.oversizedRows > 0) { + throw new Error("usage ledger contains an oversized row"); + } + if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { + lastError = new Error("usage aggregation inputs changed during rebuild"); + continue; + } + + const state = makeRetainedAggregate(accumulator, scan, overlayVersion, timeZone); + const result = publishRetainedAggregate(state); + if (apiKeyAccumulator && options.configuredApiKeyIds) { + cacheApiKeyUsageFromRollup( + apiKeyAccumulator.snapshot(), + options.configuredApiKeyIds, + state.identityKey, + state.revision?.size ?? 0, + options.managementUsageMaxReadBytes, + options.now, + ); + } + return result; + } catch (error) { + lastError = error; + if (!(error instanceof UsageLedgerRebuildRequiredError) || attempt + 1 >= MAX_REBUILD_ATTEMPTS) { + throw error; + } + } + } + throw lastError ?? new Error("usage aggregate rebuild did not settle"); +} + +function requiresRebuild( + state: RetainedUsageAggregate, + observed: UsageLogRevision | null, + overlayVersion: number, + timeZone: string, +): boolean { + if (state.overlayVersion !== overlayVersion || state.timeZone !== timeZone) return true; + if (state.identityKey !== usageLogIdentityKey(observed)) return true; + if (!state.revision || !observed) return state.revision !== observed; + if (observed.size < state.revision.size) return true; + // At the same size, metadata movement cannot be an append. Rebuild so a + // detectable same-inode replacement/edit never extends stale counters. + return observed.size === state.revision.size && usageLogRevisionKey(observed) !== state.revisionKey; +} + +async function appendAggregate( + state: RetainedUsageAggregate, + options: UsageAggregateOptions, +): Promise { + pinnedAggregates.add(state); + let rebuildAfterUnpin = false; + try { + // Clone first and publish only after the scanner verifies the captured + // suffix. A callback error, mutation, or oversized row leaves retained + // state byte-for-byte untouched. + const candidate = state.accumulator.clone(); + const scan = await scanUsageLedgerCooperatively({ + startAtBytes: state.processedThroughBytes, + expectedIdentityKey: state.identityKey, + expectedProcessedThroughDigest: state.processedThroughDigest, + onEntry: entry => candidate.add(entry), + }); + if (scan.oversizedRows > 0) { + if (retainedAggregate === state) retainedAggregate = null; + throw new Error("usage ledger contains an oversized row"); + } + if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { + if (retainedAggregate === state) retainedAggregate = null; + rebuildAfterUnpin = true; + } else { + const next: RetainedUsageAggregate = { + ...state, + accumulator: candidate, + revision: scan.revision, + identityKey: usageLogIdentityKey(scan.revision), + revisionKey: usageLogRevisionKey(scan.revision), + processedThroughBytes: scan.processedThroughBytes, + processedThroughDigest: scan.processedThroughDigest, + retainedAt: Date.now(), + }; + retainedAggregate = next; + enforceAppOwnedMemoryBudget(); + return resultFrom(next, "append"); + } + } catch (error) { + if (retainedAggregate === state) retainedAggregate = null; + if (error instanceof UsageLedgerRebuildRequiredError) rebuildAfterUnpin = true; + else throw error; + } finally { + pinnedAggregates.delete(state); + } + if (rebuildAfterUnpin) return rebuildAggregate(options); + throw new Error("usage aggregate append did not settle"); +} + +async function refreshAggregate(options: UsageAggregateOptions): Promise { + const state = retainedAggregate; + if (!state) return rebuildAggregate(options); + + const observed = currentUsageLogRevision(); + const overlayVersion = userCostOverlayVersion(); + const timeZone = currentTimeZone(); + if (requiresRebuild(state, observed, overlayVersion, timeZone)) { + retainedAggregate = null; + return rebuildAggregate(options); + } + if (usageLogRevisionKey(observed) === state.revisionKey) return resultFrom(state, "unchanged"); + return appendAggregate(state, options); +} + +export async function getUsageAggregate( + options: UsageAggregateOptions = {}, +): Promise { + if (baseFlight) return baseFlight; + const flight = refreshAggregate(options); + baseFlight = flight; + try { + return await flight; + } finally { + if (baseFlight === flight) baseFlight = null; + } +} + +function normalizeFilterValue(value: string | null | undefined): string | null { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + return normalized || null; +} + +function normalizeExactFilterValue(value: string | null | undefined): string | null { + const normalized = typeof value === "string" ? value.trim() : ""; + return normalized || null; +} + +export async function getFilteredUsageAggregate(filter: { + provider?: string | null; + model?: string | null; + apiKeyId?: string | null; +}): Promise { + const normalizedFilter = { + provider: normalizeFilterValue(filter.provider), + model: normalizeFilterValue(filter.model), + apiKeyId: normalizeExactFilterValue(filter.apiKeyId), + }; + const key = JSON.stringify([ + normalizedFilter.provider, + normalizedFilter.model, + normalizedFilter.apiKeyId, + ]); + const existing = filteredFlights.get(key); + if (existing) return existing; + + const flight = refreshFilteredAggregate(key, normalizedFilter); + filteredFlights.set(key, flight); + try { + return await flight; + } finally { + if (filteredFlights.get(key) === flight) filteredFlights.delete(key); + } +} + +type NormalizedUsageFilter = { + provider: string | null; + model: string | null; + apiKeyId: string | null; +}; + +function trimRetainedFilteredAggregates(): void { + while (retainedFilteredAggregates.size > MAX_RETAINED_FILTERED_AGGREGATES) { + const oldest = [...retainedFilteredAggregates] + .filter(([, state]) => !pinnedAggregates.has(state)) + .sort(([, left], [, right]) => left.retainedAt - right.retainedAt)[0]; + if (!oldest) return; + retainedFilteredAggregates.delete(oldest[0]); + } +} + +function publishFilteredAggregate( + key: string, + state: RetainedUsageAggregate, + update: UsageAggregateResult["update"], +): UsageAggregateResult { + retainedFilteredAggregates.set(key, state); + trimRetainedFilteredAggregates(); + enforceAppOwnedMemoryBudget(); + return resultFrom(state, update); +} + +async function rebuildFilteredAggregate( + key: string, + filter: NormalizedUsageFilter, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_REBUILD_ATTEMPTS; attempt += 1) { + const overlayVersion = userCostOverlayVersion(); + const timeZone = currentTimeZone(); + const accumulator = createUsageSummaryAccumulator({ filter, mode: "row-unique" }); + try { + const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); + if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); + if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { + lastError = new Error("usage aggregation inputs changed during filtered scan"); + continue; + } + const state = makeRetainedAggregate(accumulator, scan, overlayVersion, timeZone); + return publishFilteredAggregate(key, state, "rebuild"); + } catch (error) { + lastError = error; + if (!(error instanceof UsageLedgerRebuildRequiredError) || attempt + 1 >= MAX_REBUILD_ATTEMPTS) { + throw error; + } + } + } + throw lastError ?? new Error("filtered usage scan did not settle"); +} + +async function appendFilteredAggregate( + key: string, + state: RetainedUsageAggregate, + filter: NormalizedUsageFilter, +): Promise { + pinnedAggregates.add(state); + let rebuildAfterUnpin = false; + try { + const candidate = state.accumulator.clone(); + const scan = await scanUsageLedgerCooperatively({ + startAtBytes: state.processedThroughBytes, + expectedIdentityKey: state.identityKey, + expectedProcessedThroughDigest: state.processedThroughDigest, + onEntry: entry => candidate.add(entry), + }); + if (scan.oversizedRows > 0) { + if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); + throw new Error("usage ledger contains an oversized row"); + } + if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { + if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); + rebuildAfterUnpin = true; + } else { + const next: RetainedUsageAggregate = { + ...state, + accumulator: candidate, + revision: scan.revision, + identityKey: usageLogIdentityKey(scan.revision), + revisionKey: usageLogRevisionKey(scan.revision), + processedThroughBytes: scan.processedThroughBytes, + processedThroughDigest: scan.processedThroughDigest, + retainedAt: Date.now(), + }; + return publishFilteredAggregate(key, next, "append"); + } + } catch (error) { + if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); + if (error instanceof UsageLedgerRebuildRequiredError) rebuildAfterUnpin = true; + else throw error; + } finally { + pinnedAggregates.delete(state); + trimRetainedFilteredAggregates(); + } + if (rebuildAfterUnpin) return rebuildFilteredAggregate(key, filter); + throw new Error("filtered usage append did not settle"); +} + +async function refreshFilteredAggregate( + key: string, + filter: NormalizedUsageFilter, +): Promise { + const state = retainedFilteredAggregates.get(key); + if (!state) return rebuildFilteredAggregate(key, filter); + const observed = currentUsageLogRevision(); + const overlayVersion = userCostOverlayVersion(); + const timeZone = currentTimeZone(); + if (requiresRebuild(state, observed, overlayVersion, timeZone)) { + retainedFilteredAggregates.delete(key); + return rebuildFilteredAggregate(key, filter); + } + if (state.revisionKey === usageLogRevisionKey(observed)) { + state.retainedAt = Date.now(); + return resultFrom(state, "unchanged"); + } + return appendFilteredAggregate(key, state, filter); +} + +export function usageAggregateRetainedStats(): UsageAggregateRetainedStats { + const states = [ + ...(retainedAggregate ? [retainedAggregate] : []), + ...retainedFilteredAggregates.values(), + ]; + if (states.length === 0) { + return { count: 0, bytes: 0, evictableBytes: 0, pinnedBytes: 0, oldestAt: null }; + } + let bytes = 0; + let evictableBytes = 0; + let pinnedBytes = 0; + let oldestAt: number | null = null; + for (const state of states) { + const stateBytes = state.accumulator.estimatedBytes; + bytes += stateBytes; + if (pinnedAggregates.has(state)) pinnedBytes += stateBytes; + else { + evictableBytes += stateBytes; + oldestAt = oldestAt === null ? state.retainedAt : Math.min(oldestAt, state.retainedAt); + } + } + return { + count: states.length, + bytes, + evictableBytes, + pinnedBytes, + oldestAt, + }; +} + +export function discardRetainedUsageAggregate(): number { + const candidates: Array<{ key: string | null; state: RetainedUsageAggregate }> = [ + ...(retainedAggregate && !pinnedAggregates.has(retainedAggregate) + ? [{ key: null, state: retainedAggregate }] + : []), + ...[...retainedFilteredAggregates] + .filter(([, state]) => !pinnedAggregates.has(state)) + .map(([key, state]) => ({ key, state })), + ]; + const oldest = candidates.sort((left, right) => left.state.retainedAt - right.state.retainedAt)[0]; + if (!oldest) return 0; + const released = oldest.state.accumulator.estimatedBytes; + if (oldest.key === null) retainedAggregate = null; + else retainedFilteredAggregates.delete(oldest.key); + return released; +} + +export function resetUsageAggregateCacheForTests(): void { + retainedAggregate = null; + pinnedAggregates.clear(); + baseFlight = null; + filteredFlights.clear(); + retainedFilteredAggregates.clear(); +} diff --git a/src/server/management/usage-summary-cache.ts b/src/server/management/usage-summary-cache.ts index 1b509a03da..1e6815c0a2 100644 --- a/src/server/management/usage-summary-cache.ts +++ b/src/server/management/usage-summary-cache.ts @@ -6,6 +6,8 @@ export type CachedUsageSummary = UsageSummary & { truncatedPrefixBytes: number; entriesTruncated: boolean; entriesDropped: number; + snapshotWindowStart: number | null; + snapshotWindowEnd: number | null; }; export interface UsageSummaryCacheEntry { @@ -15,6 +17,8 @@ export interface UsageSummaryCacheEntry { maxReadBytes: number; /** userCostOverlayVersion() when the summary was computed; overlay edits invalidate the entry. */ overlayVersion: number; + /** Local calendar zone used to build day/range buckets. */ + timeZone: string; expiresAt: number; /** Generation freshness: ignore size/mtime until this instant. */ freshUntil: number; diff --git a/src/server/models-capabilities.ts b/src/server/models-capabilities.ts index c7d6aa3c82..b619d162e8 100644 --- a/src/server/models-capabilities.ts +++ b/src/server/models-capabilities.ts @@ -1,3 +1,5 @@ +import type { CursorEffortTable } from "../integrations/cursor-effort-table"; + /** * Extended capability advertisement for the OpenAI-shape `GET /v1/models` list. * @@ -23,7 +25,8 @@ export const OPENAI_FAMILY_API_TYPES: ReadonlySet = new Set(["chat_compl * The reasoning-effort ladder Cursor's local-agent runtime attaches to a model, keyed by the * model id after its last `/`. Cursor decides this from its own table rather than from the * gateway's `reasoning_effort` list, so the dashboard can only PREDICT it; the values here - * mirror that table (read from the 3.18.25 bundle) and carry no Cursor behavior of their own. + * form the fallback mirror of the 3.18.25 table; the live table is read by + * `src/integrations/cursor-effort-table.ts`. These values carry no Cursor behavior of their own. * Null means Cursor shows no Reasoning control for the id. Distinct from * `src/adapters/cursor/effort-map.ts`, which maps opencodex efforts onto Cursor's *backend* * tiers for the outbound provider; this is what Cursor's *local* picker renders. @@ -43,15 +46,62 @@ const CURSOR_EFFORT_FAMILIES: ReadonlyArray<{ test: RegExp; ladder: readonly str ]; export function cursorEffortFamily(modelId: string): string[] | null { + const id = normalizeCursorPickerId(modelId); + for (const family of CURSOR_EFFORT_FAMILIES) { + if (family.test.test(id)) return family.ladder.length > 0 ? [...family.ladder] : null; + } + return null; +} + +export interface CursorEffortPrediction { + ladder: string[] | null; + source: "bundle" | "static"; + /** Bundle family id when one matched (e.g. "anthropic-opus-5"); null otherwise. */ + family: string | null; + outputCap?: number; +} + +export function normalizeCursorPickerId(modelId: string): string { let id = modelId.trim().toLowerCase(); const slash = id.lastIndexOf("/"); if (slash !== -1) id = id.slice(slash + 1); const at = id.indexOf("@"); if (at !== -1) id = id.slice(0, at); - for (const family of CURSOR_EFFORT_FAMILIES) { - if (family.test.test(id)) return family.ladder.length > 0 ? [...family.ladder] : null; + return id; +} + +/** + * `supportsReasoning` is what the gateway row will advertise in + * `capabilities.supports_reasoning`; Cursor's gemini family withholds its control when that is + * false (`effortRequiresReasoningCapability`). Callers that do not know the row pass nothing + * and get the id-only prediction. + */ +export function predictCursorEffort( + modelId: string, + table: CursorEffortTable | null, + supportsReasoning?: boolean, +): CursorEffortPrediction { + const id = normalizeCursorPickerId(modelId); + if (table) { + for (const family of table.families) { + if (family.pattern.test(id)) { + if (family.requiresReasoningCapability && supportsReasoning === false) { + return { ladder: null, source: "bundle", family: family.id }; + } + return { + ladder: family.ladder.length > 0 ? [...family.ladder] : null, + source: "bundle", + family: family.id, + ...(family.outputCap !== undefined ? { outputCap: family.outputCap } : {}), + }; + } + } + if (table.bareGpt5?.pattern.test(id)) return { ladder: [...table.bareGpt5.ladder], source: "bundle", family: "gpt-5" }; + return { ladder: null, source: "bundle", family: null }; } - return null; + const staticLadder = cursorEffortFamily(modelId); + const gated = supportsReasoning === false && id.startsWith("gemini-") ? null : staticLadder; + return { ladder: gated, source: "static", family: null }; } export interface ModelCapabilityInput { @@ -64,6 +114,7 @@ export interface ModelCapabilityInput { * as costing more). */ longContextWindow?: number; + maxOutputTokens?: number; inputModalities?: readonly string[]; } @@ -71,6 +122,7 @@ export interface ModelCapabilityFields { api_types: readonly string[]; capabilities: { context_length?: number; + max_output_tokens?: number; /** Cursor's extended-row filter REQUIRES this to contain "text"; every route emits text. */ output_modalities: string[]; input_modalities?: string[]; @@ -91,13 +143,15 @@ export interface ModelCapabilityFields { function positiveInt(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value)) return undefined; const floored = Math.floor(value); - return floored > 0 ? floored : undefined; + // Catalog limits are safe integers everywhere else; an unsafe finite value is a bad row. + return floored > 0 && Number.isSafeInteger(floored) ? floored : undefined; } export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabilityFields { const efforts = (input.reasoningEfforts ?? []).filter(effort => typeof effort === "string" && effort.length > 0); const contextLength = positiveInt(input.contextWindow); const longContextLength = positiveInt(input.longContextWindow); + const maxOutputTokens = positiveInt(input.maxOutputTokens); const hasLongTier = contextLength !== undefined && longContextLength !== undefined && longContextLength > contextLength; const modalities = Array.isArray(input.inputModalities) ? input.inputModalities.filter(modality => typeof modality === "string" && modality.length > 0) @@ -109,6 +163,7 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil ...(hasLongTier ? { context_length: longContextLength } : contextLength !== undefined ? { context_length: contextLength } : {}), + ...(maxOutputTokens !== undefined ? { max_output_tokens: maxOutputTokens } : {}), // Once a gateway advertises api_types, Cursor keeps only rows whose output_modalities // include "text"; omitting the key drops the row from the extended catalog. output_modalities: ["text"], diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e2e23f241d..ce6abf5c3d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -68,12 +68,14 @@ import { resolvePassiveRouteSubjectId } from "../passive-route-linker"; import { advanceComboAfterFailure, comboDefaultEffort, + comboFailureCooldownScope, comboFailureDecision, comboIdFromRawBody, comboRequestHasImageInput, concreteComboRequestBody, getCombo, isComboTargetInCooldown, + comboCooldownRetryAfterSeconds, NoAvailableComboTargetsError, noteComboSuccess, parseRetryAfterMs, @@ -326,6 +328,7 @@ import { restoreImageGenCallsInJson, } from "../responses-image-gen-repair"; import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from "../responses-model-rewrite"; +import { parseRequestEffortRowId } from "../effort-row"; import { collectSelfNamedNamespaceScrubAuthorization, createSelfNamedToolCallNamespaceScrubRewrite, @@ -1434,15 +1437,29 @@ export function decodeRequestErrorResponse(err: unknown, label: string): Respons -export function comboUnavailableResponse(message: string): Response { +export function comboUnavailableResponse( + message: string, + options?: { retryAfter?: string | null }, +): Response { + const headers = new Headers({ "Content-Type": "application/json" }); + const retryAfter = options?.retryAfter?.trim(); + if (retryAfter && retryAfter.length > 0 && retryAfter.length <= 128) { + headers.set("Retry-After", retryAfter); + } return new Response( JSON.stringify({ error: { message, type: "server_error", code: "combo_unavailable" }, }), - { status: 503, headers: { "Content-Type": "application/json" } }, + { status: 503, headers }, ); } +function comboUnavailable(comboId: string, now = Date.now()): Response { + return comboUnavailableResponse(`No available targets for combo: ${comboId}`, { + retryAfter: comboCooldownRetryAfterSeconds(comboId, now), + }); +} + export interface ConsumedComboFailure { @@ -2297,7 +2314,7 @@ export async function handleComboResponses( config, { parentThreadId: inboundClientThreadId }, ); - return comboUnavailableResponse(`No available targets for combo: ${comboId}`); + return comboUnavailable(comboId); } let recovered = false; try { @@ -2334,7 +2351,7 @@ export async function handleComboResponses( } if (!pick) { - return comboUnavailableResponse(`No available targets for combo: ${comboId}`); + return comboUnavailable(comboId); } // One immutable combo selection trace, before any child dispatch; child // adoption below must never replace it with a concrete child route trace. @@ -2541,7 +2558,13 @@ export async function handleComboResponses( const nextPick = advanceComboAfterFailure(config, pick, { retryAfter: failure.retryAfter, now: Date.now(), + cooldownScope: comboFailureCooldownScope(failure.response.status, failure.classificationText, { + code: failure.upstreamCode, + }), eligible: payloadEligible, + status: failure.response.status, + code: failure.upstreamCode, + message: failure.classificationText, }); if (!nextPick) adoptFailedChildLog(childLog); pick = nextPick; @@ -2697,6 +2720,23 @@ async function handleResponsesInner( } return decodeRequestErrorResponse(err, "responses"); } + // An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher + // as its base id, so the selector is normalized here, before comboIdFromRawBody reads model. + const comboEffortRow = !options.comboAttempt && body && typeof body === "object" && !Array.isArray(body) + && typeof (body as { model?: unknown }).model === "string" + ? parseRequestEffortRowId((body as { model: string }).model, config) + : null; + if (comboEffortRow) { + const raw = body as Record; + raw.model = comboEffortRow.baseId; + const rawReasoning = raw.reasoning; + raw.reasoning = { + ...(rawReasoning && typeof rawReasoning === "object" && !Array.isArray(rawReasoning) + ? rawReasoning as Record + : {}), + effort: comboEffortRow.effort, + }; + } const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null; if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) { options.onRequestBodyRead?.(); @@ -2751,6 +2791,20 @@ async function handleResponsesInner( let toolBridgeMaps: ReturnType; try { parsed = parseRequest(body); + const effortRow = parseRequestEffortRowId(parsed.modelId, config); + if (effortRow) { + parsed.modelId = effortRow.baseId; + parsed.options.reasoning = effortRow.effort; + const raw = parsed._rawBody as Record; + const rawReasoning = raw.reasoning; + raw.model = effortRow.baseId; + raw.reasoning = { + ...(rawReasoning && typeof rawReasoning === "object" && !Array.isArray(rawReasoning) + ? rawReasoning as Record + : {}), + effort: effortRow.effort, + }; + } if (options.comboReplaySnapshot?.recoveredPlaintext) { markBodyNonPersistable(parsed._rawBody); } @@ -2881,7 +2935,7 @@ async function handleResponsesInner( logCtx.routeDecision = route.routeDecision; } catch (err) { if (err instanceof NoAvailableComboTargetsError) { - return comboUnavailableResponse(err.message); + return comboUnavailable(err.comboId); } if (err instanceof NoEligiblePolicyCandidateError) { // Persist the evaluation trace (per-candidate exclusions + the @@ -2991,7 +3045,7 @@ async function handleResponsesInner( logCtx.routeDecision = route.routeDecision; } catch (err) { if (err instanceof NoAvailableComboTargetsError) { - return comboUnavailableResponse(err.message); + return comboUnavailable(err.comboId); } if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; @@ -3115,7 +3169,7 @@ async function handleResponsesInner( logCtx.routeDecision = route.routeDecision; } catch (err) { if (err instanceof NoAvailableComboTargetsError) { - return comboUnavailableResponse(err.message); + return comboUnavailable(err.comboId); } if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; diff --git a/src/types/config.ts b/src/types/config.ts index 9bead2ad10..06270fc172 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -348,7 +348,10 @@ export interface OcxConfig { * the guess is wrong. */ oauthOpenBrowser?: boolean; - /** Maximum usage-log bytes read for one management snapshot. */ + /** + * @deprecated Compatibility-only limit for bounded legacy usage readers. + * `GET /api/usage` always aggregates the complete ledger. + */ managementUsageMaxReadBytes?: number; providers: Record; defaultProvider: string; @@ -366,6 +369,12 @@ export interface OcxConfig { }; /** Enable the shipped model alias patterns for providers without an override. */ defaultModelAliases?: boolean; + /** + * Opt-in Cursor Private Inference compatibility rows. When true, `/v1/models` + * adds `--` selectors for reasoning-capable model ids absent + * from Cursor's built-in effort table. Omitted/false preserves discovery output. + */ + cursorEffortRows?: boolean; /** Explicit top-level deletion intent used by stale whole-config rebases. */ configRebaseProvenance?: OcxConfigRebaseProvenance | Record; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ diff --git a/src/types/tools.ts b/src/types/tools.ts index 8f713be620..3de31c2de6 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -35,20 +35,23 @@ export function namespacedToolName(namespace: string | undefined, name: string): * Codex unified-exec name normalization. * * Codex's code-mode shell tool is declared as `exec` (a freeform custom tool whose own - * description mentions the nested `await tools.exec_command(...)` helper). Routed models — - * DeepSeek in particular — sometimes echo that helper name as the tool-call name, emitting - * `exec_command` or `apply_patch` instead of the declared `exec`. Accept these nested helper - * names only when the request catalog actually declares `exec` and does not itself declare the - * emitted name (an MCP server may legitimately advertise one under its own namespace). + * description mentions the nested `await tools.exec_command(...)` helper). Some routed providers + * echo that helper name as the tool-call name, emitting `exec_command`, `write_stdin`, or + * `apply_patch` instead of the declared `exec`. Accept these nested helper names only when the + * request catalog actually declares `exec` and does not itself declare the emitted name (an MCP + * server may legitimately advertise one under its own namespace). */ const LEGACY_SHELL_BRIDGE_TOOL_NAMES = ["exec_command", "shell_command"] as const; -const CODE_MODE_HELPER_TOOL_NAMES = [...LEGACY_SHELL_BRIDGE_TOOL_NAMES, "apply_patch"] as const; +const CODE_MODE_HELPER_TOOL_NAMES = [ + ...LEGACY_SHELL_BRIDGE_TOOL_NAMES, + "write_stdin", + "apply_patch", +] as const; /** * The one declared name that turns nested-helper normalization on. Declaring it is not just a - * name: it also decides whether an emitted `exec_command`/`shell_command`/`apply_patch` is - * accepted as that shell tool, so callers that build declared-name sets must add it only for a - * genuine bare declaration. + * name: it also decides whether an emitted helper name is accepted as that shell tool, so callers + * that build declared-name sets must add it only for a genuine bare declaration. */ export const CODE_MODE_EXEC_TOOL_NAME = "exec"; diff --git a/src/usage/expected-prices.ts b/src/usage/expected-prices.ts index 23e5412150..5d71b6ff4c 100644 --- a/src/usage/expected-prices.ts +++ b/src/usage/expected-prices.ts @@ -1,3 +1,5 @@ +import { normalizeCursorClaudeId } from "../adapters/cursor/claude-id"; + /** * Expected-price overlay for models whose jawcode cost rows are missing or all-zero * (subscription/OAuth surfaces). Sourced from official pricing pages only @@ -56,6 +58,10 @@ const GEMINI_36_FLASH: Cost4 = { input: 1.5, output: 7.5, cacheRead: 0.15, cache // through 2026-12-31, stepping up to $1.50 / $7.50 on 2027-01-01. Revisit this row // then — the promotional rate is dated on the pricing page, not open-ended. const GEMINI_37_FLASH: Cost4 = { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }; +// Gemini 3.8 Flash carries the same published promotional shape as 3.7 through 2026-12-31, +// rising to $1.50 / $7.50 on 2027-01-01. A SEPARATE constant on purpose: equal today, but +// aliasing them would silently drag 3.8 along if 3.7's row is ever re-verified differently. +const GEMINI_38_FLASH: Cost4 = { input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }; const MINIMAX_M21_HIGHSPEED: Cost4 = { input: 0.6, output: 2.4, cacheRead: 0.03, cacheWrite: 0.375 }; const KIMI_K3: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3 }; const KIMI_K27_CODE: Cost4 = { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0.95 }; @@ -79,8 +85,19 @@ const ANTHROPIC_PRICING = "https://platform.claude.com/docs/en/about-claude/pric const GEMINI_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-07-22); cacheWrite=0: storage is billed per-hour, not per-token"; const GEMINI_37_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-08-14); 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 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"; +/* + * Shared by both Meta providers. Overlays resolve by EXACT provider id, so `meta-muse` + * cannot inherit `meta-model`'s rows — and an unpriced provider whose whole warning is + * "treat every call as billable" would report no cost at all. + */ +const META_MUSE_SPARK_13: Cost4 = { input: 1.25, output: 4.25, cacheRead: 0.15, cacheWrite: 0 }; +const META_MUSE_SPARK_13_CONTRIBUTOR: Cost4 = { input: 0.1, output: 0.2, cacheRead: 0.002, cacheWrite: 0 }; +const META_SPARK_SOURCE = `Meta Model API published price ${META_MODEL_PRICING}`; +const META_SPARK_CONTRIBUTOR_SOURCE = `Meta Model API published Contributor-tier price ${META_MODEL_PRICING}; data-sharing discount tier`; 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. @@ -100,13 +117,8 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ // bundle collapses anthropic-apikey onto anthropic). { provider: "anthropic", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input`, verifiedAt: "2026-09-02", status: "verified" }, { provider: "anthropic-apikey", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input`, verifiedAt: "2026-09-02", status: "verified" }, - // Cursor seeds Fable 5.1 preemptively under three spellings (adapters/cursor/catalog.ts); - // the model-level vendor fallback only searches jawcode metadata, which has no Fable 5.1 - // row yet, so each Cursor spelling needs its own overlay. Vendor list price, like the - // cursor/claude-opus-5 row. + // Cursor canonicalizes every Fable 5.1 spelling onto this sole overlay row. { provider: "cursor", modelId: "claude-fable-5-1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-02", status: "verified-derived" }, - { provider: "cursor", modelId: "claude-fable-5.1", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-02", status: "verified-derived" }, - { provider: "cursor", modelId: "claude-5.1-fable", cost4: CLAUDE_FABLE_51, source: `anthropic official Claude Fable 5.1 ${ANTHROPIC_PRICING}; cache hit = 0.025x base input; vendor list price applied to the Cursor surface`, verifiedAt: "2026-09-02", status: "verified-derived" }, // claude-opus-5 is exposed by three providers but absent from the jawcode bundle, so // cost resolution returned null and the Logs `~$` column rendered an em dash. The // model-level vendor fallback only searches jawcode metadata, never overlays, so one @@ -126,6 +138,10 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ // 3.7 Flash rides CCA, whose billing equivalence to the Developer API list price is // not published, so this is `verified-derived` rather than `verified`: the number is // proven, the claim that Antigravity charges it is inferred. + { provider: "google-antigravity", modelId: "gemini-3.8-flash", cost4: GEMINI_38_FLASH, source: `derived: Gemini 3.8 Flash promotional rate through 2026-12-31 ${GEMINI_38_PRICING}`, verifiedAt: "2026-09-03", status: "verified-derived" }, + { provider: "google-antigravity", modelId: "gemini-3.8-flash-low", cost4: GEMINI_38_FLASH, source: `derived: gemini-3.8-flash ${GEMINI_38_PRICING}`, verifiedAt: "2026-09-03", status: "verified-derived" }, + { provider: "google-antigravity", modelId: "gemini-3.8-flash-medium", cost4: GEMINI_38_FLASH, source: `derived: gemini-3.8-flash ${GEMINI_38_PRICING}`, verifiedAt: "2026-09-03", status: "verified-derived" }, + { provider: "google-antigravity", modelId: "gemini-3.8-flash-high", cost4: GEMINI_38_FLASH, source: `derived: gemini-3.8-flash ${GEMINI_38_PRICING}`, verifiedAt: "2026-09-03", status: "verified-derived" }, { provider: "google-antigravity", modelId: "gemini-3.7-flash", cost4: GEMINI_37_FLASH, source: `derived: Gemini 3.7 Flash promotional rate through 2026-12-31 ${GEMINI_37_PRICING}`, verifiedAt: "2026-08-14", status: "verified-derived" }, // Retained after the 3.6 retirement: historical usage.jsonl rows still carry these // ids, and dropping the row would silently zero the cost of requests already made. @@ -139,6 +155,19 @@ 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: META_MUSE_SPARK_13, source: META_SPARK_SOURCE, verifiedAt: "2026-09-03", status: "verified" }, + { provider: "meta-model", modelId: "muse-spark-1.3-contributor", cost4: META_MUSE_SPARK_13_CONTRIBUTOR, source: META_SPARK_CONTRIBUTOR_SOURCE, verifiedAt: "2026-09-03", status: "verified" }, + // Same endpoint, same list price, different credential. Meta does not authorize this + // reuse and settlement is not observable, so these are the public Model API rates as a + // conservative estimate — not evidence of how the call is actually billed. + { provider: "meta-muse", modelId: "muse-spark-1.3", cost4: META_MUSE_SPARK_13, source: META_SPARK_SOURCE, verifiedAt: "2026-09-03", status: "verified-derived" }, + { provider: "meta-muse", modelId: "muse-spark-1.3-contributor", cost4: META_MUSE_SPARK_13_CONTRIBUTOR, source: META_SPARK_CONTRIBUTOR_SOURCE, verifiedAt: "2026-09-03", status: "verified-derived" }, // 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. @@ -163,6 +192,7 @@ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [ { provider: "google", modelId: "gemini-3.6-flash", cost4: GEMINI_36_FLASH, source: GEMINI_PRICING, verifiedAt: "2026-07-22", status: "verified" }, // Developer API row: the price IS published for this surface, so `verified`. { provider: "google", modelId: "gemini-3.7-flash", cost4: GEMINI_37_FLASH, source: GEMINI_37_PRICING, verifiedAt: "2026-08-14", status: "verified" }, + { provider: "google", modelId: "gemini-3.8-flash", cost4: GEMINI_38_FLASH, source: GEMINI_38_PRICING, verifiedAt: "2026-09-03", status: "verified" }, { provider: "google-antigravity", modelId: "gemini-3.1-pro-preview", cost4: GEMINI_31_PRO, source: GEMINI_PRICING, verifiedAt: "2026-07-20", status: "verified" }, // Antigravity-bundled third-party models — derived from the underlying vendor's // official API price (Antigravity itself bills via subscription quota). @@ -236,8 +266,14 @@ export function findExpectedPriceOverlay( overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS, ): ExpectedPriceOverlay | undefined { const exact = overlays.filter(row => row.provider === provider && row.modelId === modelId); - return exact.find(row => row.status === "verified") + const match = exact.find(row => row.status === "verified") ?? exact.find(row => row.status === "verified-derived"); + if (match || provider !== "cursor") return match; + const canonicalBaseId = normalizeCursorClaudeId(modelId)?.canonicalBaseId; + if (!canonicalBaseId) return undefined; + const canonical = overlays.filter(row => row.provider === provider && row.modelId === canonicalBaseId); + return canonical.find(row => row.status === "verified") + ?? canonical.find(row => row.status === "verified-derived"); } /** OpenAI Fast price multipliers retained as a compatibility export. */ diff --git a/src/usage/ledger-scanner.ts b/src/usage/ledger-scanner.ts new file mode 100644 index 0000000000..2827579e06 --- /dev/null +++ b/src/usage/ledger-scanner.ts @@ -0,0 +1,448 @@ +import { createHash } from "node:crypto"; +import { closeSync, fstatSync, openSync, readSync } from "node:fs"; +import { + currentUsageLogRevision, + normalizePersistedUsageRow, + usageLogIdentityKey, + usageLogPath, + usageLogRevisionKey, + type PersistedUsageEntry, + type UsageLogRevision, +} from "./log"; + +export const USAGE_LEDGER_READ_CHUNK_BYTES = 1024 * 1024; +// Normalized writer rows carry a <=16 KiB route trace and <=500-character captured +// upstream error, so 1 MiB leaves wide headroom even for an extreme multi-attempt row. +// Hand-edited rows can still exceed it; those are reported separately instead of making +// one unterminated line an unbounded allocation. +export const USAGE_LEDGER_MAX_LINE_BYTES = 1024 * 1024; +export const USAGE_LEDGER_BOUNDARY_DIGEST_BYTES = 64 * 1024; + +export interface ScanUsageLedgerOptions { + signal?: AbortSignal; + onEntry: (entry: PersistedUsageEntry) => void; + /** Absolute LF boundary returned by a previous scan. Defaults to byte zero. */ + startAtBytes?: number; + /** Stable path/dev/ino/birthtime identity; required when startAtBytes is nonzero. */ + expectedIdentityKey?: string; + /** Trailing digest at the previous boundary; required when startAtBytes is nonzero. */ + expectedProcessedThroughDigest?: string; + /** Test seam for forcing byte boundaries; production always uses the 1 MiB default. */ + chunkBytes?: number; +} + +export interface UsageLedgerScanResult { + /** Revision whose EOF was captured when the scan opened the ledger. */ + revision: UsageLogRevision | null; + parsedRows: number; + /** LF-complete malformed/schema-invalid rows plus a non-empty bounded torn suffix. */ + invalidRows: number; + /** Rows skipped after exceeding USAGE_LEDGER_MAX_LINE_BYTES. */ + oversizedRows: number; + bytesRead: number; + /** Absolute byte offset immediately after the last handled LF. */ + processedThroughBytes: number; + /** SHA-256 over at most the last 64 KiB ending at processedThroughBytes. */ + processedThroughDigest: string; +} + +export type UsageLedgerRebuildReason = + | "identity_mismatch" + | "shrink" + | "boundary_mismatch" + | "content_changed"; + +export class UsageLedgerRebuildRequiredError extends Error { + readonly code = "usage_ledger_rebuild_required"; + + constructor(readonly reason: UsageLedgerRebuildReason) { + super(`usage ledger rebuild required: ${reason}`); + this.name = "UsageLedgerRebuildRequiredError"; + } +} + +function revisionFromStat( + path: string, + stat: ReturnType, +): UsageLogRevision { + if (!stat.isFile()) throw new Error("usage log is not a regular file"); + return { + path, + dev: Number(stat.dev), + ino: Number(stat.ino), + birthtimeMs: Number(stat.birthtimeMs), + size: Number(stat.size), + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + }; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (!signal?.aborted) return; + throw signal.reason ?? new Error("usage ledger scan aborted"); +} + +function isMissingFileError(error: unknown): boolean { + return error !== null + && typeof error === "object" + && "code" in error + && error.code === "ENOENT"; +} + +function isJsonWhitespace(bytes: Buffer, length: number): boolean { + for (let index = 0; index < length; index += 1) { + const byte = bytes[index]; + if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0d) return false; + } + return true; +} + +class RollingByteWindow { + private readonly bytes: Buffer; + private start = 0; + private length = 0; + + constructor(private readonly capacity: number) { + this.bytes = Buffer.allocUnsafe(capacity); + } + + append(source: Buffer, from = 0, to = source.byteLength): void { + const sourceLength = to - from; + if (sourceLength <= 0) return; + if (sourceLength >= this.capacity) { + source.copy(this.bytes, 0, to - this.capacity, to); + this.start = 0; + this.length = this.capacity; + return; + } + + const overflow = Math.max(0, this.length + sourceLength - this.capacity); + this.start = (this.start + overflow) % this.capacity; + this.length -= overflow; + const writeAt = (this.start + this.length) % this.capacity; + const firstLength = Math.min(sourceLength, this.capacity - writeAt); + source.copy(this.bytes, writeAt, from, from + firstLength); + if (firstLength < sourceLength) { + source.copy(this.bytes, 0, from + firstLength, to); + } + this.length += sourceLength; + } + + appendByte(byte: number): void { + if (this.length < this.capacity) { + this.bytes[(this.start + this.length) % this.capacity] = byte; + this.length += 1; + return; + } + this.bytes[this.start] = byte; + this.start = (this.start + 1) % this.capacity; + } + + appendWindow(source: RollingByteWindow): void { + if (source.length === 0) return; + const firstLength = Math.min(source.length, source.capacity - source.start); + this.append(source.bytes, source.start, source.start + firstLength); + if (firstLength < source.length) { + this.append(source.bytes, 0, source.length - firstLength); + } + } + + reset(): void { + this.start = 0; + this.length = 0; + } + + digest(): string { + const hash = createHash("sha256"); + if (this.length === 0) return hash.digest("hex"); + const firstLength = Math.min(this.length, this.capacity - this.start); + hash.update(this.bytes.subarray(this.start, this.start + firstLength)); + if (firstLength < this.length) { + hash.update(this.bytes.subarray(0, this.length - firstLength)); + } + return hash.digest("hex"); + } +} + +function rebuildRequired(reason: UsageLedgerRebuildReason): UsageLedgerRebuildRequiredError { + return new UsageLedgerRebuildRequiredError(reason); +} + +function captureRangeIntoWindow( + fd: number, + from: number, + to: number, + scratch: Buffer, + window: RollingByteWindow, + signal: AbortSignal | undefined, +): void { + for (let position = from; position < to;) { + throwIfAborted(signal); + const requested = Math.min(scratch.byteLength, to - position); + const read = readSync(fd, scratch, 0, requested, position); + if (read === 0) throw rebuildRequired("shrink"); + window.append(scratch, 0, read); + position += read; + } +} + +async function digestRangeCooperatively( + fd: number, + from: number, + to: number, + buffer: Buffer, + signal: AbortSignal | undefined, +): Promise { + const hash = createHash("sha256"); + for (let position = from; position < to;) { + throwIfAborted(signal); + const requested = Math.min(buffer.byteLength, to - position); + const read = readSync(fd, buffer, 0, requested, position); + if (read === 0) throw rebuildRequired("shrink"); + hash.update(buffer.subarray(0, read)); + position += read; + if (position < to) await new Promise(resolve => setTimeout(resolve, 0)); + } + return hash.digest("hex"); +} + +/** + * Cooperatively scans a full ledger or validated append suffix with bounded memory. + * + * The opened EOF is the snapshot boundary: bytes appended after the initial fstat are + * deliberately left for the next scan. Rows are framed as raw bytes before UTF-8 decoding, + * so a multi-byte character may safely cross any read boundary. Only LF-terminated rows are + * published; a torn final write is skipped rather than accepted prematurely. + */ +export async function scanUsageLedgerCooperatively( + options: ScanUsageLedgerOptions, +): Promise { + const chunkBytes = options.chunkBytes ?? USAGE_LEDGER_READ_CHUNK_BYTES; + if (!Number.isSafeInteger(chunkBytes) || chunkBytes <= 0 || chunkBytes > USAGE_LEDGER_READ_CHUNK_BYTES) { + throw new RangeError(`usage ledger chunk bytes must be between 1 and ${USAGE_LEDGER_READ_CHUNK_BYTES}`); + } + const startAtBytes = options.startAtBytes ?? 0; + if (!Number.isSafeInteger(startAtBytes) || startAtBytes < 0) { + throw new RangeError("usage ledger start offset must be a non-negative safe integer"); + } + if (startAtBytes > 0 + && (options.expectedIdentityKey === undefined + || options.expectedProcessedThroughDigest === undefined)) { + throw new TypeError( + "usage ledger append scan requires expectedIdentityKey and expectedProcessedThroughDigest", + ); + } + throwIfAborted(options.signal); + + const path = usageLogPath(); + let fd: number; + try { + fd = openSync(path, "r"); + } catch (error) { + if (isMissingFileError(error)) { + if (startAtBytes > 0 + || (options.expectedIdentityKey + && options.expectedIdentityKey !== usageLogIdentityKey(null))) { + throw rebuildRequired("identity_mismatch"); + } + return { + revision: null, + parsedRows: 0, + invalidRows: 0, + oversizedRows: 0, + bytesRead: 0, + processedThroughBytes: 0, + processedThroughDigest: createHash("sha256").digest("hex"), + }; + } + throw error; + } + + const decoder = new TextDecoder("utf-8", { fatal: true }); + const chunk = Buffer.allocUnsafe(chunkBytes); + const line = Buffer.allocUnsafe(USAGE_LEDGER_MAX_LINE_BYTES); + let lineLength = 0; + let droppingOversizedLine = false; + let parsedRows = 0; + let invalidRows = 0; + let oversizedRows = 0; + let bytesRead = 0; + let bytesSinceYield = 0; + let linesSinceYield = 0; + let processedThroughBytes = startAtBytes; + const capturedHash = createHash("sha256"); + const committedTail = new RollingByteWindow(USAGE_LEDGER_BOUNDARY_DIGEST_BYTES); + const pendingTail = new RollingByteWindow(USAGE_LEDGER_BOUNDARY_DIGEST_BYTES); + let pendingLineBytes = 0; + + try { + const openedRevision = revisionFromStat(path, fstatSync(fd)); + const scanEnd = openedRevision.size; + const openedIdentityKey = usageLogIdentityKey(openedRevision); + if (options.expectedIdentityKey && options.expectedIdentityKey !== openedIdentityKey) { + throw rebuildRequired("identity_mismatch"); + } + if (scanEnd < startAtBytes) throw rebuildRequired("shrink"); + if (startAtBytes > 0) { + const preceding = Buffer.allocUnsafe(1); + const read = readSync(fd, preceding, 0, 1, startAtBytes - 1); + if (read !== 1) throw rebuildRequired("shrink"); + if (preceding[0] !== 0x0a) throw rebuildRequired("boundary_mismatch"); + } + if (options.expectedProcessedThroughDigest !== undefined) { + captureRangeIntoWindow( + fd, + Math.max(0, startAtBytes - USAGE_LEDGER_BOUNDARY_DIGEST_BYTES), + startAtBytes, + line, + committedTail, + options.signal, + ); + if (committedTail.digest() !== options.expectedProcessedThroughDigest) { + throw rebuildRequired("content_changed"); + } + } + + const publishLine = (): void => { + if (lineLength === 0 || isJsonWhitespace(line, lineLength)) return; + let entry: PersistedUsageEntry | undefined; + try { + const text = decoder.decode(line.subarray(0, lineLength)); + entry = normalizePersistedUsageRow(JSON.parse(text)); + } catch { + invalidRows += 1; + return; + } + if (!entry) { + invalidRows += 1; + return; + } + options.onEntry(entry); + parsedRows += 1; + }; + + for (let position = startAtBytes; position < scanEnd;) { + throwIfAborted(options.signal); + const chunkStart = position; + const requested = Math.min(chunk.byteLength, scanEnd - position); + const read = readSync(fd, chunk, 0, requested, position); + if (read === 0) throw rebuildRequired("shrink"); + position += read; + bytesRead += read; + bytesSinceYield += read; + capturedHash.update(chunk.subarray(0, read)); + + let cursor = 0; + while (cursor < read) { + const newline = chunk.indexOf(0x0a, cursor); + const segmentEnd = newline >= 0 && newline < read ? newline : read; + const segmentLength = segmentEnd - cursor; + + if (segmentLength > 0) { + pendingTail.append(chunk, cursor, segmentEnd); + pendingLineBytes = Math.min( + USAGE_LEDGER_BOUNDARY_DIGEST_BYTES, + pendingLineBytes + segmentLength, + ); + } + + if (!droppingOversizedLine) { + if (lineLength + segmentLength > USAGE_LEDGER_MAX_LINE_BYTES) { + oversizedRows += 1; + droppingOversizedLine = true; + lineLength = 0; + } else if (segmentLength > 0) { + chunk.copy(line, lineLength, cursor, segmentEnd); + lineLength += segmentLength; + } + } + + if (newline < 0 || newline >= read) break; + linesSinceYield += 1; + processedThroughBytes = chunkStart + newline + 1; + if (pendingLineBytes + 1 >= USAGE_LEDGER_BOUNDARY_DIGEST_BYTES) { + committedTail.reset(); + } + committedTail.appendWindow(pendingTail); + committedTail.appendByte(0x0a); + pendingTail.reset(); + pendingLineBytes = 0; + if (droppingOversizedLine) { + droppingOversizedLine = false; + } else { + publishLine(); + } + lineLength = 0; + cursor = newline + 1; + } + + if (position < scanEnd + && (bytesSinceYield >= USAGE_LEDGER_READ_CHUNK_BYTES || linesSinceYield >= 1_000)) { + await new Promise(resolve => setTimeout(resolve, 0)); + bytesSinceYield = 0; + linesSinceYield = 0; + } + } + + // A non-empty suffix without LF is not a committed JSONL row, even when it happens + // to contain valid JSON. Count it as invalid and leave it out of the aggregate. + if (!droppingOversizedLine && lineLength > 0 && !isJsonWhitespace(line, lineLength)) { + invalidRows += 1; + } + + throwIfAborted(options.signal); + const endingRevision = revisionFromStat(path, fstatSync(fd)); + if (usageLogIdentityKey(endingRevision) !== openedIdentityKey) { + throw rebuildRequired("identity_mismatch"); + } + if (endingRevision.size < scanEnd) throw rebuildRequired("shrink"); + const pathRevision = currentUsageLogRevision(); + if (!pathRevision || usageLogIdentityKey(pathRevision) !== openedIdentityKey) { + throw rebuildRequired("identity_mismatch"); + } + if (pathRevision.size < scanEnd) throw rebuildRequired("shrink"); + + // A pure append changes size/mtime/ctime but leaves the captured prefix intact and + // is safe to ignore until the next scan. Re-read only that prefix when a mutation + // was observed, so a same-inode rewrite (including rewrite + growth) cannot publish + // a mixture of old and new rows after a cooperative yield. + const mutationObserved = usageLogRevisionKey(endingRevision) !== usageLogRevisionKey(openedRevision) + || usageLogRevisionKey(pathRevision) !== usageLogRevisionKey(openedRevision); + if (mutationObserved) { + const capturedDigest = capturedHash.digest("hex"); + const verifiedDigest = await digestRangeCooperatively( + fd, + startAtBytes, + scanEnd, + line, + options.signal, + ); + const verifiedFdRevision = revisionFromStat(path, fstatSync(fd)); + const verifiedPathRevision = currentUsageLogRevision(); + if (capturedDigest !== verifiedDigest) throw rebuildRequired("content_changed"); + if (!verifiedPathRevision + || usageLogIdentityKey(verifiedFdRevision) !== openedIdentityKey + || usageLogIdentityKey(verifiedPathRevision) !== openedIdentityKey) { + throw rebuildRequired("identity_mismatch"); + } + if (verifiedFdRevision.size < scanEnd || verifiedPathRevision.size < scanEnd) { + throw rebuildRequired("shrink"); + } + } + + throwIfAborted(options.signal); + const processedThroughDigest = committedTail.digest(); + + return { + revision: openedRevision, + parsedRows, + invalidRows, + oversizedRows, + bytesRead, + processedThroughBytes, + processedThroughDigest, + }; + } finally { + closeSync(fd); + } +} diff --git a/src/usage/log.ts b/src/usage/log.ts index 7e056f97b0..6a74ae7f96 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -1190,7 +1190,7 @@ export async function readUsageEntriesForManagement(): Promise; if (typeof row.requestId !== "string" || typeof row.provider !== "string") return undefined; diff --git a/src/usage/summary.ts b/src/usage/summary.ts index b37aac8533..53148331f8 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -15,6 +15,8 @@ export const USAGE_RANGES = ["today", "7d", "30d", "all"] as const; export type UsageRange = typeof USAGE_RANGES[number]; export const USAGE_SURFACES = ["all", "codex", "claude", "grok"] as const; export type UsageSurface = typeof USAGE_SURFACES[number]; +/** Maximum number of calendar buckets returned by the all-history chart. */ +export const MAX_USAGE_DAY_BUCKETS = 366; export interface UsageSummaryTotals { requests: number; @@ -147,7 +149,7 @@ export interface UsageSummary { } /** - * Echo of an applied provider/model projection. + * Echo of an applied API-key/provider/model projection. * * Present only on a filtered response so a consumer can distinguish "no rows * matched" from "no traffic in this window", and can tell that the totals it @@ -236,16 +238,6 @@ export function computeEntryCost(entry: PersistedUsageEntry): EntryCostInfo { const DAY_MS = 86_400_000; export const MAX_USAGE_MODEL_BREAKDOWN_ROWS = 256; -function retainedBreakdownRows( - rows: T[], - aggregateOverflow: (overflow: T[]) => T, -): T[] { - if (rows.length <= MAX_USAGE_MODEL_BREAKDOWN_ROWS) return rows; - const keep = rows.slice(0, MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1); - keep.push(aggregateOverflow(rows.slice(MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1))); - return keep; -} - export function parseRange(input: string | null | undefined): UsageRange { // `1d` normalises here rather than becoming a second union member: a second // member would need its own cache slot, its own grid arm and its own test @@ -287,17 +279,16 @@ export function rangeWindow(range: UsageRange, now: number): { since: number | n function localDateKey(ts: number): string { const d = new Date(ts); - const y = d.getFullYear(); + const y = String(d.getFullYear()).padStart(4, "0"); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } -function dayCountForAllRange(entries: PersistedUsageEntry[], now: number): number { - if (entries.length === 0) return 1; - const oldest = entries.reduce((min, e) => Math.min(min, e.timestamp), entries[0].timestamp); +function dayCountForAllRange(oldest: number | null, now: number): number { + if (oldest === null) return 1; const days = Math.ceil((now - oldest) / DAY_MS) + 1; - return Math.max(1, days); + return Math.min(MAX_USAGE_DAY_BUCKETS, Math.max(1, days)); } function blankTotals(): UsageSummaryTotals { @@ -333,6 +324,7 @@ interface UsageAttribution { provider: string; model: string; resolvedModel?: string; + accountLogLabel?: string; usageStatus: UsageStatus; usage?: PersistedUsageEntry["usage"]; totalTokens?: number; @@ -365,7 +357,7 @@ function usageModelIdentity( } function usageModelKey(providerKey: string, model: string): string { - return `${providerKey}/${model}`; + return `${providerKey}\0${model}`; } function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { @@ -374,6 +366,7 @@ function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { requestId: entry.requestId, provider: entry.provider, ...usageModelIdentity(entry.provider, entry.model, entry.resolvedModel), + ...(entry.accountLogLabel ? { accountLogLabel: entry.accountLogLabel } : {}), usageStatus: entry.usageStatus, ...(entry.usage ? { usage: entry.usage } : {}), ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), @@ -383,6 +376,7 @@ function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { requestId: entry.requestId, provider: attempt.provider, ...usageModelIdentity(attempt.provider, attempt.model), + ...(attempt.accountLogLabel ? { accountLogLabel: attempt.accountLogLabel } : {}), usageStatus: attempt.usageStatus, ...(attempt.usage ? { usage: attempt.usage } : {}), ...(attempt.totalTokens !== undefined ? { totalTokens: attempt.totalTokens } : {}), @@ -446,17 +440,6 @@ function projectedComboUsage( }; } -function foldAttributionStatuses(statuses: readonly UsageStatus[]): UsageStatus { - if (statuses.length > 0 && statuses.every(status => status === "unsupported")) { - return "unsupported"; - } - if (statuses.some(status => status === "unreported" || status === "unsupported")) { - return "unreported"; - } - if (statuses.some(status => status === "estimated")) return "estimated"; - return statuses.length > 0 ? "reported" : "unreported"; -} - function bumpStatus(totals: UsageSummaryTotals, status: UsageStatus): void { totals.requests += 1; if (isMeasuredStatus(status)) totals.measuredRequests += 1; @@ -505,500 +488,728 @@ function addEstimatedCost( totals.estimatedCostUsd += costInfo.costTotal; } -function buildDayGrid(range: UsageRange, since: number | null, now: number, entries: PersistedUsageEntry[], costMap: Map): UsageDay[] { - const window = rangeWindow(range, now); - const days = range === "all" ? dayCountForAllRange(entries, now) : window.days; - const grid = new Map(); - // Per-day model breakdown accumulator, keyed by day then provider/model, so the 7d bar chart can - // render a per-model stacked bar with a hover tooltip without a second pass over the entries. - interface DayModelAccumulator extends UsageDayModel { - cacheObserved?: boolean; +const REQUEST_REPORTED = 1 << 0; +const REQUEST_ESTIMATED = 1 << 1; +const REQUEST_UNREPORTED = 1 << 2; +const REQUEST_UNSUPPORTED = 1 << 3; +const REQUEST_PRICED = 1 << 4; +const REQUEST_UNPRICED = 1 << 5; +const REQUEST_STATUS_MASK = REQUEST_REPORTED | REQUEST_ESTIMATED | REQUEST_UNREPORTED | REQUEST_UNSUPPORTED; + +type UsagePartitionSurface = Exclude | "other"; +export type UsageAccumulatorMode = "exact" | "row-unique"; + +interface UsageRequestCounts { + requests: number; + measuredRequests: number; + reportedRequests: number; + estimatedRequests: number; + pricedRequests: number; + unpricedRequests: number; +} + +interface UsageModelOverlap { + models: ReadonlyArray; + count: number; +} + +interface UsageModelAccumulator { + provider: string; + model: string; + resolvedModel?: string; + firstSeen: number; + attemptCount: number; + dayTotalTokens: number; + summaryTotalTokens: number; + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + cacheObserved: boolean; + estimatedCostUsd?: number; + requestCounts: UsageRequestCounts; + requestFacts?: Map; +} + +interface UsageAccountAccumulator { + accountLogLabel: string; + ambiguous: boolean; + firstSeen: number; + requests: number; + requestIds?: Set; + attemptCount: number; + measuredAttempts: number; + reportedAttempts: number; + estimatedAttempts: number; + unmeteredAttempts: number; + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + reasoningOutputTokens: number; + totalTokens: number; + estimatedCostUsd?: number; + pricedAttempts: number; + unpricedAttempts: number; +} + +interface UsagePartition { + date: string; + dayStart: number; + surface: UsagePartitionSurface; + oldestTimestamp: number | null; + totals: UsageSummaryTotals; + models: Map; + providers?: Map; + accounts: Map; + modelOverlaps: Map; +} + +interface UsageDayAccumulator { + totals: UsageSummaryTotals; + models: Map; + modelOverlaps: UsageModelOverlap[]; +} + +interface NormalizedUsageFilter { + provider: string | null; + model: string | null; + apiKeyId: string | null; +} + +export interface UsageSummaryAccumulator { + add(entry: PersistedUsageEntry): void; + /** Return a mutation-independent snapshot that may continue accepting rows. */ + clone(): UsageSummaryAccumulator; + summarize( + range: UsageRange, + now: number, + surface?: UsageSurface, + ): UsageSummary & { filter?: UsageFilterEcho }; + readonly snapshotWindow: { start: number | null; end: number | null }; + /** Conservative O(1) retained-state estimate; excludes scan and summarize temporaries. */ + readonly estimatedBytes: number; +} + +function requestStatusFact(status: UsageStatus): number { + if (status === "reported") return REQUEST_REPORTED; + if (status === "estimated") return REQUEST_ESTIMATED; + if (status === "unsupported") return REQUEST_UNSUPPORTED; + return REQUEST_UNREPORTED; +} + +function statusFromRequestFacts(facts: number): UsageStatus { + const statuses = facts & REQUEST_STATUS_MASK; + if (statuses === REQUEST_UNSUPPORTED) return "unsupported"; + if ((statuses & (REQUEST_UNREPORTED | REQUEST_UNSUPPORTED)) !== 0) return "unreported"; + if ((statuses & REQUEST_ESTIMATED) !== 0) return "estimated"; + return (statuses & REQUEST_REPORTED) !== 0 ? "reported" : "unreported"; +} + +function blankRequestCounts(): UsageRequestCounts { + return { + requests: 0, + measuredRequests: 0, + reportedRequests: 0, + estimatedRequests: 0, + pricedRequests: 0, + unpricedRequests: 0, + }; +} + +function bumpRequestCounts(counts: UsageRequestCounts, facts: number, amount = 1): void { + counts.requests += amount; + const status = statusFromRequestFacts(facts); + if (isMeasuredStatus(status)) counts.measuredRequests += amount; + if (status === "reported") counts.reportedRequests += amount; + else if (status === "estimated") counts.estimatedRequests += amount; + if ((facts & REQUEST_PRICED) !== 0) counts.pricedRequests += amount; + if ((facts & REQUEST_UNPRICED) !== 0) counts.unpricedRequests += amount; +} + +function mergeRequestCounts(target: UsageRequestCounts, source: UsageRequestCounts): void { + target.requests += source.requests; + target.measuredRequests += source.measuredRequests; + target.reportedRequests += source.reportedRequests; + target.estimatedRequests += source.estimatedRequests; + target.pricedRequests += source.pricedRequests; + target.unpricedRequests += source.unpricedRequests; +} + +function mergeRequestFacts(target: Map, source: Map): void { + for (const [requestId, facts] of source) { + target.set(requestId, (target.get(requestId) ?? 0) | facts); } - const dayModels = new Map>(); - const dayModelRequests = new Map>(); - const bumpDayModel = (dayKey: string, attribution: UsageAttribution): void => { - let models = dayModels.get(dayKey); - if (!models) { models = new Map(); dayModels.set(dayKey, models); } - const providerKey = baseProviderLabel(attribution.provider); - const mKey = usageModelKey(providerKey, attribution.model); - let m = models.get(mKey); - if (!m) { - m = { - model: attribution.model, - provider: providerKey, - requests: 0, - attemptCount: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - cacheHitRate: null, - }; - models.set(mKey, m); - } - const requestKey = `${dayKey}\0${mKey}`; - let requests = dayModelRequests.get(requestKey); - if (!requests) { requests = new Set(); dayModelRequests.set(requestKey, requests); } - requests.add(attribution.requestId); - m.requests = requests.size; - m.attemptCount += 1; - if (attribution.usage) { - m.inputTokens = (m.inputTokens ?? 0) + attribution.usage.inputTokens; - m.outputTokens = (m.outputTokens ?? 0) + attribution.usage.outputTokens; - const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); - if (hasCacheTelemetry) m.cacheObserved = true; - if (typeof read === "number") m.cacheReadInputTokens = (m.cacheReadInputTokens ?? 0) + read; - if (typeof creation === "number") m.cacheCreationInputTokens = (m.cacheCreationInputTokens ?? 0) + creation; - } - m.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; +} + +function requestCountsFor(model: UsageModelAccumulator): UsageRequestCounts { + if (!model.requestFacts) return model.requestCounts; + const counts = blankRequestCounts(); + for (const facts of model.requestFacts.values()) bumpRequestCounts(counts, facts); + return counts; +} + +function mergeTotals(target: UsageSummaryTotals, source: UsageSummaryTotals): void { + target.requests += source.requests; + target.attemptCount += source.attemptCount; + target.measuredRequests += source.measuredRequests; + target.reportedRequests += source.reportedRequests; + target.unreportedRequests += source.unreportedRequests; + target.unsupportedRequests += source.unsupportedRequests; + target.estimatedRequests += source.estimatedRequests; + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cachedInputTokens += source.cachedInputTokens; + target.cacheReadInputTokens += source.cacheReadInputTokens; + target.cacheCreationInputTokens += source.cacheCreationInputTokens; + target.reasoningOutputTokens += source.reasoningOutputTokens; + target.totalTokens += source.totalTokens; + target.estimatedCostUsd += source.estimatedCostUsd; + target.pricedRequests += source.pricedRequests; + target.unpricedRequests += source.unpricedRequests; + target.unmeteredRequests += source.unmeteredRequests; +} + +function blankModelAccumulator( + provider: string, + model: string, + resolvedModel: string | undefined, + firstSeen: number, + mode: UsageAccumulatorMode, +): UsageModelAccumulator { + return { + provider, + model, + ...(resolvedModel ? { resolvedModel } : {}), + firstSeen, + attemptCount: 0, + dayTotalTokens: 0, + summaryTotalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + cacheObserved: false, + requestCounts: blankRequestCounts(), + ...(mode === "exact" ? { requestFacts: new Map() } : {}), + }; +} + +function cloneModelAccumulator(source: UsageModelAccumulator): UsageModelAccumulator { + return { + ...source, + requestCounts: { ...source.requestCounts }, + ...(source.requestFacts ? { requestFacts: new Map(source.requestFacts) } : {}), }; - const startOfToday = startOfLocalDay(now); - for (let i = days - 1; i >= 0; i--) { - const d = new Date(startOfToday); - d.setDate(d.getDate() - i); - const key = localDateKey(d.getTime()); - grid.set(key, { date: key, requests: 0, measuredRequests: 0, reportedRequests: 0, totalTokens: 0, estimatedCostUsd: 0, models: [] }); +} + +function mergeModelAccumulator(target: UsageModelAccumulator, source: UsageModelAccumulator): void { + if (source.firstSeen < target.firstSeen) { + target.firstSeen = source.firstSeen; + target.resolvedModel = source.resolvedModel; } - for (const entry of entries) { - const key = localDateKey(entry.timestamp); - let day = grid.get(key); - if (!day) { - day = { date: key, requests: 0, measuredRequests: 0, reportedRequests: 0, totalTokens: 0, estimatedCostUsd: 0, models: [] }; - grid.set(key, day); - } - day.requests += 1; - if (isMeasuredStatus(entry.usageStatus)) day.measuredRequests += 1; - if (entry.usageStatus === "reported") day.reportedRequests += 1; - day.totalTokens += usageDisplayTotalTokens(entry.usage, entry.totalTokens) ?? 0; - for (const attribution of usageAttributions(entry)) bumpDayModel(key, attribution); - const costInfo = costMap.get(entry); - if (costInfo?.isPriced) { - if (entry.attempts?.length && costInfo.attemptEstimates) { - for (let i = 0; i < entry.attempts.length; i++) { - const attempt = entry.attempts[i]; - const attemptEst = costInfo.attemptEstimates[i]; - if (attemptEst) { - const aProviderKey = baseProviderLabel(attempt.provider); - const aIdentity = usageModelIdentity(attempt.provider, attempt.model); - const aKey = usageModelKey(aProviderKey, aIdentity.model); - const m = dayModels.get(key)?.get(aKey); - if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; - } - } - } else if (costInfo.estimate) { - const providerKey = baseProviderLabel(entry.provider); - const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); - const mKey = usageModelKey(providerKey, identity.model); - const m = dayModels.get(key)?.get(mKey); - if (m) m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + costInfo.estimate.cost.total; - } - day.estimatedCostUsd += costInfo.costTotal; - } + target.attemptCount += source.attemptCount; + target.dayTotalTokens += source.dayTotalTokens; + target.summaryTotalTokens += source.summaryTotalTokens; + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cacheReadInputTokens += source.cacheReadInputTokens; + target.cacheCreationInputTokens += source.cacheCreationInputTokens; + target.cacheObserved ||= source.cacheObserved; + if (source.estimatedCostUsd !== undefined) { + target.estimatedCostUsd = (target.estimatedCostUsd ?? 0) + source.estimatedCostUsd; } - void since; - const out = [...grid.values()].sort((a, b) => a.date.localeCompare(b.date)); - for (const day of out) { - const models = dayModels.get(day.date); - if (models) { - for (const m of models.values()) { - m.cacheHitRate = calculateCacheHitRate(!!m.cacheObserved, m.inputTokens ?? 0, m.cacheReadInputTokens ?? 0); - } - const sorted = [...models.values()].sort((a, b) => b.requests - a.requests); - const retained = retainedBreakdownRows(sorted, overflow => { - const requests = new Set(); - let attemptCount = 0; - let totalTokens = 0; - let inputTokens = 0; - let outputTokens = 0; - let cacheReadInputTokens = 0; - let cacheCreationInputTokens = 0; - let cacheObserved = false; - let estimatedCostUsd: number | undefined; - for (const model of overflow) { - attemptCount += model.attemptCount; - totalTokens += model.totalTokens; - inputTokens += model.inputTokens ?? 0; - outputTokens += model.outputTokens ?? 0; - cacheReadInputTokens += model.cacheReadInputTokens ?? 0; - cacheCreationInputTokens += model.cacheCreationInputTokens ?? 0; - if (model.cacheObserved) cacheObserved = true; - if (model.estimatedCostUsd !== undefined) { - estimatedCostUsd = (estimatedCostUsd ?? 0) + model.estimatedCostUsd; - } - const requestKey = `${day.date}\0${usageModelKey(model.provider, model.model)}`; - for (const requestId of dayModelRequests.get(requestKey) ?? []) requests.add(requestId); - } - const cacheHitRate = calculateCacheHitRate(cacheObserved, inputTokens, cacheReadInputTokens); - return { - model: "other", - provider: "other", - requests: requests.size, - attemptCount, - totalTokens, - inputTokens, - outputTokens, - cacheReadInputTokens, - cacheCreationInputTokens, - cacheHitRate, - ...(estimatedCostUsd !== undefined ? { estimatedCostUsd } : {}), - }; - }); - for (const model of retained) delete model.cacheObserved; - day.models = retained; - } + if (target.requestFacts && source.requestFacts) mergeRequestFacts(target.requestFacts, source.requestFacts); + else mergeRequestCounts(target.requestCounts, source.requestCounts); +} + +function mergeModelMaps( + target: Map, + source: Map, +): void { + for (const [key, model] of source) { + const current = target.get(key); + if (current) mergeModelAccumulator(current, model); + else target.set(key, cloneModelAccumulator(model)); } - return out; } -function buildModels(entries: PersistedUsageEntry[], totalTokens: number, costMap: Map): UsageModel[] { - interface ModelAccumulator extends UsageModel { - cacheObserved?: boolean; +function cloneAccountAccumulator(source: UsageAccountAccumulator): UsageAccountAccumulator { + return { + ...source, + ...(source.requestIds ? { requestIds: new Set(source.requestIds) } : {}), + }; +} + +function mergeAccountAccumulator(target: UsageAccountAccumulator, source: UsageAccountAccumulator): void { + target.firstSeen = Math.min(target.firstSeen, source.firstSeen); + if (target.requestIds && source.requestIds) { + for (const requestId of source.requestIds) target.requestIds.add(requestId); + } else { + target.requests += source.requests; } - const byKey = new Map(); - const statusesByKey = new Map>(); - for (const entry of entries) { - for (const attribution of usageAttributions(entry)) { - const providerKey = baseProviderLabel(attribution.provider); - // resolvedModel is a routing detail, not a row identity. - const key = usageModelKey(providerKey, attribution.model); - let model = byKey.get(key); - if (!model) { - model = { - provider: providerKey, - model: attribution.model, - ...(attribution.resolvedModel ? { resolvedModel: attribution.resolvedModel } : {}), - requests: 0, - attemptCount: 0, - measuredRequests: 0, - reportedRequests: 0, - estimatedRequests: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cachedInputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - pricedRequests: 0, - unpricedRequests: 0, - priceCoverageRatio: 0, - shareRatio: 0, - }; - byKey.set(key, model); - } - model.attemptCount += 1; - let requests = statusesByKey.get(key); - if (!requests) { requests = new Map(); statusesByKey.set(key, requests); } - const statuses = requests.get(attribution.requestId) ?? []; - statuses.push(attribution.usageStatus); - requests.set(attribution.requestId, statuses); - if (attribution.usage) { - model.inputTokens += attribution.usage.inputTokens; - model.outputTokens += attribution.usage.outputTokens; - const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); - if (hasCacheTelemetry) model.cacheObserved = true; - if (typeof read === "number") { - model.cachedInputTokens = (model.cachedInputTokens ?? 0) + read; - model.cacheReadInputTokens = (model.cacheReadInputTokens ?? 0) + read; - } - if (typeof creation === "number") { - model.cacheCreationInputTokens = (model.cacheCreationInputTokens ?? 0) + creation; - } - model.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; - } - } + target.attemptCount += source.attemptCount; + target.measuredAttempts += source.measuredAttempts; + target.reportedAttempts += source.reportedAttempts; + target.estimatedAttempts += source.estimatedAttempts; + target.unmeteredAttempts += source.unmeteredAttempts; + target.inputTokens += source.inputTokens; + target.outputTokens += source.outputTokens; + target.cacheReadInputTokens += source.cacheReadInputTokens; + target.cacheCreationInputTokens += source.cacheCreationInputTokens; + target.reasoningOutputTokens += source.reasoningOutputTokens; + target.totalTokens += source.totalTokens; + if (source.estimatedCostUsd !== undefined) { + target.estimatedCostUsd = (target.estimatedCostUsd ?? 0) + source.estimatedCostUsd; } - for (const [key, model] of byKey) { - const groups = statusesByKey.get(key) ?? new Map(); - model.requests = groups.size; - for (const statuses of groups.values()) { - const status = foldAttributionStatuses(statuses); - if (isMeasuredStatus(status)) model.measuredRequests += 1; - if (status === "reported") model.reportedRequests += 1; - else if (status === "estimated") model.estimatedRequests += 1; - } + target.pricedAttempts += source.pricedAttempts; + target.unpricedAttempts += source.unpricedAttempts; +} + +function usagePartitionSurface(entry: PersistedUsageEntry): UsagePartitionSurface { + if (entry.surface === undefined) return "codex"; + if (entry.surface === "claude" || entry.surface === "claude-desktop") return "claude"; + if (entry.surface === "grok") return "grok"; + return "other"; +} + +function usageSurfaceMatches(partition: UsagePartitionSurface, surface: UsageSurface): boolean { + return surface === "all" || partition === surface; +} + +const LEGACY_AMBIGUOUS_ACCOUNT_LABEL = "legacy-ambiguous"; + +function legacyCodexAccountLabel(provider: string): string | null { + if (baseProviderLabel(provider) !== "openai") return null; + const suffix = provider.match(/-(main|p[a-f0-9]{6})$/)?.[1]; + return suffix ?? LEGACY_AMBIGUOUS_ACCOUNT_LABEL; +} + +/** + * An explicitly stamped label of EITHER family is authoritative for any provider (#2699). + * The legacy fallback stays openai-only so unrelated unlabeled providers are not guessed. + */ +function accountLabelForAttribution(provider: string, explicit: unknown): string | null { + if (isCodexUsageAccountLogLabel(explicit)) return explicit; + return legacyCodexAccountLabel(provider); +} + +function filterMatchesAttribution( + filter: NormalizedUsageFilter, + provider: string, + model: string, +): boolean { + if (filter.provider !== null && baseProviderLabel(provider).toLowerCase() !== filter.provider) return false; + if (filter.model !== null && model.toLowerCase() !== filter.model) return false; + return true; +} + +function projectedEntryForFilter( + entry: PersistedUsageEntry, + filter: NormalizedUsageFilter, +): { entry: PersistedUsageEntry; comboOverlap: boolean } | null { + if (filter.apiKeyId !== null && entry.apiKeyId !== filter.apiKeyId) return null; + if (!entry.attempts?.length) { + const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); + return filterMatchesAttribution(filter, entry.provider, identity.model) + ? { entry, comboOverlap: false } + : null; } - // Accumulate per-model estimated cost & price coverage by request ID - const pricedRequestsByModel = new Map>(); - const unpricedRequestsByModel = new Map>(); - for (const entry of entries) { - const costInfo = costMap.get(entry); - if (entry.attempts?.length) { - const attemptEstimates = costInfo?.attemptEstimates; - for (let i = 0; i < entry.attempts.length; i++) { - const attempt = entry.attempts[i]; - const attemptEst = attemptEstimates?.[i]; - const aProviderKey = baseProviderLabel(attempt.provider); - const aIdentity = usageModelIdentity(attempt.provider, attempt.model); - const aKey = usageModelKey(aProviderKey, aIdentity.model); - if (attemptEst) { - const m = byKey.get(aKey); - if (m) { - m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + attemptEst.cost.total; - } - let s = pricedRequestsByModel.get(aKey); - if (!s) { s = new Set(); pricedRequestsByModel.set(aKey, s); } - s.add(entry.requestId); - } else { - let s = unpricedRequestsByModel.get(aKey); - if (!s) { s = new Set(); unpricedRequestsByModel.set(aKey, s); } - s.add(entry.requestId); - } - } - } else { - const providerKey = baseProviderLabel(entry.provider); - const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); - const key = usageModelKey(providerKey, identity.model); - const estimate = costInfo?.estimate; - if (estimate) { - const m = byKey.get(key); - if (m) { - m.estimatedCostUsd = (m.estimatedCostUsd ?? 0) + estimate.cost.total; - } - let s = pricedRequestsByModel.get(key); - if (!s) { s = new Set(); pricedRequestsByModel.set(key, s); } - s.add(entry.requestId); - } else { - let s = unpricedRequestsByModel.get(key); - if (!s) { s = new Set(); unpricedRequestsByModel.set(key, s); } - s.add(entry.requestId); + const attempts = entry.attempts.filter(attempt => { + const identity = usageModelIdentity(attempt.provider, attempt.model); + return filterMatchesAttribution(filter, attempt.provider, identity.model); + }); + if (attempts.length === 0) return null; + const { usage: _parentUsage, totalTokens: _parentTotalTokens, ...withoutParentUsage } = entry; + return { + entry: { ...withoutParentUsage, attempts, ...projectedComboUsage(attempts) }, + comboOverlap: entry.attempts.length > 1, + }; +} + +function overflowModelAccumulator( + models: UsageModelAccumulator[], + overlaps: readonly UsageModelOverlap[], +): UsageModelAccumulator { + const mode: UsageAccumulatorMode = models[0]?.requestFacts ? "exact" : "row-unique"; + const other = blankModelAccumulator("other", "other", undefined, models[0]?.firstSeen ?? 0, mode); + for (const model of models) mergeModelAccumulator(other, model); + if (mode === "row-unique" && overlaps.length > 0) { + const overflowKeys = new Set(models.map(model => usageModelKey(model.provider, model.model))); + for (const overlap of overlaps) { + const retained = overlap.models.filter(([modelKey]) => overflowKeys.has(modelKey)); + if (retained.length < 2) continue; + let combinedFacts = 0; + for (const [, facts] of retained) { + bumpRequestCounts(other.requestCounts, facts, -overlap.count); + combinedFacts |= facts; } + bumpRequestCounts(other.requestCounts, combinedFacts, overlap.count); } } - const models = [...byKey.values()]; - for (const [key, m] of byKey) { - m.pricedRequests = pricedRequestsByModel.get(key)?.size ?? 0; - m.unpricedRequests = unpricedRequestsByModel.get(key)?.size ?? 0; - m.shareRatio = totalTokens === 0 ? 0 : m.totalTokens / totalTokens; - m.cacheHitRate = calculateCacheHitRate(!!m.cacheObserved, m.inputTokens, m.cacheReadInputTokens ?? 0); - m.priceCoverageRatio = m.requests > 0 ? m.pricedRequests / m.requests : 0; - } - const sorted = models.sort((a, b) => b.requests - a.requests); - const retained = retainedBreakdownRows(sorted, overflow => { - const statusesByRequest = new Map(); - const overflowPricedRequests = new Set(); - const overflowUnpricedRequests = new Set(); - let cacheObserved = false; - const other: ModelAccumulator = { - provider: "other", - model: "other", - requests: 0, - attemptCount: 0, - measuredRequests: 0, - reportedRequests: 0, - estimatedRequests: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cachedInputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - pricedRequests: 0, - unpricedRequests: 0, - priceCoverageRatio: 0, - shareRatio: 0, + other.provider = "other"; + other.model = "other"; + delete other.resolvedModel; + return other; +} + +function retainedModelAccumulators( + models: UsageModelAccumulator[], + overlaps: readonly UsageModelOverlap[], +): UsageModelAccumulator[] { + if (models.length <= MAX_USAGE_MODEL_BREAKDOWN_ROWS) return models; + return [ + ...models.slice(0, MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1), + overflowModelAccumulator(models.slice(MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1), overlaps), + ]; +} + +function buildDayModels( + models: Map, + overlaps: readonly UsageModelOverlap[], +): UsageDayModel[] { + const sorted = [...models.values()].sort((a, b) => + requestCountsFor(b).requests - requestCountsFor(a).requests || a.firstSeen - b.firstSeen + ); + return retainedModelAccumulators(sorted, overlaps).map(model => ({ + model: model.model, + provider: model.provider, + requests: requestCountsFor(model).requests, + attemptCount: model.attemptCount, + totalTokens: model.dayTotalTokens, + inputTokens: model.inputTokens, + outputTokens: model.outputTokens, + cacheReadInputTokens: model.cacheReadInputTokens, + cacheCreationInputTokens: model.cacheCreationInputTokens, + cacheHitRate: calculateCacheHitRate(model.cacheObserved, model.inputTokens, model.cacheReadInputTokens), + ...(model.estimatedCostUsd !== undefined ? { estimatedCostUsd: model.estimatedCostUsd } : {}), + })); +} + +function buildUsageModels( + models: Map, + totalTokens: number, + overlaps: readonly UsageModelOverlap[], +): UsageModel[] { + const sorted = [...models.values()].sort((a, b) => + requestCountsFor(b).requests - requestCountsFor(a).requests || a.firstSeen - b.firstSeen + ); + return retainedModelAccumulators(sorted, overlaps).map(model => { + const counts = requestCountsFor(model); + const requests = counts.requests; + return { + provider: model.provider, + model: model.model, + ...(model.resolvedModel ? { resolvedModel: model.resolvedModel } : {}), + requests, + attemptCount: model.attemptCount, + measuredRequests: counts.measuredRequests, + reportedRequests: counts.reportedRequests, + estimatedRequests: counts.estimatedRequests, + totalTokens: model.summaryTotalTokens, + inputTokens: model.inputTokens, + outputTokens: model.outputTokens, + cachedInputTokens: model.cacheReadInputTokens, + cacheReadInputTokens: model.cacheReadInputTokens, + cacheCreationInputTokens: model.cacheCreationInputTokens, + cacheHitRate: calculateCacheHitRate(model.cacheObserved, model.inputTokens, model.cacheReadInputTokens), + priceCoverageRatio: requests > 0 ? counts.pricedRequests / requests : 0, + pricedRequests: counts.pricedRequests, + unpricedRequests: counts.unpricedRequests, + shareRatio: totalTokens === 0 ? 0 : model.summaryTotalTokens / totalTokens, + ...(model.estimatedCostUsd !== undefined ? { estimatedCostUsd: model.estimatedCostUsd } : {}), }; - for (const model of overflow) { - other.attemptCount += model.attemptCount; - other.totalTokens += model.totalTokens; - other.inputTokens += model.inputTokens; - other.outputTokens += model.outputTokens; - if (model.cacheObserved) cacheObserved = true; - other.cachedInputTokens = (other.cachedInputTokens ?? 0) + (model.cachedInputTokens ?? 0); - other.cacheReadInputTokens = (other.cacheReadInputTokens ?? 0) + (model.cacheReadInputTokens ?? 0); - other.cacheCreationInputTokens = (other.cacheCreationInputTokens ?? 0) + (model.cacheCreationInputTokens ?? 0); - if (model.estimatedCostUsd !== undefined) { - other.estimatedCostUsd = (other.estimatedCostUsd ?? 0) + model.estimatedCostUsd; - } - const key = usageModelKey(model.provider, model.model); - for (const [requestId, statuses] of statusesByKey.get(key) ?? []) { - const combined = statusesByRequest.get(requestId) ?? []; - combined.push(...statuses); - statusesByRequest.set(requestId, combined); - } - for (const reqId of pricedRequestsByModel.get(key) ?? []) overflowPricedRequests.add(reqId); - for (const reqId of unpricedRequestsByModel.get(key) ?? []) overflowUnpricedRequests.add(reqId); - } - other.requests = statusesByRequest.size; - other.pricedRequests = overflowPricedRequests.size; - other.unpricedRequests = overflowUnpricedRequests.size; - for (const statuses of statusesByRequest.values()) { - const status = foldAttributionStatuses(statuses); - if (isMeasuredStatus(status)) other.measuredRequests += 1; - if (status === "reported") other.reportedRequests += 1; - else if (status === "estimated") other.estimatedRequests += 1; - } - other.shareRatio = totalTokens === 0 ? 0 : other.totalTokens / totalTokens; - other.cacheHitRate = calculateCacheHitRate(cacheObserved, other.inputTokens, other.cacheReadInputTokens ?? 0); - other.priceCoverageRatio = other.requests > 0 ? other.pricedRequests / other.requests : 0; - return other; }); - for (const model of retained) delete model.cacheObserved; - return retained; } -function buildProviders(entries: PersistedUsageEntry[], totalTokens: number, costMap: Map): UsageProvider[] { - interface ProviderAccumulator extends UsageProvider { - cacheObserved?: boolean; +function buildUsageProviders( + models: Map, + totalTokens: number, +): UsageProvider[] { + const providers = new Map(); + for (const model of models.values()) { + const current = providers.get(model.provider); + if (current) mergeModelAccumulator(current, model); + else providers.set(model.provider, cloneModelAccumulator(model)); + } + return [...providers.values()] + .sort((a, b) => requestCountsFor(b).requests - requestCountsFor(a).requests || a.firstSeen - b.firstSeen) + .map(provider => { + const counts = requestCountsFor(provider); + const requests = counts.requests; + return { + provider: provider.provider, + requests, + attemptCount: provider.attemptCount, + measuredRequests: counts.measuredRequests, + reportedRequests: counts.reportedRequests, + estimatedRequests: counts.estimatedRequests, + totalTokens: provider.summaryTotalTokens, + inputTokens: provider.inputTokens, + outputTokens: provider.outputTokens, + cachedInputTokens: provider.cacheReadInputTokens, + cacheReadInputTokens: provider.cacheReadInputTokens, + cacheCreationInputTokens: provider.cacheCreationInputTokens, + cacheHitRate: calculateCacheHitRate(provider.cacheObserved, provider.inputTokens, provider.cacheReadInputTokens), + priceCoverageRatio: requests > 0 ? counts.pricedRequests / requests : 0, + pricedRequests: counts.pricedRequests, + unpricedRequests: counts.unpricedRequests, + shareRatio: totalTokens === 0 ? 0 : provider.summaryTotalTokens / totalTokens, + ...(provider.estimatedCostUsd !== undefined ? { estimatedCostUsd: provider.estimatedCostUsd } : {}), + }; + }); +} + +function buildUsageAccounts(accounts: Map): UsageAccount[] { + return [...accounts.values()] + .sort((a, b) => b.totalTokens - a.totalTokens || a.firstSeen - b.firstSeen) + .map(account => ({ + accountLogLabel: account.accountLogLabel, + ambiguous: account.ambiguous, + requests: account.requestIds?.size ?? account.requests, + attemptCount: account.attemptCount, + measuredAttempts: account.measuredAttempts, + reportedAttempts: account.reportedAttempts, + estimatedAttempts: account.estimatedAttempts, + unmeteredAttempts: account.unmeteredAttempts, + inputTokens: account.inputTokens, + outputTokens: account.outputTokens, + cacheReadInputTokens: account.cacheReadInputTokens, + cacheCreationInputTokens: account.cacheCreationInputTokens, + reasoningOutputTokens: account.reasoningOutputTokens, + totalTokens: account.totalTokens, + usageCoverageRatio: account.attemptCount === 0 ? 0 : account.measuredAttempts / account.attemptCount, + ...(account.estimatedCostUsd !== undefined ? { estimatedCostUsd: account.estimatedCostUsd } : {}), + pricedAttempts: account.pricedAttempts, + unpricedAttempts: account.unpricedAttempts, + priceCoverageRatio: account.measuredAttempts === 0 ? 0 : account.pricedAttempts / account.measuredAttempts, + })); +} + +// Retained-size estimates intentionally favor over-counting. They are updated only when +// retained structures grow, so memory-budget checks stay O(1) even on very large ledgers. +const ESTIMATED_ACCUMULATOR_BASE_BYTES = 2_048; +const ESTIMATED_PARTITION_BYTES = 1_024; +const ESTIMATED_BREAKDOWN_BYTES = 1_024; +const ESTIMATED_EXACT_REQUEST_ID_BYTES = 1_024; +const ESTIMATED_REQUEST_FACT_BYTES = 512; +const ESTIMATED_ACCOUNT_REQUEST_BYTES = 256; +const ESTIMATED_OVERLAP_BYTES = 128; +const ESTIMATED_OVERLAP_MODEL_BYTES = 256; + +class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator { + private readonly partitions = new Map(); + private readonly requestIds: Map | null; + private readonly filter: NormalizedUsageFilter | null; + private readonly mode: UsageAccumulatorMode; + private nextRequestId = 0; + private nextOrdinal = 0; + private snapshotStart: number | null = null; + private snapshotEnd: number | null = null; + private comboOverlap = false; + private estimatedRetainedBytes = ESTIMATED_ACCUMULATOR_BASE_BYTES; + + constructor(options?: { + filter?: { provider?: string | null; model?: string | null; apiKeyId?: string | null }; + mode?: UsageAccumulatorMode; + }) { + const provider = normalizeFilterValue(options?.filter?.provider); + const model = normalizeFilterValue(options?.filter?.model); + const apiKeyId = normalizeExactFilterValue(options?.filter?.apiKeyId); + this.filter = provider === null && model === null && apiKeyId === null + ? null + : { provider, model, apiKeyId }; + this.mode = options?.mode ?? "exact"; + this.requestIds = this.mode === "exact" ? new Map() : null; } - const byKey = new Map(); - const statusesByKey = new Map>(); - for (const entry of entries) { - for (const attribution of usageAttributions(entry)) { - const providerKey = baseProviderLabel(attribution.provider); - let provider = byKey.get(providerKey); - if (!provider) { - provider = { - provider: providerKey, - requests: 0, - attemptCount: 0, - measuredRequests: 0, - reportedRequests: 0, - estimatedRequests: 0, - totalTokens: 0, - inputTokens: 0, - outputTokens: 0, - cachedInputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - pricedRequests: 0, - unpricedRequests: 0, - priceCoverageRatio: 0, - shareRatio: 0, - }; - byKey.set(providerKey, provider); + + get snapshotWindow(): { start: number | null; end: number | null } { + return { start: this.snapshotStart, end: this.snapshotEnd }; + } + + get estimatedBytes(): number { + return this.estimatedRetainedBytes; + } + + clone(): UsageSummaryAccumulator { + const cloned = new StreamingUsageSummaryAccumulator({ + ...(this.filter ? { filter: this.filter } : {}), + mode: this.mode, + }); + cloned.nextRequestId = this.nextRequestId; + cloned.nextOrdinal = this.nextOrdinal; + cloned.snapshotStart = this.snapshotStart; + cloned.snapshotEnd = this.snapshotEnd; + cloned.comboOverlap = this.comboOverlap; + cloned.estimatedRetainedBytes = this.estimatedRetainedBytes; + if (this.requestIds && cloned.requestIds) { + for (const [requestId, key] of this.requestIds) cloned.requestIds.set(requestId, key); + } + for (const [key, partition] of this.partitions) { + const models = new Map(); + for (const [modelKey, model] of partition.models) { + models.set(modelKey, cloneModelAccumulator(model)); } - provider.attemptCount += 1; - let requests = statusesByKey.get(providerKey); - if (!requests) { requests = new Map(); statusesByKey.set(providerKey, requests); } - const statuses = requests.get(attribution.requestId) ?? []; - statuses.push(attribution.usageStatus); - requests.set(attribution.requestId, statuses); - if (attribution.usage) { - provider.inputTokens = (provider.inputTokens ?? 0) + attribution.usage.inputTokens; - provider.outputTokens = (provider.outputTokens ?? 0) + attribution.usage.outputTokens; - const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); - if (hasCacheTelemetry) provider.cacheObserved = true; - if (typeof read === "number") { - provider.cachedInputTokens = (provider.cachedInputTokens ?? 0) + read; - provider.cacheReadInputTokens = (provider.cacheReadInputTokens ?? 0) + read; - } - if (typeof creation === "number") { - provider.cacheCreationInputTokens = (provider.cacheCreationInputTokens ?? 0) + creation; - } - provider.totalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; + const providers = partition.providers + ? new Map([...partition.providers].map(([providerKey, provider]) => [providerKey, cloneModelAccumulator(provider)])) + : undefined; + const accounts = new Map(); + for (const [label, account] of partition.accounts) { + accounts.set(label, cloneAccountAccumulator(account)); } + cloned.partitions.set(key, { + ...partition, + totals: { ...partition.totals }, + models, + ...(providers ? { providers } : {}), + accounts, + modelOverlaps: new Map( + [...partition.modelOverlaps].map(([signature, overlap]) => [signature, { ...overlap }]), + ), + }); } + return cloned; + } + + private requestKey(requestId: string): number { + if (!this.requestIds) throw new Error("row-unique accumulators do not retain request ids"); + const existing = this.requestIds.get(requestId); + if (existing !== undefined) return existing; + const key = this.nextRequestId++; + this.requestIds.set(requestId, key); + this.estimatedRetainedBytes += ESTIMATED_EXACT_REQUEST_ID_BYTES + requestId.length * 2; + return key; } - for (const [key, provider] of byKey) { - const groups = statusesByKey.get(key) ?? new Map(); - provider.requests = groups.size; - for (const statuses of groups.values()) { - const status = foldAttributionStatuses(statuses); - if (isMeasuredStatus(status)) provider.measuredRequests += 1; - if (status === "reported") provider.reportedRequests += 1; - else if (status === "estimated") provider.estimatedRequests += 1; + + private partitionFor(entry: PersistedUsageEntry): UsagePartition { + const date = localDateKey(entry.timestamp); + const dayStart = startOfLocalDay(entry.timestamp); + const surface = usagePartitionSurface(entry); + const key = `${date}\0${surface}`; + let partition = this.partitions.get(key); + if (!partition) { + partition = { + date, + dayStart, + surface, + oldestTimestamp: null, + totals: blankTotals(), + models: new Map(), + ...(this.mode === "row-unique" ? { providers: new Map() } : {}), + accounts: new Map(), + modelOverlaps: new Map(), + }; + this.partitions.set(key, partition); + this.estimatedRetainedBytes += ESTIMATED_PARTITION_BYTES; } + if (Number.isFinite(entry.timestamp)) { + partition.oldestTimestamp = partition.oldestTimestamp === null + ? entry.timestamp + : Math.min(partition.oldestTimestamp, entry.timestamp); + } + return partition; } - const pricedRequestsByProvider = new Map>(); - const unpricedRequestsByProvider = new Map>(); - for (const entry of entries) { - const costInfo = costMap.get(entry); - if (entry.attempts?.length) { - const attemptEstimates = costInfo?.attemptEstimates; - for (let i = 0; i < entry.attempts.length; i++) { - const attempt = entry.attempts[i]; - const attemptEst = attemptEstimates?.[i]; - const aProviderKey = baseProviderLabel(attempt.provider); - if (attemptEst) { - const p = byKey.get(aProviderKey); - if (p) { - p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + attemptEst.cost.total; - } - let s = pricedRequestsByProvider.get(aProviderKey); - if (!s) { s = new Set(); pricedRequestsByProvider.set(aProviderKey, s); } - s.add(entry.requestId); - } else { - let s = unpricedRequestsByProvider.get(aProviderKey); - if (!s) { s = new Set(); unpricedRequestsByProvider.set(aProviderKey, s); } - s.add(entry.requestId); - } - } - } else { - const providerKey = baseProviderLabel(entry.provider); - const estimate = costInfo?.estimate; - if (estimate) { - const p = byKey.get(providerKey); - if (p) { - p.estimatedCostUsd = (p.estimatedCostUsd ?? 0) + estimate.cost.total; - } - let s = pricedRequestsByProvider.get(providerKey); - if (!s) { s = new Set(); pricedRequestsByProvider.set(providerKey, s); } - s.add(entry.requestId); - } else { - let s = unpricedRequestsByProvider.get(providerKey); - if (!s) { s = new Set(); unpricedRequestsByProvider.set(providerKey, s); } - s.add(entry.requestId); - } + + private addAttributionMetrics( + breakdown: UsageModelAccumulator, + attribution: UsageAttribution, + estimate: AttemptCostEstimate | CostEstimate | null, + ): void { + breakdown.attemptCount += 1; + if (attribution.usage) { + breakdown.inputTokens += attribution.usage.inputTokens; + breakdown.outputTokens += attribution.usage.outputTokens; + const { read, creation, hasCacheTelemetry } = cacheTokensFromUsage(attribution.usage); + breakdown.cacheObserved ||= hasCacheTelemetry; + if (typeof read === "number") breakdown.cacheReadInputTokens += read; + if (typeof creation === "number") breakdown.cacheCreationInputTokens += creation; + breakdown.summaryTotalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; } + breakdown.dayTotalTokens += usageDisplayTotalTokens(attribution.usage, attribution.totalTokens) ?? 0; + if (estimate) breakdown.estimatedCostUsd = (breakdown.estimatedCostUsd ?? 0) + estimate.cost.total; } - const providers = [...byKey.values()]; - for (const [key, p] of byKey) { - p.pricedRequests = pricedRequestsByProvider.get(key)?.size ?? 0; - p.unpricedRequests = unpricedRequestsByProvider.get(key)?.size ?? 0; - p.shareRatio = totalTokens === 0 ? 0 : p.totalTokens / totalTokens; - p.cacheHitRate = calculateCacheHitRate(!!p.cacheObserved, p.inputTokens ?? 0, p.cacheReadInputTokens ?? 0); - p.priceCoverageRatio = p.requests > 0 ? p.pricedRequests / p.requests : 0; + + private addModelAttribution( + partition: UsagePartition, + attribution: UsageAttribution, + estimate: AttemptCostEstimate | CostEstimate | null, + ordinal: number, + ): string { + const provider = baseProviderLabel(attribution.provider); + const key = usageModelKey(provider, attribution.model); + let model = partition.models.get(key); + if (!model) { + model = blankModelAccumulator(provider, attribution.model, attribution.resolvedModel, ordinal, this.mode); + partition.models.set(key, model); + this.estimatedRetainedBytes += ESTIMATED_BREAKDOWN_BYTES + key.length * 2; + } + this.addAttributionMetrics(model, attribution, estimate); + return key; } - const sorted = providers.sort((a, b) => b.requests - a.requests); - for (const provider of sorted) delete provider.cacheObserved; - return sorted; -} -const LEGACY_AMBIGUOUS_ACCOUNT_LABEL = "legacy-ambiguous"; + private addProviderAttribution( + partition: UsagePartition, + attribution: UsageAttribution, + estimate: AttemptCostEstimate | CostEstimate | null, + ordinal: number, + ): string { + const providerKey = baseProviderLabel(attribution.provider); + const providers = partition.providers; + if (!providers) return providerKey; + let provider = providers.get(providerKey); + if (!provider) { + provider = blankModelAccumulator(providerKey, "", undefined, ordinal, "row-unique"); + providers.set(providerKey, provider); + this.estimatedRetainedBytes += ESTIMATED_BREAKDOWN_BYTES + providerKey.length * 2; + } + this.addAttributionMetrics(provider, attribution, estimate); + return providerKey; + } -function legacyCodexAccountLabel(provider: string): string | null { - if (baseProviderLabel(provider) !== "openai") return null; - const suffix = provider.match(/-(main|p[a-f0-9]{6})$/)?.[1]; - return suffix ?? LEGACY_AMBIGUOUS_ACCOUNT_LABEL; -} + private addBreakdownRequest( + breakdown: UsageModelAccumulator, + facts: number, + requestKey: number | null, + ): void { + if (breakdown.requestFacts) { + if (requestKey === null) throw new Error("exact accumulators require request identity"); + const previous = breakdown.requestFacts.get(requestKey); + breakdown.requestFacts.set(requestKey, (previous ?? 0) | facts); + if (previous === undefined) this.estimatedRetainedBytes += ESTIMATED_REQUEST_FACT_BYTES; + return; + } + bumpRequestCounts(breakdown.requestCounts, facts); + } -/** - * An explicitly stamped label of EITHER family is authoritative for any provider (#2699). - * - * No `o`-label branch is needed here: `isCodexUsageAccountLogLabel` now accepts both families, - * and adding a second predicate call would be a no-op guarded by a comment claiming otherwise. - * - * The legacy fallback stays openai-only on purpose. It infers an account from the PROVIDER - * string, and inferring for a non-Codex row would merge unrelated accounts under one label -- - * so an unlabeled xai row is dropped from the account table rather than guessed at. - */ -function accountLabelForAttribution(provider: string, explicit: unknown): string | null { - if (isCodexUsageAccountLogLabel(explicit)) return explicit; - return legacyCodexAccountLabel(provider); -} + private addAccountRequest(account: UsageAccountAccumulator, requestKey: number | null): void { + if (account.requestIds) { + if (requestKey === null) throw new Error("exact accumulators require request identity"); + const previousSize = account.requestIds.size; + account.requestIds.add(requestKey); + if (account.requestIds.size !== previousSize) { + this.estimatedRetainedBytes += ESTIMATED_ACCOUNT_REQUEST_BYTES; + } + return; + } + account.requests += 1; + } -function buildAccounts(entries: PersistedUsageEntry[], costMap: Map): UsageAccount[] { - const byLabel = new Map(); - const requestIds = new Map>(); - - const add = (input: { - requestId: string; - provider: string; - accountLogLabel?: string; - usageStatus: UsageStatus; - usage?: PersistedUsageEntry["usage"]; - totalTokens?: number; - estimate: AttemptCostEstimate | CostEstimate | null; - }): void => { - const label = accountLabelForAttribution(input.provider, input.accountLogLabel); - if (!label) return; - let row = byLabel.get(label); - if (!row) { - row = { + private addAccountAttribution( + partition: UsagePartition, + attribution: UsageAttribution, + estimate: AttemptCostEstimate | CostEstimate | null, + ordinal: number, + ): string | null { + const label = accountLabelForAttribution(attribution.provider, attribution.accountLogLabel); + if (!label) return null; + let account = partition.accounts.get(label); + if (!account) { + account = { accountLogLabel: label, ambiguous: label === LEGACY_AMBIGUOUS_ACCOUNT_LABEL, + firstSeen: ordinal, requests: 0, + ...(this.mode === "exact" ? { requestIds: new Set() } : {}), attemptCount: 0, measuredAttempts: 0, reportedAttempts: 0, @@ -1010,77 +1221,222 @@ function buildAccounts(entries: PersistedUsageEntry[], costMap: Map(); + const providerFacts = new Map(); + const accountLabels = new Set(); + for (let index = 0; index < attributions.length; index++) { + const attribution = attributions[index]!; + const estimate = entry.attempts?.length + ? costInfo.attemptEstimates?.[index] ?? null + : costInfo.estimate; + const ordinal = this.nextOrdinal++; + const facts = requestStatusFact(attribution.usageStatus) + | (estimate ? REQUEST_PRICED : REQUEST_UNPRICED); + const modelKey = this.addModelAttribution(partition, attribution, estimate, ordinal); + modelFacts.set(modelKey, (modelFacts.get(modelKey) ?? 0) | facts); + if (this.mode === "row-unique") { + const providerKey = this.addProviderAttribution(partition, attribution, estimate, ordinal); + providerFacts.set(providerKey, (providerFacts.get(providerKey) ?? 0) | facts); + } + const accountLabel = this.addAccountAttribution(partition, attribution, estimate, ordinal); + if (accountLabel) accountLabels.add(accountLabel); + } + for (const [modelKey, facts] of modelFacts) { + this.addBreakdownRequest(partition.models.get(modelKey)!, facts, requestKey); + } + if (partition.providers) { + for (const [providerKey, facts] of providerFacts) { + this.addBreakdownRequest(partition.providers.get(providerKey)!, facts, null); + } + } + for (const label of accountLabels) { + this.addAccountRequest(partition.accounts.get(label)!, requestKey); + } + if (this.mode === "row-unique" && modelFacts.size > 1) { + const models = [...modelFacts].sort(([a], [b]) => a.localeCompare(b)); + const signature = JSON.stringify(models); + const overlap = partition.modelOverlaps.get(signature); + if (overlap) { + overlap.count += 1; + } else { + partition.modelOverlaps.set(signature, { models, count: 1 }); + this.estimatedRetainedBytes += ESTIMATED_OVERLAP_BYTES + + models.length * ESTIMATED_OVERLAP_MODEL_BYTES + + signature.length * 2; } - continue; } - add({ - requestId: entry.requestId, - provider: entry.provider, - ...(entry.accountLogLabel ? { accountLogLabel: entry.accountLogLabel } : {}), - usageStatus: entry.usageStatus, - ...(entry.usage ? { usage: entry.usage } : {}), - ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}), - estimate: costInfo?.estimate ?? null, - }); } - for (const row of byLabel.values()) { - row.usageCoverageRatio = row.attemptCount === 0 ? 0 : row.measuredAttempts / row.attemptCount; - row.priceCoverageRatio = row.measuredAttempts === 0 ? 0 : row.pricedAttempts / row.measuredAttempts; + summarize( + range: UsageRange, + now: number, + surface: UsageSurface = "all", + ): UsageSummary & { filter?: UsageFilterEcho } { + const { since, days: fixedDays } = rangeWindow(range, now); + const totals = blankTotals(); + const models = new Map(); + const providers = new Map(); + const accounts = new Map(); + const dayAccumulators = new Map(); + const modelOverlaps: UsageModelOverlap[] = []; + let oldestTimestamp: number | null = null; + + for (const partition of this.partitions.values()) { + if (!usageSurfaceMatches(partition.surface, surface)) continue; + if (since !== null && partition.dayStart < since) continue; + mergeTotals(totals, partition.totals); + mergeModelMaps(models, partition.models); + if (partition.providers) mergeModelMaps(providers, partition.providers); + modelOverlaps.push(...partition.modelOverlaps.values()); + for (const [label, account] of partition.accounts) { + const current = accounts.get(label); + if (current) mergeAccountAccumulator(current, account); + else accounts.set(label, cloneAccountAccumulator(account)); + } + if (partition.oldestTimestamp !== null) { + oldestTimestamp = oldestTimestamp === null + ? partition.oldestTimestamp + : Math.min(oldestTimestamp, partition.oldestTimestamp); + } + let day = dayAccumulators.get(partition.date); + if (!day) { + day = { totals: blankTotals(), models: new Map(), modelOverlaps: [] }; + dayAccumulators.set(partition.date, day); + } + mergeTotals(day.totals, partition.totals); + mergeModelMaps(day.models, partition.models); + day.modelOverlaps.push(...partition.modelOverlaps.values()); + } + finalizeCoverage(totals); + + const dayCount = range === "all" ? dayCountForAllRange(oldestTimestamp, now) : fixedDays; + const startOfToday = startOfLocalDay(now); + const firstVisibleDay = new Date(startOfToday); + firstVisibleDay.setDate(firstVisibleDay.getDate() - dayCount + 1); + const firstVisibleDate = localDateKey(firstVisibleDay.getTime()); + const lastVisibleDate = localDateKey(startOfToday); + for (let offset = dayCount - 1; offset >= 0; offset--) { + const date = new Date(startOfToday); + date.setDate(date.getDate() - offset); + const key = localDateKey(date.getTime()); + if (!dayAccumulators.has(key)) { + dayAccumulators.set(key, { totals: blankTotals(), models: new Map(), modelOverlaps: [] }); + } + } + const days = [...dayAccumulators] + // All-history totals, models, providers, and accounts still cover every + // retained row. Only the chart buckets are bounded so one malformed or + // ancient timestamp cannot synthesize an enormous JSON response. + .filter(([date]) => range !== "all" + || (date >= firstVisibleDate && date <= lastVisibleDate)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, day]): UsageDay => ({ + date, + requests: day.totals.requests, + measuredRequests: day.totals.measuredRequests, + reportedRequests: day.totals.reportedRequests, + totalTokens: day.totals.totalTokens, + estimatedCostUsd: day.totals.estimatedCostUsd, + models: buildDayModels(day.models, day.modelOverlaps), + })); + + const summary: UsageSummary = { + range, + surface, + since, + generatedAt: now, + summary: totals, + days, + models: buildUsageModels(models, totals.totalTokens, modelOverlaps), + providers: buildUsageProviders(this.mode === "row-unique" ? providers : models, totals.totalTokens), + accounts: buildUsageAccounts(accounts), + }; + if (!this.filter) return summary; + const matches = (provider: string, model: string): boolean => + filterMatchesAttribution(this.filter!, provider, model); + const retainedModels = summary.models.filter(row => matches(row.provider, row.model)); + const retainedProviders = new Set(retainedModels.map(row => row.provider)); + return { + ...summary, + days: summary.days.map(day => ({ + ...day, + models: day.models.filter(row => matches(row.provider, row.model)), + })), + models: retainedModels, + providers: summary.providers.filter(row => retainedProviders.has(row.provider)), + accounts: this.filter.provider === null && this.filter.model === null + ? summary.accounts + : [], + filter: { + provider: this.filter.provider, + model: this.filter.model, + apiKeyId: this.filter.apiKeyId, + matched: summary.summary.requests > 0, + comboOverlap: this.comboOverlap, + }, + }; } - return [...byLabel.values()].sort((a, b) => b.totalTokens - a.totalTokens); +} + +export function createUsageSummaryAccumulator(options?: { + filter?: { provider?: string | null; model?: string | null; apiKeyId?: string | null }; + mode?: UsageAccumulatorMode; +}): UsageSummaryAccumulator { + return new StreamingUsageSummaryAccumulator(options); } export function summarizeUsage( @@ -1089,40 +1445,9 @@ export function summarizeUsage( now: number, surface: UsageSurface = "all", ): UsageSummary { - const { since } = rangeWindow(range, now); - const filteredEntries = entries.filter(entry => { - if (since !== null && entry.timestamp < since) return false; - if (surface === "claude") return entry.surface === "claude" || entry.surface === "claude-desktop"; - if (surface === "grok") return entry.surface === "grok"; - // Codex = the historical unlabelled bucket. Before the grok tag existed every - // non-Claude turn landed here, and `surface !== "claude"` also swallowed - // claude-desktop — disjoint predicates fix both. - if (surface === "codex") return entry.surface === undefined; - return true; - }); - const costMap = new Map(); - for (const entry of filteredEntries) { - costMap.set(entry, computeEntryCost(entry)); - } - const totals = blankTotals(); - for (const entry of filteredEntries) { - bumpStatus(totals, entry.usageStatus); - totals.attemptCount += entry.attempts?.length ?? 1; - addTokens(totals, entry); - addEstimatedCost(totals, entry, costMap.get(entry)!); - } - finalizeCoverage(totals); - return { - range, - surface, - since, - generatedAt: now, - summary: totals, - days: buildDayGrid(range, since, now, filteredEntries, costMap), - models: buildModels(filteredEntries, totals.totalTokens, costMap), - providers: buildProviders(filteredEntries, totals.totalTokens, costMap), - accounts: buildAccounts(filteredEntries, costMap), - }; + const accumulator = createUsageSummaryAccumulator(); + for (const entry of entries) accumulator.add(entry); + return accumulator.summarize(range, now, surface); } function normalizeFilterValue(input: string | null | undefined): string | null { @@ -1138,12 +1463,9 @@ function normalizeExactFilterValue(input: string | null | undefined): string | n /** * Narrow an already-summarised window to one provider and/or model. * - * Deliberately a projection over a finished summary rather than a parameter to - * {@link summarizeUsage}. The management route caches summaries under - * `range:surface` and warms that key space as a cross-product; a filtered - * summary that reached either would be served to the next UNFILTERED caller, - * the dashboard included. Keeping the filter outside the producer makes that - * mistake unrepresentable rather than merely discouraged. + * The compatibility wrapper feeds source rows through a filter-bound streaming + * accumulator. The management route can use the same accumulator directly and + * still keep filtered results outside its unfiltered `range:surface` cache. * * Totals are recomputed from the retained rows. For combo traffic a request is * counted once per participating model, so a filtered request count can exceed @@ -1165,78 +1487,16 @@ export function projectUsageSummary( const model = normalizeFilterValue(filter.model); const apiKeyId = normalizeExactFilterValue(filter.apiKeyId); if (provider === null && model === null && apiKeyId === null) return summary; - - // Re-summarise from the entries the summary was built from, rather than - // projecting over its rows. - // - // Projecting rows looked cheaper and was wrong in three ways that only show - // up together: breakdown rows past MAX_USAGE_MODEL_BREAKDOWN_ROWS are - // collapsed into a synthetic "other" row, so a provider living only in that - // tail is unfindable and reports matched:false despite real usage; a - // provider row is a whole-provider aggregate, so a model filter kept the - // provider's OTHER models in providers[] while models[] and the totals - // excluded them, contradicting itself inside one response; and a model row - // carries a single optional cost, so priced/unpriced/unmetered counts could - // only be guessed per model rather than counted per request. - // - // Key ownership is the outer slice: no provider/model attribution or bucket - // construction may observe rows belonging to another client key. - const keyFilteredEntries = apiKeyId === null - ? entries ?? [] - : (entries ?? []).filter(entry => entry.apiKeyId === apiKeyId); - - // The entries are already in hand on every path that filters, so the honest - // computation is also the simple one. - const matches = (rowProvider: string, rowModel: string): boolean => { - if (provider !== null && baseProviderLabel(rowProvider).toLowerCase() !== provider) return false; - if (model !== null && rowModel.toLowerCase() !== model) return false; - return true; - }; - - // Narrow to matching ATTRIBUTIONS, not matching entries. - // - // Keeping a whole combo entry because one of its attempts matched drags the - // other attempts' tokens and cost into the filtered totals: a two-attempt - // combo filtered to its cheap model reported the expensive model's spend - // too. Rewriting the entry down to its matching attempts is what makes the - // filtered numbers mean what the flag says. - let comboOverlap = false; - const filtered: PersistedUsageEntry[] = []; - for (const entry of keyFilteredEntries) { - if (!entry.attempts?.length) { - const identity = usageModelIdentity(entry.provider, entry.model, entry.resolvedModel); - if (matches(entry.provider, identity.model)) filtered.push(entry); - continue; - } - const attempts = entry.attempts.filter(a => { - const identity = usageModelIdentity(a.provider, a.model); - return matches(a.provider, identity.model); - }); - if (attempts.length === 0) continue; - if (entry.attempts.length > 1) comboOverlap = true; - const { usage: _parentUsage, totalTokens: _parentTotalTokens, ...withoutParentUsage } = entry; - filtered.push({ ...withoutParentUsage, attempts, ...projectedComboUsage(attempts) }); - } - - const projected = summarizeUsage(filtered, summary.range, summary.generatedAt, summary.surface); - const matched = projected.summary.requests > 0; - const models = projected.models.filter(row => matches(row.provider, row.model)); - const retainedProviders = new Set(models.map(row => row.provider)); + const accumulator = createUsageSummaryAccumulator({ filter: { provider, model, apiKeyId } }); + for (const entry of entries ?? []) accumulator.add(entry); + const projected = accumulator.summarize(summary.range, summary.generatedAt, summary.surface); return { ...summary, summary: projected.summary, - days: projected.days.map(day => ({ ...day, models: day.models.filter(row => matches(row.provider, row.model)) })), - models, - providers: projected.providers.filter(row => retainedProviders.has(row.provider)), - // Account rows are not provider-partitioned in a way this projection could - // honestly re-derive, and unfiltered account totals sitting beside filtered - // model totals would invite exactly the wrong reading — so a provider or model - // filter drops them. - // - // An apiKeyId-only filter is different: it selects whole entries, so the account - // rows projected from those entries are exactly the accounts that key used. They - // are honest under that filter and are kept. - accounts: provider === null && model === null ? projected.accounts : [], - filter: { provider, model, apiKeyId, matched, comboOverlap }, + days: projected.days, + models: projected.models, + providers: projected.providers, + accounts: projected.accounts, + filter: projected.filter, }; } diff --git a/src/web-search/index.ts b/src/web-search/index.ts index 8dad3b3d73..719c22efac 100644 --- a/src/web-search/index.ts +++ b/src/web-search/index.ts @@ -23,7 +23,7 @@ const DEFAULT_ANTHROPIC_SIDECAR_MODEL = "claude-sonnet-5"; // Default Grok model for the xai-backed sidecar (probe-verified with hosted tools, devlog 003). const DEFAULT_XAI_SIDECAR_MODEL = "grok-4.6"; // Default Gemini model for the gemini-backed sidecar (CCA grounding probe, devlog 002). -const DEFAULT_GEMINI_SIDECAR_MODEL = "gemini-3.7-flash"; +const DEFAULT_GEMINI_SIDECAR_MODEL = "gemini-3.8-flash"; // "low" is the lightest effort the ChatGPT backend allows with web_search ("minimal" is rejected: // "tools cannot be used with reasoning.effort 'minimal'") — keeps the sidecar fast/cheap. const DEFAULT_SIDECAR_REASONING = "low"; diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index ff75b15e88..725fbdcd5e 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -128,7 +128,7 @@ this document owns is which module holds which area and what invariant that area | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | -| Usage | `GET /api/usage` aggregate read-only summary derived from `~/.opencodex/usage.jsonl`; measured / reported / unreported / unsupported / estimated counts, daily zero-filled grid, model and provider breakdowns. Never exposes prompts. | +| Usage | `GET /api/usage` aggregate read-only summary derived from the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | @@ -339,6 +339,12 @@ An opt-in shadow-call rewrite persists the bounded, redacted original helper mod request content or inferring a helper subtype from timing. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. +The management route streams the complete ledger from its beginning in fixed 1 MiB chunks on a +cold rebuild, then retains compact numeric aggregate state and resumes at the last verified LF for +ordinary appends. It does not retain the full input or a normalized object for every request, and +neither the old byte window nor the parsed-entry cap can discard an earlier prefix before range and +surface filtering. `managementUsageMaxReadBytes` remains a recognized compatibility setting for +bounded legacy readers, but it is not an accuracy limit or tuning knob for `GET /api/usage`. A Codex-surface response also includes an `accounts` breakdown keyed by the stable non-PII `accountLogLabel`; current cards join those rows to the management account DTO and show the 30-day token total, API-equivalent cost estimate, and measurement coverage. New main-pool rows use `main`, @@ -350,20 +356,35 @@ estimated` split exists for, and why coverage is reported alongside totals. The main Dashboard surfaces a 30d token / coverage summary. The in-memory `requestLog` is capped at 200 entries and is **not** the source of truth for aggregation — the JSONL on disk is. -The management API caches only the compact summary for an exact file revision and query; it never -retains normalized per-request rows after a response. The cache invalidates on any identity, size, or -timestamp change and at the next range expiry or local-day boundary. Rebuilds parse in bounded -batches and yield between them, so unrelated management requests remain serviceable even for a large -existing log. The Dashboard polls its 30-day usage summary independently once per minute, so usage -work cannot delay health/provider/settings state or run every five seconds. +The management API retains the compact accumulator plus bounded query summaries; it never retains +normalized per-request rows after a response. File identity changes, shrinkage, same-size metadata +changes, pricing-overlay changes, and local-time-zone changes force a cold rebuild. Ordinary growth +is treated as an append: the scanner verifies the previous LF and its trailing 64 KiB digest, then +folds only the suffix into a cloned accumulator and publishes it after validation. Concurrent callers +share that work. Cold rebuilds scan the whole ledger in fixed-size chunks and yield between bounded +batches, so memory stays bounded and unrelated management requests remain serviceable even for a +large existing log. The first read is proportional to ledger size; steady-state refresh work is +proportional to newly appended bytes. The Dashboard polls its 30-day usage summary independently once +per minute, so usage work cannot delay health/provider/settings state or run every five seconds. + +`usage.jsonl` is an append-only runtime ledger. A manual in-place edit earlier than the trailing +64 KiB checkpoint followed by file growth is intentionally outside the incremental detector's +contract: validating arbitrary historical rewrites on every refresh would require rereading the +whole prefix. Replace or truncate the file, or restart the proxy, after manually changing historical +rows so the next request performs a cold rebuild. + +The wire fields `historyTruncated`, `truncatedPrefixBytes`, `entriesTruncated`, and `entriesDropped` +remain in the response for compatibility with older GUI and CLI clients. A successful whole-ledger +scan reports `false`, `0`, `false`, and `0`; clients must not interpret those fields as evidence that +`managementUsageMaxReadBytes` was raised or that a bounded tail was selected. [Decision Log] - 목적과 의도: Keep dashboard and management requests responsive as `usage.jsonl` grows. -- 기존 구현 및 제약 조건: The JSONL file remains the durable source of truth and may be truncated, replaced, or hand-edited. -- 검토한 주요 대안: Retain normalized rows, maintain a second database, or cache only revision-keyed summaries and cooperatively rebuild them. -- 선택한 방식: Keep only bounded summary results, share full reads by exact file identity, yield during parsing, and poll usage separately at a slower cadence. -- 다른 대안 대신 이 방식을 선택한 이유: It bounds resident heap and avoids a second persistence format while keeping unrelated endpoints responsive. -- 장점, 단점 및 영향: Unchanged queries are cheap and memory stays bounded; a changed large log still consumes rebuild CPU, but cooperatively and at most once per observed revision/query. +- 기존 구현 및 제약 조건: The append-only JSONL file remains the durable source of truth and may be truncated or replaced. A tail-only byte/row bound kept memory finite but made historical totals incomplete on busy installations; arbitrary in-place historical edits cannot be detected without rereading the prefix. +- 검토한 주요 대안: Raise the byte/row caps, retain normalized rows, maintain a second database, or stream the complete ledger into compact accumulators and cache only revision-keyed summaries. +- 선택한 방식: Stream the complete ledger in fixed 1 MiB chunks for a cold rebuild, retain only compact aggregate state plus an LF/digest checkpoint, fold verified append suffixes atomically, share concurrent work, yield during parsing, and poll usage separately at a slower cadence. +- 다른 대안 대신 이 방식을 선택한 이유: It restores complete historical aggregation without making correctness depend on an operator-sized read limit, retaining every parsed row, or introducing a second persistence format. +- 장점, 단점 및 영향: Unchanged queries are cheap, normal refreshes read only appended bytes, and memory stays bounded. Cold starts and explicit invalidations still consume file-size-proportional IO/CPU. A same-inode historical rewrite outside the trailing checkpoint requires replacement, truncation, or restart to force that cold rebuild. For diagnosing upstream-shape / usage-extraction issues run `ocx debug usage on` (or set `OPENCODEX_USAGE_DEBUG=1` before start). The proxy then writes a rolling debug record per finalized diff --git a/tests/api-key-attribution.test.ts b/tests/api-key-attribution.test.ts index 9eb95487cd..4630e21f0f 100644 --- a/tests/api-key-attribution.test.ts +++ b/tests/api-key-attribution.test.ts @@ -1,11 +1,13 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync} from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; import { startServer } from "../src/server"; import { AUTH_MATRIX } from "../src/server/auth-cors"; -import { clearApiKeyUsageCacheForTests, rollupApiKeyUsage } from "../src/server/management/api-key-usage"; +import { clearApiKeyUsageCacheForTests, readApiKeyUsageRollup, rollupApiKeyUsage } from "../src/server/management/api-key-usage"; +import { resetUsageAggregateCacheForTests } from "../src/server/management/usage-aggregate-cache"; +import * as usageLedgerScannerModule from "../src/usage/ledger-scanner"; import { normalizeUsageEntryForTest, usageLogPath, type PersistedUsageEntry } from "../src/usage/log"; import type { OcxConfig } from "../src/types"; import { removeTreeWithRetry } from "./helpers/remove-tree"; @@ -52,9 +54,11 @@ beforeEach(() => { delete process.env.OPENCODEX_API_AUTH_TOKEN; process.env.OPENCODEX_ADMIN_AUTH_TOKEN = ADMIN_TOKEN; clearApiKeyUsageCacheForTests(); + resetUsageAggregateCacheForTests(); }); afterEach(() => { + resetUsageAggregateCacheForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousDataToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; @@ -316,6 +320,67 @@ describe("attribution reaches usage.jsonl", () => { } }); + test("/api/usage seeds a complete API-key rollup beyond the former byte limit", async () => { + const now = Date.now(); + const config = remoteConfig(); + config.managementUsageMaxReadBytes = 256; + saveConfig(config); + const rows = [ + ...Array.from({ length: 20 }, (_, index) => ({ + requestId: `key-one-${index}`, + timestamp: now - index, + provider: "test", + model: "gpt-test", + status: 200, + durationMs: 1, + usageStatus: "reported", + admissionKind: "configured", + apiKeyId: "key-one", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + })), + { + requestId: "key-two-tail", + timestamp: now, + provider: "test", + model: "gpt-test", + status: 200, + durationMs: 1, + usageStatus: "reported", + admissionKind: "configured", + apiKeyId: "key-two", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + }, + ]; + writeFileSync(usageLogPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`); + + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively").mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + const server = startServer(0); + try { + const usage = await fetch(new URL("/api/usage?range=all", server.url), { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }).then(response => response.json()) as Record; + expect(usage.historyTruncated).toBe(false); + expect(scans).toBe(1); + + const payload = await keysGet(server); + const keys = payload.keys as Array>; + expect((keys.find(key => key.id === "key-one")!.usage as Record).totalRequests).toBe(20); + expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(1); + expect(payload.historyTruncated).toBeUndefined(); + expect(scans).toBe(1); + } finally { + scanSpy.mockRestore(); + await server.stop(true); + } + }); + test("an unreadable usage snapshot degrades to zeroes, not a failed route", async () => { saveConfig(remoteConfig()); const server = startServer(0); @@ -334,6 +399,45 @@ describe("attribution reaches usage.jsonl", () => { } }); + test("an oversized usage row cannot seed a partial key rollup", async () => { + saveConfig(remoteConfig()); + const now = Date.now(); + const oversized = { + requestId: "oversized-key-one", + timestamp: now, + provider: "test", + model: "gpt-test", + status: 200, + durationMs: 1, + usageStatus: "reported", + admissionKind: "configured", + apiKeyId: "key-one", + padding: "x".repeat(usageLedgerScannerModule.USAGE_LEDGER_MAX_LINE_BYTES), + }; + const valid = { + requestId: "valid-key-two", + timestamp: now, + provider: "test", + model: "gpt-test", + status: 200, + durationMs: 1, + usageStatus: "reported", + admissionKind: "configured", + apiKeyId: "key-two", + }; + writeFileSync(usageLogPath(), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); + const server = startServer(0); + try { + const payload = await keysGet(server); + const keys = payload.keys as Array>; + expect((keys.find(key => key.id === "key-one")!.usage as Record).totalRequests).toBe(0); + expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(0); + expect(payload.attributionSince).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + test("a long key id survives the round trip intact", async () => { const config = remoteConfig(); const longId = "k".repeat(80); @@ -455,6 +559,34 @@ describe("rollupApiKeyUsage", () => { const { attributionSince } = rollupApiKeyUsage([row({})], ["k"], now); expect(attributionSince).toBeUndefined(); }); + + test("concurrent cache misses singleflight only within the same configured-id key", async () => { + const persisted = row({ admissionKind: "configured", apiKeyId: "key-one" }); + writeFileSync(usageLogPath(), `${JSON.stringify(persisted)}\n`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively").mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + try { + await Promise.all([ + readApiKeyUsageRollup(["key-one"], 256), + readApiKeyUsageRollup(["key-one"], 256), + ]); + expect(scans).toBe(1); + + clearApiKeyUsageCacheForTests(); + scans = 0; + await Promise.all([ + readApiKeyUsageRollup(["key-one"], 256), + readApiKeyUsageRollup(["key-two"], 256), + ]); + expect(scans).toBe(2); + } finally { + scanSpy.mockRestore(); + } + }); }); describe("durable compatibility", () => { diff --git a/tests/api-keys-routes.test.ts b/tests/api-keys-routes.test.ts index 088d4027d6..745f7c5a43 100644 --- a/tests/api-keys-routes.test.ts +++ b/tests/api-keys-routes.test.ts @@ -6,6 +6,7 @@ import { loadConfig, readConfigDiagnostics, saveConfig } from "../src/config"; import { startServer } from "../src/server"; import { isDataPlaneAdmissionSecret } from "../src/server/auth-cors"; import { ownAdmissionTokens } from "../src/claude/auth-detect"; +import { commitClientKeyRotation, startClientKeyRotation } from "../src/client/hub-client"; import type { OcxConfig } from "../src/types"; import { removeTreeWithRetry } from "./helpers/remove-tree"; @@ -94,6 +95,45 @@ afterEach(() => { }); describe("API key rotation", () => { + test("BUG-R3303 completes the server-to-client rotation round trip with the persisted creation time", async () => { + saveConfig(baseConfig()); + const server = startServer(0); + try { + const created = await keysRequest(server, "POST", { name: "client" }); + const oldKey = created.json.key as string; + const id = created.json.id as string; + const fetchImpl: typeof fetch = async (input, init) => { + const requested = new URL(String(input)); + return fetch(new URL(`${requested.pathname}${requested.search}`, server.url), init); + }; + const credential = { kind: "admin" as const, value: new TextEncoder().encode(ADMIN_TOKEN) }; + + const started = await startClientKeyRotation( + "https://hub.example.test", + credential, + id, + { fetchImpl }, + ); + const pending = (loadConfig().apiKeys ?? [])[0]?.pendingRotation; + expect(started.createdAt).toBe(pending?.createdAt); + expect(started.expiresAt).toBe(pending?.expiresAt); + expect(isDataPlaneAdmissionSecret(oldKey, loadConfig())).toBe(true); + expect(isDataPlaneAdmissionSecret(started.key, loadConfig())).toBe(true); + + await commitClientKeyRotation( + "https://hub.example.test", + credential, + id, + started.rotationId, + { fetchImpl }, + ); + expect(isDataPlaneAdmissionSecret(oldKey, loadConfig())).toBe(false); + expect(isDataPlaneAdmissionSecret(started.key, loadConfig())).toBe(true); + } finally { + await server.stop(true); + } + }); + test("overlaps under one id, masks the pending secret, and commits atomically", async () => { saveConfig(baseConfig()); const server = startServer(0); diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index ed458fab9d..e379e52926 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -12,7 +12,9 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isol import { removeTreeWithRetry } from "./helpers/remove-tree"; import { resetUsageReadCacheForTests, setManagementUsageMaxEntriesForTests, usageReadCacheStatsForTests } from "../src/usage/log"; import * as usageLogModule from "../src/usage/log"; +import * as usageLedgerScannerModule from "../src/usage/ledger-scanner"; import { getUsageSummaryCacheEntry, resetUsageSummaryCacheForTests } from "../src/server/management/usage-summary-cache"; +import * as usageAggregateCacheModule from "../src/server/management/usage-aggregate-cache"; let testDir = ""; let previousHome: string | undefined; @@ -76,7 +78,8 @@ beforeEach(() => { isolatedCodexHome = installIsolatedCodexHome("ocx-api-usage-codex-"); testDir = mkdtempSync(join(tmpdir(), "ocx-api-usage-")); process.env.OPENCODEX_HOME = testDir; - resetUsageReadCacheForTests(); + resetUsageSummaryCacheForTests(); + usageAggregateCacheModule.resetUsageAggregateCacheForTests(); // The overlay registry is MODULE-level state that outlives a test file, and // this file asserts on `userCostOverlayVersion()` moving. A preserved // disk-only provider left behind by an earlier test — or by an earlier file in @@ -95,6 +98,7 @@ afterEach(() => { // wedged shutdown on Linux CI must not leave the 5s poll timer keeping the // isolate worker alive for later shard files (e.g. cli-restore-back). stopUserCostOverlayReconciler(); + usageAggregateCacheModule.resetUsageAggregateCacheForTests(); // Leave no overlay state for the next file, for the same reason. resetPreservedDiskOnlyProvidersForTests(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; @@ -105,6 +109,54 @@ afterEach(() => { }); describe("GET /api/usage", () => { + test("concurrent cold requests share one base-ledger scan", async () => { + writeFixture(Date.now()); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const originalGetAggregate = usageAggregateCacheModule.getUsageAggregate; + let releaseScan!: () => void; + const scanGate = new Promise(resolve => { releaseScan = resolve; }); + let scannerEntered!: () => void; + const scannerStarted = new Promise(resolve => { scannerEntered = resolve; }); + let aggregateCalls = 0; + let secondAggregateCall!: () => void; + const bothRequestsEntered = new Promise(resolve => { secondAggregateCall = resolve; }); + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scannerEntered(); + await scanGate; + return originalScan(options); + }); + const aggregateSpy = spyOn(usageAggregateCacheModule, "getUsageAggregate") + .mockImplementation(options => { + aggregateCalls += 1; + if (aggregateCalls === 2) secondAggregateCall(); + return originalGetAggregate(options); + }); + const server = startServer(0); + try { + const first = fetch(new URL("/api/usage?range=30d", server.url)); + await scannerStarted; + const second = fetch(new URL("/api/usage?range=7d", server.url)); + await bothRequestsEntered; + expect(aggregateCalls).toBe(2); + expect(scanSpy).toHaveBeenCalledTimes(1); + releaseScan(); + + const [firstBody, secondBody] = await Promise.all([ + first.then(response => response.json()), + second.then(response => response.json()), + ]); + expect(firstBody.summary.requests).toBe(3); + expect(secondBody.summary.requests).toBe(2); + expect(scanSpy).toHaveBeenCalledTimes(1); + } finally { + releaseScan(); + aggregateSpy.mockRestore(); + scanSpy.mockRestore(); + await server.stop(true); + } + }); + test("returns documented shape with summary, days, models, providers, and accounts", async () => { writeFixture(Date.now()); const server = startServer(0); @@ -129,60 +181,54 @@ describe("GET /api/usage", () => { } }); - test("usage route cache preserves truncation metadata and invalidates when configured byte limit changes", async () => { - writeFixture(Date.now()); + test("a former byte limit no longer drops history and complete metadata is cached", async () => { + const now = Date.now(); + writeFixture(now); saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 256 }); const server = startServer(0); try { const first = await fetch(new URL("/api/usage?range=all", server.url)).then(response => response.json()); const second = await fetch(new URL("/api/usage?range=all", server.url)).then(response => response.json()); - expect(first.historyTruncated).toBe(true); - expect(first.truncatedPrefixBytes).toBeGreaterThan(0); + expect(first.summary).toMatchObject({ requests: 3, totalTokens: 165 }); expect(second).toMatchObject({ - historyTruncated: first.historyTruncated, - truncatedPrefixBytes: first.truncatedPrefixBytes, - entriesTruncated: first.entriesTruncated, - entriesDropped: first.entriesDropped, + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + snapshotWindowStart: now - 10 * 86_400_000, + snapshotWindowEnd: now - 1 * 86_400_000, }); + expect(getUsageSummaryCacheEntry("all:all")?.summary.summary.requests).toBe(3); } finally { await server.stop(true); } }); - // #1497: on a busy installation the newest `managementUsageMaxReadBytes` can cover far less - // than the selected range, so `30d` and "Available history" summarize the same moving tail. - // The response now names the window the reader actually loaded. It describes the READ, not - // the query — usage.jsonl is appended on request completion while rows carry the request - // start time, so the oldest loaded row does not bound what the dropped prefix contains, and - // no field here may be read as a completeness claim. + // #1497: the scanner reads every complete row while retaining only aggregate + // state, so the response window now spans the complete valid ledger rather + // than a bounded tail. describe("snapshot window disclosure (#1497)", () => { - test("a truncated read reports the loaded window, and it matches the rows that survived", async () => { + test("a former tail-sized read reports the complete fixture window", async () => { const now = Date.now(); writeFixture(now); saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 256 }); const server = startServer(0); try { const body = await fetch(new URL("/api/usage?range=30d", server.url)).then(r => r.json()); - expect(body.historyTruncated).toBe(true); - expect(typeof body.snapshotWindowStart).toBe("number"); - expect(typeof body.snapshotWindowEnd).toBe("number"); - expect(body.snapshotWindowStart).toBeLessThanOrEqual(body.snapshotWindowEnd); - // The dropped prefix is the OLDEST part of the file, so a truncated read cannot still - // start at the fixture's oldest row. - expect(body.snapshotWindowStart).toBeGreaterThan(now - 10 * 86_400_000); + expect(body.historyTruncated).toBe(false); + expect(body.truncatedPrefixBytes).toBe(0); + expect(body.summary.requests).toBe(3); + expect(body.snapshotWindowStart).toBe(now - 10 * 86_400_000); + expect(body.snapshotWindowEnd).toBe(now - 1 * 86_400_000); } finally { await server.stop(true); } }); - test("the window describes the read, so range and surface filters do not move it", async () => { - // A tail small enough to truncate but large enough to retain rows the filters will - // actually discard. Retaining a single row would make every filter a no-op and the - // assertions vacuous, which is exactly what an earlier version of this test did. + test("the complete window is independent of range and surface filters", async () => { const now = Date.now(); const oldest = now - 200 * 86_400_000; const rows = [ - // Dropped by the byte limit: only here to make the read truncated. ...Array.from({ length: 40 }, (_, i) => ({ requestId: `ocx-prefix-${i}`, timestamp: oldest, @@ -194,7 +240,7 @@ describe("GET /api/usage", () => { usage: { inputTokens: 1, outputTokens: 1 }, totalTokens: 2, })), - // Retained, and deliberately outside a 30d window so the range filter discards it. + // Outside a 30d window, so only the range filter discards it. { requestId: "ocx-window-old", timestamp: now - 90 * 86_400_000, @@ -206,7 +252,7 @@ describe("GET /api/usage", () => { usage: { inputTokens: 10, outputTokens: 5 }, totalTokens: 15, }, - // Retained and inside 30d, but a Codex surface so the claude filter discards it. + // Inside 30d, but a Codex surface so the claude filter discards it. { requestId: "ocx-window-codex", timestamp: now - 2 * 86_400_000, @@ -218,7 +264,7 @@ describe("GET /api/usage", () => { usage: { inputTokens: 10, outputTokens: 5 }, totalTokens: 15, }, - // Retained, inside 30d, and a claude surface: survives every filter. + // Inside 30d and on the Claude surface. { requestId: "ocx-window-claude", timestamp: now - 1 * 86_400_000, @@ -233,7 +279,6 @@ describe("GET /api/usage", () => { }, ]; writeFileSync(join(testDir, "usage.jsonl"), `${rows.map(r => JSON.stringify(r)).join("\n")}\n`); - // Sized to keep the last three rows and drop the 40-row prefix. const tailBytes = rows.slice(-3).reduce((sum, r) => sum + Buffer.byteLength(`${JSON.stringify(r)}\n`), 0); saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: tailBytes + 8 }); const server = startServer(0); @@ -242,14 +287,12 @@ describe("GET /api/usage", () => { const thirty = await fetch(new URL("/api/usage?range=30d", server.url)).then(r => r.json()); const claude = await fetch(new URL("/api/usage?range=all&surface=claude", server.url)).then(r => r.json()); - expect(all.historyTruncated).toBe(true); - // The retained set really is what the filters will cut down. - expect(all.summary.requests).toBe(3); + expect(all.historyTruncated).toBe(false); + expect(all.summary.requests).toBe(43); expect(thirty.summary.requests).toBe(2); expect(claude.summary.requests).toBe(1); - // Exact bounds, computed independently of the reader. - expect(all.snapshotWindowStart).toBe(now - 90 * 86_400_000); + expect(all.snapshotWindowStart).toBe(oldest); expect(all.snapshotWindowEnd).toBe(now - 1 * 86_400_000); for (const body of [thirty, claude]) { @@ -296,10 +339,8 @@ describe("GET /api/usage", () => { const server = startServer(0); try { const first = await fetch(new URL("/api/usage?range=all", server.url)).then(r => r.json()); + expect(getUsageSummaryCacheEntry("all:all")).toBeDefined(); const second = await fetch(new URL("/api/usage?range=all", server.url)).then(r => r.json()); - // Prove the second response is a cache hit rather than a second full read; otherwise - // this asserts nothing about the cache path. - expect(usageReadCacheStatsForTests().fullReads).toBe(1); expect(typeof first.snapshotWindowStart).toBe("number"); expect(typeof first.snapshotWindowEnd).toBe("number"); expect(second.snapshotWindowStart).toBe(first.snapshotWindowStart); @@ -312,12 +353,18 @@ describe("GET /api/usage", () => { test("reuses only a compact summary for an unchanged revision", async () => { writeFixture(Date.now()); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively").mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); const server = startServer(0); try { const first = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); const second = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(second.summary).toEqual(first.summary); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); + expect(getUsageSummaryCacheEntry("30d:all")?.summary.summary).toEqual(first.summary); appendFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify({ requestId: "ocx-appended", @@ -332,21 +379,20 @@ describe("GET /api/usage", () => { })}\n`); const stale = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(stale.summary.requests).toBe(first.summary.requests); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); const originalNow = Date.now(); const clock = spyOn(Date, "now").mockReturnValue(originalNow + 60_001); try { const changed = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(changed.summary.requests).toBe(first.summary.requests + 1); - // The append is picked up by extending the retained tail, so the whole 64 MiB - // window is NOT reparsed: a second full read here is the regression this guards. - expect(usageReadCacheStatsForTests().fullReads).toBe(1); - expect(usageReadCacheStatsForTests().tailReads).toBeGreaterThan(0); + expect(scanStarts).toHaveLength(2); + expect(scanStarts[0]).toBe(0); + expect(scanStarts[1]).toBeGreaterThan(0); } finally { clock.mockRestore(); } } finally { + scanSpy.mockRestore(); await server.stop(true); } }); @@ -363,7 +409,7 @@ describe("GET /api/usage", () => { const first = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); const second = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(second.summary).toEqual(first.summary); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); + const cachedOverlayVersion = getUsageSummaryCacheEntry("30d:all")?.overlayVersion ?? -1; // A modelCosts save refreshes the overlay registry and bumps its version; // the cached summary must not be reused even though the usage log is unchanged. refreshUserCostOverlays({ @@ -377,9 +423,7 @@ describe("GET /api/usage", () => { } as unknown as OcxConfig); const changed = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(changed.summary.requests).toBe(first.summary.requests); - // The ledger did not change, so the recompute reuses the retained tail rather - // than reparsing the window; only the summary cache is invalidated. - expect(usageReadCacheStatsForTests().fullReads).toBe(1); + expect(getUsageSummaryCacheEntry("30d:all")?.overlayVersion).toBeGreaterThan(cachedOverlayVersion); } finally { // This test installs a module-level blsc overlay; clear it even when an // assertion or shutdown fails so later tests cannot resolve @@ -389,21 +433,44 @@ describe("GET /api/usage", () => { } }); - test("usage route does not cache a summary whose overlay version changed mid-read", async () => { + test("usage route cache invalidates when the local calendar time zone changes", async () => { + const previousTimeZone = process.env.TZ; + process.env.TZ = "UTC"; + writeFixture(Date.now()); + const server = startServer(0); + try { + await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(getUsageSummaryCacheEntry("30d:all")?.timeZone).toBe("UTC"); + + process.env.TZ = "America/Los_Angeles"; + await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); + expect(getUsageSummaryCacheEntry("30d:all")?.timeZone).toBe("America/Los_Angeles"); + } finally { + if (previousTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = previousTimeZone; + await server.stop(true); + } + }); + + test("usage route retries an overlay change and caches only the settled rebuild", async () => { writeFixture(Date.now()); refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); resetUsageSummaryCacheForTests(); const versionBefore = userCostOverlayVersion(); - // Deterministically bump the overlay version DURING the snapshot read, so + // Deterministically bump the overlay version DURING the ledger scan, so // the summary is computed under a version that is stale before the cache // stamp — the interleaving that previously stamped an old-price summary as // current. The spy must be installed before the first /api/usage request: // a warm request would be served from the summary cache and never reach // the read. - const originalRead = usageLogModule.readUsageSnapshotForManagement; + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; let bumped = false; - const spy = spyOn(usageLogModule, "readUsageSnapshotForManagement").mockImplementation(async (maxReadBytes?: number) => { - const snapshot = await originalRead(maxReadBytes); + let scans = 0; + const scanOverlayVersions: number[] = []; + const spy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively").mockImplementation(async options => { + scans += 1; + scanOverlayVersions.push(userCostOverlayVersion()); + const snapshot = await originalScan(options); if (!bumped) { bumped = true; refreshUserCostOverlays({ @@ -423,26 +490,24 @@ describe("GET /api/usage", () => { const raced = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); expect(bumped).toBe(true); expect(userCostOverlayVersion()).toBeGreaterThan(versionBefore); - // The mid-read change must NOT leave a cache entry: the mixed-price - // summary is served uncached so the next request recomputes. - expect(getUsageSummaryCacheEntry("30d:all")).toBeUndefined(); + // The retained rebuild detects the changed pricing input and retries the + // full scan before publishing. No mixed-version aggregate is visible; + // the one route response and its cache entry both come from the settled + // second scan. + expect(scans).toBe(2); + expect(getUsageSummaryCacheEntry("30d:all")?.overlayVersion) + .toBe(scanOverlayVersions[1]); spy.mockRestore(); - // Once the overlay is settled, the next request recomputes and caches - // under the new version. - // - // Capture the version the settled request will price under BEFORE issuing - // it. The live counter is not a stable oracle here: the server's own - // overlay reconciler refreshes the registry on its poll, so re-reading it - // after the response can observe a later version than the one the summary - // was computed with. The contract under test is "the cache is stamped with - // the version its summary was priced under", not "the counter never moves - // again" — asserting the latter made this test fail on any machine where a - // poll landed inside the request. - const settledVersion = userCostOverlayVersion(); + // The process-global overlay may move again after the response (for + // example when the config poller reloads disk). That cannot retroactively + // change the version the settled scan used; the next request must either + // reuse that exact version or rebuild under a newer one. + const nextRequestVersion = userCostOverlayVersion(); const settled = await fetch(new URL("/api/usage?range=30d", server.url)).then(res => res.json()); - expect(settled.summary.requests).toBe(raced.summary.requests); - expect(getUsageSummaryCacheEntry("30d:all")?.overlayVersion).toBeGreaterThanOrEqual(settledVersion); + expect(settled.summary).toEqual(raced.summary); + expect(getUsageSummaryCacheEntry("30d:all")?.overlayVersion) + .toBeGreaterThanOrEqual(nextRequestVersion); } finally { spy.mockRestore(); // Clear the module-level overlay and summary cache even when an @@ -556,6 +621,20 @@ describe("GET /api/usage", () => { } }); + test("a model filter remains active when the provider parameter is empty", async () => { + writeFixture(Date.now()); + const server = startServer(0); + try { + const body = await fetch(new URL("/api/usage?range=all&provider=&model=gpt-5.5", server.url)).then(res => res.json()); + expect(body.filter).toMatchObject({ provider: null, model: "gpt-5.5", matched: true }); + expect(body.summary.requests).toBe(2); + expect(body.models.every((row: { model: string }) => row.model === "gpt-5.5")).toBe(true); + expect(body.accounts).toEqual([]); + } finally { + await server.stop(true); + } + }); + test("a filter that matches nothing reports an empty window, not the unfiltered one", async () => { writeFixture(Date.now()); const server = startServer(0); @@ -576,10 +655,8 @@ describe("GET /api/usage", () => { writeFixture(Date.now()); const server = startServer(0); try { - // The cache key is `range:surface` and the warm loop writes every key on - // a miss. If the filter reached the producer, this filtered request would - // store a narrowed summary under "all:all" and the dashboard would then - // be served one provider's totals as the whole window. + // A filtered scan never writes the range:surface cache. Otherwise the + // dashboard could be served one provider's totals as the whole window. const filtered = await fetch(new URL("/api/usage?range=all&provider=no-such-provider", server.url)).then(res => res.json()); expect(filtered.summary.requests).toBe(0); @@ -686,261 +763,23 @@ describe("GET /api/usage", () => { } }); - test("missing usage.jsonl returns zeroed summary, not 500", async () => { - const server = startServer(0); - try { - const res = await fetch(new URL("/api/usage", server.url)); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.summary.requests).toBe(0); - expect(body.summary.measuredRequests).toBe(0); - expect(body.summary.totalTokens).toBe(0); - expect(body.summary.coverageRatio).toBe(0); - } finally { - await server.stop(true); - } - }); - - test("repeated appends do not reparse the retained prefix", async () => { - const now = Date.now(); - writeFixture(now); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - const server = startServer(0); - const clock = spyOn(Date, "now"); - try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - const afterFirst = usageReadCacheStatsForTests(); - expect(afterFirst.fullReads).toBe(1); - const baselineParsed = afterFirst.parsedLines; - expect(baselineParsed).toBeGreaterThan(0); - - // Append one row at a time, stepping past the 60s freshness window each round so - // every request is a genuine cache miss that reaches the reader. - let requests = 0; - for (let round = 1; round <= 5; round++) { - appendFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify({ - requestId: `ocx-append-${round}`, - timestamp: now, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`); - clock.mockReturnValue(now + round * 60_001); - const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - requests = body.summary.requests; - } - - const afterAppends = usageReadCacheStatsForTests(); - // Each round parses only its own appended line, so growth equals the number of - // appended rows. A reparse regression would instead re-add the whole grown - // prefix every round (baselineParsed+1 ... baselineParsed+5). - expect(afterAppends.parsedLines - baselineParsed).toBe(5); - expect(afterAppends.fullReads).toBe(1); - expect(afterAppends.tailReads).toBeGreaterThanOrEqual(5); - // The rows are still correct, not merely cheap. - expect(requests).toBe(afterFirst.parsedLines + 5); - } finally { - clock.mockRestore(); - await server.stop(true); - } - }); - - test("an append burst larger than the byte window falls back to a bounded full read", async () => { + test("an oversized row fails closed instead of caching a partial aggregate", async () => { const now = Date.now(); - const maxReadBytes = 512; - const row = (id: string): string => `${JSON.stringify({ - requestId: id, + const oversized = { + requestId: "ocx-oversized", timestamp: now, provider: "openai", model: "gpt-5.5", status: 200, durationMs: 1, usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - const path = join(testDir, "usage.jsonl"); - writeFileSync(path, row("seed")); - - await usageLogModule.readUsageSnapshotForManagement(maxReadBytes); - const parsedBeforeBurst = usageReadCacheStatsForTests().parsedLines; - appendFileSync(path, Array.from({ length: 100 }, (_, index) => row(`burst-${index}`)).join("")); - - const snapshot = await usageLogModule.readUsageSnapshotForManagement(maxReadBytes); - const stats = usageReadCacheStatsForTests(); - expect(stats.fullReads).toBe(2); - expect(stats.tailReads).toBe(0); - expect(stats.parsedLines - parsedBeforeBurst).toBe(snapshot.entries.length); - expect(snapshot.entries.length).toBeLessThan(100); - expect(snapshot.entries.some(entry => entry.requestId === "burst-99")).toBe(true); - }); - - test("appends to an over-window ledger stay incremental and bounded", async () => { - const now = Date.now(); - writeFixture(now); - // A tiny window makes the bound reachable with a handful of rows. - saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 1024 }); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - const server = startServer(0); - const clock = spyOn(Date, "now"); - try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); - - // Append well past the window. This is the shape of the real 245 MB ledger, and - // the case the whole optimization exists for: a reader that refused to extend - // whenever the retained window started earlier than the current window would do a - // FULL reparse on every single append here, which is where the memory blow-up - // came from in the first place. - for (let round = 1; round <= 12; round++) { - appendFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify({ - requestId: `ocx-window-${round}`, - timestamp: now, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`); - clock.mockReturnValue(now + round * 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - } - - const stats = usageReadCacheStatsForTests(); - // Most rounds must be served incrementally rather than reparsed. - expect(stats.tailReads).toBeGreaterThanOrEqual(6); - // Re-anchoring still happens, so retention cannot grow with the file forever, - // but it is amortized rather than paid per append. - expect(stats.fullReads).toBeLessThan(12); - clock.mockReturnValue(now + 13 * 60_001); - const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(body.historyTruncated).toBe(true); - } finally { - clock.mockRestore(); - await server.stop(true); - } - }); - - test("an in-place rewrite that keeps the inode is not served from the retained tail", async () => { - const now = Date.now(); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - // Fixed-width request ids so the rewritten rows are byte-for-byte the same length - // as the originals. A newline therefore still lands exactly at the previously - // covered offset, which defeats the record-boundary check -- only re-verifying the - // covered prefix can catch this rewrite. - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - writeFileSync(join(testDir, "usage.jsonl"), `${row("aaa1")}${row("aaa2")}${row("aaa3")}`); - const server = startServer(0); - const clock = spyOn(Date, "now"); - try { - clock.mockReturnValue(now); - const first = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(first.summary.requests).toBe(3); - - // Replace all three rows in place and append a fourth. The inode, device and - // birthtime are unchanged and the file only grew, so neither the identity check - // nor the shrink check sees it, and the boundary check is satisfied because the - // replacement rows have identical widths. - writeFileSync( - join(testDir, "usage.jsonl"), - `${row("bbb1")}${row("bbb2")}${row("bbb3")}${row("bbb4")}`, - ); - - clock.mockReturnValue(now + 60_001); - const after = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - // Without the prefix check this returns the three STALE rows concatenated with - // the one newly appended row -- still 4 requests, but three of them no longer - // exist in the file. Assert on identity, not just the count. - expect(after.summary.requests).toBe(4); - expect(after.models.every((model: { model: string }) => typeof model.model === "string")).toBe(true); - // Serving this from the retained tail would have required no second full read. - expect(usageReadCacheStatsForTests().fullReads).toBe(2); - expect(usageReadCacheStatsForTests().tailReads).toBe(0); - } finally { - clock.mockRestore(); - await server.stop(true); - } - }); - - test("an in-place edit in the middle of a large prefix is not served from the retained tail", async () => { - const now = Date.now(); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - // Large enough that a SAMPLED prefix digest would cover a vanishing fraction of the - // file. The edit below is deliberately placed away from both ends, where sampled - // probes do not reach -- the case that makes sampling unsafe for an ordinary - // fixed-width edit rather than only an adversarial one. - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - const rows = Array.from({ length: 4000 }, (_, index) => row(`old${String(index).padStart(6, "0")}`)); - const rowBytes = Buffer.byteLength(rows[0]!); - writeFileSync(join(testDir, "usage.jsonl"), rows.join("")); - const server = startServer(0); - const clock = spyOn(Date, "now"); - try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); - - // Overwrite one row in the middle, byte-identical in width so the file size and - // every record boundary are unchanged, then append. - const replacement = row("new002500"); - expect(Buffer.byteLength(replacement)).toBe(rowBytes); - const handle = openSync(join(testDir, "usage.jsonl"), "r+"); - try { - writeSync(handle, Buffer.from(replacement), 0, rowBytes, 2500 * rowBytes); - } finally { - closeSync(handle); - } - appendFileSync(join(testDir, "usage.jsonl"), row("appended1")); - - clock.mockReturnValue(now + 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - // The mid-prefix rewrite must invalidate the retained rows: a sampled digest would - // miss it and serve old002500, which no longer exists in the file. - expect(usageReadCacheStatsForTests().fullReads).toBe(2); - expect(usageReadCacheStatsForTests().tailReads).toBe(0); - } finally { - clock.mockRestore(); - await server.stop(true); - } - }); - - test("an over-window ledger reports a stable window instead of sawtoothing", async () => { - const now = Date.now(); - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, + usage: { inputTokens: 100, outputTokens: 50 }, + totalTokens: 150, + padding: "x".repeat(usageLedgerScannerModule.USAGE_LEDGER_MAX_LINE_BYTES), + }; + const valid = { + requestId: "ocx-valid-after-oversized", + timestamp: now, provider: "openai", model: "gpt-5.5", status: 200, @@ -948,183 +787,83 @@ describe("GET /api/usage", () => { usageStatus: "reported", usage: { inputTokens: 1, outputTokens: 1 }, totalTokens: 2, - })}\n`; - // Start above the window so every append slides it forward. - const seed = Array.from({ length: 60 }, (_, index) => row(`seed${String(index).padStart(6, "0")}`)); - writeFileSync(join(testDir, "usage.jsonl"), seed.join("")); - saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 4096 }); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); + }; + writeFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); const server = startServer(0); - const clock = spyOn(Date, "now"); try { - const counts: number[] = []; - for (let round = 1; round <= 40; round++) { - appendFileSync(join(testDir, "usage.jsonl"), row(`add${String(round).padStart(7, "0")}`)); - clock.mockReturnValue(now + round * 60_001); - const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - counts.push(body.summary.requests); - } - // Retaining a window wider than maxReadBytes and then re-anchoring made visible - // history collapse by roughly half on a single poll of an append-only file, so - // dashboard totals swung between refreshes. The window is now trimmed on every - // read, so the visible count stays flat. - const min = Math.min(...counts); - const max = Math.max(...counts); - expect(max - min).toBeLessThanOrEqual(1); + const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); + expect(body.error).toBe("read_failed"); + expect(body.summary.requests).toBe(0); + expect(body.historyTruncated).toBe(false); + expect(getUsageSummaryCacheEntry("all:all")).toBeUndefined(); } finally { - clock.mockRestore(); await server.stop(true); } }); - test("unparseable lines do not make the window trim lose history", async () => { - const now = Date.now(); - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - // Interleave lines that parse to nothing -- a torn write, a hand-edit, a pre-schema - // legacy row. Their bytes still occupy the file, so if the recorded row lengths omit - // them the trim walk under-counts the byte distance and silently drops extra rows. - const seed: string[] = []; - for (let index = 0; index < 120; index++) { - seed.push(row(`R${String(index).padStart(6, "0")}`)); - if (index % 5 === 0) seed.push("{ not json at all ~~~\n"); - } - writeFileSync(join(testDir, "usage.jsonl"), seed.join("")); - saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 4096 }); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); + test("missing usage.jsonl returns zeroed summary, not 500", async () => { const server = startServer(0); - const clock = spyOn(Date, "now"); try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - for (let round = 1; round <= 40; round++) { - appendFileSync(join(testDir, "usage.jsonl"), row(`A${String(round).padStart(6, "0")}`)); - if (round % 5 === 0) appendFileSync(join(testDir, "usage.jsonl"), "{ torn write\n"); - clock.mockReturnValue(now + round * 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - } - const cached = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - // The incremental path must have stayed engaged. Without skipped-line accounting - // the recorded lengths stop summing to the byte span, the consistency check - // rejects every reuse, and this collapses back to a full read per poll -- correct - // output, but the optimization is gone. - const stats = usageReadCacheStatsForTests(); - expect(stats.tailReads).toBeGreaterThanOrEqual(20); - - // A cold read of the same window is the ground truth. - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - clock.mockReturnValue(now + 41 * 60_001); - const fresh = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(cached.summary.requests).toBe(fresh.summary.requests); - expect(cached.truncatedPrefixBytes).toBe(fresh.truncatedPrefixBytes); + const res = await fetch(new URL("/api/usage", server.url)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.summary.requests).toBe(0); + expect(body.summary.measuredRequests).toBe(0); + expect(body.summary.totalTokens).toBe(0); + expect(body.summary.coverageRatio).toBe(0); } finally { - clock.mockRestore(); await server.stop(true); } }); - test("the entry cap re-anchors instead of reporting a window a cold read disagrees with", async () => { - const now = Date.now(); - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, - provider: "openai", - model: "gpt-5.5", - status: 200, - durationMs: 1, - usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\n`; - // Bind the ENTRY cap rather than the byte window: a generous window with a small cap - // is the only way to reach this path without a half-million-row fixture. - setManagementUsageMaxEntriesForTests(25); - writeFileSync( - join(testDir, "usage.jsonl"), - Array.from({ length: 40 }, (_, index) => row(`R${String(index).padStart(6, "0")}`)).join(""), - ); - saveConfig({ ...baseConfig(), managementUsageMaxReadBytes: 1024 * 1024 }); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); + test("one complete scan warms every unfiltered range and surface cache slot", async () => { + writeFixture(Date.now()); const server = startServer(0); - const clock = spyOn(Date, "now"); try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - for (let round = 1; round <= 12; round++) { - appendFileSync(join(testDir, "usage.jsonl"), row(`A${String(round).padStart(6, "0")}`)); - clock.mockReturnValue(now + round * 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); + await fetch(new URL("/api/usage?range=7d&surface=claude", server.url)).then(res => res.json()); + for (const range of ["today", "7d", "30d", "all"]) { + for (const surface of ["all", "codex", "claude", "grok"]) { + expect(getUsageSummaryCacheEntry(`${range}:${surface}`)).toBeDefined(); + } } - const cached = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - // A cold read applies the entry cap across the whole window and reports byte - // truncation for the window boundary alone; an incremental read cannot reconstruct - // that ordering, so it must re-anchor rather than report a disagreeing window. - // This is reachable in production: real rows average ~118 bytes, so 500,000 of them - // fit inside the 64 MiB window and both truncations can apply at once. - expect(usageReadCacheStatsForTests().fullReads).toBeGreaterThan(1); - - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); - clock.mockReturnValue(now + 13 * 60_001); - const fresh = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(cached.summary.requests).toBe(fresh.summary.requests); - expect(cached.truncatedPrefixBytes).toBe(fresh.truncatedPrefixBytes); + const aggregateStats = usageAggregateCacheModule.usageAggregateRetainedStats(); + expect(aggregateStats).toMatchObject({ count: 1, pinnedBytes: 0 }); + expect(aggregateStats.bytes).toBeGreaterThan(0); + const memory = await fetch(new URL("/api/system/memory", server.url)).then(res => res.json()); + expect(memory.appOwnedBytes.stores.usage_snapshot).toMatchObject({ + count: 1, + bytes: aggregateStats.bytes, + }); } finally { - setManagementUsageMaxEntriesForTests(null); - clock.mockRestore(); await server.stop(true); } }); - test("a CRLF ledger still uses the incremental path", async () => { + test("large daily token totals stay exact beyond 32-bit counters", async () => { const now = Date.now(); - const row = (id: string): string => `${JSON.stringify({ - requestId: id, - timestamp: now - 86_400_000, + const perDayTokens = 4_000_000_000; + const rows = Array.from({ length: 30 }, (_, index) => ({ + requestId: `ocx-large-${index}`, + timestamp: now - index * 86_400_000, provider: "openai", model: "gpt-5.5", status: 200, durationMs: 1, usageStatus: "reported", - usage: { inputTokens: 1, outputTokens: 1 }, - totalTokens: 2, - })}\r\n`; - writeFileSync( - join(testDir, "usage.jsonl"), - Array.from({ length: 20 }, (_, index) => row(`C${String(index).padStart(6, "0")}`)).join(""), - ); - resetUsageReadCacheForTests(); - resetUsageSummaryCacheForTests(); + usage: { inputTokens: perDayTokens, outputTokens: 0 }, + totalTokens: perDayTokens, + })); + writeFileSync(join(testDir, "usage.jsonl"), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`); const server = startServer(0); - const clock = spyOn(Date, "now"); try { - clock.mockReturnValue(now); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - for (let round = 1; round <= 5; round++) { - appendFileSync(join(testDir, "usage.jsonl"), row(`D${String(round).padStart(6, "0")}`)); - clock.mockReturnValue(now + round * 60_001); - await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - } - // A CRLF line owes two separator bytes. Counting one leaves the recorded lengths - // short of the real span, the accounting self-check rejects every reuse, and the - // reader silently falls back to a full parse on every poll. - expect(usageReadCacheStatsForTests().tailReads).toBeGreaterThanOrEqual(5); - expect(usageReadCacheStatsForTests().fullReads).toBe(1); + const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); + const expectedTokens = 120_000_000_000; + expect(body.summary).toMatchObject({ requests: 30, totalTokens: expectedTokens }); + expect(body.models[0].totalTokens).toBe(expectedTokens); + expect(body.providers[0].totalTokens).toBe(expectedTokens); + expect(body.days.reduce((sum: number, day: { totalTokens: number }) => sum + day.totalTokens, 0)).toBe(expectedTokens); + expect(body.historyTruncated).toBe(false); } finally { - clock.mockRestore(); await server.stop(true); } }); diff --git a/tests/bridge-legacy-shell-normalization.test.ts b/tests/bridge-legacy-shell-normalization.test.ts index 79b4e4aa98..36600084d9 100644 --- a/tests/bridge-legacy-shell-normalization.test.ts +++ b/tests/bridge-legacy-shell-normalization.test.ts @@ -14,9 +14,9 @@ async function drain(stream: ReadableStream): Promise { return out; } -async function* toolTurn(name: string): AsyncGenerator { +async function* toolTurn(name: string, argumentsText = '{"cmd":"ls"}'): AsyncGenerator { yield { type: "tool_call_start", id: "call-1", name } as AdapterEvent; - yield { type: "tool_call_delta", id: "call-1", arguments: '{"cmd":"ls"}' } as AdapterEvent; + yield { type: "tool_call_delta", id: "call-1", arguments: argumentsText } as AdapterEvent; yield { type: "tool_call_end", id: "call-1" } as AdapterEvent; yield { type: "done" } as AdapterEvent; } @@ -25,7 +25,7 @@ async function* toolTurn(name: string): AsyncGenerator { // nested `tools.exec_command(...)` helper. Routed models echo the helper name back, and the // undeclared-tool guard turned that into a 502 mid-turn. These pin the SSE path the guard // actually runs on, which the review flagged as untested. -describe("bridge normalizes legacy shell names against the declared catalog (#2493)", () => { +describe("bridge normalizes code-mode helper names against the declared catalog", () => { test("exec_command is delivered as the declared exec instead of failing the turn", async () => { const sse = await drain(bridgeToResponsesSSE( toolTurn("exec_command"), "deepseek-x", undefined, new Set(["exec"]), undefined, undefined, 50_000, @@ -47,6 +47,22 @@ describe("bridge normalizes legacy shell names against the declared catalog (#24 expect(sse).toContain('await tools.exec_command({\\"cmd\\":\\"ls\\"})'); }); + test("write_stdin is wrapped through the declared exec tool", async () => { + const sse = await drain(bridgeToResponsesSSE( + toolTurn("write_stdin", '{"session_id":17,"yield_time_ms":1000}'), + "fixture-model", + undefined, + new Set(["exec"]), + undefined, + undefined, + 50_000, + { declaredToolNames: new Set(["exec"]) }, + )); + expect(sse).not.toContain("undeclared client tool"); + expect(sse).toContain('"name":"exec"'); + expect(sse).toContain('await tools.write_stdin({\\"session_id\\":17,\\"yield_time_ms\\":1000})'); + }); + test("a genuinely undeclared tool still fails the turn", async () => { const sse = await drain(bridgeToResponsesSSE( toolTurn("other_tool"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 6a725e1c9b..824ba64013 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { saveConfig } from "../src/config"; +import { loadConfig, saveConfig } from "../src/config"; import { startServer } from "../src/server"; import { ownedServiceHomeInspection } from "./helpers/owned-service-home-inspection"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; @@ -1383,6 +1383,149 @@ test("chat-native preserves a structured cyber_policy type on JSON and SSE failu } }); +test("chat-native shares the transient send budget across same-target 429 recovery", async () => { + let upstreamSends = 0; + const upstream = Bun.serve({ + port: 0, + fetch() { + upstreamSends += 1; + if (upstreamSends === 1) { + return Response.json({ error: { message: "rate limited", type: "rate_limit_error" } }, { + status: 429, + headers: { "retry-after": "0" }, + }); + } + return Response.json({ error: { message: "temporarily unavailable", type: "server_error" } }, { + status: 503, + headers: { "retry-after": "0" }, + }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`, { + authMode: "key", + transientRetryOn5xx: { attempts: 3 }, + retryOn429: { attempts: 1, intervalMs: 100, maxIntervalMs: 100, respectRetryAfter: false }, + })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), + }); + expect(response.status).toBe(503); + await response.text(); + expect(upstreamSends).toBe(3); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native shares the transient send budget across key rotation", async () => { + const { clearKeyCooldowns } = await import("../src/providers/key-failover"); + clearKeyCooldowns("mock"); + const authorizations: Array = []; + const upstream = Bun.serve({ + port: 0, + fetch(req) { + authorizations.push(req.headers.get("authorization")); + if (authorizations.length === 1) { + return Response.json({ error: { message: "temporarily unavailable", type: "server_error" } }, { + status: 503, + headers: { "retry-after": "0" }, + }); + } + if (authorizations.length <= 3) { + return Response.json({ error: { message: "rate limited", type: "rate_limit_error" } }, { + status: 429, + headers: { "retry-after": "60" }, + }); + } + return Response.json({ + id: "chatcmpl_budget_escape", + object: "chat.completion", + choices: [{ + index: 0, + message: { role: "assistant", content: "escaped budget" }, + finish_reason: "stop", + }], + }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`, { + authMode: "key", + apiKey: "key-one", + apiKeyPool: [ + { id: "one", key: "key-one" }, + { id: "two", key: "key-two" }, + { id: "three", key: "key-three" }, + ], + transientRetryOn5xx: { attempts: 3 }, + })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), + }); + expect(response.status).toBe(429); + await response.text(); + expect(authorizations).toEqual([ + "Bearer key-one", + "Bearer key-one", + "Bearer key-two", + ]); + } finally { + await server.stop(true); + upstream.stop(true); + clearKeyCooldowns("mock"); + } +}); + +test("chat-native records terminal key cooldown after the send budget is exhausted", async () => { + const { clearKeyCooldowns, getKeyCooldownUntil } = await import("../src/providers/key-failover"); + clearKeyCooldowns("mock"); + let upstreamSends = 0; + const upstream = Bun.serve({ + port: 0, + fetch() { + upstreamSends += 1; + return Response.json({ error: { message: "rate limited", type: "rate_limit_error" } }, { + status: 429, + headers: { "retry-after": "60" }, + }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`, { + authMode: "key", + apiKey: "key-one", + apiKeyPool: [ + { id: "one", key: "key-one" }, + { id: "two", key: "key-two" }, + ], + transientRetryOn5xx: { attempts: 1 }, + })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), + }); + expect(response.status).toBe(429); + await response.text(); + expect(upstreamSends).toBe(1); + expect(getKeyCooldownUntil("mock", "one")).not.toBeNull(); + expect(loadConfig().providers.mock?.apiKey).toBe("key-two"); + } finally { + await server.stop(true); + upstream.stop(true); + clearKeyCooldowns("mock"); + } +}); + test("chat-native preserves same-key retry, key rotation, usage, and request logging", async () => { const { clearRequestLogsForTests, getRequestLogEntries } = await import("../src/server/request-log"); const { clearKeyCooldowns } = await import("../src/providers/key-failover"); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index 8a70f4e675..d282349d19 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -901,6 +901,8 @@ describe("GitHub Actions hardening", () => { "require", "require", "require", + // pr-referenced-authors.cjs, for the carry-attribution assessor. + "require", ] as const; /** Reads every allowed-base PR performs before any enforcement writes. */ @@ -915,6 +917,9 @@ describe("GitHub Actions hardening", () => { "pulls.get", "pulls.listFiles", "pulls.get", + // The carry-attribution assessor reads the branch's commit messages: a + // Co-authored-by trailer can live in a commit rather than the body. + "pulls.listCommits", ...tail, ]; } @@ -930,6 +935,7 @@ describe("GitHub Actions hardening", () => { "pulls.get", "pulls.listFiles", "pulls.get", + "pulls.listCommits", ...tail, ]; } @@ -950,6 +956,8 @@ describe("GitHub Actions hardening", () => { "pulls.listFiles", "pulls.listFiles", "pulls.get", + "pulls.listCommits", + "pulls.listCommits", ...tail, ]; } @@ -1343,7 +1351,9 @@ describe("GitHub Actions hardening", () => { name !== "github.rest.repos.listPullRequestsAssociatedWithCommit" && name !== "github.rest.issues.listEvents" && // Hygiene reassessment reads the changed-file list; not a write. - name !== "github.rest.pulls.listFiles", + name !== "github.rest.pulls.listFiles" && + // Carry attribution reads the branch's commit messages; not a write. + name !== "github.rest.pulls.listCommits", ); expect([...new Set(restWrites)].sort()).toEqual([ "github.rest.issues.addLabels", @@ -5261,4 +5271,3 @@ describe("gui exhaustive-deps suppression stays scoped and effective", () => { expect(models).not.toContain("react-doctor-disable-next-line"); }); }); - diff --git a/tests/claude-cli.test.ts b/tests/claude-cli.test.ts index e33fb38035..8381b99e84 100644 --- a/tests/claude-cli.test.ts +++ b/tests/claude-cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { buildClaudeEnv, claudeNotFoundHint, rootSkipPermissionsNotice, shouldAllowRootSkipPermissions } from "../src/cli/claude"; +import { buildClaudeEnv, claudeNotFoundHint, ensureProxyForClaude, rootSkipPermissionsNotice, shouldAllowRootSkipPermissions } from "../src/cli/claude"; import { commandInvocation } from "../src/lib/win-exec"; +import type { LivenessIo, LiveProxy } from "../src/server/proxy-liveness"; import type { OcxConfig } from "../src/types"; function cfg(extra?: Partial): OcxConfig { @@ -25,6 +26,20 @@ const AUTH_PRESENT = { }, }; +describe("ocx claude proxy liveness", () => { + test("retries the initial liveness probe before spawning a proxy", async () => { + const seen: (number | undefined)[] = []; + const findLiveProxy = async (io?: LivenessIo): Promise => { + seen.push(io?.attempts); + // retry semantics are covered by tests/proxy-liveness.test.ts:102-119; this pins that the launcher hands the stop-path budget down. + return { pid: 4242, port: 10100, source: "runtime" }; + }; + + expect(await ensureProxyForClaude({ findLiveProxy })).toBe(10100); + expect(seen).toEqual([3]); + }); +}); + describe("ocx claude env assembly", () => { test("connected target injects only the hub base and client admission token", () => { const env = buildClaudeEnv(cfg(), { diff --git a/tests/cli-status-json.test.ts b/tests/cli-status-json.test.ts index 1e9f8532ca..fa402923ef 100644 --- a/tests/cli-status-json.test.ts +++ b/tests/cli-status-json.test.ts @@ -300,6 +300,41 @@ describe("CLI status JSON", () => { expect(target.dashboardUrl).toBe("http://localhost:58195/"); }); + test("listen target keeps the loopback dashboard URL unchanged", () => { + const target = selectListenTarget( + { port: 10100, hostname: "127.0.0.1" }, + null, + null, + ); + + expect(target.dashboardUrl).toBe("http://localhost:10100/"); + }); + + test("hub listen target prefers its management public origin", () => { + const target = selectListenTarget( + { + port: 10100, + hostname: "100.64.0.10", + runtimeRole: "hub", + hub: { managementPublicOrigin: "https://hub.example.test" }, + }, + null, + null, + ); + + expect(target.dashboardUrl).toBe("https://hub.example.test/"); + }); + + test("non-loopback listen target uses its configured hostname", () => { + const target = selectListenTarget( + { port: 10100, hostname: "100.64.0.11" }, + null, + null, + ); + + expect(target.dashboardUrl).toBe("http://100.64.0.11:10100/"); + }); + test("resolveStatusPid preserves an authoritative null from live orphan checks", () => { expect(resolveStatusPid({ pid: null }, 4242)).toBeNull(); expect(resolveStatusPid({ pid: 1111 }, 4242)).toBe(1111); diff --git a/tests/client-connect.test.ts b/tests/client-connect.test.ts index 313e2d1d1b..b55d034458 100644 --- a/tests/client-connect.test.ts +++ b/tests/client-connect.test.ts @@ -184,6 +184,14 @@ describe("remote hub client boundary", () => { errors.length = 0; expect(await handleConnectCommand(["revoke", "client-key-override", "--admin-token-stdin"])).toBe(2); expect(errors.join(" ")).not.toContain("client-key-override"); + errors.length = 0; + expect(await handleConnectCommand([ + "https://hub.example.test", + "--admin-token-stdin", + "--catalog-timeout", + "0", + ])).toBe(2); + expect(errors.join(" ")).toContain("--catalog-timeout must be an integer >= 1"); } finally { spy.mockRestore(); } diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 7b8015041e..4ae9926b30 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -226,6 +226,20 @@ describe("combo catalog capability intersection", () => { }); }); + test("combo output ceiling is the smallest known member ceiling and stays unknown if any member is unknown", () => { + const known = deriveComboCatalogModel("known-output", normalizedCombo(), [ + { provider: "a", id: "m1", contextWindow: 128_000, maxOutputTokens: 64_000 }, + { provider: "b", id: "m2", contextWindow: 128_000, maxOutputTokens: 32_000 }, + ]); + expect(known?.maxOutputTokens).toBe(32_000); + + const partial = deriveComboCatalogModel("partial-output", normalizedCombo(), [ + { provider: "a", id: "m1", contextWindow: 128_000, maxOutputTokens: 64_000 }, + { provider: "b", id: "m2", contextWindow: 128_000 }, + ]); + expect(partial).not.toHaveProperty("maxOutputTokens"); + }); + test("handles vision, missing modalities, reasoning defaults, and parallel tools conservatively", () => { expect(deriveComboCatalogModel("vision", normalizedCombo({ defaultEffort: "low" }), [ memberA, @@ -1825,7 +1839,7 @@ describe("provider discovered model display names", () => { modelDisplayNames: { "grok-4.6": "Grok 4.6" }, }; - test("an exact provider model id receives only the configured display name", () => { + test("an exact provider model id receives the configured display name without losing catalog metadata", () => { const discovered = { provider: "xai", id: "grok-4.6", @@ -1848,10 +1862,40 @@ describe("provider discovered model display names", () => { const { displayName: _afterDisplayName, ...afterIdentity } = output; expect(output.displayName).toBe("Grok 4.6"); - expect(afterIdentity).toEqual({ ...beforeIdentity, supportsServiceTier: false }); + expect(afterIdentity).toEqual({ + ...beforeIdentity, + maxOutputTokens: 500_000, + supportsServiceTier: false, + }); expect(catalogModelSlug(output)).toBe("xai/grok-4.6"); }); + test("output ceilings prefer live metadata and only model-scoped config may narrow", () => { + const generated = applyProviderConfigHints("xai", { + ...provider, + defaultMaxOutputTokens: 1, + }, { provider: "xai", id: "grok-4.6" }); + expect(generated.maxOutputTokens).toBe(500_000); + + const narrowed = applyProviderConfigHints("xai", { + ...provider, + modelMaxOutputTokens: { "grok-4.6": 64_000 }, + }, { provider: "xai", id: "grok-4.6", maxOutputTokens: 128_000 }); + expect(narrowed.maxOutputTokens).toBe(64_000); + + const discoveredSmaller = applyProviderConfigHints("xai", { + ...provider, + modelMaxOutputTokens: { "grok-4.6": 64_000 }, + }, { provider: "xai", id: "grok-4.6", maxOutputTokens: 32_000 }); + expect(discoveredSmaller.maxOutputTokens).toBe(32_000); + + const defaultOnly = applyProviderConfigHints("unknown", { + ...provider, + defaultMaxOutputTokens: 1, + }, { provider: "unknown", id: "unknown-model" }); + expect(defaultOnly.maxOutputTokens).toBeUndefined(); + }); + test("display names use exact case-sensitive ids and stay provider scoped", () => { const wrongCase = applyProviderConfigHints("xai", provider, { provider: "xai", id: "GROK-4.6" }); const otherProvider = applyProviderConfigHints("other", { @@ -3754,7 +3798,7 @@ describe("Codex catalog routed normalization", () => { expect(fetchCalls).toBe(0); expect(ids).toEqual([...(provider.models ?? [])].sort()); - expect(ids).toHaveLength(6); + expect(ids).toHaveLength(7); expect(getProviderDiscoveryStatus(providerName)).toBeUndefined(); markProviderDiscoveryFailed(providerName, { reason: "http", httpStatus: 404 }); @@ -4715,6 +4759,8 @@ describe("Codex catalog routed normalization", () => { expect(slugs.has("deepseek/deepseek-v4-flash")).toBe(true); expect(slugs.has("deepseek/deepseek-v4-pro")).toBe(true); + expect(models.find(model => model.id === "deepseek-v4-flash")?.maxOutputTokens) + .toBe(384_000); for (const model of models) { expect(model.contextWindow).toBe(1_048_576); expect(model.inputModalities).toEqual(["text"]); @@ -5887,6 +5933,7 @@ describe("OpenAI API trusted catalog augmentation", () => { expect(rows.find(row => row.provider === "openai-apikey" && row.id === "gpt-5.6-sol")).toMatchObject({ contextWindow: 1_050_000, maxInputTokens: 922_000, + maxOutputTokens: 128_000, inputModalities: ["text", "image"], reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], }); @@ -5920,6 +5967,7 @@ describe("OpenAI API trusted catalog augmentation", () => { expect(row).toMatchObject({ contextWindow: 1_050_000, maxInputTokens: 922_000, + maxOutputTokens: 128_000, inputModalities: ["text", "image"], reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], }); @@ -6022,7 +6070,8 @@ describe("OpenAI API trusted catalog augmentation", () => { try { const equalDifferentOrder = { provider: "openai-apikey", id: "gpt-5.6-sol", contextWindow: 1_050_000, maxInputTokens: 922_000, - inputModalities: ["image", "text", "image"], reasoningEfforts: ["max", "low", "xhigh", "medium", "high", "low"], owned_by: "openai-apikey", + maxOutputTokens: 128_000, inputModalities: ["image", "text", "image"], + reasoningEfforts: ["max", "low", "xhigh", "medium", "high", "low"], owned_by: "openai-apikey", }; augmentRoutedModelsWithRegistryOpenAiApiRows([equalDifferentOrder], openAiApiCatalogConfig()); expect(warn).not.toHaveBeenCalled(); diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index 2006778c2d..98e9d97b95 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -373,9 +373,9 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 7 + 14 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 8 + 14 + 2 + 2 convergence calls", () => { const counts = Object.fromEntries([ - ["provider-routes.ts", 7], + ["provider-routes.ts", 8], ["model-routes.ts", 14], ["combo-routes.ts", 2], ["agent-settings-routes.ts", 2], @@ -387,7 +387,7 @@ test("the route inventory contains exactly the specified 7 + 14 + 2 + 2 converge return [file, count]; })); expect(counts).toEqual({ - "provider-routes.ts": 7, + "provider-routes.ts": 8, "model-routes.ts": 14, "combo-routes.ts": 2, "agent-settings-routes.ts": 2, @@ -456,3 +456,23 @@ test("the attested reload route converges the Codex catalog like the other write const handlerBody = source.slice(handlerStart, source.indexOf("url.pathname ===", handlerStart + 1)); expect(handlerBody).toContain("await convergeCodexCatalog()"); }); + +/** + * The eighth provider-route call belongs to the atomic provider batch PUT: one commit can + * add, edit, or remove several routed rows, so the post-commit live state must converge once. + * Keep this route-specific assertion beside the total so the inventory cannot be satisfied + * by an unrelated extra call while the batch route loses its own convergence. + */ +test("the atomic provider batch route converges the Codex catalog once", () => { + const source = readFileSync( + join(import.meta.dir, "..", "src", "server", "management", "provider-routes.ts"), + "utf8", + ); + const handlerStart = source.indexOf('url.pathname === "/api/providers" && req.method === "PUT"'); + expect(handlerStart).toBeGreaterThan(-1); + const handlerBody = source.slice(handlerStart, source.indexOf( + 'url.pathname === "/api/providers" && req.method === "POST"', + handlerStart + 1, + )); + expect(handlerBody.match(/await convergeCodexCatalog\(\)/g)?.length).toBe(1); +}); diff --git a/tests/codex-desired-state.test.ts b/tests/codex-desired-state.test.ts index b1a6ecb89e..43c82d6acc 100644 --- a/tests/codex-desired-state.test.ts +++ b/tests/codex-desired-state.test.ts @@ -204,6 +204,35 @@ describe("the startup gate", () => { expect(shouldSyncGrokOnStart({ ...baseConfig(), runtimeRole: "standalone" })).toBe(true); }); + test("a hub with an unauthenticated loopback listener syncs only enabled local clients (#3306)", async () => { + const hubClient = { + ...baseConfig(), + runtimeRole: "hub" as const, + hostname: "100.64.0.10", + unauthenticatedLoopbackListener: { enabled: true as const, port: 10102 }, + }; + + expect(shouldSyncCodexOnStart(hubClient)).toBe(true); + expect(shouldSyncGrokOnStart(hubClient)).toBe(true); + expect(shouldSyncCodexOnStart({ + ...hubClient, + clientIntegrations: { codex: false }, + })).toBe(false); + expect(shouldSyncGrokOnStart({ + ...hubClient, + clientIntegrations: { grok: false }, + })).toBe(false); + + let calls = 0; + const result = await syncCodexOnStartIfEnabled( + 10100, + hubClient, + async () => { calls += 1; return undefined; }, + ); + expect(result.ran).toBe(true); + expect(calls).toBe(1); + }); + test("absence, an empty object, and an explicit true all still sync", async () => { for (const clientIntegrations of [undefined, {}, { codex: true }]) { let calls = 0; diff --git a/tests/combos.test.ts b/tests/combos.test.ts index 9a52092bec..511bdae1a2 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -17,6 +17,8 @@ import { comboPublicModelId, comboRequestHasImageInput, concreteComboRequestBody, + comboCooldownRetryAfterSeconds, + COMBO_REQUEST_RATE_COOLDOWN_MS, coolComboTarget, earliestQuotaResetAt, getCombo, @@ -29,6 +31,7 @@ import { normalizeComboConfig, parseComboModelId, parseRetryAfterMs, + remainingComboCooldownMs, pickComboTarget, preservesPhysicalComboProvider, resetComboEffortWarningStateForTests, @@ -37,7 +40,12 @@ import { tryPickComboModel, UnknownComboError, } from "../src/combos"; -import { comboFailureDecision } from "../src/combos/failover"; +import { + comboFailureCooldownScope, + comboFailureDecision, + isTransientRequestRateLimit, +} from "../src/combos/failover"; +import { comboUnavailableResponse } from "../src/server/responses/core"; import { getConfigPath, readConfigDiagnostics, saveConfig } from "../src/config"; import { routeModel } from "../src/router"; import { handleManagementAPI } from "../src/server/management-api"; @@ -361,6 +369,7 @@ describe("combo target cooldowns", () => { expect(parseRetryAfterMs("120", now)).toBe(120_000); expect(parseRetryAfterMs("999999", now)).toBe(600_000); expect(parseRetryAfterMs(new Date(now + 90_000).toUTCString(), now)).toBe(90_000); + expect(parseRetryAfterMs(new Date(now + 90_000).toUTCString().toLowerCase(), now)).toBe(90_000); expect(parseRetryAfterMs(new Date(now + 900_000).toUTCString(), now)).toBe(600_000); }); @@ -373,6 +382,37 @@ describe("combo target cooldowns", () => { expect(parseRetryAfterMs(new Date(now - 1_000).toUTCString(), now)).toBeUndefined(); }); + test("can preserve valid immediate Retry-After directives", () => { + const now = Date.parse("2026-07-18T00:00:00.000Z"); + const options = { preserveImmediate: true }; + expect(parseRetryAfterMs("0", now, options)).toBe(1); + expect(parseRetryAfterMs(new Date(now - 1_000).toUTCString(), now, options)).toBe(1); + expect(parseRetryAfterMs("Sunday, 06-Nov-94 08:49:37 GMT", now, options)).toBe(1); + expect(parseRetryAfterMs("Sunday, 06-Nov-50 08:49:37 GMT", now, options)).toBe(600_000); + expect(parseRetryAfterMs("Sun Nov 6 08:49:37 1994", now, options)).toBe(1); + expect(parseRetryAfterMs("not-a-date", now, options)).toBeUndefined(); + expect(parseRetryAfterMs("-1", now, options)).toBeUndefined(); + expect(parseRetryAfterMs("March 1, 2020", now, options)).toBeUndefined(); + expect(parseRetryAfterMs("Sun Sep 99 99:99:99 2026", now, options)).toBeUndefined(); + const centuryBoundary = Date.parse("2099-12-31T23:59:00.000Z"); + expect(parseRetryAfterMs("Friday, 01-Jan-00 00:01:00 GMT", centuryBoundary, options)).toBe(120_000); + const fullTimestampBoundary = Date.parse("2026-01-01T00:00:00.000Z"); + expect(parseRetryAfterMs("Wednesday, 01-Jan-76 00:00:00 GMT", fullTimestampBoundary, options)).toBe(600_000); + expect(parseRetryAfterMs("Friday, 31-Dec-76 00:00:00 GMT", fullTimestampBoundary, options)).toBe(1); + }); + + test("parses asctime Retry-After values as UTC outside the UTC process timezone", () => { + const originalTimezone = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; + try { + const now = Date.parse("2026-09-06T00:59:00.000Z"); + expect(parseRetryAfterMs("Sun Sep 6 01:00:00 2026", now)).toBe(60_000); + } finally { + if (originalTimezone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimezone; + } + }); + test("expires cooldowns and clears only the requested combo", () => { coolComboTarget("free", target, { now: 1_000, cooldownMs: 100 }); coolComboTarget("other", target, { now: 1_000, cooldownMs: 100 }); @@ -382,6 +422,55 @@ describe("combo target cooldowns", () => { clearComboTargetCooldowns("other"); expect(isComboTargetInCooldown("other", target, 1_050)).toBe(false); }); + + test("uses a short cooldown for request-rate 1302 without Retry-After", () => { + coolComboTarget("free", target, { + now: 1_000, + code: "1302", + message: "Rate limit reached for requests", + }); + expect(isComboTargetInCooldown("free", target, 1_000 + COMBO_REQUEST_RATE_COOLDOWN_MS - 1)).toBe(true); + expect(isComboTargetInCooldown("free", target, 1_000 + COMBO_REQUEST_RATE_COOLDOWN_MS)).toBe(false); + }); + + test("keeps the default cooldown for usage-window 1308", () => { + coolComboTarget("free", target, { + now: 1_000, + code: "1308", + message: "Usage limit reached for 5 hour", + }); + expect(isComboTargetInCooldown("free", target, 1_000 + 59_999)).toBe(true); + expect(isComboTargetInCooldown("free", target, 1_000 + 60_000)).toBe(false); + }); + + test("honors explicit Retry-After over the request-rate default", () => { + coolComboTarget("free", target, { + now: 1_000, + retryAfter: "30", + code: "1302", + }); + expect(isComboTargetInCooldown("free", target, 1_000 + 29_999)).toBe(true); + expect(isComboTargetInCooldown("free", target, 1_000 + 30_000)).toBe(false); + }); + + test("reports the soonest remaining cooldown as Retry-After seconds", () => { + const later = { provider: "b", model: "m2" }; + coolComboTarget("free", target, { now: 1_000, cooldownMs: 5_000 }); + coolComboTarget("free", later, { now: 1_000, cooldownMs: 20_000 }); + expect(remainingComboCooldownMs("free", 1_000)).toBe(5_000); + expect(comboCooldownRetryAfterSeconds("free", 1_000)).toBe("5"); + expect(comboCooldownRetryAfterSeconds("free", 3_500)).toBe("3"); + expect(comboCooldownRetryAfterSeconds("missing", 1_000)).toBeUndefined(); + }); + + test("combo unavailable responses advertise remaining cooldown as Retry-After", () => { + coolComboTarget("free", target, { now: 1_000, cooldownMs: 5_000 }); + const response = comboUnavailableResponse("No available targets for combo: free", { + retryAfter: comboCooldownRetryAfterSeconds("free", 1_000), + }); + expect(response.status).toBe(503); + expect(response.headers.get("Retry-After")).toBe("5"); + }); }); describe("combo failure policy and advancement", () => { @@ -414,6 +503,85 @@ describe("combo failure policy and advancement", () => { expect(comboFailureDecision(413, "request too large")).toBe("stop"); }); + test("provider-scoped free-tier and monthly quota failures hop without weakening generic 400 handling", () => { + const orca = JSON.stringify({ error: { + type: "invalid_request_error", + code: "free_rate_limited", + message: "This prompt is longer than the free tier allows for a single request.", + }}); + expect(comboFailureDecision(400, orca, { code: "free_rate_limited" })).toBe("hop"); + expect(comboFailureCooldownScope(400, orca, { code: "free_rate_limited" })).toBe("provider"); + expect(comboFailureDecision(400, "ordinary invalid request", { code: "invalid_request_error" })).toBe("stop"); + expect(comboFailureCooldownScope(429, "Monthly usage limit reached. Resets in 14 days.", { + code: "GoUsageLimitError", + })).toBe("provider"); + expect(isTransientRequestRateLimit({ + status: 429, + code: "GoUsageLimitError", + message: "Monthly usage limit reached. Resets in 14 days.", + })).toBe(false); + expect(comboFailureCooldownScope(429, "Rate limit reached for requests", { code: "1302" })).toBe("target"); + expect(isTransientRequestRateLimit({ + status: 429, + code: "1302", + message: "Rate limit reached for requests", + })).toBe(true); + }); + + test("failover skips providers with fresh exhausted quota evidence before dispatch", () => { + const now = 50_000; + const config = baseConfig(); + setCachedProviderQuotaForTests("a", { + monthlyPercent: 100, + monthlyResetAt: now + 14 * 24 * 60 * 60_000, + updatedAt: now, + }); + const pick = pickComboTarget(config, "free", { now }); + expect(pick?.target.provider).toBe("b"); + }); + + test("elapsed quota reset does not permanently blacklist a provider", () => { + const now = 50_000; + const config = baseConfig(); + setCachedProviderQuotaForTests("a", { + monthlyPercent: 100, + monthlyResetAt: now - 1, + updatedAt: now, + }); + const pick = pickComboTarget(config, "free", { now }); + expect(pick?.target.provider).toBe("a"); + }); + + test("exhausted credits without an unlimited flag skip the provider", () => { + const now = 50_000; + const config = baseConfig(); + setCachedProviderQuotaForTests("a", { + creditsUsd: { used: 10, limit: 10, remaining: 0, percent: 100 }, + updatedAt: now, + }); + expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("b"); + }); + + test("provider-scoped cooldown skips sibling models but leaves other providers eligible", () => { + const config = baseConfig({ + combos: { + free: { + targets: [ + { provider: "a", model: "m1" }, + { provider: "a", model: "m1b" }, + { provider: "b", model: "m2" }, + ], + }, + }, + }); + config.providers.a!.models = ["m1", "m1b"]; + const first = pickComboTarget(config, "free", { now: 1_000 })!; + const next = advanceComboAfterFailure(config, first, { now: 1_000, cooldownScope: "provider" })!; + expect(next.target.provider).toBe("b"); + expect(isComboTargetInCooldown("free", { provider: "a", model: "m1b" }, 1_001)).toBe(true); + expect(isComboTargetInCooldown("free", { provider: "b", model: "m2" }, 1_001)).toBe(false); + }); + test("failure clears the active sticky target without adding a success", () => { const config = rrConfig(2, [1, 1]); const combo = getCombo(config, "free")!; diff --git a/tests/command-code-provider.test.ts b/tests/command-code-provider.test.ts index f434687a1c..76aed55ccf 100644 --- a/tests/command-code-provider.test.ts +++ b/tests/command-code-provider.test.ts @@ -121,6 +121,8 @@ describe("Command Code provider", () => { "gpt-5.6-sol", "MiniMaxAI/MiniMax-M3", "moonshotai/Kimi-K3", + "meta/muse-spark-1.3", + "meta/muse-spark-1.3-contributor", "meta/muse-spark-1.2", "meta/muse-spark-1.2-contributor", ]; @@ -384,6 +386,13 @@ describe("Command Code provider", () => { expect(commandCodeReasoningEfforts("meta/muse-spark-1.2-contributor")).toEqual( ["low", "medium", "high", "xhigh", "max"], ); + // 1.3 shipped as the same-shaped successor and carries the identical ladder. + expect(commandCodeReasoningEfforts("meta/muse-spark-1.3-contributor")).toEqual( + ["low", "medium", "high", "xhigh", "max"], + ); + expect(commandCodeReasoningEfforts("meta/muse-spark-1.3")).toEqual( + ["low", "medium", "high", "xhigh", "max"], + ); expect(commandCodeReasoningEfforts("meta/muse-spark-1.2")).toEqual( ["low", "medium", "high", "xhigh", "max"], ); diff --git a/tests/cursor-catalog.test.ts b/tests/cursor-catalog.test.ts index 5a690b1614..a5826fd151 100644 --- a/tests/cursor-catalog.test.ts +++ b/tests/cursor-catalog.test.ts @@ -3,6 +3,8 @@ import { CURSOR_CAPABILITIES, cursorUmbrellaRows, parseCursorVariantId, + recordLiveCursorClaudeModels, + resetLiveCursorClaudeWireIdentitiesForTests, resolveCursorSelection, } from "../src/adapters/cursor/catalog"; import { @@ -32,6 +34,26 @@ const LEGACY_EFFORT_IDS = [ const CODEX_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra", undefined] as const; +const EXISTING_CLAUDE_WIRE_SNAPSHOT = { + "claude-opus-5@high": "claude-opus-5-thinking-high", + "claude-opus-5-thinking-fast@max": "claude-opus-5-thinking-max-fast", + "claude-4.6-opus@max": "claude-4.6-opus-max-thinking", + "claude-4.6-opus-thinking@high": "claude-4.6-opus-high-thinking", + "claude-4.5-sonnet@high": "claude-4.5-sonnet-thinking", + "claude-4.5-sonnet-thinking@max": "claude-4.5-sonnet-thinking", +} as const; + +function existingClaudeWireSnapshot(): Record { + return { + "claude-opus-5@high": resolveCursorSelection("claude-opus-5", "high").wireId, + "claude-opus-5-thinking-fast@max": resolveCursorSelection("claude-opus-5-thinking-fast", "max").wireId, + "claude-4.6-opus@max": resolveCursorSelection("claude-4.6-opus", "max").wireId, + "claude-4.6-opus-thinking@high": resolveCursorSelection("claude-4.6-opus-thinking", "high").wireId, + "claude-4.5-sonnet@high": resolveCursorSelection("claude-4.5-sonnet", "high").wireId, + "claude-4.5-sonnet-thinking@max": resolveCursorSelection("claude-4.5-sonnet-thinking", "max").wireId, + }; +} + /** Legacy composition: what request-builder sends today for a picked id + effort. */ function legacyWireId(pickedId: string, reasoning: string | undefined): string { // request-builder strips the synthetic -1m marker before composing. @@ -95,6 +117,23 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = } } }); + + test("existing Claude wire ids are byte-identical before and after live-roster state is reset", () => { + resetLiveCursorClaudeWireIdentitiesForTests(); + const before = existingClaudeWireSnapshot(); + try { + recordLiveCursorClaudeModels([ + "claude-5-opus-thinking-high", + "claude-opus-4-6-thinking-high", + "claude-sonnet-4-5-thinking", + ]); + } finally { + resetLiveCursorClaudeWireIdentitiesForTests(); + } + const after = existingClaudeWireSnapshot(); + expect(before).toEqual(EXISTING_CLAUDE_WIRE_SNAPSHOT); + expect(after).toEqual(EXISTING_CLAUDE_WIRE_SNAPSHOT); + }); }); describe("parser precedence", () => { @@ -120,6 +159,16 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = expect(parseCursorVariantId("grok-4.6-high-fast")).toMatchObject({ baseId: "grok-4.6", kind: "fast", level: "high" }); }); + test("every Fable 5.1 spelling parses to the canonical capability base", () => { + for (const id of ["claude-fable-5-1", "claude-fable-5.1", "claude-5.1-fable"]) { + expect(parseCursorVariantId(id), id).toMatchObject({ + baseId: "claude-fable-5-1", + kind: "thinking", + known: true, + }); + } + }); + test("unknown ids pass through unchanged", () => { const parsed = parseCursorVariantId("composer-9.9-special"); expect(parsed.known).toBe(false); @@ -142,6 +191,26 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = expect(resolveCursorSelection("claude-opus-5-thinking-fast", "max").wireId).toBe("claude-opus-5-thinking-max-fast"); }); + test("Fable 5.1 saved aliases stay routable with their exact spelling when no roster is recorded", () => { + resetLiveCursorClaudeWireIdentitiesForTests(); + expect(resolveCursorSelection("claude-fable-5.1", "high").wireId).toBe("claude-fable-5.1-thinking-high"); + expect(resolveCursorSelection("claude-5.1-fable", "max").wireId).toBe("claude-5.1-fable-max-thinking"); + expect(resolveCursorSelection("claude-fable-5.1-thinking", "xhigh").wireId) + .toBe("claude-fable-5.1-thinking-xhigh"); + expect(resolveCursorSelection("claude-5.1-fable-thinking", "max").wireId) + .toBe("claude-5.1-fable-max-thinking"); + }); + + test("the live roster spelling overrides both requested and canonical spellings", () => { + recordLiveCursorClaudeModels(["claude-5.1-fable-high-thinking"]); + try { + expect(resolveCursorSelection("claude-fable-5-1", "high").wireId).toBe("claude-5.1-fable-high-thinking"); + expect(resolveCursorSelection("claude-fable-5.1", "high").wireId).toBe("claude-5.1-fable-high-thinking"); + } finally { + resetLiveCursorClaudeWireIdentitiesForTests(); + } + }); + test("ultra arms maxMode only on evidence-gated bases", () => { const kimi = resolveCursorSelection("kimi-k3-1m", "ultra"); expect(kimi.maxMode).toBe(true); @@ -164,6 +233,7 @@ describe("cursor umbrella catalog (devlog 260828_cursor_umbrella_catalog)", () = expect(ids).not.toContain("claude-opus-5-thinking"); expect(ids).not.toContain("claude-opus-5-fast"); expect(ids).not.toContain("kimi-k3-1m"); + expect(ids.filter(id => id.includes("fable") && id.includes("5-1"))).toEqual(["claude-fable-5-1"]); expect(rows.length).toBe(Object.keys(CURSOR_CAPABILITIES).length); const kimi = rows.find(row => row.id === "kimi-k3"); expect(kimi?.maxModeVerified).toBe(true); diff --git a/tests/cursor-claude-id.test.ts b/tests/cursor-claude-id.test.ts new file mode 100644 index 0000000000..7c424d17bf --- /dev/null +++ b/tests/cursor-claude-id.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + composeCursorClaudeWireId, + normalizeCursorClaudeId, +} from "../src/adapters/cursor/claude-id"; + +describe("Cursor Claude id normalization", () => { + test("normalizes every observed Fable 5.1 spelling to one capability base", () => { + for (const id of ["claude-fable-5-1", "claude-fable-5.1", "claude-5.1-fable"]) { + expect(normalizeCursorClaudeId(id), id).toMatchObject({ + canonicalBaseId: "claude-fable-5-1", + thinking: false, + fast: false, + }); + } + }); + + test("extracts thinking, fast, and effort from both marker orders", () => { + expect(normalizeCursorClaudeId("claude-fable-5.1-thinking-xhigh-fast")).toMatchObject({ + canonicalBaseId: "claude-fable-5-1", + sourceBaseId: "claude-fable-5.1", + spelling: "anthropic", + thinking: true, + fast: true, + level: "xhigh", + }); + expect(normalizeCursorClaudeId("claude-5.1-fable-max-thinking-fast")).toMatchObject({ + canonicalBaseId: "claude-fable-5-1", + sourceBaseId: "claude-5.1-fable", + spelling: "version-first", + thinking: true, + fast: true, + level: "max", + }); + expect(normalizeCursorClaudeId("claude-opus-5-high-fast")).toMatchObject({ + canonicalBaseId: "claude-opus-5", + thinking: false, + fast: true, + level: "high", + }); + }); + + test("preserves the exact dotted source base for wire round-trips", () => { + expect(normalizeCursorClaudeId(" CLAUDE-FABLE-5.1-THINKING-HIGH ")).toMatchObject({ + canonicalBaseId: "claude-fable-5-1", + sourceBaseId: "claude-fable-5.1", + spelling: "anthropic", + thinking: true, + fast: false, + level: "high", + }); + }); + + test("does not absorb real 1m rows or unknown Claude products", () => { + expect(normalizeCursorClaudeId("claude-4-sonnet-1m")).toBeUndefined(); + expect(normalizeCursorClaudeId("claude-fable-5-1-preview")).toBeUndefined(); + expect(normalizeCursorClaudeId("claude-composer-5-1")).toBeUndefined(); + }); + + test("composes Anthropic-style and version-first wire orders exactly", () => { + const anthropic = normalizeCursorClaudeId("claude-fable-5.1")!; + const versionFirst = normalizeCursorClaudeId("claude-5.1-fable")!; + expect(composeCursorClaudeWireId(anthropic, { + thinking: true, + fast: true, + effort: "xhigh", + })).toBe("claude-fable-5.1-thinking-xhigh-fast"); + expect(composeCursorClaudeWireId(versionFirst, { + thinking: true, + fast: false, + effort: "max", + })).toBe("claude-5.1-fable-max-thinking"); + expect(composeCursorClaudeWireId(versionFirst, { + thinking: true, + fast: true, + effort: "high", + bareThinking: true, + })).toBe("claude-5.1-fable-thinking-fast"); + expect(composeCursorClaudeWireId(anthropic, { + thinking: false, + fast: true, + effort: "medium", + })).toBe("claude-fable-5.1-medium-fast"); + }); +}); diff --git a/tests/cursor-discovery.test.ts b/tests/cursor-discovery.test.ts index efa3062ba8..0087a2bb8e 100644 --- a/tests/cursor-discovery.test.ts +++ b/tests/cursor-discovery.test.ts @@ -57,12 +57,10 @@ describe("Cursor discovery metadata", () => { expect(ids).toContain("glm-5.2"); expect(ids).toContain("kimi-k2.7-code"); expect(ids).toContain("kimi-k3"); - // 260902 preemptive seed: Fable 5.1 registered ahead of Cursor's lineup update, at 1M, - // under the three spellings Cursor has used for Claude ids. - for (const spelling of ["claude-fable-5-1", "claude-fable-5.1", "claude-5.1-fable"]) { - expect(ids).toContain(spelling); - expect(cursorModelContextWindows(CURSOR_STATIC_MODELS)[spelling]).toBe(1_000_000); - } + // Fable 5.1 has one canonical picker row; saved/live spellings stay adapter aliases. + expect(ids.filter(id => id.includes("fable") && (id.includes("5-1") || id.includes("5.1")))) + .toEqual(["claude-fable-5-1"]); + expect(cursorModelContextWindows(CURSOR_STATIC_MODELS)["claude-fable-5-1"]).toBe(1_000_000); // Any live Fable spelling the seed does not carry still infers a 1M window. expect(inferCursorContextWindow("claude-fable-6")).toBe(1_000_000); // Umbrella merge (devlog 260828): fast duplicate rows folded into bases. @@ -91,6 +89,10 @@ describe("Cursor discovery metadata", () => { expect(isCursorModelAvailableForAccount("claude-4-sonnet", ["claude-4-sonnet-1m"])).toBe(false); expect(isCursorModelAvailableForAccount("gpt-5.5", ["gpt-5.5-extra-high"])).toBe(false); expect(isCursorModelAvailableForAccount("gpt-5.5-extra", ["gpt-5.5-extra-high"])).toBe(true); + expect(isCursorModelAvailableForAccount("claude-fable-5-1", ["claude-fable-5.1-thinking-high"])).toBe(true); + expect(isCursorModelAvailableForAccount("claude-fable-5-1", ["claude-5.1-fable-high-thinking"])).toBe(true); + expect(isCursorModelAvailableForAccount("claude-fable-5-1", ["claude-fable-5-2-thinking-high"])).toBe(false); + expect(isCursorModelAvailableForAccount("claude-fable-5-2", ["claude-fable-5-1-thinking-high"])).toBe(false); // Issue #117: Cursor GetUsableModels may return ids with a `cursor-` wire prefix. expect(isCursorModelAvailableForAccount("grok-4.5", ["cursor-grok-4.5-high"])).toBe(true); diff --git a/tests/cursor-display-names.test.ts b/tests/cursor-display-names.test.ts index 201b68c3a2..8c36f13615 100644 --- a/tests/cursor-display-names.test.ts +++ b/tests/cursor-display-names.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { cursorModelDisplayNames } from "../src/adapters/cursor/discovery"; +import { cursorModelDisplayNames, CURSOR_STATIC_MODELS, isCursorBrandedLabel } from "../src/adapters/cursor/discovery"; import { cursorUmbrellaRows } from "../src/adapters/cursor/catalog"; import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; @@ -7,11 +7,13 @@ import { configuredModelDisplayName } from "../src/codex/catalog/provider-fetch" import type { OcxProviderConfig } from "../src/types"; /** - * The Codex picker showed raw slugs (`cursor/kimi-k3`) because `routedDisplayName` - * (codex/catalog/sync.ts) passes a routed slug through unchanged, and nothing carried - * Cursor's labels into `providers.cursor.modelDisplayNames` — the registry entry type had - * no such field. These assert the full registry -> config -> catalog-hint path, not just - * that a label table exists (devlog 260902_cursor_unified_identity). + * `routedDisplayName` (codex/catalog/sync.ts) passes a routed slug through unchanged, so a + * Cursor row reads `cursor/kimi-k3` like every other provider's rows. #3222 labeled every + * seeded row and the picker lost its `cursor/` prefix, which made Cursor rows + * indistinguishable from the same model under another provider. Only labels that carry + * Cursor's own brand ("Cursor Grok 4.6") are published; the rest keep the routed slug. + * These assert the full registry -> config -> catalog-hint path, not just that a label + * table exists. */ describe("cursor picker labels reach the catalog", () => { const cursorEntry = () => { @@ -20,24 +22,33 @@ describe("cursor picker labels reach the catalog", () => { return entry; }; - test("the registry entry carries a label for every seeded row", () => { + test("the registry entry labels only Cursor-branded rows", () => { const labels = cursorModelDisplayNames(); expect(cursorEntry().modelDisplayNames).toEqual(labels); + const seededIds = new Set(CURSOR_STATIC_MODELS.map(model => model.id)); + for (const [id, label] of Object.entries(labels)) { + expect(seededIds.has(id)).toBe(true); + expect(isCursorBrandedLabel(label)).toBe(true); + } for (const row of cursorUmbrellaRows()) { - expect(labels[row.id]).toBe(row.displayName); + if (isCursorBrandedLabel(row.displayName)) expect(labels[row.id]).toBe(row.displayName); + else expect(labels).not.toHaveProperty(row.id); } - // The label is a human name, never the id echoed back. - expect(labels["kimi-k3"]).toBe("Kimi K3"); + // Cursor's own product name stays; a third-party model keeps its `cursor/` slug. expect(labels["grok-4.6"]).toBe("Cursor Grok 4.6"); - expect(labels["claude-opus-5"]).toBe("Claude Opus 5"); - expect(labels.auto).toBe("Auto"); + expect(labels["grok-4.5"]).toBe("Cursor Grok 4.5"); + expect(labels).not.toHaveProperty("kimi-k3"); + expect(labels).not.toHaveProperty("claude-opus-5"); + expect(labels).not.toHaveProperty("auto"); + expect(labels).not.toHaveProperty("composer-2.5"); }); - test("a fresh seed exposes the labels through configuredModelDisplayName", () => { + test("a fresh seed exposes only the branded labels through configuredModelDisplayName", () => { const seeded = providerConfigSeed(cursorEntry()); - expect(configuredModelDisplayName(seeded, "kimi-k3")).toBe("Kimi K3"); - expect(configuredModelDisplayName(seeded, "claude-4-sonnet-1m")).toBe("Claude Sonnet 4 (1M)"); - expect(configuredModelDisplayName(seeded, "composer-2.5-fast")).toBe("Composer 2.5 Fast"); + expect(configuredModelDisplayName(seeded, "grok-4.6")).toBe("Cursor Grok 4.6"); + expect(configuredModelDisplayName(seeded, "kimi-k3")).toBeUndefined(); + expect(configuredModelDisplayName(seeded, "claude-4-sonnet-1m")).toBeUndefined(); + expect(configuredModelDisplayName(seeded, "composer-2.5-fast")).toBeUndefined(); }); test("enrich backfills an existing install per model, preserving operator renames", () => { @@ -49,7 +60,8 @@ describe("cursor picker labels reach the catalog", () => { enrichProviderFromRegistry("cursor", existing); // Operator value survives... expect(configuredModelDisplayName(existing, "kimi-k3")).toBe("My K3"); - // ...while every other row still gains its label instead of staying unlabeled. + // ...the branded row gains its label, and an unbranded row stays on its routed slug. expect(configuredModelDisplayName(existing, "grok-4.6")).toBe("Cursor Grok 4.6"); + expect(configuredModelDisplayName(existing, "claude-opus-5")).toBeUndefined(); }); }); diff --git a/tests/cursor-effort-rows.test.ts b/tests/cursor-effort-rows.test.ts new file mode 100644 index 0000000000..4929696af8 --- /dev/null +++ b/tests/cursor-effort-rows.test.ts @@ -0,0 +1,314 @@ +import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { saveConfig } from "../src/config"; +import { + resetCodexModelEntitlementCacheForTests, + seedCodexModelEntitlementsForTests, +} from "../src/codex/model-entitlements"; +import { buildCursorIntegrationStatus } from "../src/server/management/cursor-integration-routes"; +import { handleChatCompletions } from "../src/server/chat-completions"; +import { handleClaudeMessages } from "../src/server/claude-messages"; +import { + effortRowId, + expandCursorEffortRow, + parseEffortRowId, +} from "../src/server/effort-row"; +import { handleResponses } from "../src/server/responses"; +import { startServer } from "../src/server"; +import type { RequestLogContext } from "../src/server/request-log"; +import type { OcxConfig } from "../src/types"; +import { removeTreeWithRetry } from "./helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "./helpers/test-budget"; + +setDefaultTimeout(SERVER_BUDGET_MS); + +const previousHome = process.env.OPENCODEX_HOME; +let testHome = ""; + +beforeEach(() => { + testHome = mkdtempSync(join(tmpdir(), "ocx-cursor-effort-rows-")); + process.env.OPENCODEX_HOME = testHome; +}); + +afterEach(() => { + resetCodexModelEntitlementCacheForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testHome) removeTreeWithRetry(testHome); + testHome = ""; +}); + +function discoveryConfig(cursorEffortRows?: boolean): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "anthropic", + ...(cursorEffortRows === undefined ? {} : { cursorEffortRows }), + providers: { + anthropic: { + adapter: "openai-chat", + baseUrl: "https://anthropic.test/v1", + liveModels: false, + models: ["claude-fable-5-1", "claude-opus-5"], + modelReasoningEfforts: { + "claude-fable-5-1": ["none", "low", "high", "max"], + "claude-opus-5": ["low", "high", "max"], + }, + }, + cursor: { + adapter: "openai-chat", + baseUrl: "https://cursor.test/v1", + liveModels: false, + models: ["kimi-k3", "gpt-5.6-sol"], + modelReasoningEfforts: { + "kimi-k3": ["minimal", "medium", "ultra"], + "gpt-5.6-sol": ["low", "medium", "high", "xhigh"], + }, + }, + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false, + }, + }, + }; +} + +async function rawModelList(config: OcxConfig): Promise<{ text: string; data: Array> }> { + saveConfig(config); + const server = startServer(0, { managementApi: { loadCursorEffortTable: () => null } }); + try { + const response = await fetch(new URL("/v1/models", server.url)); + expect(response.status).toBe(200); + const text = await response.text(); + return { text, data: (JSON.parse(text) as { data: Array> }).data }; + } finally { + await server.stop(true); + } +} + +function mockChatUpstream(): { server: ReturnType; captured: Array> } { + const captured: Array> = []; + const server = Bun.serve({ + port: 0, + async fetch(req) { + const body = await req.json() as Record; + captured.push(body); + if (body.stream !== true) { + return Response.json({ + id: "chatcmpl_effort_row", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + } + return new Response([ + 'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}\n\n', + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }, + }); + return { server, captured }; +} + +function ingressConfig(baseUrl: string): OcxConfig { + return { + port: 0, + cursorEffortRows: true, + defaultProvider: "fixture", + subagentEffortCap: "high", + providers: { + fixture: { + adapter: "openai-chat", + baseUrl, + apiKey: "fixture-key", + allowPrivateNetwork: true, + liveModels: false, + models: ["claude-effort-row-fixture"], + modelReasoningEfforts: { + "claude-effort-row-fixture": ["low", "high", "max"], + }, + }, + }, + }; +} + +const childHeaders = { + "content-type": "application/json", + "x-openai-subagent": "collab_spawn", +}; + +describe("Cursor effort variant rows", () => { + test("parseEffortRowId enables only the -- grammar behind cursorEffortRows", () => { + expect(parseEffortRowId("kimi/k3--high", {})).toBeNull(); + expect(parseEffortRowId("kimi/k3--high", { cursorEffortRows: false })).toBeNull(); + for (const id of ["kimi/k3@high", "kimi/k3:high", "kimi/k3-high", "kimi/k3--", "kimi/k3--turbo", "kimi/k3--none"]) { + expect(parseEffortRowId(id, { cursorEffortRows: true })).toBeNull(); + } + expect(parseEffortRowId("kimi/k3--high", { cursorEffortRows: true })).toEqual({ + baseId: "kimi/k3", + effort: "high", + }); + expect(parseEffortRowId("kimi/k3--high", { cursorEffortRows: true }, { + knownIds: new Set(["kimi/k3--high"]), + })).toBeNull(); + }); + + test("Cursor-table model ids never become effort rows", () => { + expect(parseEffortRowId("anthropic/claude-opus-5--high", { cursorEffortRows: true })).toBeNull(); + expect(parseEffortRowId("gpt-5.6-sol--high", { cursorEffortRows: true })).toBeNull(); + }); + + test("cursorEffortRows false is byte-identical to an omitted flag", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); + const omitted = await rawModelList(discoveryConfig()); + const disabled = await rawModelList(discoveryConfig(false)); + expect(disabled.text).toBe(omitted.text); + }); + + test("raw model discovery clones one complete row per supported effort only for table-less ids", async () => { + seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); + const { data } = await rawModelList(discoveryConfig(true)); + const ids = data.map(row => row.id); + expect(ids).toContain("anthropic/claude-fable-5-1--low"); + expect(ids).toContain("anthropic/claude-fable-5-1--high"); + expect(ids).toContain("anthropic/claude-fable-5-1--max"); + expect(ids).not.toContain("anthropic/claude-fable-5-1--none"); + expect(ids).toContain("cursor/kimi-k3--minimal"); + expect(ids).toContain("cursor/kimi-k3--medium"); + expect(ids).toContain("cursor/kimi-k3--ultra"); + expect(ids.some(id => id === "anthropic/claude-opus-5--high")).toBe(false); + expect(ids.some(id => id === "cursor/gpt-5.6-sol--high")).toBe(false); + + for (const baseId of ["anthropic/claude-fable-5-1", "cursor/kimi-k3"]) { + const base = data.find(row => row.id === baseId)!; + const variants = data.filter(row => typeof row.id === "string" && row.id.startsWith(`${baseId}--`)); + const { id: _baseId, ...baseRest } = base; + for (const variant of variants) { + const { id: _variantId, ...variantRest } = variant; + expect(variantRest).toEqual(baseRest); + } + } + + expect(expandCursorEffortRow( + { id: "table-less", marker: { nested: true } }, + ["none", "high"], + { cursorEffortRows: true }, + )).toEqual([ + { id: "table-less", marker: { nested: true } }, + { id: "table-less--high", marker: { nested: true } }, + ]); + }); + + test("Responses effort rows route the base model and pass through the existing cap", async () => { + const upstream = mockChatUpstream(); + try { + const config = ingressConfig(`${upstream.server.url.toString().replace(/\/$/u, "")}/v1`); + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: childHeaders, + body: JSON.stringify({ + model: "fixture/claude-effort-row-fixture--max", + stream: false, + input: "hello", + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(upstream.captured[0]).toMatchObject({ + model: "claude-effort-row-fixture", + reasoning_effort: "high", + }); + } finally { + upstream.server.stop(true); + } + }); + + test("Chat effort rows use Responses normalization instead of the native-chat shortcut", async () => { + const upstream = mockChatUpstream(); + try { + const config = ingressConfig(`${upstream.server.url.toString().replace(/\/$/u, "")}/v1`); + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: childHeaders, + body: JSON.stringify({ + model: "fixture/claude-effort-row-fixture--max", + stream: false, + messages: [{ role: "user", content: "hello" }], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(upstream.captured[0]).toMatchObject({ + model: "claude-effort-row-fixture", + reasoning_effort: "high", + }); + } finally { + upstream.server.stop(true); + } + }); + + test("Messages effort rows resolve after route directives and before native passthrough", async () => { + const upstream = mockChatUpstream(); + try { + const config = ingressConfig(`${upstream.server.url.toString().replace(/\/$/u, "")}/v1`); + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { + ...childHeaders, + "x-api-key": "native-fixture-credential", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-fallback-model", + max_tokens: 128, + stream: false, + system: [{ type: "text", text: "" }], + messages: [{ role: "user", content: "hello" }], + }), + }), config, { model: "", provider: "" } as RequestLogContext); + expect(response.status).toBe(200); + await response.text(); + expect(upstream.captured[0]).toMatchObject({ + model: "claude-effort-row-fixture", + reasoning_effort: "high", + }); + } finally { + upstream.server.stop(true); + } + }); + + test("Cursor integration status marks table-less bases and reports generated row ids", async () => { + const config = discoveryConfig(true); + const status = await buildCursorIntegrationStatus({ + config, + deps: { + loadCursorEffortTable: () => null, + readRuntimePort: () => null, + }, + }, []); + const fable = status.models.find(model => model.id === "anthropic/claude-fable-5-1")!; + expect(fable.tableLess).toBe(true); + expect(fable.effortRows).toEqual([ + effortRowId(fable.id, "low"), + effortRowId(fable.id, "high"), + effortRowId(fable.id, "max"), + ]); + const kimi = status.models.find(model => model.id === "cursor/kimi-k3")!; + expect(kimi.tableLess).toBe(true); + expect(kimi.effortRows).toEqual([ + effortRowId(kimi.id, "minimal"), + effortRowId(kimi.id, "medium"), + effortRowId(kimi.id, "ultra"), + ]); + for (const id of ["anthropic/claude-opus-5", "cursor/gpt-5.6-sol"]) { + const model = status.models.find(row => row.id === id)!; + expect(model.tableLess).toBe(false); + expect(model.effortRows).toEqual([]); + } + }); +}); diff --git a/tests/cursor-effort-suffix.test.ts b/tests/cursor-effort-suffix.test.ts index c509e2fc56..917c2b1b2e 100644 --- a/tests/cursor-effort-suffix.test.ts +++ b/tests/cursor-effort-suffix.test.ts @@ -203,6 +203,19 @@ describe("Cursor per-model reasoning-effort suffix", () => { expect(cursorModelEffortLadder("glm-5.2")).toEqual(["high", "max"]); expect(cursorModelEffortLadder("composer-2.5")).toBeUndefined(); }); + + test("all Fable 5.1 spellings share the canonical effort ladder", () => { + for (const id of [ + "claude-fable-5-1", + "claude-fable-5.1", + "claude-5.1-fable", + "claude-fable-5.1-thinking", + "claude-5.1-fable-thinking", + ]) { + expect(cursorModelEffortLadder(id), id).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(cursorEffortSuffix(id, "xhigh"), id).toBe("xhigh"); + } + }); }); describe("#2569 Cursor catalog tracks the live GetUsableModels roster", () => { @@ -232,7 +245,7 @@ describe("#2569 Cursor catalog tracks the live GetUsableModels roster", () => { }); }); -describe("#2569 Cursor explicit-thinking variants", () => { +describe("#2569 Cursor explicit-thinking wire order", () => { /** * Suffix ORDER differs per family and the wrong one is rejected ERROR_BAD_MODEL_NAME. * Cases recorded from the live GetUsableModels roster on 2026-08-25. @@ -244,9 +257,9 @@ describe("#2569 Cursor explicit-thinking variants", () => { ["claude-opus-4-8-thinking-fast", "xhigh", "claude-opus-4-8-thinking-xhigh-fast"], ["claude-sonnet-5-thinking", "medium", "claude-sonnet-5-thinking-medium"], ["claude-fable-5-thinking", "xhigh", "claude-fable-5-thinking-xhigh"], + // The same canonical Fable family preserves each input's own wire spelling/order. ["claude-fable-5-1-thinking", "xhigh", "claude-fable-5-1-thinking-xhigh"], ["claude-fable-5.1-thinking", "xhigh", "claude-fable-5.1-thinking-xhigh"], - // Version-first spelling follows the 4.x families: marker at the END. ["claude-5.1-fable-thinking", "max", "claude-5.1-fable-max-thinking"], // The marker moves to the END for these families. ["claude-4.6-opus-thinking", "max", "claude-4.6-opus-max-thinking"], diff --git a/tests/cursor-effort-table.test.ts b/tests/cursor-effort-table.test.ts new file mode 100644 index 0000000000..f3f32fda58 --- /dev/null +++ b/tests/cursor-effort-table.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + loadCursorEffortTable, + parseCursorEffortTable, + resetCursorEffortTableCacheForTests, + type CursorEffortTable, + type CursorEffortTableDeps, +} from "../src/integrations/cursor-effort-table"; +import type { CursorInstall } from "../src/integrations/cursor-detect"; +import { predictCursorEffort } from "../src/server/models-capabilities"; + +const FIXTURE = readFileSync(join(import.meta.dir, "fixtures/cursor-agent-exec-effort-table.min.js"), "utf8"); +const INSTALL: CursorInstall = { build: "private-inference", path: "/Applications/Cursor Private Inference.app", version: "3.18.25" }; + +function parsedFixtureTable(): CursorEffortTable { + const parsed = parseCursorEffortTable(FIXTURE); + if (!parsed) throw new Error("Cursor effort fixture did not parse"); + return { ...parsed, version: INSTALL.version, bundlePath: "/fixture/main.js" }; +} + +describe("Cursor installed-bundle effort table", () => { + beforeEach(() => resetCursorEffortTableCacheForTests()); + + test("parses the 3.18.25 literal window from unrelated minified source", () => { + const table = parsedFixtureTable(); + expect(table.families).toHaveLength(16); + expect(table.families.find(family => family.id === "anthropic-opus-5")).toMatchObject({ + ladder: ["low", "medium", "high", "xhigh", "max"], + param: "output_config.effort", + defaultValue: "high", + outputCap: 128000, + }); + expect(table.families.find(family => family.id === "gemini")?.requiresReasoningCapability).toBe(true); + expect(table.families.find(family => family.id === "anthropic-haiku-4-5")).toMatchObject({ + ladder: [], + outputCap: 32768, + }); + expect(table.bareGpt5?.defaultValue).toBe("medium"); + }); + + test("predicts by normalized picker id and preserves unmatched bundle rows as null", () => { + const table = parsedFixtureTable(); + expect(predictCursorEffort("anthropic/claude-opus-5", table)).toMatchObject({ + ladder: ["low", "medium", "high", "xhigh", "max"], + source: "bundle", + family: "anthropic-opus-5", + }); + expect(predictCursorEffort("anthropic/claude-fable-5-1", table)).toEqual({ ladder: null, source: "bundle", family: null }); + expect(predictCursorEffort("cursor/kimi-k3", table)).toEqual({ ladder: null, source: "bundle", family: null }); + expect(predictCursorEffort("gpt-5.4", table)).toEqual({ + ladder: ["low", "medium", "high", "xhigh"], + source: "bundle", + family: "gpt-5", + }); + expect(predictCursorEffort("xai/grok-4.6@main", table)).toMatchObject({ + ladder: ["minimal", "low", "medium", "high", "xhigh"], + source: "bundle", + family: "grok-4.6", + }); + }); + + test("activates the static fallback for missing installs, missing literals, and malformed regexes", () => { + const missingStat: CursorEffortTableDeps = { + platform: "darwin", + stat: () => null, + readText: () => { throw new Error("readText must not run without a stat"); }, + }; + expect(loadCursorEffortTable(INSTALL, missingStat)).toBeNull(); + + const loadSource = (source: string, mtimeMs: number) => loadCursorEffortTable(INSTALL, { + platform: "darwin", + stat: () => ({ mtimeMs, size: source.length }), + readText: () => source, + }); + expect(loadSource("function unrelated(){}", 1)).toBeNull(); + expect(loadSource(FIXTURE.replace("/^claude-opus-5$/u", "/[/u"), 2)).toBeNull(); + // A build that adds a property to ONE family row must not yield a partial table. + expect(loadSource(FIXTURE.replace('effort:k,outputCap:128e3}', 'effort:k,outputCap:128e3,newFlag:!0}'), 3)).toBeNull(); + // A malformed bare gpt-5 pattern rejects the whole parse instead of throwing. + expect(loadSource(FIXTURE.replace("/^gpt-5(?:\\.\\d+)?$/u.test(t)", "/^gpt-5(/u.test(t)"), 4)).toBeNull(); + expect(predictCursorEffort("anthropic/claude-opus-5", null)).toEqual({ + ladder: ["low", "medium", "high", "xhigh", "max"], + source: "static", + family: null, + }); + }); + + test("gemini withholds its ladder when the row will not advertise supports_reasoning", () => { + const table = parsedFixtureTable(); + expect(predictCursorEffort("cursor/gemini-3.7-flash", table, true).ladder).toEqual(["minimal", "low", "medium", "high"]); + expect(predictCursorEffort("cursor/gemini-3.7-flash", table, false)).toEqual({ ladder: null, source: "bundle", family: "gemini" }); + expect(predictCursorEffort("cursor/gemini-3.7-flash", null, false).ladder).toBeNull(); + // Other families ignore the flag: Cursor gates only gemini on it. + expect(predictCursorEffort("anthropic/claude-opus-5", table, false).ladder).toHaveLength(5); + }); + + test("caches by bundle path, mtime, and size and re-reads after mtime changes", () => { + let mtimeMs = 1; + let reads = 0; + const deps: CursorEffortTableDeps = { + platform: "darwin", + stat: () => ({ mtimeMs, size: FIXTURE.length }), + readText: () => { + reads += 1; + return FIXTURE; + }, + }; + expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16); + expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16); + expect(reads).toBe(1); + mtimeMs = 2; + expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16); + expect(reads).toBe(2); + }); +}); diff --git a/tests/cursor-integration-status.test.ts b/tests/cursor-integration-status.test.ts index a0955d9f54..35348dc933 100644 --- a/tests/cursor-integration-status.test.ts +++ b/tests/cursor-integration-status.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { mkdtempSync, readFileSync} from "node:fs"; +import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { saveConfig } from "../src/config"; @@ -8,6 +8,7 @@ import { seedCodexModelEntitlementsForTests, } from "../src/codex/model-entitlements"; import { cursorProductJsonCandidates, detectCursorInstalls, type CursorDetectDeps } from "../src/integrations/cursor-detect"; +import { parseCursorEffortTable, type CursorEffortTable } from "../src/integrations/cursor-effort-table"; import { cursorLastSeen, recordCursorSeen, resetCursorSeenForTests } from "../src/integrations/cursor-seen"; import { cursorEffortFamily } from "../src/server/models-capabilities"; import { startServer } from "../src/server"; @@ -93,6 +94,14 @@ describe("cursorEffortFamily", () => { const previousHome = process.env.OPENCODEX_HOME; let testHome = ""; +const CURSOR_EFFORT_FIXTURE = readFileSync(join(import.meta.dir, "fixtures/cursor-agent-exec-effort-table.min.js"), "utf8"); +const STATIC_CURSOR_EFFORT_DEPS = { managementApi: { loadCursorEffortTable: () => null } }; + +function fixtureEffortTable(): CursorEffortTable { + const parsed = parseCursorEffortTable(CURSOR_EFFORT_FIXTURE); + if (!parsed) throw new Error("Cursor effort fixture did not parse"); + return { ...parsed, version: "3.18.25", bundlePath: "/fixture/main.js" }; +} function statusConfig(): OcxConfig { return { @@ -134,7 +143,7 @@ describe("GET /api/native-integrations/cursor", () => { test("reports gateway values, model expectations, and a last-seen Cursor request", async () => { seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); saveConfig(statusConfig()); - const server = startServer(0); + const server = startServer(0, STATIC_CURSOR_EFFORT_DEPS); try { const adminToken = readFileSync(join(testHome, "admin-api-token"), "utf8").trim(); const headers = { "x-opencodex-api-key": adminToken }; @@ -144,7 +153,8 @@ describe("GET /api/native-integrations/cursor", () => { const first = await before.json() as { gateway: { baseUrl: string; apiKeyMode: string; placeholder: string }; lastSeen: unknown; - models: Array<{ id: string; reasoning: string[] | null; context: { defaultWindow: number; longWindow: number } | null }>; + effortTable: { source: string }; + models: Array<{ id: string; reasoning: string[] | null; family: string | null; context: { defaultWindow: number; longWindow: number } | null }>; privateInference: { installed: boolean }; guideUrl: string; }; @@ -152,14 +162,25 @@ describe("GET /api/native-integrations/cursor", () => { expect(first.gateway.apiKeyMode).toBe("placeholder"); expect(first.gateway.placeholder).toBe("opencodex-loopback"); expect(first.lastSeen).toBeNull(); + expect(first.effortTable.source).toBe("static"); expect(typeof first.privateInference.installed).toBe("boolean"); expect(first.guideUrl).toContain("cursor-private-inference"); const k3 = first.models.find(model => model.id === "kimi/k3"); - expect(k3).toEqual({ id: "kimi/k3", reasoning: null, context: null }); + expect(k3).toEqual({ + id: "kimi/k3", + reasoning: null, + family: null, + tableLess: true, + effortRows: [], + context: null, + }); const sol = first.models.find(model => model.id === "gpt-5.6-sol"); expect(sol).toEqual({ id: "gpt-5.6-sol", reasoning: ["low", "medium", "high", "xhigh"], + family: null, + tableLess: false, + effortRows: [], context: { defaultWindow: 272000, longWindow: 922000 }, }); @@ -178,7 +199,7 @@ describe("GET /api/native-integrations/cursor", () => { const config = statusConfig(); config.apiKeys = [{ id: "k1", name: "test", key: "ocx_test_key_value_1234567890", createdAt: new Date(0).toISOString() }]; saveConfig(config); - const server = startServer(0); + const server = startServer(0, STATIC_CURSOR_EFFORT_DEPS); try { const adminToken = readFileSync(join(testHome, "admin-api-token"), "utf8").trim(); const res = await fetch(new URL("/api/native-integrations/cursor", server.url), { headers: { "x-opencodex-api-key": adminToken } }); @@ -192,7 +213,7 @@ describe("GET /api/native-integrations/cursor", () => { test("a disabled model leaves the prediction the same way it leaves /v1/models", async () => { seedCodexModelEntitlementsForTests("main", ["gpt-5.6-sol"]); saveConfig({ ...statusConfig(), disabledModels: ["kimi/k3"] }); - const server = startServer(0); + const server = startServer(0, STATIC_CURSOR_EFFORT_DEPS); try { const adminToken = readFileSync(join(testHome, "admin-api-token"), "utf8").trim(); const status = await fetch(new URL("/api/native-integrations/cursor", server.url), { headers: { "x-opencodex-api-key": adminToken } }); @@ -205,4 +226,24 @@ describe("GET /api/native-integrations/cursor", () => { await server.stop(true); } }); + + test("reports bundle effort-table provenance and unmatched model families through the server deps seam", async () => { + saveConfig(statusConfig()); + const server = startServer(0, { managementApi: { loadCursorEffortTable: () => fixtureEffortTable() } }); + try { + const adminToken = readFileSync(join(testHome, "admin-api-token"), "utf8").trim(); + const status = await fetch(new URL("/api/native-integrations/cursor", server.url), { + headers: { "x-opencodex-api-key": adminToken }, + }); + expect(status.status).toBe(200); + const body = await status.json() as { + effortTable: { source: string; version: string | null; families: number | null }; + models: Array<{ id: string; family: string | null }>; + }; + expect(body.effortTable).toEqual({ source: "bundle", version: "3.18.25", families: 16 }); + expect(body.models.find(model => model.id === "kimi/k3")?.family).toBeNull(); + } finally { + await server.stop(true); + } + }); }); diff --git a/tests/cursor-local-models-schema.test.ts b/tests/cursor-local-models-schema.test.ts index 5b03ea576f..2b0c9759c0 100644 --- a/tests/cursor-local-models-schema.test.ts +++ b/tests/cursor-local-models-schema.test.ts @@ -43,6 +43,7 @@ function capabilityConfig(): OcxConfig { }, modelDefaultReasoningEfforts: { k3: "high" }, modelContextWindows: { k3: 200000 }, + modelMaxOutputTokens: { k3: 64_000 }, modelInputModalities: { k3: ["text", "image"] }, }, openai: { @@ -103,12 +104,25 @@ describe("modelCapabilityFields", () => { const tiered = modelCapabilityFields({ contextWindow: 272000, longContextWindow: 922000 }); expect(tiered.capabilities.context_length).toBe(922000); expect(tiered.pricing).toEqual({ overrides: [{ min_prompt_tokens: 272000 }] }); + expect("long_context_threshold_tokens" in tiered).toBe(false); // Equal or smaller opt-in window: plain context_length, no pricing block. const flat = modelCapabilityFields({ contextWindow: 272000, longContextWindow: 272000 }); expect(flat.capabilities.context_length).toBe(272000); expect("pricing" in flat).toBe(false); + expect("long_context_threshold_tokens" in flat).toBe(false); expect("pricing" in modelCapabilityFields({ longContextWindow: 922000 })).toBe(false); }); + + test("max output tokens are sanitized independently of reasoning", () => { + expect(modelCapabilityFields({ maxOutputTokens: 128000 }).capabilities.max_output_tokens) + .toBe(128000); + expect("max_output_tokens" in modelCapabilityFields({ maxOutputTokens: 0 }).capabilities) + .toBe(false); + expect("max_output_tokens" in modelCapabilityFields({ maxOutputTokens: Number.MAX_SAFE_INTEGER + 2 }).capabilities) + .toBe(false); + expect(modelCapabilityFields({ maxOutputTokens: 1.9 }).capabilities.supports_reasoning) + .toBe(false); + }); }); describe("nativeOpenAiContextTier", () => { @@ -142,6 +156,7 @@ describe("raw /v1/models list advertises Cursor local-agent capabilities", () => expect(k3!.api_types).toEqual(["chat_completions", "responses", "anthropic_messages"]); expect(k3!.capabilities).toEqual({ context_length: 200000, + max_output_tokens: 64_000, output_modalities: ["text"], input_modalities: ["text", "image"], supports_tool_use: true, @@ -160,6 +175,7 @@ describe("raw /v1/models list advertises Cursor local-agent capabilities", () => const plainCaps = plain!.capabilities as Record; expect(plainCaps.supports_reasoning).toBe(false); expect("reasoning_effort" in plainCaps).toBe(false); + expect("max_output_tokens" in plainCaps).toBe(false); const sol = body.data.find(m => m.id === "gpt-5.6-sol"); expect(sol).toBeDefined(); @@ -169,7 +185,9 @@ describe("raw /v1/models list advertises Cursor local-agent capabilities", () => expect(solCaps.reasoning_effort).toEqual(nativeReasoningEfforts("gpt-5.6-sol")); // Native GPT-5.6: 272k default window, 922k opt-in ceiling → Cursor Context selector. expect(solCaps.context_length).toBe(922000); + expect(solCaps.max_output_tokens).toBe(128_000); expect(sol!.pricing).toEqual({ overrides: [{ min_prompt_tokens: 272000 }] }); + expect("long_context_threshold_tokens" in sol!).toBe(false); expect(solCaps.supports_vision).toBe(true); // Routed rows have no separate opt-in tier, so no pricing block. expect("pricing" in k3!).toBe(false); diff --git a/tests/cursor-umbrella-rows.test.ts b/tests/cursor-umbrella-rows.test.ts index 31a3f03a48..7cb1bb1186 100644 --- a/tests/cursor-umbrella-rows.test.ts +++ b/tests/cursor-umbrella-rows.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test"; import { cursorUmbrellaRows, + liveCursorClaudeWireIdentitiesForTests, + recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels, + resetLiveCursorClaudeWireIdentitiesForTests, resolveCursorSelection, } from "../src/adapters/cursor/catalog"; import { @@ -33,6 +36,9 @@ describe("cursor umbrella picker rows (devlog 260828_cursor_umbrella_catalog)", expect(ids).not.toContain("claude-opus-5-fast"); expect(ids).not.toContain("grok-4.5-fast"); expect(ids).not.toContain("grok-4.6-fast"); + expect(ids).not.toContain("claude-fable-5.1"); + expect(ids).not.toContain("claude-5.1-fable"); + expect(ids.filter(id => id === "claude-fable-5-1")).toHaveLength(1); // composer-2.5-fast has no umbrella base with effort dimensions; it stays. expect(ids).toContain("composer-2.5-fast"); }); @@ -42,7 +48,7 @@ describe("cursor umbrella picker rows (devlog 260828_cursor_umbrella_catalog)", }); test("the seed is composed of routers + umbrella bases + declared product ids", () => { - // 4 routers + 34 umbrella bases + 13 product ids + 3 real-id exceptions. + // 4 routers + 32 umbrella bases + 13 product ids + 3 real-id exceptions. // Derived, not frozen: the hard-coded count drifted twice already (51 -> 54 when // #3211 pre-seeded Claude Fable 5.1 under three spellings), so the expectation now // comes from the same capability table the seed is built from. @@ -125,4 +131,26 @@ describe("cursor umbrella picker rows (devlog 260828_cursor_umbrella_catalog)", expect(resolveCursorSelection("kimi-k3", "ultra").maxMode).toBe(true); }); }); + + describe("live Claude wire identity", () => { + test("each successful roster replaces the spelling map atomically and reset clears it", () => { + resetLiveCursorClaudeWireIdentitiesForTests(); + recordLiveCursorClaudeModels([ + "claude-5.1-fable-high-thinking", + "claude-opus-5-thinking-high", + ]); + expect([...liveCursorClaudeWireIdentitiesForTests().entries()]).toEqual([ + ["claude-fable-5-1", { sourceBaseId: "claude-5.1-fable", spelling: "version-first" }], + ["claude-opus-5", { sourceBaseId: "claude-opus-5", spelling: "anthropic" }], + ]); + + recordLiveCursorClaudeModels(["claude-fable-5.1-thinking-xhigh"]); + expect([...liveCursorClaudeWireIdentitiesForTests().entries()]).toEqual([ + ["claude-fable-5-1", { sourceBaseId: "claude-fable-5.1", spelling: "anthropic" }], + ]); + + resetLiveCursorClaudeWireIdentitiesForTests(); + expect(liveCursorClaudeWireIdentitiesForTests().size).toBe(0); + }); + }); }); diff --git a/tests/fixtures/cursor-agent-exec-effort-table.min.js b/tests/fixtures/cursor-agent-exec-effort-table.min.js new file mode 100644 index 0000000000..46585ca56b --- /dev/null +++ b/tests/fixtures/cursor-agent-exec-effort-table.min.js @@ -0,0 +1,3 @@ +function zz(e){return e+1} +const w={param:"reasoning_effort",values:["low","medium","high","xhigh"],defaultValue:"medium"};function _(e){const t=function(e){let t=e.trim().toLowerCase();const n=t.lastIndexOf("/");-1!==n&&(t=t.slice(n+1));const r=t.indexOf("@");return-1!==r&&(t=t.slice(0,r)),t}(e);if(/^gpt-5(?:\.\d+)?$/u.test(t))return w}const T={param:"output_config.effort",values:["low","medium","high","max"],defaultValue:"high"},k={param:"output_config.effort",values:["low","medium","high","xhigh","max"],defaultValue:"high"},S={param:"reasoning_effort",values:["minimal","low","medium","high","xhigh"],defaultValue:"high"},b=[{id:"anthropic-opus-5",matches:e=>/^claude-opus-5$/u.test(e),effort:k,outputCap:128e3},{id:"anthropic-opus-4-7-4-8",matches:e=>/^claude-opus-4[-.](?:7|8)$/u.test(e),effort:k,outputCap:128e3},{id:"anthropic-opus-4-6",matches:e=>/^claude-opus-4[-.]6$/u.test(e),effort:T,outputCap:128e3},{id:"anthropic-opus-4-5",matches:e=>/^claude-opus-4[-.]5$/u.test(e),effort:T,outputCap:64e3},{id:"anthropic-sonnet-4-6",matches:e=>/^claude-sonnet-4[-.]6$/u.test(e),effort:T,outputCap:64e3},{id:"anthropic-sonnet-5",matches:e=>/^claude-sonnet-5$/u.test(e),effort:k,outputCap:128e3},{id:"anthropic-sonnet-no-effort",matches:e=>/^claude-sonnet-4(?:[-.]5)?$/u.test(e),outputCap:64e3},{id:"anthropic-haiku-4-5",matches:e=>/^claude-haiku-4[-.]5$/u.test(e),outputCap:32768},{id:"grok-4.3",matches:e=>/^grok-4[.-]3$/u.test(e),effort:S},{id:"grok-4.5",matches:e=>/^grok-4[.-]5(?:-(?:batch|build|nocomp))?$/u.test(e),effort:S},{id:"grok-4.6",matches:e=>/^grok-4[.-]6(?:-(?:batch|build|nocomp))?$/u.test(e),effort:S},{id:"grok-build-latest",matches:e=>/^grok-build-latest$/u.test(e),effort:S},{id:"grok-reasoning-no-effort",matches:e=>/^grok-(?:composer(?:-2\.5(?:-fast)?)?|4\.20-0309-reasoning|4\.20-multi-agent-0309|420-clanker-reasoning)$/u.test(e)},{id:"gpt-5.6",matches:e=>/^gpt-5[.-]6-(?:luna|sol|terra)$/u.test(e),effort:{param:"reasoning_effort",values:["low","medium","high","xhigh"],defaultValue:"medium"}},{id:"gemini-no-effort",matches:e=>/^gemini-3\.[1-9].*flash-lite/u.test(e)},{id:"gemini",matches:e=>/^gemini-/u.test(e),effort:{param:"reasoning_effort",values:["minimal","low","medium","high"],defaultValue:"medium"},effortRequiresReasoningCapability:!0}]; +const yy={ok:!0};yy.ok&&zz(1) diff --git a/tests/fixtures/openai-provider-option-migration-child.ts b/tests/fixtures/openai-provider-option-migration-child.ts index 5822b345c4..30dd0843c3 100644 --- a/tests/fixtures/openai-provider-option-migration-child.ts +++ b/tests/fixtures/openai-provider-option-migration-child.ts @@ -17,6 +17,16 @@ mkdirSync(codexHome, { recursive: true, mode: 0o700 }); process.env.OPENCODEX_HOME = opencodexHome; process.env.CODEX_HOME = codexHome; +const ACL_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const PRINCIPAL_OK = { + success: true, + exitCode: 0, + timedOut: false, + stdout: "S-1-5-21-1-2-3-1001\nocx-provider-option-migration\n", +}; +let aclSeamCalls = 0; +let principalSeamCalls = 0; + const forward = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", @@ -80,11 +90,30 @@ writeFileSync(v1BackupPath, v1Sentinel, { mode: 0o600 }); chmodSync(configPath, 0o600); chmodSync(v1BackupPath, 0o600); -const [configModule, startupModule, migrationModule] = await Promise.all([ +const [configModule, configPaths, startupModule, migrationModule, windowsAcl, windowsPrincipal] = await Promise.all([ import("../../src/config"), + import("../../src/config/paths"), import("../../src/providers/openai-tier-startup"), import("../../src/providers/openai-tiers"), + import("../../src/lib/windows-secret-acl"), + import("../../src/lib/windows-user-principal"), ]); +windowsAcl.setIcaclsRunnerForTests(() => { + aclSeamCalls += 1; + return ACL_OK; +}); +windowsAcl.setAsyncIcaclsRunnerForTests(async () => { + aclSeamCalls += 1; + return ACL_OK; +}); +windowsPrincipal.setWindowsPrincipalRunnerForTests(() => { + principalSeamCalls += 1; + return PRINCIPAL_OK; +}); +windowsPrincipal.setAsyncWindowsPrincipalRunnerForTests(async () => { + principalSeamCalls += 1; + return PRINCIPAL_OK; +}); const warnings: string[] = []; const originalWarn = console.warn; console.warn = message => { warnings.push(String(message)); }; @@ -161,12 +190,14 @@ try { && relevantWarnings.every(warning => warning === "[openai-provider-migration] providerContextCaps.openai + providerContextCaps.openai-multi: kept lower positive cap"); process.stdout.write(JSON.stringify({ + aclSeamCalls, backupMatchesOriginal: backupBytes === original, backupMode: statSync(v2BackupPath).mode & 0o777, v1BackupUnchanged: readFileSync(v1BackupPath, "utf8") === v1Sentinel, firstProviderIds: Object.keys(first.providers), firstDefaultProvider: first.defaultProvider, mode: first.providers.openai?.codexAccountMode, + principalSeamCalls, hiddenLegacy: !Object.hasOwn(first.providers, "openai-multi") && !Object.hasOwn(first.providers, "chatgpt"), marker: first.openaiProviderTierVersion, selectedModels: first.providers.openai?.selectedModels, @@ -193,4 +224,11 @@ try { }) + "\n"); } finally { console.warn = originalWarn; + await configPaths.flushConfigDirHardeningForTests(); + windowsAcl.setIcaclsRunnerForTests(null); + windowsAcl.setAsyncIcaclsRunnerForTests(null); + windowsAcl.resetHardenedStateForTests(); + windowsPrincipal.setWindowsPrincipalRunnerForTests(null); + windowsPrincipal.setAsyncWindowsPrincipalRunnerForTests(null); + windowsPrincipal.resetWindowsPrincipalForTests(); } diff --git a/tests/gemini-37-flash-migration.test.ts b/tests/gemini-37-flash-migration.test.ts index c577c76060..968de54fb7 100644 --- a/tests/gemini-37-flash-migration.test.ts +++ b/tests/gemini-37-flash-migration.test.ts @@ -35,16 +35,26 @@ const RETIRED_TIERS: Record = { }; describe("Gemini 3.7 Flash replaces 3.6 on Antigravity", () => { - test("3.7 is the only picker-visible Flash model", () => { + test("no retired Flash id is picker-visible", () => { expect(ANTIGRAVITY_MODELS).toContain("gemini-3.7-flash"); for (const retired of Object.keys(RETIRED_TIERS)) { expect(ANTIGRAVITY_MODELS).not.toContain(retired); } }); + test("3.7 survives the 3.8 launch instead of being retired with it", () => { + // Google documents 3.7 Flash as "remains fully supported" and CCA still serves it, so + // unlike the 3.6 generation it must NOT join the retired map when its successor ships. + // A regression here would strand every saved 3.7 selection on a redirect it never needed. + expect(RETIRED_TIERS).not.toHaveProperty("gemini-3.7-flash"); + expect(ANTIGRAVITY_MODELS).toContain("gemini-3.7-flash"); + expect(ANTIGRAVITY_MODEL_EFFORTS["gemini-3.7-flash"]).toEqual(["low", "medium", "high"]); + }); + test("the provider default points at the live model", () => { const entry = PROVIDER_REGISTRY.find(row => row.id === "google-antigravity"); - expect(entry?.defaultModel).toBe("gemini-3.7-flash"); + expect(entry?.defaultModel).toBe("gemini-3.8-flash"); + expect(entry?.models).toContain("gemini-3.8-flash"); expect(entry?.models).toContain("gemini-3.7-flash"); }); @@ -323,3 +333,85 @@ describe("the -tiered wire rename reaches the request path", () => { } }); }); + +// Gemini 3.8 arrives on top of 3.7 rather than replacing it: Google documents 3.7 as still +// fully supported and CCA serves 3.5/3.6/3.7/3.8 together. It also publishes three suffixed +// wire ids and no `-tiered` row, so its tiers ride the suffix — the 3.6 shape, not the 3.7 one. +describe("Gemini 3.8 Flash lands additively with a suffix ladder", () => { + test("3.8 is picker-visible with its own capability records", () => { + expect(ANTIGRAVITY_MODELS).toContain("gemini-3.8-flash"); + expect(ANTIGRAVITY_MODEL_CONTEXT_WINDOWS["gemini-3.8-flash"]).toBe(1_048_576); + expect(ANTIGRAVITY_MODEL_EFFORTS["gemini-3.8-flash"]).toEqual(["low", "medium", "high"]); + // Google documents video/audio/PDF, but this proxy transports only text and image parts. + expect(ANTIGRAVITY_MODEL_INPUT_MODALITIES["gemini-3.8-flash"]).toEqual(["text", "image"]); + // `minimal` errors on this generation, so it must not appear in the ladder. + expect(ANTIGRAVITY_MODEL_EFFORTS["gemini-3.8-flash"]).not.toContain("minimal"); + }); + + test("each effort routes to its own wire id and carries no thinking level", () => { + // The suffix already names the tier. Sending thinkingLevel beside it states the effort + // twice, and CCA accepts the contradiction (a `-low` wire id with HIGH returns 200), so a + // mismatch would silently run at an unknown tier instead of failing loudly. + for (const [effort, wireModelId] of [ + ["low", "gemini-3.8-flash-low"], + ["medium", "gemini-3.8-flash-medium"], + ["high", "gemini-3.8-flash-high"], + ] as const) { + expect(resolveAntigravityEffortWireModel("gemini-3.8-flash", effort)) + .toEqual({ wireModelId }); + } + }); + + test("an unset effort falls to the documented medium default", () => { + expect(resolveAntigravityEffortWireModel("gemini-3.8-flash")) + .toEqual({ wireModelId: "gemini-3.8-flash-medium" }); + }); + + test("efforts above the CCA ladder clamp to high rather than falling back to medium", () => { + for (const effort of ["xhigh", "max", "ultra"]) { + expect(resolveAntigravityEffortWireModel("gemini-3.8-flash", effort)) + .toEqual({ wireModelId: "gemini-3.8-flash-high" }); + } + }); + + test("3.8 usage rows aggregate onto the base while retired ids keep their own identity", () => { + for (const wire of ["gemini-3.8-flash-low", "gemini-3.8-flash-medium", "gemini-3.8-flash-high"]) { + expect(canonicalAntigravityUsageModel(wire)).toBe("gemini-3.8-flash"); + } + // Retirement changes what we CALL, not what we RECORD: historical spend must not migrate. + expect(canonicalAntigravityUsageModel("gemini-3.6-flash-high")).toBe("gemini-3.6-flash-high"); + expect(canonicalAntigravityUsageModel("gemini-3.7-flash")).toBe("gemini-3.7-flash"); + }); + + test("retired 3.6 and 3.5 ids still redirect to 3.7 with their recorded tier", () => { + // 3.8 becoming current must not re-point the retirement target: those ids were retired + // onto 3.7, and 3.7 is still served. + expect(resolveAntigravityEffortWireModel("gemini-3.6-flash-high")) + .toEqual({ wireModelId: "gemini-3.7-flash-tiered", thinkingLevel: "high" }); + expect(resolveAntigravityEffortWireModel("gemini-3.5-flash-extra-low")) + .toEqual({ wireModelId: "gemini-3.7-flash-tiered", thinkingLevel: "low" }); + }); + + test("3.7 keeps its single-wire tiered routing untouched", () => { + expect(resolveAntigravityEffortWireModel("gemini-3.7-flash", "high")) + .toEqual({ wireModelId: "gemini-3.7-flash-tiered", thinkingLevel: "high" }); + }); + + test("Antigravity 3.8 cost resolves to the derived overlay, not a bundled verified price", () => { + // Declaring the overlay is not enough. Bundled generated metadata is consulted FIRST and + // returns status "verified", so a cost block on the google/gemini-3.8-flash source row + // would shadow this overlay and assert a CCA billing equivalence Google never published. + // The source record omits cost precisely so this lookup lands here. + const matched = resolveMatchedPrice("google-antigravity", "gemini-3.8-flash"); + expect(matched?.status).toBe("verified-derived"); + expect(matched?.cost4).toEqual({ input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }); + expect(matched?.source).not.toBe("jawcode"); + }); + + test("the direct Google surface may claim a verified 3.8 price", () => { + // The Developer API price IS published for this surface, so unlike the CCA row it is not + // an inference. + expect(resolveMatchedPrice("google", "gemini-3.8-flash")?.cost4) + .toEqual({ input: 0.75, output: 3.75, cacheRead: 0.075, cacheWrite: 0 }); + }); +}); diff --git a/tests/gemini-web-search.test.ts b/tests/gemini-web-search.test.ts index 0f0bd782a1..8dedb42063 100644 --- a/tests/gemini-web-search.test.ts +++ b/tests/gemini-web-search.test.ts @@ -10,6 +10,7 @@ mock.module("../src/oauth/store", () => ({ import { mapCcaGroundedResponse } from "../src/web-search/gemini-executor"; import { findGeminiSidecarProvider, planWebSearch } from "../src/web-search"; +import { resolveAntigravityEffortWireModel } from "../src/providers/antigravity-models"; import { parseRequest } from "../src/responses/parser"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; @@ -72,12 +73,23 @@ describe("mapCcaGroundedResponse (002 live capture shape)", () => { describe("planWebSearch gemini arm (L8)", () => { const healthy = { accounts: [{ id: "a1", credential: { projectId: "proj-1" } }], activeAccountId: "a1" }; - test("explicit gemini + OAuth + projectId -> plan with geminiSidecar and 3.7-flash default", () => { + test("explicit gemini + OAuth + projectId -> plan with geminiSidecar and 3.8-flash default", () => { accountSets = { "google-antigravity": healthy }; const plan = planWebSearch(config({ webSearchSidecar: { backend: "gemini" } }), parsedWithWebSearch(), false, routed, "model", undefined); expect(plan?.backend).toBe("gemini"); expect(plan?.geminiSidecar?.providerName).toBe("google-antigravity"); - expect(plan?.settings.model).toBe("gemini-3.7-flash"); + // Tracks the Antigravity default: the sidecar runs google_search grounding over the same + // CCA transport, so a sidecar pinned to the previous generation would drift from it. + expect(plan?.settings.model).toBe("gemini-3.8-flash"); + }); + + test("a 3.8 sidecar call rides the suffix wire id with no thinking level", async () => { + // The 3.7 case below sends gemini-3.7-flash-tiered plus thinkingLevel because that model + // carries its tier in the request. 3.8 carries it in the wire id instead, so the envelope + // must differ — this asserts the suffix-tier decision actually reached the sidecar path. + const { wireModelId, thinkingLevel } = resolveAntigravityEffortWireModel("gemini-3.8-flash", "low"); + expect(wireModelId).toBe("gemini-3.8-flash-low"); + expect(thinkingLevel).toBeUndefined(); }); test.each([ diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index e7311eae62..295c92dd67 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -282,12 +282,54 @@ describe("google adapter — Antigravity system prompt compatibility", () => { expect(systemInstructionText(ccaEnvelope.request)).not.toContain(REJECTED_CLAUDE_SDK_PARAGRAPH); }); - test("preserves the paragraph for another Cloud Code Assist model", async () => { + test("removes the rejected paragraph for CCA Gemini 3.8 Flash too", async () => { + // Probed 2026-09-03: 3.8 answers 429 RESOURCE_EXHAUSTED while this paragraph survives + // into systemInstruction, and 200 once stripped. Since 3.8 is now the default, a + // 3.7-only guard would 429 every Claude-Agent-shaped request and report it as quota. + const parsed = systemPromptParsed("gemini-3.8-flash"); + const envelope = JSON.parse((await createGoogleAdapter(ccaProvider).buildRequest(parsed)).body) as { + request: Record; + }; + + expect(systemInstructionText(envelope.request)).not.toContain(REJECTED_CLAUDE_SDK_PARAGRAPH); + }); + + test("removes it for a raw 3.8 suffix selector published by a partial ladder", async () => { + // When CCA returns an incomplete tier set the picker publishes raw suffix ids, so + // parsed.modelId can be the wire id rather than the collapsed base — exactly the ids the + // 429 probe used. A base-only membership test would lose the guard precisely when CCA is + // already degraded. + for (const suffixId of ["gemini-3.8-flash-low", "gemini-3.8-flash-medium", "gemini-3.8-flash-high"]) { + const built = await createGoogleAdapter(ccaProvider).buildRequest(systemPromptParsed(suffixId)); + const envelope = JSON.parse(built.body) as { request: Record }; + + expect(systemInstructionText(envelope.request)).not.toContain(REJECTED_CLAUDE_SDK_PARAGRAPH); + } + }); + + test("removes it for a RETIRED id that rule 0 redirects onto the rejecting generation", async () => { + // A saved gemini-3.6-flash selection does not call 3.6 — rule 0 routes it to + // gemini-3.7-flash-tiered, which rejects the paragraph (probed at 429 with it intact). + // Retired ids deliberately keep their own identity for usage accounting, so they never + // canonicalize into the generation they actually reach; judging the SELECTOR would leave + // every saved 3.6/3.5 config broken. This is why the guard reads the routed wire id. const parsed = systemPromptParsed("gemini-3.6-flash"); const envelope = JSON.parse((await createGoogleAdapter(ccaProvider).buildRequest(parsed)).body) as { request: Record; }; + expect(systemInstructionText(envelope.request)).not.toContain(REJECTED_CLAUDE_SDK_PARAGRAPH); + }); + + test("preserves the paragraph for a Cloud Code Assist model that does not reject it", async () => { + // Membership is probe-established per generation, so a model with no recorded rejection + // keeps its system prompt byte-identical. Claude-on-Antigravity is the natural control: + // the paragraph is literally true for it. + const parsed = systemPromptParsed("claude-sonnet-4-6"); + const envelope = JSON.parse((await createGoogleAdapter(ccaProvider).buildRequest(parsed)).body) as { + request: Record; + }; + expect(systemInstructionText(envelope.request)).toContain(REJECTED_CLAUDE_SDK_PARAGRAPH); }); diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index d00ba03b8a..65d74be116 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -76,9 +76,11 @@ describe("antigravity CCA envelope", () => { expect(req.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); }); - test("exposes Gemini 3.7 Flash while retired Flash ids resolve to it", async () => { - // Collapsed picker: base models only. + test("exposes Gemini 3.8 and 3.7 Flash while retired Flash ids resolve to 3.7", async () => { + // Collapsed picker: base models only. 3.8 leads because CCA ranks it first in the + // Recommended sort; 3.7 stays because Google still serves it. expect(ANTIGRAVITY_MODELS).toEqual([ + "gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.1-pro", "gemini-3.1-flash-image", @@ -127,6 +129,38 @@ describe("antigravity CCA envelope", () => { } }); + test("static and discovered resolution agree for every 3.8 effort", () => { + // These two paths answered differently for the same input: discovery clamped max/xhigh/ + // ultra to `high` before its lookup, while static resolution failed the `in effortMap` + // test and fell back to the medium default. Same request, two tiers, decided by whether + // discovery happened to have run. Asserting the paths SEPARATELY is what let that hide, + // so this compares them directly. + const modelIds = ["gemini-3.8-flash-low", "gemini-3.8-flash-medium", "gemini-3.8-flash-high"]; + const rows = parseAntigravityAvailableModels({ + models: Object.fromEntries(modelIds.map(id => [id, { maxTokens: 1_048_576 }])), + agentModelSorts: [{ groups: [{ modelIds }] }], + })!; + expect(rows.map(model => model.id)).toEqual(["gemini-3.8-flash"]); + + const baseUrl = "https://cca-38-parity.example"; + registerAntigravityDiscoveredWireModels(baseUrl, rows); + for (const effort of [undefined, "low", "medium", "high", "xhigh", "max", "ultra"]) { + expect(resolveAntigravityEffortWireModel("gemini-3.8-flash", effort, baseUrl)) + .toEqual(resolveAntigravityEffortWireModel("gemini-3.8-flash", effort)); + } + }); + + test("a partial 3.8 ladder stays as raw wire rows instead of a half-collapsed model", () => { + // A collapsed row promises three rungs. Publishing it from two would advertise a tier the + // backend did not offer, so the degradation is deliberate: raw suffix ids remain routable. + const modelIds = ["gemini-3.8-flash-low", "gemini-3.8-flash-high"]; + const rows = parseAntigravityAvailableModels({ + models: Object.fromEntries(modelIds.map(id => [id, { maxTokens: 1_048_576 }])), + agentModelSorts: [{ groups: [{ modelIds }] }], + })!; + expect(rows.map(model => model.id)).toEqual(modelIds); + }); + test("collapses a complete CCA Gemini tier set but retains partial sets as wire IDs", () => { const payload = (modelIds: string[]) => ({ models: Object.fromEntries(modelIds.map(id => [id, { maxTokens: 1_048_576 }])), diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 509f61e606..a187b9c3d2 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -774,7 +774,12 @@ describe("google provider hardening", () => { const vertex = PROVIDER_REGISTRY.find(entry => entry.id === "google-vertex"); expect(google?.defaultModel).toBe("gemini-3.5-flash"); - expect(google?.models).toEqual(["gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3.7-flash"]); + expect(google?.models).toEqual(["gemini-3.8-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3.7-flash"]); + expect(google?.modelContextWindows?.["gemini-3.8-flash"]).toBe(1_048_576); + expect(google?.modelInputModalities?.["gemini-3.8-flash"]).toEqual(["text", "image"]); + // `minimal` is a documented validation error on this generation, so the ladder must not + // inherit the shape its 3.5/3.6/3.7 neighbours carry. + expect(google?.modelReasoningEfforts?.["gemini-3.8-flash"]).toEqual(["low", "medium", "high"]); expect(google?.modelContextWindows?.["gemini-3.6-flash"]).toBe(1_048_576); expect(google?.modelContextWindows?.["gemini-3.5-flash"]).toBe(1_000_000); expect(google?.modelContextWindows?.["gemini-3.7-flash"]).toBe(1_048_576); @@ -787,8 +792,10 @@ describe("google provider hardening", () => { expect(google?.modelReasoningEfforts?.["gemini-3.5-flash"]).toEqual([ "minimal", "low", "medium", "high", ]); + // 3.7 and 3.8 exclude `minimal`: Google documents it as a validation error on both model + // pages, so advertising it would offer a rung the API rejects. expect(google?.modelReasoningEfforts?.["gemini-3.7-flash"]).toEqual([ - "minimal", "low", "medium", "high", + "low", "medium", "high", ]); expect(google?.modelReasoningEfforts?.["gemini-3.1-pro-preview"]).toEqual([ "low", "medium", "high", diff --git a/tests/google-models-listing.test.ts b/tests/google-models-listing.test.ts index 39900f8159..5a36700063 100644 --- a/tests/google-models-listing.test.ts +++ b/tests/google-models-listing.test.ts @@ -357,7 +357,7 @@ describe("google models listing via catalog", () => { expect(seen[0].url).toBe("https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000"); expect(seen[0].headers["x-goog-api-key"]).toBe("gk-123"); const ids = models.filter(m => m.provider === "google").map(m => m.id); - expect(ids).toEqual(["gemini-3.1-pro-preview", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.6-flash", "gemini-3.7-flash"]); + expect(ids).toEqual(["gemini-3.1-pro-preview", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"]); expect(ids).not.toContain("gemini-3-pro"); expect(ids).not.toContain("gemini-3-flash"); expect(getStaleCached("google")).toBeNull(); diff --git a/tests/grok-models-effort-list.test.ts b/tests/grok-models-effort-list.test.ts index 863bb8c754..c64964cef7 100644 --- a/tests/grok-models-effort-list.test.ts +++ b/tests/grok-models-effort-list.test.ts @@ -123,6 +123,9 @@ describe("raw /v1/models list reasoning-effort advertisement (Grok Build discove expect("supports_reasoning_effort" in plain!).toBe(false); expect("reasoning_effort" in plain!).toBe(false); expect("reasoning_efforts" in plain!).toBe(false); + const capabilities = plain!.capabilities as Record; + expect(capabilities.supports_reasoning).toBe(false); + expect("reasoning_effort" in capabilities).toBe(false); } finally { await server.stop(true); } diff --git a/tests/grok-sync.test.ts b/tests/grok-sync.test.ts index c38132bd0e..0b6fadd2af 100644 --- a/tests/grok-sync.test.ts +++ b/tests/grok-sync.test.ts @@ -248,6 +248,32 @@ describe("syncGrokConfig", () => { } }); + test("a hub targets its unauthenticated loopback listener instead of skipping (#3306)", async () => { + const { root, grokHome } = tempGrokHome(); + try { + const config = { + ...baseConfig, + runtimeRole: "hub", + hostname: "100.64.0.10", + unauthenticatedLoopbackListener: { enabled: true, port: 10102 }, + } as OcxConfig; + const result = await syncGrokConfig(10100, config, { + grokHome, + hostname: "100.64.0.10", + }, { + fetchAllModels: async () => [], + injectGrokConfig, + }); + + expect(result).toMatchObject({ ok: true, changed: true }); + const content = readFileSync(join(grokHome, "config.toml"), "utf8"); + expect(content).toContain('base_url = "http://127.0.0.1:10102/v1"'); + expect(content).not.toContain("100.64.0.10"); + } finally { + removeTreeWithRetry(root); + } + }); + test("catalog failure surfaces ok:false without touching the config", async () => { const { root, grokHome } = tempGrokHome(); try { diff --git a/tests/helpers/ci-watchdog.ts b/tests/helpers/ci-watchdog.ts index a794d7fe3e..f8adcfe3d9 100644 --- a/tests/helpers/ci-watchdog.ts +++ b/tests/helpers/ci-watchdog.ts @@ -22,3 +22,31 @@ export function watchdogMs(base: number): number { if (process.env.CI !== "true") return base; return Math.max(base, process.platform === "win32" ? 45_000 : 30_000); } + +/** + * Scale a *product* timing budget that a test deliberately shortened. + * + * `watchdogMs` bounds how long a test may run. This is the other half: a budget the code + * under test enforces on itself, which a test shrinks to keep the suite fast. + * + * The CL-07 fabric tests cut the producer's inactivity budget from 5 s to 750 ms so a + * hang fails in under a second. That is fine in isolation and wrong under load: the + * budget starts when the parent spawns a Bun child, and spawning one while the rest of + * the suite saturates the CPU can take longer than 750 ms by itself. The child is then + * killed for inactivity before it has run a line, and the test reports whatever the + * harness makes of a killed producer — `inactivity_timeout` where it expected + * `sandbox_violation`, or `blocked` where it expected `pass`. + * + * That failure mode is deterministic under contention, not random: eight parallel runs of + * the file reproduced five failures each, while a single run passes 49/49. It surfaced as + * a "flake" only because it needs a busy machine. + * + * A shortened budget must therefore keep enough headroom for process startup. The floor + * is the same shape as `watchdogMs`: unchanged for a lone local run, generous when the + * machine is busy. Windows spawns slowest, so it gets the larger floor. + */ +export function isolationBudgetMs(base: number): number { + const underLoad = process.env.CI === "true" || process.env.OCX_TEST_FULL_SUITE === "1"; + if (!underLoad) return base; + return Math.max(base, process.platform === "win32" ? 8_000 : 5_000); +} diff --git a/tests/helpers/enforce-pr-target-harness.ts b/tests/helpers/enforce-pr-target-harness.ts index b6d90da52f..af295cb678 100644 --- a/tests/helpers/enforce-pr-target-harness.ts +++ b/tests/helpers/enforce-pr-target-harness.ts @@ -242,6 +242,15 @@ export type RunOptions = { previous_filename?: string; }> >; + /** + * Commit messages on the pull request branch, read by the carry-attribution + * assessor. The squash body is assembled from the description and these, so + * a `Co-authored-by` trailer can legitimately live in either. + * + * Defaults to one commit carrying the PR title, which is what a + * single-commit branch looks like. + */ + commitMessages?: string[]; /** * GraphQL query fragments that should reject. Unlike `failOn: ["graphql"]`, * which fails the review-threads read, this lets a test fail a specific @@ -664,6 +673,8 @@ export async function runEnforcePrTarget( options.filePages ?? (options.files && options.files.length > 0 ? [options.files] : [[]]); const listedFileCount = filePages.flat().length; + const commitMessages = + options.commitMessages ?? [String((options.pr as { title?: string })?.title ?? "")]; const prInput = options.pr as Record; if (Object.prototype.hasOwnProperty.call(prInput, "changed_files")) { (pr as Record).changed_files = prInput.changed_files; @@ -783,6 +794,14 @@ export async function runEnforcePrTarget( const page = Number((args as { page?: number })?.page ?? 1); return respond("pulls.listFiles", args, filePages[page - 1] ?? []); }, + listCommits: (args: unknown) => { + const page = Number((args as { page?: number })?.page ?? 1); + return respond( + "pulls.listCommits", + args, + page === 1 ? commitMessages.map(message => ({ commit: { message } })) : [], + ); + }, }, issues: { // Honours `page`, so a caller that skips `paginate` sees only page one — diff --git a/tests/integrations-writer.test.ts b/tests/integrations-writer.test.ts index 25c78c48bf..54c64875ac 100644 --- a/tests/integrations-writer.test.ts +++ b/tests/integrations-writer.test.ts @@ -360,6 +360,31 @@ describe("apply", () => { expect(after.provider.opencodex!.models["mystery/model"]!.limit).toBeUndefined(); }); + test("ZCode on a hub writes and recognizes the unauthenticated loopback listener (#3306)", () => { + const configPath = installZcode(); + const request = input({ + clientId: "zcode", + config: { + ...CONFIG, + runtimeRole: "hub", + hostname: "100.64.0.10", + unauthenticatedLoopbackListener: { enabled: true, port: 10102 }, + }, + }); + + const result = applyIntegration(request); + expect(result.ok).toBe(true); + + const document = JSON.parse(readFileSync(configPath, "utf8")) as { + provider: Record; + }; + expect(document.provider.opencodex!.options).toMatchObject({ + apiKey: "opencodex-loopback", + baseURL: "http://127.0.0.1:10102/v1", + }); + expect(readIntegrationState(request)).toMatchObject({ state: "current" }); + }); + test("ZCode key-order normalization stays refreshable with derived metadata (#2759)", () => { const configPath = installZcode(); const models: ExportModel[] = [ diff --git a/tests/kiro-pool-rank.test.ts b/tests/kiro-pool-rank.test.ts index b23e05286a..b66b917ed3 100644 --- a/tests/kiro-pool-rank.test.ts +++ b/tests/kiro-pool-rank.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { clearGenericFailoverHealth, forgetGenericFailoverRoster, + genericFailoverRetryAfterSeconds, preferredInitialAccount, rotateGenericOAuthAccountOn429, } from "../src/oauth/generic-account-failover"; @@ -146,16 +147,16 @@ describe("pre-dispatch account preference", () => { const originalHome = process.env.OPENCODEX_HOME; let home: string; - async function seedAccounts(count: number): Promise { + async function seedAccounts(count: number, providerName = "xai"): Promise { for (let i = 0; i < count; i++) { - await saveCredential("xai", { + await saveCredential(providerName, { access: `access-${i}`, refresh: `refresh-${i}`, expires: Date.now() + 3_600_000, accountId: `uuid-${i}`, } as never, { addAccount: true }); } - return getAccountSet("xai")?.accounts.map(a => a.id) ?? []; + return getAccountSet(providerName)?.accounts.map(a => a.id) ?? []; } test("the account with more headroom is chosen before the first request", async () => { @@ -238,6 +239,109 @@ describe("pre-dispatch account preference", () => { } }); + test("an exhausted account without Retry-After stays cooled through its reset window", async () => { + home = mkdtempSync(join(tmpdir(), "ocx-predispatch-")); + process.env.OPENCODEX_HOME = home; + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + try { + const ids = await seedAccounts(2, "kiro"); + const now = Date.now(); + seedExhausted(ids[0]!, now + 60 * 60_000); + const kiroConfig = { + providers: { kiro: OAUTH_PROVIDER }, + } as unknown as OcxConfig; + + expect(rotateGenericOAuthAccountOn429(kiroConfig, "kiro", ids[0]!, null, now)).toBe(ids[1]); + expect(genericFailoverRetryAfterSeconds("kiro", now)).toBe(60 * 60); + } finally { + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); + } + }); + + test("an unparseable Retry-After uses an exhausted account reset", async () => { + home = mkdtempSync(join(tmpdir(), "ocx-predispatch-")); + process.env.OPENCODEX_HOME = home; + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + try { + const ids = await seedAccounts(2, "kiro"); + const now = Date.now(); + seedExhausted(ids[0]!, now + 60 * 60_000); + const kiroConfig = { + providers: { kiro: OAUTH_PROVIDER }, + } as unknown as OcxConfig; + + expect( + rotateGenericOAuthAccountOn429(kiroConfig, "kiro", ids[0]!, "not-a-duration", now), + ).toBe(ids[1]); + expect(genericFailoverRetryAfterSeconds("kiro", now)).toBe(60 * 60); + } finally { + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); + } + }); + + test("valid immediate Retry-After values override an exhausted account reset", async () => { + home = mkdtempSync(join(tmpdir(), "ocx-predispatch-")); + process.env.OPENCODEX_HOME = home; + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + try { + const ids = await seedAccounts(2, "kiro"); + const now = Date.now(); + seedExhausted(ids[0]!, now + 60 * 60_000); + const kiroConfig = { + providers: { kiro: OAUTH_PROVIDER }, + } as unknown as OcxConfig; + + for (const retryAfter of ["0", new Date(now - 1_000).toUTCString()]) { + clearGenericFailoverHealth(); + expect( + rotateGenericOAuthAccountOn429(kiroConfig, "kiro", ids[0]!, retryAfter, now), + ).toBe(ids[1]); + expect(genericFailoverRetryAfterSeconds("kiro", now)).toBe(1); + } + } finally { + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); + } + }); + + test("a valid Retry-After overrides an exhausted account reset", async () => { + home = mkdtempSync(join(tmpdir(), "ocx-predispatch-")); + process.env.OPENCODEX_HOME = home; + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + try { + const ids = await seedAccounts(2, "kiro"); + const now = Date.now(); + seedExhausted(ids[0]!, now + 60 * 60_000); + const kiroConfig = { + providers: { kiro: OAUTH_PROVIDER }, + } as unknown as OcxConfig; + + expect(rotateGenericOAuthAccountOn429(kiroConfig, "kiro", ids[0]!, "120", now)).toBe(ids[1]); + expect(genericFailoverRetryAfterSeconds("kiro", now)).toBe(120); + } finally { + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); + } + }); + test("a quota-less provider is never redirected, even when the ACTIVE account is cooled", async () => { // The inverse of the case above, and the one that actually broke the no-op guarantee: // cooling the active account collapses the eligible list to a single candidate, which diff --git a/tests/lab-fabric-task.test.ts b/tests/lab-fabric-task.test.ts index 68ea26a954..e82a494913 100644 --- a/tests/lab-fabric-task.test.ts +++ b/tests/lab-fabric-task.test.ts @@ -58,7 +58,7 @@ import { minimalFabricChildEnv, setFabricProducerIsolationLimitsForTests } from import { taskSubjectApplicableToRequirements } from "../src/lab/projection/verification"; import { createHostIssuedFabricPatchExecutor } from "../src/lib/fabric-task-host"; import type { TrustedFabricPatchExecutor } from "../src/lab/fabric/types"; -import { watchdogMs } from "./helpers/ci-watchdog"; +import { isolationBudgetMs, watchdogMs } from "./helpers/ci-watchdog"; import { fabricCorrectPatchExecutor, fabricMockRoute, @@ -87,9 +87,27 @@ async function terminateChildWithin(child: Bun.Subprocess): Promise { } const CREDENTIAL_CANARY = "credential-canary-abcdefghijklmnopqrstuvwxyz1234567890"; +/* + * Shortened so a hung producer fails in about a second instead of the product's 30 s / 5 s. + * + * The budgets are scaled under load. They start counting when the parent spawns a Bun + * CHILD, and spawning one while the rest of the suite saturates the CPU can exceed 750 ms + * on its own — the child is then killed for inactivity before running a line, and the + * assertion sees `inactivity_timeout` or `blocked` instead of the outcome it set up. + * Deterministic under contention, not random: eight parallel runs of this file reproduced + * five failures each while a lone run passes 49/49. + */ +const FAST_FABRIC_INACTIVITY_MS = isolationBudgetMs(750); const FAST_FABRIC_ISOLATION = Object.freeze({ - totalTimeoutMs: 2_000, - inactivityTimeoutMs: 750, + /* + * The total budget must stay a fixed MULTIPLE of the inactivity budget, not a fixed + * number. `fabricActivityPatchExecutor` deliberately sleeps 40% of the inactivity + * budget three times to prove that activity resets the deadline — so the run needs + * ~1.2x inactivity to finish, and pinning the total at 2 s while inactivity scales up + * would starve exactly the test that exercises the scaling. + */ + totalTimeoutMs: Math.max(2_000, Math.round(FAST_FABRIC_INACTIVITY_MS * 2.5)), + inactivityTimeoutMs: FAST_FABRIC_INACTIVITY_MS, }); const HOMES: string[] = []; diff --git a/tests/legacy-shell-compat.test.ts b/tests/legacy-shell-compat.test.ts index 9fcc217b0e..60b3fb4449 100644 --- a/tests/legacy-shell-compat.test.ts +++ b/tests/legacy-shell-compat.test.ts @@ -43,6 +43,28 @@ describe("code-mode helper compatibility", () => { expect(received).toEqual({ workdir: "/tmp", cmd: "pwd" }); }); + test("write_stdin arguments remain data and target the nested helper", async () => { + const args = { + session_id: 17, + chars: "`); throw new Error('escaped') //", + yield_time_ms: 1_000, + }; + const source = compileCodeModeHelperInput(JSON.stringify(args), "write_stdin"); + let received: unknown; + let output: unknown; + const run = new AsyncFunction("tools", "text", source); + + await run({ + write_stdin: async (value: unknown) => { + received = value; + return { output: "more" }; + }, + }, (value: unknown) => { output = value; }); + + expect(received).toEqual(args); + expect(output).toEqual({ output: "more" }); + }); + test("apply_patch text remains one string argument", async () => { const patch = "*** Begin Patch\n*** Add File: note.txt\n+`); throw new Error('escaped')\n*** End Patch"; const source = compileCodeModeHelperInput(patch, "apply_patch"); diff --git a/tests/memory-watchdog.test.ts b/tests/memory-watchdog.test.ts index 2e75b4cc51..edc9cc757a 100644 --- a/tests/memory-watchdog.test.ts +++ b/tests/memory-watchdog.test.ts @@ -19,6 +19,7 @@ import { } from "../src/lib/app-owned-memory"; import { registerDefaultAppOwnedMemoryStores } from "../src/lib/app-owned-memory-stores"; import { appendDebugLogLine, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; +import { resetUsageAggregateCacheForTests } from "../src/server/management/usage-aggregate-cache"; function config(): OcxConfig { return { @@ -39,6 +40,7 @@ afterEach(() => { getActiveMemoryWatchdog()?.stop(); resetAppOwnedMemoryForTests(); resetDebugLogBufferForTests(); + resetUsageAggregateCacheForTests(); }); function sampleAt(at: number, rssMb: number, externalMb = 1, arrayBuffersMb = 1): MemorySampleBase { 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/meta-muse-oauth.test.ts b/tests/meta-muse-oauth.test.ts new file mode 100644 index 0000000000..d735b3f952 --- /dev/null +++ b/tests/meta-muse-oauth.test.ts @@ -0,0 +1,228 @@ +/** + * Meta Muse Code credential provider (`meta-muse`). + * + * This provider reuses the API key the Muse Code CLI stores — a credential Meta scopes + * to its own CLI. It exists because the repository owner authorized it explicitly, and + * the tests below pin the guards that make that choice informed rather than silent: + * the warning fires before any read, the note discloses what is unsupported, the login + * cannot be spawned or refreshed into a different identity, and the credential never + * reaches a message or a status object. + */ +import { describe, expect, test } from "bun:test"; +import { OAUTH_PROVIDERS } from "../src/oauth"; +import { loginMetaMuse, refreshMetaMuseToken } from "../src/oauth/meta-muse"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { supportsPerAccountQuota } from "../src/providers/quota"; +import { routeModel } from "../src/router"; +import type { OcxConfig } from "../src/types"; + +const MODELS = ["muse-spark-1.3", "muse-spark-1.3-contributor"] as const; + +/** A synthetic key of the measured grammar. Assembled at runtime, never a real value. */ +const CANARY = `LLM|${"1".repeat(16)}|${"c".repeat(27)}`; + +function entry() { + const found = getProviderRegistryEntry("meta-muse"); + if (!found) throw new Error("missing meta-muse registry entry"); + return found; +} + +function pointer(overrides: Record = {}): string { + return JSON.stringify({ + schema_version: 2, + providers: { meta: { mechanism: "oauth", storage: "keychain", user_email: "Someone@Example.COM", ...overrides } }, + }); +} + +const okFetch = (async () => new Response(JSON.stringify({ data: [] }), { status: 200 })) as unknown as typeof fetch; + +function deps(over: Partial[1]> = {}) { + return { + platform: "darwin", + readPointer: async () => pointer(), + readKeychain: async () => JSON.stringify({ secret_schema_version: 1, api_key: CANARY, access_token: "x".repeat(280) }), + fetchImpl: okFetch, + ...over, + }; +} + +describe("meta-muse registry entry", () => { + test("routes to Meta's Responses endpoint as an OAuth provider", () => { + expect(entry().baseUrl).toBe("https://api.meta.ai/v1"); + expect(entry().adapter).toBe("openai-responses"); + expect(entry().authKind).toBe("oauth"); + expect(entry().oauthId).toBe("meta-muse"); + }); + + test("reuses the ladder, window, modalities and identity wire map from the key provider", () => { + for (const id of MODELS) { + expect(entry().modelReasoningEfforts?.[id]).toEqual(["minimal", "low", "medium", "high", "xhigh"]); + expect(entry().modelReasoningEffortMap?.[id]?.minimal).toBe("minimal"); + expect(entry().modelContextWindows?.[id]).toBe(1_048_576); + expect(entry().modelInputModalities?.[id]).toEqual(["text", "image"]); + } + }); + + test("keeps live discovery off — the real roster carries image and voice models", () => { + expect(entry().liveModels).toBeFalsy(); + expect(entry().models).toEqual([...MODELS]); + }); + + test("the note discloses every unsupported-use fact a user needs before opting in", () => { + const note = entry().note ?? ""; + expect(note).toContain("UNSUPPORTED"); + expect(note).toContain("treat every call as billable"); + expect(note).toContain("auth store"); + // The env-var trap: Meta calls it MODEL_API_KEY, opencodex reads META_MODEL_API_KEY. + expect(note).toContain("META_MODEL_API_KEY"); + // Must not promise quota display that only wp5 delivers. + expect(note).toContain("does not yet read or display it"); + }); + + test("never generates unattended traffic on a vendor-restricted credential", () => { + expect(OAUTH_PROVIDERS["meta-muse"]?.defaultRefreshPolicy).toBe("disabled"); + }); + + /* + * supportsPerAccountQuota gates fetchAccountQuota, whose fallback sends any + * non-Kiro/non-Antigravity bearer to Anthropic's usage endpoint. Flipping this without + * a dedicated branch would ship a Meta key to Anthropic. + */ + test("stays out of the per-account quota probe path", () => { + expect(supportsPerAccountQuota("meta-muse")).toBe(false); + }); + + test("does not capture the live command-code meta/ model namespace", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "command-code", + providers: { + "command-code": { adapter: "command-code", baseUrl: "https://api.commandcode.ai", apiKey: "cc", authMode: "key" }, + "meta-muse": { adapter: "openai-responses", baseUrl: "https://api.meta.ai/v1", apiKey: "mm", authMode: "oauth" }, + }, + }; + expect(routeModel(config, "meta/muse-spark-1.3").providerName).toBe("command-code"); + expect(routeModel(config, "meta-muse/muse-spark-1.3").providerName).toBe("meta-muse"); + }); +}); + +describe("meta-muse credential import", () => { + test("warns before it reads anything", async () => { + const seen: string[] = []; + let readPointerCalled = false; + await loginMetaMuse( + { onProgress: m => seen.push(m) }, + deps({ readPointer: async () => { readPointerCalled = true; expect(seen.length).toBeGreaterThan(0); return pointer(); } }), + ); + expect(readPointerCalled).toBe(true); + const warning = seen[0] ?? ""; + expect(warning).toContain("UNSUPPORTED"); + expect(warning).toContain("billable"); + expect(warning).toContain("meta-model"); + }); + + test("imports the api_key, not the access_token that 401s", async () => { + const creds = await loginMetaMuse({}, deps()); + expect(creds.access).toBe(CANARY); + expect(creds.refresh).toBe(CANARY); + expect(creds.expires).toBe(Number.MAX_SAFE_INTEGER); + expect(creds.source).toBe("local-cli"); + }); + + test("carries a normalized email, and no accountId, so the display mask applies", async () => { + const creds = await loginMetaMuse({}, deps()); + expect(creds.email).toBe("someone@example.com"); + expect(creds.accountId).toBeUndefined(); + }); + + test("a controller without a signal still logs in", async () => { + // AbortSignal.any([undefined, ...]) throws; the CLI controller supplies no signal. + await expect(loginMetaMuse({}, deps())).resolves.toBeDefined(); + }); + + test("an aborted controller signal aborts the login", async () => { + const ac = new AbortController(); + ac.abort(); + const failing = (async () => { throw new DOMException("aborted", "AbortError"); }) as unknown as typeof fetch; + await expect(loginMetaMuse({ signal: ac.signal }, deps({ fetchImpl: failing }))).rejects.toThrow(); + }); + + /* + * `security` can raise an interactive approval prompt that nobody answers on a headless + * or locked machine. That read happens BEFORE the validation timeout is created, so + * without its own deadline the login would hang with no bound at all. + */ + test("a blocked Keychain read fails instead of hanging", async () => { + const started = Date.now(); + await expect(loginMetaMuse({}, deps({ + // Mimics the real reader's contract: it resolves null once its deadline fires. + readKeychain: async () => null, + }))).rejects.toThrow(/within 5s/); + expect(Date.now() - started).toBeLessThan(5_000); + }); + + test("the caller's abort signal is handed to the Keychain reader", async () => { + const ac = new AbortController(); + let received: AbortSignal | undefined; + await loginMetaMuse({ signal: ac.signal }, deps({ + readKeychain: async (signal) => { + received = signal; + return JSON.stringify({ api_key: CANARY }); + }, + })); + expect(received).toBe(ac.signal); + }); + + for (const [label, over] of [ + ["a non-darwin platform", { platform: "linux" }], + ["no credential file", { readPointer: async () => null }], + ["a malformed credential file", { readPointer: async () => "{not json" }], + ["no signed-in Meta account", { readPointer: async () => JSON.stringify({ providers: {} }) }], + ["an unverified storage backend", { readPointer: async () => pointer({ storage: "file" }) }], + ["an unreadable keychain", { readKeychain: async () => null }], + ["a malformed keychain payload", { readKeychain: async () => "{not json" }], + ["a payload with only an access_token", { readKeychain: async () => JSON.stringify({ access_token: "x".repeat(280) }) }], + ["a key of the wrong shape", { readKeychain: async () => JSON.stringify({ api_key: "not-a-meta-key" }) }], + ] as const) { + test(`refuses ${label} with an actionable message`, async () => { + await expect(loginMetaMuse({}, deps(over as never))).rejects.toThrow(); + }); + } + + test("a rejected credential fails without echoing it", async () => { + const denied = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; + await expect(loginMetaMuse({}, deps({ fetchImpl: denied }))).rejects.toThrow(/401/); + }); + + /* + * The canary must never appear anywhere a human or a log can read it. This is the + * assertion that would catch a well-meaning "include the key in the error for + * debugging" change. + */ + test("no failure path echoes the credential", async () => { + const denied = (async () => new Response("nope", { status: 401 })) as unknown as typeof fetch; + const progress: string[] = []; + let message = ""; + try { + await loginMetaMuse({ onProgress: m => progress.push(m) }, deps({ fetchImpl: denied })); + } catch (error) { + message = String((error as Error).message) + String((error as Error).stack ?? ""); + } + expect(message).not.toContain(CANARY); + for (const line of progress) expect(line).not.toContain(CANARY); + }); +}); + +describe("meta-muse refresh", () => { + test("returns the same static key and reads no credential store", async () => { + // Re-importing here would let a DIFFERENT Muse account silently overwrite this slot. + const creds = await refreshMetaMuseToken(CANARY); + expect(creds.access).toBe(CANARY); + expect(creds.refresh).toBe(CANARY); + expect(creds.expires).toBe(Number.MAX_SAFE_INTEGER); + }); + + test("an empty key is refused rather than replayed", async () => { + await expect(refreshMetaMuseToken("")).rejects.toThrow(/ocx login meta-muse/); + }); +}); diff --git a/tests/muse-spark-web-search-compat.test.ts b/tests/muse-spark-web-search-compat.test.ts index 59b589178a..e8f7dc491b 100644 --- a/tests/muse-spark-web-search-compat.test.ts +++ b/tests/muse-spark-web-search-compat.test.ts @@ -80,8 +80,42 @@ describe("#2617 Muse Spark web_search compatibility", () => { test("the registry routes only the named exact models to Responses", () => { const defaults = getProviderRegistryEntry("opencode-go")?.modelWireDefaults ?? {}; + expect(defaults["muse-spark-1.3-contributor"]).toBe("openai-responses"); expect(defaults["muse-spark-1.2-contributor"]).toBe("openai-responses"); // An exact-model allowlist, not a family rule: a sibling must not be dragged along. expect(defaults["muse-spark-1.2"]).toBeUndefined(); + expect(defaults["muse-spark-1.3"]).toBeUndefined(); + }); + + /** + * 1.3 shipped 2026-09-02 on the same Zen wire with the same spec as 1.2. The guard + * used to be an equality check on the 1.2 id, so selecting 1.3 would have sent the + * refused field straight through and 400ed every Codex web_search request. + */ + test("Muse Spark 1.3 Contributor gets the same web_search sanitization", () => { + const body = build("muse-spark-1.3-contributor", { tools: [webSearchTool()] }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search"); + expect(tool.search_context_size).toBe("medium"); + expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + }); + + test("1.3 keeps the field on web_search_preview, where the gateway accepts it", () => { + const body = build("muse-spark-1.3-contributor", { + tools: [{ ...webSearchTool(), type: "web_search_preview" }], + }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search_preview"); + expect(tool.search_content_types).toEqual(["text", "image"]); + }); + + test("a nested additional_tools declaration is sanitized for 1.3 too", () => { + const body = build("muse-spark-1.3-contributor", { + input: [{ type: "additional_tools", tools: [webSearchTool()] }], + }); + const item = (body.input as Array>)[0]!; + const nested = (item.tools as Array>)[0]!; + expect(nested.type).toBe("web_search"); + expect(Object.hasOwn(nested, "search_content_types")).toBe(false); }); }); diff --git a/tests/oauth-provider-reconcile.test.ts b/tests/oauth-provider-reconcile.test.ts index 08e94c1dda..f57c70b6d5 100644 --- a/tests/oauth-provider-reconcile.test.ts +++ b/tests/oauth-provider-reconcile.test.ts @@ -79,8 +79,9 @@ describe("OAuth provider reconciliation", () => { expect(reconcileOAuthProviders(config)).toBe(true); const provider = config.providers["google-antigravity"]; - expect(provider.defaultModel).toBe("gemini-3.7-flash"); + expect(provider.defaultModel).toBe("gemini-3.8-flash"); expect(provider.models).toEqual([ + "gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.1-pro", "gemini-3.1-flash-image", @@ -93,6 +94,7 @@ describe("OAuth provider reconciliation", () => { expect(provider.models).not.toContain("gemini-3.6-flash-low"); expect(provider.models).not.toContain("gemini-3.6-flash-medium"); expect(provider.models).not.toContain("gemini-3.6-flash-high"); + expect(provider.modelContextWindows?.["gemini-3.8-flash"]).toBe(1_048_576); expect(provider.modelContextWindows?.["gemini-3.7-flash"]).toBe(1_048_576); expect(provider.liveModels).toBe(true); expect(provider.project).toBe("config-project-sentinel"); @@ -104,7 +106,7 @@ describe("OAuth provider reconciliation", () => { }); const persisted = loadConfig(); - expect(persisted.providers["google-antigravity"]?.defaultModel).toBe("gemini-3.7-flash"); + expect(persisted.providers["google-antigravity"]?.defaultModel).toBe("gemini-3.8-flash"); expect(persisted.providers["google-antigravity"]?.liveModels).toBe(true); expect(reconcileOAuthProviders(config)).toBe(false); }); @@ -139,7 +141,40 @@ describe("OAuth provider reconciliation", () => { upsertOAuthProvider(config, "google-antigravity"); expect(config.providers["google-antigravity"].liveModels).toBe(true); - expect(config.providers["google-antigravity"].models).toHaveLength(6); + expect(config.providers["google-antigravity"].models).toHaveLength(7); + }); + + test("an explicit 3.7 default survives the 3.8 launch while its capabilities refresh", () => { + // The 3.5 case above starts from a RETIRED id, so it only exercises the stale-default + // healing branch. This one is the opposite claim, and the one that matters for an + // additive rollout: a user who deliberately chose 3.7 must still be on 3.7 afterwards. + // Google still serves it, so healing it onto 3.8 would be silently overriding a choice. + saveCredential("google-antigravity", { access: "a", refresh: "r", projectId: "p" }); + const config = { + port: 10100, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + defaultModel: "gemini-3.7-flash", + models: ["gemini-3.7-flash"], + liveModels: true, + }, + }, + } satisfies OcxConfig; + + reconcileOAuthProviders(config); + const provider = config.providers["google-antigravity"]; + + expect(provider.defaultModel).toBe("gemini-3.7-flash"); + expect(provider.models).toContain("gemini-3.7-flash"); + expect(provider.models).toContain("gemini-3.8-flash"); + // Capability records still refresh from the registry — preservation is about the + // user's CHOICE, not about freezing the row. + expect(provider.modelReasoningEfforts?.["gemini-3.8-flash"]).toEqual(["low", "medium", "high"]); }); test("preserves an explicit Antigravity static opt-out without the legacy migration marker", () => { diff --git a/tests/oauth-tos-warning.test.ts b/tests/oauth-tos-warning.test.ts index 699eaddb15..2859b14711 100644 --- a/tests/oauth-tos-warning.test.ts +++ b/tests/oauth-tos-warning.test.ts @@ -9,6 +9,9 @@ describe("oauth ToS risk map", () => { test("flags high-risk subscription OAuth providers", () => { expect(oauthTosRisk("anthropic")).toBe("high"); expect(oauthTosRisk("google-antigravity")).toBe("high"); + // Meta restricts the Muse Code credential to its own CLI in writing, which is the + // documented difference between "high" and "elevated". + expect(oauthTosRisk("meta-muse")).toBe("high"); expect(oauthTosRisk("Anthropic")).toBe("high"); expect(oauthTosRisk(" anthropic ")).toBe("high"); }); @@ -22,6 +25,8 @@ describe("oauth ToS risk map", () => { expect(oauthTosRisk("xai")).toBeNull(); expect(oauthTosRisk("kimi")).toBeNull(); expect(oauthTosRisk("kiro")).toBeNull(); + // The supported Meta path: the user's own key on a documented endpoint. + expect(oauthTosRisk("meta-model")).toBeNull(); expect(oauthTosRisk("")).toBeNull(); expect(oauthTosRisk(" ")).toBeNull(); }); diff --git a/tests/openai-provider-option-e2e.test.ts b/tests/openai-provider-option-e2e.test.ts index 424fa103c1..d21276754b 100644 --- a/tests/openai-provider-option-e2e.test.ts +++ b/tests/openai-provider-option-e2e.test.ts @@ -27,12 +27,14 @@ type Capture = { }; type MigrationReceipt = { + aclSeamCalls: number; backupMatchesOriginal: boolean; backupMode: number; v1BackupUnchanged: boolean; firstProviderIds: string[]; firstDefaultProvider: string; mode: string; + principalSeamCalls: number; hiddenLegacy: boolean; marker: number; selectedModels: string[]; @@ -50,6 +52,14 @@ type MigrationReceipt = { collisionFailsBeforeSave: boolean; }; +const ACL_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; +const PRINCIPAL_OK = { + success: true, + exitCode: 0, + timedOut: false, + stdout: "S-1-5-21-1-2-3-1001\nocx-provider-option-e2e\n", +}; + function hashTree(path: string): string { const hash = createHash("sha256"); if (!existsSync(path)) return hash.update("absent").digest("hex"); @@ -122,8 +132,12 @@ describe("OpenAI provider-option integration spine", () => { CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, }; const savedFetch = globalThis.fetch; + const savedWebSocket = globalThis.WebSocket; + const blockedUpstreamWebSocketUrls: string[] = []; const captures: Capture[] = []; const resets: Array<() => void> = []; + let aclSeamCalls = 0; + let principalSeamCalls = 0; let loopbackOrigin: string | null = null; let server: { url: URL; stop(closeActiveConnections?: boolean): Promise } | null = null; @@ -162,6 +176,13 @@ describe("OpenAI provider-option integration spine", () => { }) + "\n", { mode: 0o600 }); chmodSync(authPath, 0o600); + globalThis.WebSocket = new Proxy(savedWebSocket, { + construct(_target, args) { + const url = String(args[0]); + blockedUpstreamWebSocketUrls.push(url); + throw new Error(`deny-by-default WebSocket blocked: ${url}`); + }, + }) as typeof WebSocket; globalThis.fetch = (async (input, init) => { const request = new Request(input, init); const url = new URL(request.url); @@ -231,6 +252,8 @@ describe("OpenAI provider-option integration spine", () => { serverModule, mainAccount, sidecar, + windowsAcl, + windowsPrincipal, ] = await Promise.all([ import("../src/config"), import("../src/providers/derive"), @@ -245,8 +268,27 @@ describe("OpenAI provider-option integration spine", () => { import("../src/server"), import("../src/codex/main-account"), import("../src/providers/openai-sidecar"), + import("../src/lib/windows-secret-acl"), + import("../src/lib/windows-user-principal"), ]); + windowsAcl.setIcaclsRunnerForTests(() => { + aclSeamCalls += 1; + return ACL_OK; + }); + windowsAcl.setAsyncIcaclsRunnerForTests(async () => { + aclSeamCalls += 1; + return ACL_OK; + }); + windowsPrincipal.setWindowsPrincipalRunnerForTests(() => { + principalSeamCalls += 1; + return PRINCIPAL_OK; + }); + windowsPrincipal.setAsyncWindowsPrincipalRunnerForTests(async () => { + principalSeamCalls += 1; + return PRINCIPAL_OK; + }); + resets.push( requestLog.clearRequestLogsForTests, catalog.resetCatalogRuntimeStateForTests, @@ -258,6 +300,12 @@ describe("OpenAI provider-option integration spine", () => { websocketRegistry.clearCodexWebSocketRegistry, () => authApi.clearAccountNeedsReauth("fixture-pool"), () => authApi.clearAccountNeedsReauth(mainAccount.MAIN_CODEX_ACCOUNT_ID), + () => windowsAcl.setIcaclsRunnerForTests(null), + () => windowsAcl.setAsyncIcaclsRunnerForTests(null), + windowsAcl.resetHardenedStateForTests, + () => windowsPrincipal.setWindowsPrincipalRunnerForTests(null), + () => windowsPrincipal.setAsyncWindowsPrincipalRunnerForTests(null), + windowsPrincipal.resetWindowsPrincipalForTests, ); const seed = (id: string) => deriveModule.providerConfigSeed( @@ -351,10 +399,9 @@ describe("OpenAI provider-option integration spine", () => { expect(captures.at(-1)).toMatchObject({ authorization: "Bearer fixture-pool-access", accountId: "fixture-pool-account" }); expect(captures.at(-1)?.body.reasoning).toBeUndefined(); - const NativeWebSocket = globalThis.WebSocket; const expectedWsUrl = new URL("/v1/responses", server.url); expectedWsUrl.protocol = "ws:"; - const ws = new NativeWebSocket(expectedWsUrl, { + const ws = new savedWebSocket(expectedWsUrl, { headers: { authorization: "Bearer fixture-caller-main" }, } as unknown as string[]); await new Promise((resolve, reject) => { @@ -542,12 +589,14 @@ describe("OpenAI provider-option integration spine", () => { expect(exitCode).toBe(0); const receipt = JSON.parse(stdout) as MigrationReceipt; expect(receipt).toEqual({ + aclSeamCalls: expect.any(Number), backupMatchesOriginal: true, backupMode: expect.any(Number), v1BackupUnchanged: true, firstProviderIds: ["openai", "openai-apikey", "custom"], firstDefaultProvider: "openai", mode: "pool", + principalSeamCalls: expect.any(Number), hiddenLegacy: true, marker: 2, selectedModels: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"], @@ -566,11 +615,21 @@ describe("OpenAI provider-option integration spine", () => { }); if (process.platform !== "win32") { expect(receipt.backupMode).toBe(0o600); + } else { + expect(receipt.aclSeamCalls).toBeGreaterThan(0); + expect(receipt.principalSeamCalls).toBeGreaterThan(0); } } finally { removeTreeWithRetry(migrationRoot); } + expect(new Set(blockedUpstreamWebSocketUrls)).toEqual(new Set([ + "wss://chatgpt.com/backend-api/codex/responses", + ])); + if (process.platform === "win32") { + expect(aclSeamCalls).toBeGreaterThan(0); + expect(principalSeamCalls).toBeGreaterThan(0); + } expect(captures.every(capture => upstreamTuples.has(`${capture.method} ${capture.url}`))).toBe(true); const evidenceDir = process.env.OCX_EVIDENCE_DIR; if (evidenceDir) { @@ -595,6 +654,7 @@ describe("OpenAI provider-option integration spine", () => { if (server) await server.stop(true); } finally { globalThis.fetch = savedFetch; + globalThis.WebSocket = savedWebSocket; for (const reset of resets) reset(); restoreEnv("OPENCODEX_HOME", previousEnv.OPENCODEX_HOME); restoreEnv("CODEX_HOME", previousEnv.CODEX_HOME); diff --git a/tests/opencode-go-muse-context.test.ts b/tests/opencode-go-muse-context.test.ts index a387696d71..56bdc9dcc2 100644 --- a/tests/opencode-go-muse-context.test.ts +++ b/tests/opencode-go-muse-context.test.ts @@ -14,6 +14,7 @@ import { providerConfigSeed } from "../src/providers/derive"; import type { OcxProviderConfig } from "../src/types"; const MUSE_MODEL = "muse-spark-1.2-contributor"; +const MUSE_13_MODEL = "muse-spark-1.3-contributor"; const MUSE_CONTEXT = 1_048_576; /** Seeded OpenCode Go provider config for the Muse Spark context assertions. */ @@ -51,4 +52,19 @@ describe("OpenCode Go Muse Spark context window", () => { }); expect(hinted.contextWindow).toBe(MUSE_CONTEXT); }); + + // 1.3 is the same-shaped successor on the same Zen Go roster. Without its own + // entry it would fall back to the 128k unknown-window default — the same + // regression these tests exist to prevent for 1.2. + test("Muse Spark 1.3 Contributor declares and exposes the same 1M window", () => { + const entry = PROVIDER_REGISTRY.find(e => e.id === "opencode-go"); + expect(entry?.modelContextWindows?.[MUSE_13_MODEL]).toBe(MUSE_CONTEXT); + const prov = opencodeGo(); + expect(prov.modelContextWindows?.[MUSE_13_MODEL]).toBe(MUSE_CONTEXT); + const hinted = applyProviderConfigHints("opencode-go", prov, { + id: MUSE_13_MODEL, + provider: "opencode-go", + }); + expect(hinted.contextWindow).toBe(MUSE_CONTEXT); + }); }); diff --git a/tests/opencode-go-muse-vision.test.ts b/tests/opencode-go-muse-vision.test.ts index d915b9292a..b8eca82a6c 100644 --- a/tests/opencode-go-muse-vision.test.ts +++ b/tests/opencode-go-muse-vision.test.ts @@ -15,6 +15,7 @@ import { providerConfigSeed } from "../src/providers/derive"; import type { OcxProviderConfig } from "../src/types"; const MUSE_MODEL = "muse-spark-1.2-contributor"; +const MUSE_13_MODEL = "muse-spark-1.3-contributor"; /** Seeded OpenCode Go provider config for the Muse Spark vision assertions. */ function opencodeGo(): OcxProviderConfig { @@ -73,4 +74,25 @@ describe("OpenCode Go Muse Spark image input (#vision)", () => { }); expect(hinted.inputModalities).toEqual(["text", "image"]); }); + + // 1.3 shipped 2026-09-02 on the same Zen Go roster with the same spec as 1.2. + // Zen publishes no modality metadata, so without its own declaration the newer + // model would regress to the exact text-only block 1.2 was fixed for. + test("Muse Spark 1.3 Contributor carries the same text+image declaration", () => { + const entry = PROVIDER_REGISTRY.find(e => e.id === "opencode-go"); + expect(entry?.modelInputModalities?.[MUSE_13_MODEL]).toEqual(["text", "image"]); + const prov = opencodeGo(); + expect(prov.modelInputModalities?.[MUSE_13_MODEL]).toEqual(["text", "image"]); + expect(prov.noVisionModels ?? []).not.toContain(MUSE_13_MODEL); + }); + + test("1.3's configured declaration overrides a text-only discovered row", () => { + const prov = opencodeGo(); + const hinted = applyProviderConfigHints("opencode-go", prov, { + id: MUSE_13_MODEL, + provider: "opencode-go", + inputModalities: ["text"], + }); + expect(hinted.inputModalities).toEqual(["text", "image"]); + }); }); diff --git a/tests/privacy-scan-meta-key.test.ts b/tests/privacy-scan-meta-key.test.ts new file mode 100644 index 0000000000..c8259e966f --- /dev/null +++ b/tests/privacy-scan-meta-key.test.ts @@ -0,0 +1,40 @@ +/** + * The privacy scanner must recognize a Meta Model API key. + * + * The `meta-muse` provider imports one of these from the Muse Code CLI, and the plan + * names `privacy:scan` as the gate that would catch it if it ever escaped into a tracked + * file. The pre-existing `token-looking` pattern matches `sk-`, `ghp_` and JWTs — none + * of which resemble Meta's `LLM||` shape. + * + * This exercises the REAL `scanText` used by `bun run privacy:scan`, not a copy of its + * regex: a test that re-declared the pattern would keep passing after the production + * detector was deleted. + */ +import { describe, expect, test } from "bun:test"; +import { scanText } from "../scripts/privacy-scan"; + +/** Assembled at runtime so this file contains no secret-shaped literal of its own. */ +const canary = ["LLM", "1".repeat(16), "c".repeat(27)].join("|"); + +describe("privacy scan: Meta API keys", () => { + test("flags a Meta-shaped key in a tracked file", () => { + const findings = scanText("src/example.ts", `const key = "${canary}";`); + expect(findings.some(f => f.kind === "meta-api-key")).toBe(true); + }); + + test("the pre-existing token patterns would have missed it", () => { + const findings = scanText("src/example.ts", `const key = "${canary}";`); + // Proves the new detector is doing the work, not an incidental match. + expect(findings.some(f => f.kind === "token-looking")).toBe(false); + }); + + test("ordinary prose mentioning the prefix is not a finding", () => { + const findings = scanText("docs/example.md", "Meta keys start with an LLM| prefix."); + expect(findings.some(f => f.kind === "meta-api-key")).toBe(false); + }); + + test("a Bearer header carrying one is still caught", () => { + const findings = scanText("src/example.ts", `Authorization: Bearer ${canary}`); + expect(findings.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/provider-config-batch-management.test.ts b/tests/provider-config-batch-management.test.ts new file mode 100644 index 0000000000..250c1e5afd --- /dev/null +++ b/tests/provider-config-batch-management.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as configModule from "../src/config"; +import { getConfigPath, loadConfig, saveConfig } from "../src/config"; +import * as destinationPolicy from "../src/lib/destination-policy"; +import { safeConfigDTO } from "../src/server/auth-cors"; +import { handleManagementAPI } from "../src/server/management-api"; +import type { OcxConfig } from "../src/types"; +import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; +import { ManagementRequest as Request } from "./helpers/management-auth"; +import { removeTreeWithRetry } from "./helpers/remove-tree"; + +type EditorConfig = { + defaultProvider: string; + providers: Record>; +}; + +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let testDir: string; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function seededConfig(): OcxConfig { + return { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "alpha", + providers: { + alpha: { + adapter: "openai-chat", + baseUrl: "https://alpha.example.test/v1", + defaultModel: "alpha-old", + apiKey: "sk-alpha-secret", + apiKeyPool: [{ id: "alpha-main", key: "sk-alpha-secret", label: "primary" }], + headers: { "x-private": "private-value", "x-private-two": "private-value-two" }, + project: "private-alpha-project", + }, + beta: { + adapter: "anthropic", + baseUrl: "https://beta.example.test/v1", + defaultModel: "beta-old", + apiKey: "sk-beta-secret", + headers: { "x-beta-private": "keep-me" }, + project: "private-beta-project", + }, + }, + }; +} + +function editorBaseline(config: OcxConfig): EditorConfig { + return { + defaultProvider: config.defaultProvider, + providers: Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [name, { + adapter: provider.adapter, + baseUrl: provider.baseUrl, + ...(provider.defaultModel === undefined ? {} : { defaultModel: provider.defaultModel }), + ...(provider.project === undefined ? {} : { project: provider.project }), + }])), + }; +} + +async function putBatch(liveConfig: OcxConfig, body: unknown, onCatalog = () => {}): Promise { + const request = new Request("http://127.0.0.1/api/providers", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return handleManagementAPI(request, new URL(request.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(onCatalog), + }); +} + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), "ocx-provider-batch-")); + mkdirSync(testDir, { recursive: true }); + process.env.OPENCODEX_HOME = testDir; + isolatedCodexHome = installIsolatedCodexHome("ocx-provider-batch-codex-"); +}); + +afterEach(() => { + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + removeTreeWithRetry(testDir); +}); + +describe("atomic provider editor batch", () => { + test("round-trips an unchanged realistic provider config without rewriting values or secrets", async () => { + const liveConfig: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "woong", + providers: { + woong: { + adapter: "openai-chat", + baseUrl: "https://woong.example.test/v1", + defaultModel: "woong-reasoner", + note: "private deployment", + modelContextWindows: { "woong-reasoner": 131_072 }, + modelMaxInputTokens: { "woong-reasoner": 120_000 }, + modelReasoningEfforts: { "woong-reasoner": ["low", "medium", "high"] }, + noVisionModels: ["woong-reasoner"], + allowPrivateNetwork: true, + apiKey: "sk-woong-secret", + apiKeyPool: [{ id: "woong-main", key: "sk-woong-secret", label: "primary" }], + headers: { "x-tenant-token": "tenant-secret" }, + mcpServers: { + private: { + url: "https://mcp.example.test", + headers: { authorization: "Bearer mcp-secret" }, + }, + }, + desktopExecutor: { + computerUseCommand: "private-runner", + env: { ACCESS_TOKEN: "desktop-secret" }, + }, + }, + }, + }; + const publicRow = (safeConfigDTO(liveConfig) as { + providers: Record>; + }).providers.woong!; + for (const field of [ + "apiKey", + "apiKeyPool", + "headers", + "mcpServers", + "desktopExecutor", + "modelMaxInputTokens", + ]) { + expect(publicRow).not.toHaveProperty(field); + } + expect(JSON.stringify(publicRow)).not.toContain("secret"); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline: EditorConfig = { + defaultProvider: "woong", + providers: { + woong: { + adapter: "openai-chat", + baseUrl: "https://woong.example.test/v1", + defaultModel: "woong-reasoner", + note: "private deployment", + modelContextWindows: { "woong-reasoner": 131_072 }, + modelReasoningEfforts: { "woong-reasoner": ["low", "medium", "high"] }, + noVisionModels: ["woong-reasoner"], + allowPrivateNetwork: true, + }, + }, + }; + + const destinationSpy = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + let response: Response | null; + try { + response = await putBatch(liveConfig, { baseline, next: structuredClone(baseline) }); + } finally { + destinationSpy.mockRestore(); + } + + expect(response?.status).toBe(200); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + expect(loadConfig().providers.woong).toEqual(liveConfig.providers.woong); + expect(loadConfig().providers.woong).toMatchObject({ + apiKey: "sk-woong-secret", + apiKeyPool: [{ id: "woong-main", key: "sk-woong-secret", label: "primary" }], + headers: { "x-tenant-token": "tenant-secret" }, + }); + }); + + test("updates several providers in one commit and preserves credentials and private fields", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const baseline = editorBaseline(liveConfig); + const next: EditorConfig = structuredClone(baseline); + next.defaultProvider = "beta"; + next.providers.alpha!.defaultModel = "alpha-new"; + next.providers.beta!.baseUrl = "https://beta-new.example.test/v1"; + next.providers.beta!.defaultModel = "beta-new"; + next.providers.gamma = { + adapter: "openai-chat", + baseUrl: "https://gamma.example.test/v1", + defaultModel: "gamma-1", + }; + + let catalogRefreshes = 0; + const destinationSpy = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + const mutationSpy = spyOn(configModule, "mutatePersistedConfig"); + try { + const response = await putBatch(liveConfig, { baseline, next }, () => { catalogRefreshes += 1; }); + expect(response?.status).toBe(200); + expect(mutationSpy).toHaveBeenCalledTimes(1); + } finally { + mutationSpy.mockRestore(); + destinationSpy.mockRestore(); + } + + const persisted = loadConfig(); + expect(persisted.defaultProvider).toBe("beta"); + expect(persisted.providers.alpha).toMatchObject({ + defaultModel: "alpha-new", + apiKey: "sk-alpha-secret", + apiKeyPool: [{ id: "alpha-main", key: "sk-alpha-secret", label: "primary" }], + headers: { "x-private": "private-value", "x-private-two": "private-value-two" }, + project: "private-alpha-project", + }); + expect(persisted.providers.beta).toMatchObject({ + baseUrl: "https://beta-new.example.test/v1", + defaultModel: "beta-new", + apiKey: "sk-beta-secret", + headers: { "x-beta-private": "keep-me" }, + project: "private-beta-project", + }); + expect(persisted.providers.gamma).toEqual(next.providers.gamma); + expect(liveConfig.defaultProvider).toBe("beta"); + expect(liveConfig.providers).toEqual(persisted.providers); + expect(catalogRefreshes).toBe(1); + }); + + test("rejects one invalid row with zero persisted or live change", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const beforeLive = structuredClone(liveConfig); + const baseline = editorBaseline(liveConfig); + const next = structuredClone(baseline); + next.providers.alpha!.defaultModel = "must-not-land"; + next.providers.beta!.baseUrl = "not a URL"; + let catalogRefreshes = 0; + + const response = await putBatch(liveConfig, { baseline, next }, () => { catalogRefreshes += 1; }); + + expect(response?.status).toBe(400); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + expect(liveConfig).toEqual(beforeLive); + expect(catalogRefreshes).toBe(0); + }); + + test("rejects derived public markers instead of persisting them", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline = editorBaseline(liveConfig); + + for (const [field, value] of [ + ["hasApiKey", true], + ["hasHeaders", true], + ["xaiResponsesOptInState", true], + ["virtualModels", { "alpha-pro": { wireModelId: "alpha", reasoningMode: "pro" } }], + ] as const) { + const next = structuredClone(baseline); + next.providers.alpha![field] = structuredClone(value); + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ code: "invalid_provider_editor_field" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + expect(loadConfig().providers.alpha).not.toHaveProperty(field); + } + }); + + test("rejects runtime-derived provider metadata as editor write authority", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline = editorBaseline(liveConfig); + const next = structuredClone(baseline); + next.providers.alpha!.modelMaxInputTokens = { "alpha-old": 128_000 }; + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ code: "invalid_provider_editor_field" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + expect(loadConfig().providers.alpha).not.toHaveProperty("modelMaxInputTokens"); + }); + + test("rejects credential-bearing provider fields as editor write authority", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline = editorBaseline(liveConfig); + + for (const [field, value] of [ + ["apiKey", "sk-attacker-write"], + ["apiKeyPool", [{ id: "attacker", key: "sk-attacker-write" }]], + ["headers", { authorization: "Bearer attacker-write" }], + ["mcpServers", { attacker: { url: "https://mcp.example.test", headers: { authorization: "Bearer attacker-write" } } }], + ["desktopExecutor", { computerUseCommand: "runner", env: { ACCESS_TOKEN: "attacker-write" } }], + ] as const) { + const next = structuredClone(baseline); + next.providers.alpha![field] = structuredClone(value); + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ code: "invalid_provider_editor_field" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + } + }); + + test("rejects unknown provider fields instead of creating hidden write authority", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const beforeBytes = readFileSync(getConfigPath(), "utf8"); + const baseline = editorBaseline(liveConfig); + const next = structuredClone(baseline); + next.providers.alpha!.runtimeExtension = { token: "attacker-write" }; + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(400); + expect(await response?.json()).toMatchObject({ code: "invalid_provider_editor_field" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeBytes); + }); + + test("returns 409 and preserves a concurrent edit when baseline is stale", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const baseline = editorBaseline(liveConfig); + const next = structuredClone(baseline); + next.providers.alpha!.defaultModel = "stale-write"; + + const concurrent = loadConfig(); + concurrent.providers.alpha!.defaultModel = "concurrent-write"; + saveConfig(concurrent); + const concurrentBytes = readFileSync(getConfigPath(), "utf8"); + + const response = await putBatch(liveConfig, { baseline, next }); + + expect(response?.status).toBe(409); + expect(await response?.json()).toMatchObject({ code: "stale_provider_editor_baseline" }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(concurrentBytes); + expect(loadConfig().providers.alpha?.defaultModel).toBe("concurrent-write"); + }); + + test("keeps the full-config PUT disabled", async () => { + const liveConfig = seededConfig(); + saveConfig(liveConfig); + const request = new Request("http://127.0.0.1/api/config", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(liveConfig), + }); + + const response = await handleManagementAPI(request, new URL(request.url), liveConfig); + + expect(response?.status).toBe(405); + expect(await response?.json()).toEqual({ + error: "Full config PUT is disabled. Use /api/providers POST for provider changes.", + }); + }); +}); diff --git a/tests/provider-model-discovery-contract.test.ts b/tests/provider-model-discovery-contract.test.ts index f4a088b730..a78977d161 100644 --- a/tests/provider-model-discovery-contract.test.ts +++ b/tests/provider-model-discovery-contract.test.ts @@ -246,16 +246,23 @@ describe("registry-owned provider model discovery", () => { }); test("accepts only positive safe-integer token limits from live metadata", () => { + expect(catalogHintsFromModelsApiItem("example", { + id: "valid-output", + capabilities: { max_output_tokens: 8192 }, + })).toEqual({ maxOutputTokens: 8192 }); + expect(catalogHintsFromModelsApiItem("example", { id: "fractional", context_size: 1_000, max_input_tokens: 0.5, + capabilities: { max_output_tokens: 0.5 }, })).toEqual({ contextWindow: 1_000 }); expect(catalogHintsFromModelsApiItem("example", { id: "unsafe", context_size: Number.MAX_SAFE_INTEGER + 1, max_input_tokens: Number.MAX_SAFE_INTEGER + 1, + max_output_tokens: Number.MAX_SAFE_INTEGER + 1, })).toEqual({}); }); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 5ea6539dd6..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", @@ -760,7 +760,7 @@ describe("provider registry parity", () => { expect(antigravityRegistry?.liveModels).toBe(true); expect(providerConfigSeed(antigravityRegistry!).liveModels).toBe(true); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.liveModels).toBe(true); - expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.defaultModel).toBe("gemini-3.7-flash"); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.defaultModel).toBe("gemini-3.8-flash"); // Collapsed picker: base models only, no effort-suffix variants. expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gemini-3.7-flash"); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gemini-3.1-pro"); @@ -768,13 +768,16 @@ describe("provider registry parity", () => { expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("claude-opus-4-6-thinking"); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gpt-oss-120b-medium"); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gemini-3.1-flash-image"); - expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toHaveLength(6); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gemini-3.8-flash"); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toHaveLength(7); // Effort ladders on collapsed base models. + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["gemini-3.8-flash"]).toEqual(["low", "medium", "high"]); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["gemini-3.7-flash"]).toEqual(["low", "medium", "high"]); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["gemini-3.1-pro"]).toEqual(["low", "high"]); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["claude-opus-4-6-thinking"]).toEqual(["low", "medium", "high", "max"]); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["claude-sonnet-4-6"]).toEqual(["low", "medium", "high", "max"]); // Context windows on collapsed base models. + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelContextWindows?.["gemini-3.8-flash"]).toBe(1_048_576); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelContextWindows?.["gemini-3.7-flash"]).toBe(1_048_576); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelContextWindows?.["gemini-3.1-pro"]).toBe(1_048_576); // Suffix and compat IDs are NOT in the picker list. diff --git a/tests/provider-workspace-auth.test.ts b/tests/provider-workspace-auth.test.ts index b26d9f3fc2..6e8fd045d8 100644 --- a/tests/provider-workspace-auth.test.ts +++ b/tests/provider-workspace-auth.test.ts @@ -153,7 +153,13 @@ describe("workspace account integration seam", () => { ]); expect(page).toContain("onReauth:"); expect(page).toContain("onCancelLogin: cancelLoginOAuth"); - expect(page).toContain("loginOAuth(provider, true, accountId)"); + // Reauth reaches login through the ToS-warning gate rather than calling loginOAuth + // directly: a high-risk provider (anthropic, google-antigravity, meta-muse) must show + // its warning before a REauthentication too, not only before the first login. + // `requestLoginOAuth` is the warning-aware entry point and forwards the same + // (provider, addAccount, accountId) triple. + expect(page).toContain("requestLoginOAuth(provider, true, accountId)"); + expect(page).toContain("void loginOAuth(pending.provider, pending.addAccount, pending.accountId)"); expect(page).toContain("accountId: reauthTargetId, reauth: true"); expect(page).toContain("prov.reauthIdentityMismatch"); expect(page).toContain("oauthLoginGenerationRef"); diff --git a/tests/remote-catalog.test.ts b/tests/remote-catalog.test.ts index 62ee966d63..3ac893a457 100644 --- a/tests/remote-catalog.test.ts +++ b/tests/remote-catalog.test.ts @@ -8,6 +8,56 @@ function response(body: string, headers: HeadersInit = JSON_HEADERS): Response { } describe("remote catalog adversarial consumer", () => { + test("allows a catalog download to exceed five seconds while bytes keep arriving", async () => { + const chunks = ['{"models":[', '{"slug":"provider/model"}', ']}']; + const server = Bun.serve({ + port: 0, + fetch() { + let index = 0; + return new Response(new ReadableStream({ + start(controller) { + const send = () => { + const chunk = chunks[index++]; + if (chunk === undefined) return controller.close(); + controller.enqueue(new TextEncoder().encode(chunk)); + if (index < chunks.length) setTimeout(send, 2_600); + else controller.close(); + }; + send(); + }, + }), { headers: JSON_HEADERS }); + }, + }); + try { + const result = await downloadClientCatalog(`http://127.0.0.1:${server.port}`, "ocx_data_test"); + expect(JSON.parse(result.body)).toEqual({ models: [{ slug: "provider/model" }] }); + } finally { + server.stop(true); + } + }, { timeout: 8_000 }); + + test("fails a stalled catalog download within the explicit inactivity bound", async () => { + const server = Bun.serve({ + port: 0, + fetch() { + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"models":[')); + }, + }), { headers: JSON_HEADERS }); + }, + }); + const startedAt = performance.now(); + try { + await expect(downloadClientCatalog(`http://127.0.0.1:${server.port}`, "ocx_data_test", { + timeoutMs: 50, + })).rejects.toMatchObject({ code: "unreachable" }); + expect(performance.now() - startedAt).toBeLessThan(1_000); + } finally { + server.stop(true); + } + }); + test("accepts additive fields only after the required model schema and key id pass", async () => { const body = JSON.stringify({ models: [{ slug: "provider/model", future: { enabled: true } }], futureTop: 1 }); const result = await downloadClientCatalog("https://hub.example.test", "ocx_data_test", { diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index ba00963d3f..d739d95942 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -150,6 +150,74 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test("restores streamed write_stdin arguments through unified exec", () => { + const rewrite = createRoutedCustomToolRestoreBlockRewrite( + new Set(["exec"]), + undefined, + new Set(), + new Set(["exec"]), + ); + const added = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { + type: "function_call", + id: "fc_stdin_alias", + call_id: "call_stdin_alias", + name: "write_stdin", + arguments: "", + status: "in_progress", + }, + })); + expect(dataPayload(added[0]!).item).toMatchObject({ type: "custom_tool_call", name: "exec" }); + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_stdin_alias", + delta: '{"session_id":17,"yield_time_ms":1000}', + }))).toEqual([]); + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_stdin_alias", + arguments: '{"session_id":17,"yield_time_ms":1000}', + })); + expect(dataPayload(done[0]!)).toMatchObject({ + type: "response.custom_tool_call_input.done", + input: compileCodeModeHelperInput( + '{"session_id":17,"yield_time_ms":1000}', + "write_stdin", + ), + }); + rewrite.dispose?.(); + }); + + test("restores a non-streaming write_stdin call through unified exec", () => { + const upstream = JSON.stringify({ + id: "resp_stdin", + output: [{ + type: "function_call", + id: "fc_stdin", + call_id: "call_stdin", + name: "write_stdin", + arguments: '{"session_id":17,"yield_time_ms":1000}', + status: "completed", + }], + }); + + const restored = JSON.parse(restoreRoutedCustomCallsInJson( + upstream, + new Set(["exec"]), + new Set(), + new Set(["exec"]), + )) as { output: Array> }; + expect(restored.output[0]).toMatchObject({ + type: "custom_tool_call", + name: "exec", + input: compileCodeModeHelperInput( + '{"session_id":17,"yield_time_ms":1000}', + "write_stdin", + ), + }); + }); + test("rewrites exec definitions and paired history without touching apply_patch", () => { const raw = { model: "deepseek-v4-flash", diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 070e5163cd..44c10db4e0 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -2236,7 +2236,7 @@ describe("Responses previous_response_id state", () => { await runPendingResponseStatePersistForTests(); - expect(attempts).toBe(4); + expect(attempts).toBe(1); expect(responseStatePersistPendingForTests()).toBe(true); setResponseStatePersistAttemptHookForTests(null); await runPendingResponseStatePersistForTests(); diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index 99a73cf8c1..2daf2a7ee6 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -593,6 +593,31 @@ describe("a refused turn does not become continuation state", () => { expect(JSON.stringify(expanded)).not.toContain('"name":"apply_patch"'); }); + test("a bridged write_stdin turn is remembered as the declared exec call", async () => { + const responseId = "resp_stdin_bridged"; + const accepted = await turn(responseId, { + type: "function_call", + id: "fc_stdin", + call_id: "call_stdin", + name: "write_stdin", + arguments: JSON.stringify({ session_id: 17, yield_time_ms: 1_000 }), + status: "completed", + }); + + expect(accepted.status).toBe(200); + const expanded = expandPreviousResponseInput({ + model: "fixture-model", + previous_response_id: responseId, + input: [{ role: "user", content: [{ type: "input_text", text: "continue" }] }], + tools: declaredTools, + }) as { input?: Array> }; + const rememberedCall = expanded.input?.find(item => item.call_id === "call_stdin"); + + expect(rememberedCall).toMatchObject({ type: "custom_tool_call", name: "exec" }); + expect(rememberedCall?.input).toContain("tools.write_stdin"); + expect(JSON.stringify(expanded)).not.toContain('"name":"write_stdin"'); + }); + test("a streamed bridged apply_patch turn is remembered as the declared exec call", async () => { const responseId = "resp_stream_apply_patch_bridged"; const call = { @@ -1415,7 +1440,7 @@ describe("empty and absent tool catalogs", () => { namespace: "mcp__functions", }); - for (const name of ["apply_patch", "exec_command", "shell_command"]) { + for (const name of ["apply_patch", "exec_command", "shell_command", "write_stdin"]) { const refused = await post( false, tools, @@ -1503,6 +1528,16 @@ describe("undeclaredToolCallNameInResponse", () => { expect(undeclaredToolCallNameInResponse(response, new Set())).toBe("exec_command"); }); + test("accepts write_stdin only through a bare unified exec declaration", () => { + const response = { + output: [{ type: "function_call", name: "write_stdin" }], + }; + + expect(undeclaredToolCallNameInResponse(response, new Set(["exec"]))).toBeUndefined(); + expect(undeclaredToolCallNameInResponse(response, new Set(["write_stdin"]))).toBeUndefined(); + expect(undeclaredToolCallNameInResponse(response, new Set())).toBe("write_stdin"); + }); + test("never legacy-normalizes a namespaced shell bridge call", () => { // A namespaced call (e.g. an MCP server advertising its own exec_command) must be // matched by its full wire name only — never normalized to bare `exec`. @@ -1526,7 +1561,7 @@ describe("undeclaredToolCallNameInResponse", () => { tools: [{ type: "namespace", name: "mcp", tools: [{ type: "function", name: "exec" }] }], }); - for (const name of ["exec_command", "shell_command", "apply_patch", "exec"]) { + for (const name of ["exec_command", "shell_command", "apply_patch", "write_stdin", "exec"]) { expect(undeclaredToolCallNameInResponse( { output: [{ type: "function_call", name, call_id: "call_1" }] }, declared, diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index ce6fbacc77..39053f39fb 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -440,6 +440,46 @@ describe("server combo failover 030 activation matrix", () => { expect(hits).toEqual(["a:m1:SECRET_PROMPT_X"]); }); + test("monthly quota then Orca free-prompt cap continues to a healthy third provider", async () => { + const hits: string[] = []; + const go = serve(async request => { + const body = await request.json() as { model?: string }; + hits.push(`go:${body.model}`); + return Response.json({ + error: { type: "GoUsageLimitError", message: "Monthly usage limit reached. Resets in 14 days." }, + }, { status: 429 }); + }); + const orca = serve(async request => { + const body = await request.json() as { model?: string }; + hits.push(`orca:${body.model}`); + return Response.json({ error: { + message: "This prompt is longer than the free tier allows for a single request.", + type: "invalid_request_error", + code: "free_rate_limited", + metadata: { reason: "err_free_prompt_cap" }, + } }, { status: 400 }); + }); + const backup = serve(async request => { + const body = await request.json() as { model?: string }; + hits.push(`backup:${body.model}`); + return chatSuccess("healthy fallback", "m3"); + }); + const config = comboConfig({ + a: provider("openai-chat", baseUrl(go), "key-a"), + b: provider("openai-chat", baseUrl(orca), "key-b"), + c: provider("openai-chat", baseUrl(backup), "key-c"), + }, [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + { provider: "c", model: "m3" }, + ]); + + const response = await post(config); + expect(response.status).toBe(200); + expect(JSON.stringify(await response.json())).toContain("healthy fallback"); + expect(hits).toEqual(["go:m1", "orca:m2", "backup:m3"]); + }); + test("ordinary openai-chat 503 hops to backup for non-stream and stream", async () => { const hits: string[] = []; const a = serve(async request => { @@ -844,6 +884,7 @@ describe("server combo failover 030 activation matrix", () => { liveModels: false, models: ["deepseek-chat"], modelContextWindows: { "deepseek-chat": 128_000 }, + modelMaxOutputTokens: { "deepseek-chat": 64_000 }, }), }, combo.targets, { alias: combo.alias }); saveConfig(config); @@ -853,7 +894,12 @@ describe("server combo failover 030 activation matrix", () => { const response = await fetch(new URL("/v1/models", server.url)); expect(response.status).toBe(200); const payload = await response.json() as { - data: Array<{ id: string; owned_by: string; is_combo?: boolean }>; + data: Array<{ + id: string; + owned_by: string; + is_combo?: boolean; + capabilities?: { max_output_tokens?: number }; + }>; }; return payload.data; }; @@ -868,6 +914,7 @@ describe("server combo failover 030 activation matrix", () => { const initialRows = (await publicRows()).filter(model => model.id === selector); expect(initialRows).toHaveLength(1); expect(initialRows[0]).toMatchObject({ id: selector, object: "model", created: 0, owned_by: "openai", is_combo: true }); + expect(initialRows[0]!.capabilities?.max_output_tokens).toBe(64_000); const renamed = await updateAlias("fast-chat"); expect(renamed.status).toBe(200); @@ -875,6 +922,7 @@ describe("server combo failover 030 activation matrix", () => { const renamedSelectorRows = renamedRows.filter(model => model.id === selector); expect(renamedSelectorRows).toHaveLength(1); expect(renamedSelectorRows[0]).toMatchObject({ id: selector, object: "model", created: 0, owned_by: "deepseek" }); + expect(renamedSelectorRows[0]!.capabilities?.max_output_tokens).toBe(64_000); expect(renamedSelectorRows[0].is_combo).toBeUndefined(); const renamedAliasRows = renamedRows.filter(model => model.id === "fast-chat"); expect(renamedAliasRows).toHaveLength(1); @@ -888,6 +936,7 @@ describe("server combo failover 030 activation matrix", () => { const deletedSelectorRows = deletedRows.filter(model => model.id === selector); expect(deletedSelectorRows).toHaveLength(1); expect(deletedSelectorRows[0]).toMatchObject({ id: selector, object: "model", created: 0, owned_by: "deepseek" }); + expect(deletedSelectorRows[0]!.capabilities?.max_output_tokens).toBe(64_000); expect(deletedSelectorRows[0].is_combo).toBeUndefined(); expect(deletedRows.some(model => model.is_combo === true)).toBe(false); } finally { diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index 293c0a7871..6e78651aa0 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -29,6 +29,7 @@ import { setUsageSummaryCacheEntry, usageSummaryRetainedStoreSnapshot, } from "../src/server/management/usage-summary-cache"; +import { resetUsageAggregateCacheForTests } from "../src/server/management/usage-aggregate-cache"; import { catalogConvergenceFactory } from "./helpers/catalog-convergence"; import { startupHealthFixture } from "./helpers/startup-health"; import { removeTreeWithRetry } from "./helpers/remove-tree"; @@ -80,6 +81,7 @@ function getSettings(config: OcxConfig): Promise { beforeEach(() => { resetAppOwnedMemoryForTests(); resetUsageSummaryCacheForTests(); + resetUsageAggregateCacheForTests(); invalidateStartupHealthCache(); TEST_DIR = mkdtempSync(join(tmpdir(), "ocx-settings-stream-")); process.env.OPENCODEX_HOME = TEST_DIR; @@ -88,6 +90,7 @@ beforeEach(() => { afterEach(() => { resetAppOwnedMemoryForTests(); resetUsageSummaryCacheForTests(); + resetUsageAggregateCacheForTests(); invalidateStartupHealthCache(); if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; @@ -240,6 +243,7 @@ describe("usage summary retained-store accounting", () => { identityKey: "slow-read", maxReadBytes: 64 * 1024 * 1024, overlayVersion: 0, + timeZone: seed!.timeZone, expiresAt: Date.now() + 60_000, freshUntil: Date.now() + 60_000, lastSeenSize: 0, diff --git a/tests/usage-aggregate-cache.test.ts b/tests/usage-aggregate-cache.test.ts new file mode 100644 index 0000000000..b55f008dfd --- /dev/null +++ b/tests/usage-aggregate-cache.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, + configureAppOwnedMemoryBudget, + enforceAppOwnedMemoryBudget, + registerRetainedStore, + resetAppOwnedMemoryForTests, +} from "../src/lib/app-owned-memory"; +import { APP_OWNED_RETAINED_STORE_REGISTRATIONS } from "../src/lib/app-owned-memory-stores"; +import { + getFilteredUsageAggregate, + getUsageAggregate, + resetUsageAggregateCacheForTests, + usageAggregateRetainedStats, + type UsageAggregateResult, +} from "../src/server/management/usage-aggregate-cache"; +import type { OcxConfig } from "../src/types/config"; +import { resetUsageReadCacheForTests, type PersistedUsageEntry } from "../src/usage/log"; +import * as usageLedgerScannerModule from "../src/usage/ledger-scanner"; +import { refreshUserCostOverlays } from "../src/usage/user-cost-overlays"; + +const NOW = Date.parse("2026-09-01T10:00:00.000Z"); + +let testDir = ""; +let previousHome: string | undefined; + +function entry(requestId: string): PersistedUsageEntry { + return { + requestId, + timestamp: NOW - 1_000, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 1, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + }; +} + +function line(requestId: string): string { + return `${JSON.stringify(entry(requestId))}\n`; +} + +function requests(result: UsageAggregateResult): number { + return result.accumulator.summarize("all", NOW).summary.requests; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-usage-aggregate-")); + process.env.OPENCODEX_HOME = testDir; + resetUsageAggregateCacheForTests(); + resetUsageReadCacheForTests(); + resetAppOwnedMemoryForTests(); + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); +}); + +afterEach(() => { + resetUsageAggregateCacheForTests(); + resetUsageReadCacheForTests(); + resetAppOwnedMemoryForTests(); + refreshUserCostOverlays({ providers: {} } as unknown as OcxConfig); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +describe("retained usage aggregate cache", () => { + test("settled filtered callers reuse a bounded retained aggregate", async () => { + writeFileSync(join(testDir, "usage.jsonl"), `${line("one")}${line("two")}`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + try { + const [first, concurrent] = await Promise.all([ + getFilteredUsageAggregate({ provider: " OpenAI " }), + getFilteredUsageAggregate({ provider: "openai" }), + ]); + const retained = await getFilteredUsageAggregate({ provider: "OPENAI" }); + const different = await getFilteredUsageAggregate({ provider: "anthropic" }); + + expect(scans).toBe(2); + expect(requests(first)).toBe(2); + expect(first.accumulator).toBe(concurrent.accumulator); + expect(retained.update).toBe("unchanged"); + expect(retained.accumulator).toBe(first.accumulator); + expect(requests(different)).toBe(0); + expect(usageAggregateRetainedStats().count).toBe(2); + } finally { + scanSpy.mockRestore(); + } + }); + + test("filtered retention invalidates when pricing inputs change", async () => { + writeFileSync(join(testDir, "usage.jsonl"), line("one")); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + try { + const first = await getFilteredUsageAggregate({ provider: "openai" }); + refreshUserCostOverlays({ + providers: { + openai: { + modelCosts: { + "gpt-5.5": { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 0.2 }, + }, + }, + }, + } as unknown as OcxConfig); + const refreshed = await getFilteredUsageAggregate({ provider: "openai" }); + + expect(scans).toBe(2); + expect(refreshed.update).toBe("rebuild"); + expect(refreshed.accumulator).not.toBe(first.accumulator); + expect(usageAggregateRetainedStats().count).toBe(1); + } finally { + scanSpy.mockRestore(); + } + }); + + test("filtered retention incrementally folds an ordinary append", async () => { + writeFileSync(join(testDir, "usage.jsonl"), line("one")); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); + try { + const first = await getFilteredUsageAggregate({ provider: "openai" }); + appendFileSync(join(testDir, "usage.jsonl"), line("two")); + const appended = await getFilteredUsageAggregate({ provider: "openai" }); + + expect(requests(first)).toBe(1); + expect(appended.update).toBe("append"); + expect(requests(appended)).toBe(2); + expect(scanStarts).toHaveLength(2); + expect(scanStarts[0]).toBe(0); + expect(scanStarts[1]).toBeGreaterThan(0); + } finally { + scanSpy.mockRestore(); + } + }); + + test("a missing ledger is retained as an unchanged empty aggregate", async () => { + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let scans = 0; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scans += 1; + return originalScan(options); + }); + try { + const first = await getUsageAggregate({ now: NOW }); + const second = await getUsageAggregate({ now: NOW }); + expect(scans).toBe(1); + expect(requests(first)).toBe(0); + expect(second.update).toBe("unchanged"); + expect(second.accumulator).toBe(first.accumulator); + } finally { + scanSpy.mockRestore(); + } + }); + + test("concurrent cold callers share one full base scan", async () => { + writeFileSync(join(testDir, "usage.jsonl"), `${line("one")}${line("two")}`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); + try { + const [first, second] = await Promise.all([ + getUsageAggregate({ now: NOW }), + getUsageAggregate({ now: NOW }), + ]); + expect(scanStarts).toEqual([0]); + expect(requests(first)).toBe(2); + expect(requests(second)).toBe(2); + expect(first.accumulator).toBe(second.accumulator); + } finally { + scanSpy.mockRestore(); + } + }); + + test("a shrink discards the checkpoint and performs a full rebuild", async () => { + writeFileSync(join(testDir, "usage.jsonl"), `${line("one")}${line("two")}${line("three")}`); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); + try { + const rebuilt = await getUsageAggregate({ now: NOW }); + expect(requests(rebuilt)).toBe(3); + + appendFileSync(join(testDir, "usage.jsonl"), line("four")); + const appended = await getUsageAggregate({ now: NOW }); + expect(appended.update).toBe("append"); + expect(requests(appended)).toBe(4); + + writeFileSync(join(testDir, "usage.jsonl"), line("new")); + const afterShrink = await getUsageAggregate({ now: NOW }); + expect(afterShrink.update).toBe("rebuild"); + expect(requests(afterShrink)).toBe(1); + expect(scanStarts).toHaveLength(3); + expect(scanStarts[0]).toBe(0); + expect(scanStarts[1]).toBeGreaterThan(0); + expect(scanStarts[2]).toBe(0); + } finally { + scanSpy.mockRestore(); + } + }); + + test("app-owned eviction makes the next caller perform a full rebuild", async () => { + writeFileSync(join(testDir, "usage.jsonl"), line("one")); + const usageStore = APP_OWNED_RETAINED_STORE_REGISTRATIONS + .find(registration => registration.id === "usage_snapshot"); + if (!usageStore) throw new Error("usage_snapshot retained-store registration is missing"); + registerRetainedStore(usageStore); + + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + scanStarts.push(options.startAtBytes ?? 0); + return originalScan(options); + }); + try { + await getUsageAggregate({ now: NOW }); + expect(usageAggregateRetainedStats().count).toBe(1); + + configureAppOwnedMemoryBudget(0); + enforceAppOwnedMemoryBudget(); + expect(usageAggregateRetainedStats().count).toBe(0); + + configureAppOwnedMemoryBudget(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES); + const rebuilt = await getUsageAggregate({ now: NOW }); + expect(rebuilt.update).toBe("rebuild"); + expect(requests(rebuilt)).toBe(1); + expect(scanStarts).toEqual([0, 0]); + } finally { + scanSpy.mockRestore(); + } + }); + + test("an oversized append result never publishes its partially-fed candidate", async () => { + writeFileSync(join(testDir, "usage.jsonl"), line("one")); + const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; + let forceOversizedAppend = false; + const scanStarts: number[] = []; + const scanSpy = spyOn(usageLedgerScannerModule, "scanUsageLedgerCooperatively") + .mockImplementation(async options => { + const start = options.startAtBytes ?? 0; + scanStarts.push(start); + const result = await originalScan(options); + return forceOversizedAppend && start > 0 + ? { ...result, oversizedRows: result.oversizedRows + 1 } + : result; + }); + try { + const original = await getUsageAggregate({ now: NOW }); + expect(requests(original)).toBe(1); + + appendFileSync(join(testDir, "usage.jsonl"), line("two")); + forceOversizedAppend = true; + await expect(getUsageAggregate({ now: NOW })).rejects.toThrow("oversized row"); + expect(requests(original)).toBe(1); + expect(usageAggregateRetainedStats().count).toBe(0); + + forceOversizedAppend = false; + const rebuilt = await getUsageAggregate({ now: NOW }); + expect(rebuilt.update).toBe("rebuild"); + expect(requests(rebuilt)).toBe(2); + expect(scanStarts).toHaveLength(3); + expect(scanStarts[0]).toBe(0); + expect(scanStarts[1]).toBeGreaterThan(0); + expect(scanStarts[2]).toBe(0); + } finally { + scanSpy.mockRestore(); + } + }); +}); diff --git a/tests/usage-cost.test.ts b/tests/usage-cost.test.ts index f6bf02d482..d166434a3d 100644 --- a/tests/usage-cost.test.ts +++ b/tests/usage-cost.test.ts @@ -195,10 +195,10 @@ describe("resolveMatchedPrice", () => { expect(price?.sourceRef).toContain("0.025x"); } expect(resolveMatchedPrice("anthropic-pb51d9b", "claude-fable-5-1")?.cost4).toEqual(COST4); - // Cursor seeds the id preemptively under three spellings and jawcode has no row, so - // each carries its own (derived) overlay rather than falling through to null. + // Cursor accepts all three spellings but pricing stores one canonical overlay row. for (const spelling of ["claude-fable-5-1", "claude-fable-5.1", "claude-5.1-fable"]) { expect(resolveMatchedPrice("cursor", spelling), spelling).toMatchObject({ cost4: COST4, source: "expected", status: "verified-derived" }); + expect(findExpectedPriceOverlay("cursor", spelling)?.modelId, spelling).toBe("claude-fable-5-1"); } // The cheaper cache-hit rate must not leak onto Fable 5, which stays at 0.1x. expect(resolveMatchedPrice("anthropic", "claude-fable-5")?.cost4.cacheRead).toBe(1); @@ -297,16 +297,14 @@ describe("resolveMatchedPrice", () => { expect(resolveMatchedPrice("openrouter", "anthropic-claude-3.5-sonnet")).toBeNull(); }); - test("16. shipped overlay membership: 61 keys, including Fable 5.1, Opus 5 and compatibility prices", () => { - expect(EXPECTED_PRICE_OVERLAYS.length).toBe(61); + test("16. shipped overlay membership: 68 keys, including canonical Fable 5.1, Opus 5 and compatibility prices", () => { + expect(EXPECTED_PRICE_OVERLAYS.length).toBe(68); 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 [ "anthropic/claude-fable-5-1", "anthropic-apikey/claude-fable-5-1", "cursor/claude-fable-5-1", - "cursor/claude-fable-5.1", - "cursor/claude-5.1-fable", "anthropic/claude-opus-5", "cursor/claude-opus-5", "kiro/claude-opus-5", @@ -317,6 +315,19 @@ describe("resolveMatchedPrice", () => { "minimax-cn/MiniMax-M2.1-highspeed", "deepseek/deepseek-chat", "deepseek/deepseek-reasoner", + "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", + // meta-muse reaches the same endpoint with the CLI credential; overlays resolve by + // exact provider id, so it needs its own rows or its cost column stays empty. + "meta-muse/muse-spark-1.3", + "meta-muse/muse-spark-1.3-contributor", + "google-antigravity/gemini-3.8-flash-high", + "google/gemini-3.8-flash", "google-antigravity/gemini-3.1-pro-low", "google-antigravity/gemini-3.1-pro-high", "google-antigravity/gemini-pro-agent", @@ -365,6 +376,8 @@ describe("resolveMatchedPrice", () => { "openai/daybreak-blue-latest", "openai/daybreak-red-latest", "openai-apikey/gpt-daybreak-blue-latest", + "cursor/claude-fable-5.1", + "cursor/claude-5.1-fable", ]) { expect(keys.has(impossible)).toBe(false); } diff --git a/tests/usage-ledger-scanner.test.ts b/tests/usage-ledger-scanner.test.ts new file mode 100644 index 0000000000..81021a2bec --- /dev/null +++ b/tests/usage-ledger-scanner.test.ts @@ -0,0 +1,498 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { appendFileSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + scanUsageLedgerCooperatively, + USAGE_LEDGER_BOUNDARY_DIGEST_BYTES, + USAGE_LEDGER_MAX_LINE_BYTES, + UsageLedgerRebuildRequiredError, +} from "../src/usage/ledger-scanner"; +import { usageLogIdentityKey, usageLogPath, type PersistedUsageEntry } from "../src/usage/log"; + +let testDir = ""; +let previousHome: string | undefined; + +function entry(requestId: string, overrides: Partial = {}): PersistedUsageEntry { + return { + requestId, + timestamp: 1, + provider: "openai", + model: "gpt-5.5", + status: 200, + durationMs: 1, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + ...overrides, + }; +} + +function line(requestId: string, overrides: Partial = {}): string { + return `${JSON.stringify(entry(requestId, overrides))}\n`; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + testDir = mkdtempSync(join(tmpdir(), "ocx-usage-ledger-scan-")); + process.env.OPENCODEX_HOME = testDir; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +describe("usage ledger cooperative scanner", () => { + test("a missing ledger is a complete empty snapshot", async () => { + const entries: PersistedUsageEntry[] = []; + const result = await scanUsageLedgerCooperatively({ onEntry: value => entries.push(value) }); + + expect(result).toMatchObject({ + revision: null, + parsedRows: 0, + invalidRows: 0, + oversizedRows: 0, + bytesRead: 0, + processedThroughBytes: 0, + }); + expect(result.processedThroughDigest).toHaveLength(64); + expect(entries).toEqual([]); + }); + + test("frames UTF-8 and CRLF rows before decoding even at one-byte read boundaries", async () => { + const contents = [ + JSON.stringify(entry("요청-🙂", { provider: "공급자", model: "모델-한글" })), + JSON.stringify(entry("request-two", { provider: "anthropic", model: "claude-fable-5" })), + ].join("\r\n") + "\r\n"; + writeFileSync(usageLogPath(), contents); + + const entries: PersistedUsageEntry[] = []; + const result = await scanUsageLedgerCooperatively({ + chunkBytes: 1, + onEntry: value => entries.push(value), + }); + + expect(entries.map(value => [value.requestId, value.provider, value.model])).toEqual([ + ["요청-🙂", "공급자", "모델-한글"], + ["request-two", "anthropic", "claude-fable-5"], + ]); + expect(result).toMatchObject({ + parsedRows: 2, + invalidRows: 0, + oversizedRows: 0, + bytesRead: Buffer.byteLength(contents), + }); + expect(result.revision?.size).toBe(Buffer.byteLength(contents)); + expect(result.processedThroughBytes).toBe(Buffer.byteLength(contents)); + }); + + test("the checkpoint digest tracks the last 64 KiB after the rolling window wraps", async () => { + const contents = Array.from({ length: 1_000 }, (_, index) => line(`digest-${index}`)).join(""); + const bytes = Buffer.from(contents); + expect(bytes.byteLength).toBeGreaterThan(USAGE_LEDGER_BOUNDARY_DIGEST_BYTES); + writeFileSync(usageLogPath(), bytes); + + const result = await scanUsageLedgerCooperatively({ onEntry: () => {} }); + const expected = createHash("sha256") + .update(bytes.subarray(bytes.byteLength - USAGE_LEDGER_BOUNDARY_DIGEST_BYTES)) + .digest("hex"); + + expect(result.processedThroughDigest).toBe(expected); + }); + + test("yields while scanning a large ledger and visits every row once", async () => { + const rows = Array.from({ length: 2_100 }, (_, index) => line(`row-${index}`)); + writeFileSync(usageLogPath(), rows.join("")); + let timerRan = false; + setTimeout(() => { timerRan = true; }, 0); + let totalTokens = 0; + + const result = await scanUsageLedgerCooperatively({ + chunkBytes: 128, + onEntry: value => { totalTokens += value.totalTokens ?? 0; }, + }); + + expect(timerRan).toBe(true); + expect(result.parsedRows).toBe(2_100); + expect(result.invalidRows).toBe(0); + expect(totalTokens).toBe(4_200); + }); + + test("skips malformed, invalid UTF-8, oversized, and torn final rows with bounded recovery", async () => { + const exactlyAtLimit = Buffer.concat([ + Buffer.alloc(USAGE_LEDGER_MAX_LINE_BYTES, 0x20), + Buffer.from("\n"), + ]); + const oversized = Buffer.from(`${"x".repeat(USAGE_LEDGER_MAX_LINE_BYTES + 1)}\n`); + const torn = Buffer.from(JSON.stringify(entry("valid-json-without-lf"))); + const contents = Buffer.concat([ + Buffer.from(line("valid")), + Buffer.from("{not-json}\n"), + Buffer.from(`${JSON.stringify({ requestId: "missing-provider" })}\n`), + Buffer.from([0xff, 0x0a]), + Buffer.from("\r\n"), + exactlyAtLimit, + oversized, + Buffer.from(line("after-oversized")), + torn, + ]); + writeFileSync(usageLogPath(), contents); + const ids: string[] = []; + + const result = await scanUsageLedgerCooperatively({ + onEntry: value => ids.push(value.requestId), + }); + + expect(ids).toEqual(["valid", "after-oversized"]); + expect(result).toMatchObject({ + parsedRows: 2, + invalidRows: 4, + oversizedRows: 1, + bytesRead: contents.byteLength, + processedThroughBytes: contents.byteLength - torn.byteLength, + }); + }); + + test("the line ceiling leaves headroom for an extreme writer-shaped attempt row", async () => { + const attempts = Array.from({ length: 1_000 }, (_, index) => ({ + ordinal: index + 1, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 200, + durationMs: 1, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported" as const, + usage: { inputTokens: 1, outputTokens: 1 }, + totalTokens: 2, + })); + const contents = line("many-attempts", { attempts }); + expect(Buffer.byteLength(contents)).toBeLessThan(USAGE_LEDGER_MAX_LINE_BYTES); + writeFileSync(usageLogPath(), contents); + + const ids: string[] = []; + const result = await scanUsageLedgerCooperatively({ onEntry: value => ids.push(value.requestId) }); + + expect(ids).toEqual(["many-attempts"]); + expect(result).toMatchObject({ parsedRows: 1, invalidRows: 0, oversizedRows: 0 }); + expect(result.processedThroughDigest).toBe( + createHash("sha256") + .update(Buffer.from(contents).subarray(-USAGE_LEDGER_BOUNDARY_DIGEST_BYTES)) + .digest("hex"), + ); + }); + + test("uses the opened EOF and leaves a concurrent append for the next scan", async () => { + const initial = Array.from({ length: 1_500 }, (_, index) => line(`initial-${index}`)).join(""); + writeFileSync(usageLogPath(), initial); + const firstIds: string[] = []; + const firstScan = scanUsageLedgerCooperatively({ + chunkBytes: 128, + onEntry: value => firstIds.push(value.requestId), + }); + queueMicrotask(() => appendFileSync(usageLogPath(), line("appended"))); + + const first = await firstScan; + expect(first.revision?.size).toBe(Buffer.byteLength(initial)); + expect(first.bytesRead).toBe(Buffer.byteLength(initial)); + expect(first.processedThroughBytes).toBe(Buffer.byteLength(initial)); + expect(firstIds).toHaveLength(1_500); + expect(firstIds).not.toContain("appended"); + + const secondIds: string[] = []; + const second = await scanUsageLedgerCooperatively({ onEntry: value => secondIds.push(value.requestId) }); + expect(second.parsedRows).toBe(1_501); + expect(secondIds.at(-1)).toBe("appended"); + }); + + test("continuous pure appends during verification do not invalidate the captured prefix", async () => { + const rows = Array.from({ length: 15_000 }, (_, index) => line(`stable-${index}`)); + const initial = rows.join(""); + expect(Buffer.byteLength(initial)).toBeGreaterThan(2 * 1024 * 1024); + writeFileSync(usageLogPath(), initial); + let callbacks = 0; + const scan = scanUsageLedgerCooperatively({ onEntry: () => { callbacks += 1; } }); + let appendIndex = 0; + const interval = setInterval(() => { + appendFileSync(usageLogPath(), line(`concurrent-${appendIndex++}`)); + }, 0); + + try { + const result = await scan; + expect(result.parsedRows).toBe(15_000); + expect(callbacks).toBe(15_000); + expect(result.revision?.size).toBe(Buffer.byteLength(initial)); + } finally { + clearInterval(interval); + } + expect(appendIndex).toBeGreaterThan(0); + }); + + test("an append scan visits only bytes after the previous LF checkpoint", async () => { + const initial = `${line("first")}${line("second")}`; + writeFileSync(usageLogPath(), initial); + const initialIds: string[] = []; + const first = await scanUsageLedgerCooperatively({ onEntry: value => initialIds.push(value.requestId) }); + expect(initialIds).toEqual(["first", "second"]); + + const appended = `${line("third")}${line("fourth")}`; + appendFileSync(usageLogPath(), appended); + const appendedIds: string[] = []; + const second = await scanUsageLedgerCooperatively({ + startAtBytes: first.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(first.revision), + expectedProcessedThroughDigest: first.processedThroughDigest, + onEntry: value => appendedIds.push(value.requestId), + }); + + expect(appendedIds).toEqual(["third", "fourth"]); + expect(second.bytesRead).toBe(Buffer.byteLength(appended)); + expect(second.processedThroughBytes).toBe(Buffer.byteLength(initial + appended)); + }); + + test("a torn EOF keeps the checkpoint behind it and is counted once after completion", async () => { + const committed = line("committed"); + const completedRow = Buffer.from(JSON.stringify(entry("완성-🙂"))); + const splitAt = completedRow.indexOf(Buffer.from("🙂")) + 2; + writeFileSync(usageLogPath(), Buffer.concat([ + Buffer.from(committed), + completedRow.subarray(0, splitAt), + ])); + const firstIds: string[] = []; + const first = await scanUsageLedgerCooperatively({ + chunkBytes: 3, + onEntry: value => firstIds.push(value.requestId), + }); + expect(firstIds).toEqual(["committed"]); + expect(first.invalidRows).toBe(1); + expect(first.processedThroughBytes).toBe(Buffer.byteLength(committed)); + expect(first.processedThroughDigest).toBe( + createHash("sha256").update(committed).digest("hex"), + ); + + appendFileSync(usageLogPath(), Buffer.concat([ + completedRow.subarray(splitAt), + Buffer.from("\n"), + ])); + const completedIds: string[] = []; + const second = await scanUsageLedgerCooperatively({ + chunkBytes: 2, + startAtBytes: first.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(first.revision), + expectedProcessedThroughDigest: first.processedThroughDigest, + onEntry: value => completedIds.push(value.requestId), + }); + expect(completedIds).toEqual(["완성-🙂"]); + expect(second.invalidRows).toBe(0); + + const afterIds: string[] = []; + const third = await scanUsageLedgerCooperatively({ + startAtBytes: second.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(second.revision), + expectedProcessedThroughDigest: second.processedThroughDigest, + onEntry: value => afterIds.push(value.requestId), + }); + expect(afterIds).toEqual([]); + expect(third.bytesRead).toBe(0); + }); + + test("incremental preconditions fail with explicit rebuild-required reasons", async () => { + const contents = `${line("first")}${line("second")}`; + writeFileSync(usageLogPath(), contents); + const full = await scanUsageLedgerCooperatively({ onEntry: () => {} }); + + const wrongIdentity = scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedIdentityKey: "not-the-ledger", + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + }); + await expect(wrongIdentity).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "identity_mismatch", + }); + + const middleOfRow = scanUsageLedgerCooperatively({ + startAtBytes: 2, + expectedIdentityKey: usageLogIdentityKey(full.revision), + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + }); + await expect(middleOfRow).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "boundary_mismatch", + }); + + writeFileSync(usageLogPath(), line("short")); + const shrink = scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(full.revision), + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + }); + await expect(shrink).rejects.toBeInstanceOf(UsageLedgerRebuildRequiredError); + await expect(shrink).rejects.toMatchObject({ reason: "shrink" }); + }); + + test("a nonzero checkpoint requires both its identity and trailing digest", async () => { + writeFileSync(usageLogPath(), line("checkpoint")); + const full = await scanUsageLedgerCooperatively({ onEntry: () => {} }); + + await expect(scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + })).rejects.toBeInstanceOf(TypeError); + await expect(scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(full.revision), + onEntry: () => {}, + })).rejects.toBeInstanceOf(TypeError); + }); + + test("a boundary digest rejects a same-identity rewrite before the append offset", async () => { + const original = `${line("aaaa")}${line("bbbb")}`; + writeFileSync(usageLogPath(), original); + const full = await scanUsageLedgerCooperatively({ onEntry: () => {} }); + const rewritten = original.replace("aaaa", "zzzz"); + expect(Buffer.byteLength(rewritten)).toBe(Buffer.byteLength(original)); + writeFileSync(usageLogPath(), rewritten); + + const scan = scanUsageLedgerCooperatively({ + startAtBytes: full.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(full.revision), + expectedProcessedThroughDigest: full.processedThroughDigest, + onEntry: () => {}, + }); + await expect(scan).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "content_changed", + }); + }); + + test("the returned checkpoint digest stays paired with bytes captured by the scan", async () => { + const original = line("old-checkpoint"); + const rewritten = line("new-checkpoint"); + expect(Buffer.byteLength(rewritten)).toBe(Buffer.byteLength(original)); + writeFileSync(usageLogPath(), original); + let abortChecks = 0; + const rewriteAfterVerification = { + get aborted() { + abortChecks += 1; + if (abortChecks === 4) writeFileSync(usageLogPath(), rewritten); + return false; + }, + } as AbortSignal; + + const ids: string[] = []; + const result = await scanUsageLedgerCooperatively({ + signal: rewriteAfterVerification, + onEntry: value => ids.push(value.requestId), + }); + const originalDigest = createHash("sha256").update(original).digest("hex"); + expect(abortChecks).toBeGreaterThanOrEqual(4); + expect(ids).toEqual(["old-checkpoint"]); + expect(result.processedThroughDigest).toBe(originalDigest); + + await expect(scanUsageLedgerCooperatively({ + startAtBytes: result.processedThroughBytes, + expectedIdentityKey: usageLogIdentityKey(result.revision), + expectedProcessedThroughDigest: result.processedThroughDigest, + onEntry: () => {}, + })).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "content_changed", + }); + }); + + test("rejects a shrink while the scanner is yielded", async () => { + writeFileSync( + usageLogPath(), + Array.from({ length: 1_500 }, (_, index) => line(`old-${index}`)).join(""), + ); + const scan = scanUsageLedgerCooperatively({ chunkBytes: 128, onEntry: () => {} }); + queueMicrotask(() => writeFileSync(usageLogPath(), line("replacement"))); + + await expect(scan).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "shrink", + }); + }); + + test("rejects when the path is replaced while the original descriptor stays readable", async () => { + writeFileSync( + usageLogPath(), + Array.from({ length: 1_500 }, (_, index) => line(`old-${index}`)).join(""), + ); + const scan = scanUsageLedgerCooperatively({ chunkBytes: 128, onEntry: () => {} }); + queueMicrotask(() => { + renameSync(usageLogPath(), `${usageLogPath()}.old`); + writeFileSync(usageLogPath(), line("replacement")); + }); + + await expect(scan).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "identity_mismatch", + }); + }); + + test("rejects a same-inode rewrite plus growth instead of publishing a mixed snapshot", async () => { + writeFileSync( + usageLogPath(), + Array.from({ length: 1_500 }, (_, index) => line(`old-${String(index).padStart(4, "0")}`)).join(""), + ); + const scan = scanUsageLedgerCooperatively({ chunkBytes: 128, onEntry: () => {} }); + queueMicrotask(() => { + writeFileSync( + usageLogPath(), + Array.from({ length: 1_600 }, (_, index) => line(`new-${String(index).padStart(4, "0")}`)).join(""), + ); + }); + + await expect(scan).rejects.toMatchObject({ + code: "usage_ledger_rebuild_required", + reason: "content_changed", + }); + }); + + test("honors an existing abort and an abort delivered at a cooperative yield", async () => { + const beforeStart = new AbortController(); + const beforeStartReason = new Error("stop-before-start"); + beforeStart.abort(beforeStartReason); + await expect(scanUsageLedgerCooperatively({ + signal: beforeStart.signal, + onEntry: () => {}, + })).rejects.toBe(beforeStartReason); + + writeFileSync( + usageLogPath(), + Array.from({ length: 1_500 }, (_, index) => line(`abort-${index}`)).join(""), + ); + const duringScan = new AbortController(); + const duringScanReason = new Error("stop-during-scan"); + let callbacks = 0; + const scan = scanUsageLedgerCooperatively({ + signal: duringScan.signal, + chunkBytes: 128, + onEntry: () => { callbacks += 1; }, + }); + queueMicrotask(() => duringScan.abort(duringScanReason)); + + await expect(scan).rejects.toBe(duringScanReason); + expect(callbacks).toBeGreaterThan(0); + expect(callbacks).toBeLessThan(1_500); + }); + + test("propagates accumulator failures instead of misclassifying them as invalid rows", async () => { + writeFileSync(usageLogPath(), line("callback-error")); + const sentinel = new Error("accumulator failed"); + + await expect(scanUsageLedgerCooperatively({ + onEntry: () => { throw sentinel; }, + })).rejects.toBe(sentinel); + }); +}); diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index 8917e05f6a..17db7ce4a4 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { MAX_USAGE_MODEL_BREAKDOWN_ROWS, + MAX_USAGE_DAY_BUCKETS, USAGE_RANGES, USAGE_SURFACES, + createUsageSummaryAccumulator, parseRange, parseUsageSurface, rangeWindow, @@ -890,6 +892,24 @@ describe("summarizeUsage", () => { expect(month.summary.totalTokens).toBe(4); }); + test("range filtering compares numeric day boundaries for years before 1000", () => { + const ancient = Date.UTC(999, 0, 1, 12, 0, 0); + const entries: PersistedUsageEntry[] = [ + entry({ ts: FIXED_NOW - 1, requestId: "current", usageStatus: "reported", usage: { inputTokens: 1, outputTokens: 1 }, totalTokens: 2 }), + entry({ ts: ancient, requestId: "ancient", usageStatus: "reported", usage: { inputTokens: 10, outputTokens: 10 }, totalTokens: 20 }), + ]; + + const month = summarizeUsage(entries, "30d", FIXED_NOW); + expect(month.summary.requests).toBe(1); + expect(month.summary.totalTokens).toBe(2); + expect(month.models.every(model => model.totalTokens !== 20)).toBe(true); + + const all = summarizeUsage(entries, "all", FIXED_NOW); + expect(all.summary.requests).toBe(2); + expect(all.summary.totalTokens).toBe(22); + expect(all.days).toHaveLength(MAX_USAGE_DAY_BUCKETS); + }); + test("coverageRatio stays in [0,1] and handles empty input", () => { expect(summarizeUsage([], "30d", FIXED_NOW).summary.coverageRatio).toBe(0); const onlyMissing = summarizeUsage([entry({ ts: FIXED_NOW - 1, usageStatus: "unreported" })], "30d", FIXED_NOW); @@ -1522,3 +1542,294 @@ describe("summarizeUsage", () => { }); }); + +describe("UsageSummaryAccumulator modes", () => { + const at = Date.UTC(2026, 5, 28, 10, 0, 0); + + test("exact mode preserves cross-partition request identity", () => { + const accumulator = createUsageSummaryAccumulator(); + accumulator.add(entry({ + ts: at - 3_600_000, + requestId: "duplicate-request", + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 10, outputTokens: 2 }, + })); + accumulator.add(entry({ + ts: at, + requestId: "duplicate-request", + surface: "claude", + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 20, outputTokens: 3 }, + })); + + const summary = accumulator.summarize("all", at); + expect(summary.summary.requests).toBe(2); + expect(summary.days.find(day => day.requests > 0)).toMatchObject({ + requests: 2, + totalTokens: 35, + models: [{ requests: 1, attemptCount: 2, totalTokens: 35 }], + }); + expect(summary.models[0]).toMatchObject({ requests: 1, attemptCount: 2, totalTokens: 35 }); + expect(summary.providers[0]).toMatchObject({ requests: 1, attemptCount: 2, totalTokens: 35 }); + expect(summary.accounts[0]).toMatchObject({ requests: 1, attemptCount: 2, totalTokens: 35 }); + }); + + test("row-unique mode matches exact mode for unique ledger rows", () => { + const rows = [ + entry({ + ts: at - 86_400_000, + requestId: "unique-1", + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 10 }, + }), + entry({ + ts: at, + requestId: "unique-2", + surface: "claude", + provider: "combo", + model: "combo/native", + usageStatus: "reported", + usage: { inputTokens: 70, outputTokens: 7 }, + totalTokens: 77, + attempts: [ + { + ordinal: 1, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 50, outputTokens: 5 }, + totalTokens: 55, + }, + { + ordinal: 2, + provider: "unpriced-provider", + model: "unpriced-model", + adapter: "openai-responses", + status: 200, + durationMs: 20, + sendCount: 1, + recoveryKinds: [], + accountLogLabel: "p123abc", + usageStatus: "estimated", + usage: { inputTokens: 20, outputTokens: 2 }, + totalTokens: 22, + }, + ], + }), + ]; + const exact = createUsageSummaryAccumulator(); + const compact = createUsageSummaryAccumulator({ mode: "row-unique" }); + for (const row of rows) { + exact.add(row); + compact.add(row); + } + + expect(compact.summarize("all", at)).toEqual(exact.summarize("all", at)); + }); + + test("row-unique mode counts a same-model/provider/account retry once", () => { + const accumulator = createUsageSummaryAccumulator({ mode: "row-unique" }); + accumulator.add(entry({ + ts: at, + requestId: "same-dimension-retry", + provider: "combo", + model: "combo/native", + usageStatus: "reported", + usage: { inputTokens: 30, outputTokens: 3 }, + totalTokens: 33, + attempts: [1, 2].map(ordinal => ({ + ordinal, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + accountLogLabel: "pabcdef" as const, + usageStatus: "reported" as const, + usage: { inputTokens: 15, outputTokens: ordinal }, + })), + })); + + const summary = accumulator.summarize("30d", at); + expect(summary.models[0]).toMatchObject({ requests: 1, attemptCount: 2 }); + expect(summary.providers[0]).toMatchObject({ requests: 1, attemptCount: 2 }); + expect(summary.accounts[0]).toMatchObject({ requests: 1, attemptCount: 2 }); + }); + + test("row-unique overflow folds a multi-model request only once", () => { + const rows = Array.from({ length: MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1 }, (_, index) => entry({ + ts: at + index, + requestId: `overflow-head-${index}`, + provider: "head-provider", + model: `head-model-${String(index).padStart(3, "0")}`, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + })); + rows.push(entry({ + ts: at + MAX_USAGE_MODEL_BREAKDOWN_ROWS, + requestId: "overflow-combo", + provider: "combo", + model: "combo/native", + usageStatus: "reported", + attempts: [ + { + ordinal: 1, + provider: "openai", + model: "gpt-5.5", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + usage: { inputTokens: 10, outputTokens: 1 }, + }, + { + ordinal: 2, + provider: "unpriced-provider", + model: "tail-unpriced", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "unreported", + }, + ], + })); + const exact = createUsageSummaryAccumulator(); + const compact = createUsageSummaryAccumulator({ mode: "row-unique" }); + for (const row of rows) { + exact.add(row); + compact.add(row); + } + + const exactSummary = exact.summarize("30d", at + MAX_USAGE_MODEL_BREAKDOWN_ROWS); + const compactSummary = compact.summarize("30d", at + MAX_USAGE_MODEL_BREAKDOWN_ROWS); + expect(compactSummary).toEqual(exactSummary); + const other = compactSummary.models.find(model => model.model === "other"); + expect(other).toMatchObject({ + requests: 1, + attemptCount: 2, + measuredRequests: 0, + reportedRequests: 0, + pricedRequests: 1, + unpricedRequests: 1, + }); + const dayOther = compactSummary.days.find(day => day.requests > 0)?.models + .find(model => model.model === "other"); + expect(dayOther).toMatchObject({ requests: 1, attemptCount: 2 }); + }); + + test("filtered compact overflow preserves projection compatibility", () => { + const accumulator = createUsageSummaryAccumulator({ + mode: "row-unique", + filter: { provider: "rare-provider" }, + }); + const rows: PersistedUsageEntry[] = []; + for (let index = 0; index < MAX_USAGE_MODEL_BREAKDOWN_ROWS + 1; index++) { + const row = entry({ + ts: at + index, + requestId: `filtered-overflow-${index}`, + provider: "rare-provider", + model: `rare-model-${index}`, + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + rows.push(row); + accumulator.add(row); + } + + const summary = accumulator.summarize("30d", at + MAX_USAGE_MODEL_BREAKDOWN_ROWS); + expect(summary.models).toHaveLength(MAX_USAGE_MODEL_BREAKDOWN_ROWS - 1); + expect(summary.models.some(model => model.model === "other")).toBe(false); + expect(summary.days.find(day => day.requests > 0)?.models.some(model => model.model === "other")).toBe(false); + + const base = summarizeUsage(rows, "30d", at + MAX_USAGE_MODEL_BREAKDOWN_ROWS); + expect(summary).toEqual(projectUsageSummary(base, { provider: "rare-provider" }, rows)); + }); + + test("clone mutations do not affect the source", () => { + const source = createUsageSummaryAccumulator({ mode: "row-unique" }); + source.add(entry({ ts: at, requestId: "clone-source" })); + const before = source.summarize("30d", at); + const cloned = source.clone(); + cloned.add(entry({ ts: at + 1, requestId: "clone-only" })); + + expect(source.summarize("30d", at)).toEqual(before); + expect(cloned.summarize("30d", at).summary.requests).toBe(2); + expect(cloned.estimatedBytes).toBeGreaterThanOrEqual(source.estimatedBytes); + }); + + test("estimatedBytes stays constant for ordinary compact rows in existing dimensions", () => { + const compact = createUsageSummaryAccumulator({ mode: "row-unique" }); + const exact = createUsageSummaryAccumulator(); + const first = entry({ + ts: at, + requestId: "estimate-1", + accountLogLabel: "pabcdef", + usageStatus: "reported", + usage: { inputTokens: 1, outputTokens: 1 }, + }); + const second = entry({ ...first, ts: at + 1, requestId: "estimate-2" }); + compact.add(first); + exact.add(first); + const compactAfterFirst = compact.estimatedBytes; + compact.add(second); + exact.add(second); + + expect(compact.estimatedBytes).toBe(compactAfterFirst); + expect(exact.estimatedBytes).toBeGreaterThan(compact.estimatedBytes); + }); + + test("estimatedBytes aggregates repeated multi-model overlap signatures", () => { + const compact = createUsageSummaryAccumulator({ mode: "row-unique" }); + const combo = (index: number): PersistedUsageEntry => entry({ + ts: at + index, + requestId: `repeated-overlap-${index}`, + provider: "combo", + model: "combo/native", + usageStatus: "reported", + attempts: [ + { + ordinal: 1, + provider: "unpriced-a", + model: "model-a", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + }, + { + ordinal: 2, + provider: "unpriced-b", + model: "model-b", + adapter: "openai-responses", + status: 200, + durationMs: 10, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + }, + ], + }); + compact.add(combo(0)); + const firstSignatureBytes = compact.estimatedBytes; + for (let index = 1; index <= 100; index++) compact.add(combo(index)); + + expect(compact.estimatedBytes).toBe(firstSignatureBytes); + }); +});