From 21a3f18acee57d22216e30dc90821ded90b1b941 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:17:21 +0900 Subject: [PATCH 1/3] fix(tests): walk the Lab activation chain instead of its first hop (#4704) [skip ci] The synchrony guard followed the direct callees of startServer. That catches activateLab becoming async, but activateLab calls installLabAutomationRuntime and startAutomationIfEnabled without awaiting them, so making either async with an await before its registration call left activateLab parsing as perfectly synchronous. Every assertion stayed green while startServer returned before Lab was registered, which is the one ordering the window exists to protect. walkActivationChain now follows the chain from activateLab to a bounded depth, failing on any node that is declared async or carries a body-level await. What keeps a recursive walk from becoming the false-positive machine the depth-one comment warned about is what it refuses to follow. Nested functions are already skipped by collectBodyLevelCalls, so timer callbacks, shutdown hooks, promise continuations and the deferred route executor are not activation edges. Receiver calls are not followed: a method that turns async cannot suspend its caller unless the caller awaits it, and that await is reported on the caller's own body. Names imported from outside this repository are classified automatically, so the six names that need human judgement are not buried under every join and readFileSync in the chain. inspectActivationDeclaration extends inspection to const arrows. The function-only inspector reported activationKey as missing, and a walk that read missing as fine would skip every const-arrow node. That needed its own return-type skipper: the existing one treats a top-level => as part of a function-type annotation and keeps scanning for a body brace a concise arrow never has. Four mutation cases run against the real sources through an injected loader: a nested callee declared async, an await added inside that callee, a suspension three hops down in startLabAutomationScheduler, and the arrow forms the previous inspector could not see. The async case also asserts what the depth-one scan reports on the same mutated source -- still green, which is the defect. Closes #4704 --- tests/lab/core-lab-boundary.test.ts | 361 ++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) diff --git a/tests/lab/core-lab-boundary.test.ts b/tests/lab/core-lab-boundary.test.ts index b39a0afbf0..82c97190bf 100644 --- a/tests/lab/core-lab-boundary.test.ts +++ b/tests/lab/core-lab-boundary.test.ts @@ -701,6 +701,215 @@ function resolveDeclarationFollowingReexports(file: string, name: string): Resol } +/** + * A declaration the activation walk can inspect: a named `function` OR a const arrow. + * + * inspectFunctionDeclaration knows only the first form, which is enough for the window's + * direct callees but not for the chain below it: activationKey and every returned cleanup + * receipt in the Lab activation path are const arrows, and a walk that cannot see them would + * report the most interesting nodes as "declaration not found". + */ +export type ActivationDeclaration = { + found: boolean; + async: boolean; + awaitLines: number[]; + body: string | null; +}; + +export function inspectActivationDeclaration(source: string, name: string): ActivationDeclaration { + const code = blankCommentsAndStrings(source); + const ident = escapeRegExp(name); + const fn = new RegExp("(export\\s+)?(async\\s+)?function\\s+" + ident + "\\b").exec(code); + if (fn && fn.index !== undefined) { + const body = extractFunctionBody(code, fn.index + fn[0].length); + return { found: true, async: Boolean(fn[2]), awaitLines: body === null ? [] : bodyLevelAwaitLines(body), body }; + } + const arrow = new RegExp("(?:export\\s+)?(?:const|let|var)\\s+" + ident + "\\s*(?::[^=\\n]+)?=\\s*(async\\s+)?").exec(code); + if (!arrow || arrow.index === undefined) return { found: false, async: false, awaitLines: [], body: null }; + const body = extractArrowBody(code, arrow.index + arrow[0].length); + if (body === null) return { found: false, async: false, awaitLines: [], body: null }; + return { found: true, async: Boolean(arrow[1]), awaitLines: bodyLevelAwaitLines(body), body }; +} + +/** The body of an arrow at `afterEquals`, brace form or concise form. */ +function extractArrowBody(code: string, afterEquals: number): string | null { + let i = skipWsFwd(code, afterEquals); + if (code[i] === "(") { + const afterParams = skipParamList(code, i); + if (afterParams < 0) return null; + i = skipWsFwd(code, afterParams); + if (code[i] === ":") { + i = skipArrowReturnType(code, i); + if (i < 0) return null; + i = skipWsFwd(code, i); + } + } else { + while (i < code.length && /[\w$]/.test(code[i]!)) i += 1; + i = skipWsFwd(code, i); + } + if (code[i] !== "=" || code[i + 1] !== ">") return null; + const afterArrow = i + 2; + const concise = skipConciseArrowBody(code, afterArrow); + if (concise !== afterArrow) return code.slice(afterArrow, concise); + const brace = skipWsFwd(code, afterArrow); + if (code[brace] !== "{") return null; + const end = matchPair(code, brace, "{", "}"); + return end < 0 ? null : code.slice(brace, end); +} + +/** + * Skip an arrow's return-type annotation, stopping at the arrow itself. + * + * skipReturnType cannot be reused: it treats a top-level `=>` as part of a function-TYPE + * annotation and keeps scanning for the body brace, which a concise arrow never has. Feeding + * it `activationKey` returned -1, and the most interesting nodes in the chain are const arrows. + */ +function skipArrowReturnType(code: string, colonIndex: number): number { + let i = colonIndex + 1; + let paren = 0; + let bracket = 0; + let brace = 0; + let angle = 0; + while (i < code.length) { + const ch = code[i]!; + const atTop = paren === 0 && bracket === 0 && brace === 0 && angle === 0; + if (atTop && ch === "=" && code[i + 1] === ">") return i; + if (ch === "(") paren++; + else if (ch === ")") { if (paren === 0) return -1; paren--; } + else if (ch === "[") bracket++; + else if (ch === "]") { if (bracket === 0) return -1; bracket--; } + else if (ch === "{") brace++; + else if (ch === "}") { if (brace === 0) return -1; brace--; } + else if (ch === "<") angle++; + else if (ch === ">") { if (angle > 0) angle--; } + i++; + } + return -1; +} + +export type ActivationNode = { + name: string; + file: string; + async: boolean; + awaitLines: number[]; + callees: string[]; +}; + +export type ActivationWalk = { + nodes: Map; + failures: string[]; + /** Free names deliberately not followed, as actually encountered. */ + skipped: Set; + /** Free names resolved to a module outside this repository. */ + external: Set; +}; + +type ActivationResolution = + | { kind: "declared"; file: string; source: string; declaration: ActivationDeclaration } + | { kind: "external"; spec: string } + | { kind: "missing" }; + +function resolveActivationCallee( + name: string, + fromFile: string, + fromSource: string, + load: (file: string) => string, +): ActivationResolution { + const imported = namedImportsOf(fromSource).get(name); + if (!imported) { + const local = inspectActivationDeclaration(fromSource, name); + return local.found ? { kind: "declared", file: fromFile, source: fromSource, declaration: local } : { kind: "missing" }; + } + // A non-relative specifier leaves this repository: node: builtins and packages. Their + // synchrony is not ours to assert, and pinning every join/readFileSync by hand would turn + // the classification list into noise that hides the two or three names worth reviewing. + if (!imported.spec.startsWith(".")) return { kind: "external", spec: imported.spec }; + let file = resolveSpec(imported.spec, fromFile); + if (!file) return { kind: "missing" }; + let exported = imported.exported; + for (let hop = 0; hop < 8; hop++) { + if (!existsSync(file)) return { kind: "missing" }; + const source = load(file); + const declaration = inspectActivationDeclaration(source, exported); + if (declaration.found) return { kind: "declared", file, source, declaration }; + const next = reexportOf(source, exported); + if (!next) return { kind: "missing" }; + const resolved = resolveSpec(next.spec, file); + if (!resolved) return { kind: "missing" }; + file = resolved; + exported = next.exported; + } + return { kind: "missing" }; +} + +/** + * Walk the activation call graph from `activateLab` and report every node that could suspend it. + * + * Depth one was the defect. `activateLab` calls installLabAutomationRuntime and + * startAutomationIfEnabled without awaiting them, so making either async with an await before + * its registration call left `activateLab` parsing as synchronous and the guard green while the + * window it protects was already broken. + * + * Two things make a recursive walk usable here rather than a source of false positives. + * Nested functions are skipped by collectBodyLevelCalls, so a timer callback, a shutdown hook, + * a promise continuation and the deferred route executor are not treated as activation edges — + * they run later by construction. And receiver calls are not followed: a method that becomes + * async cannot suspend its caller unless the caller awaits it, and an await is exactly what + * bodyLevelAwaitLines reports on the caller's own body. + */ +export function walkActivationChain(options: { + root: string; + entryFile: string; + entrySource: string; + notWalked: ReadonlySet; + loadSource?: (file: string) => string; + maxNodes?: number; +}): ActivationWalk { + const load = options.loadSource ?? ((file: string) => readFileSync(file, "utf8")); + const maxNodes = options.maxNodes ?? 200; + const nodes = new Map(); + const failures: string[] = []; + const skipped = new Set(); + const external = new Set(); + const queue: Array<{ name: string; fromFile: string; fromSource: string }> = [ + { name: options.root, fromFile: options.entryFile, fromSource: options.entrySource }, + ]; + while (queue.length > 0) { + const item = queue.shift()!; + if (nodes.has(item.name)) continue; + if (nodes.size >= maxNodes) { + failures.push("activation chain exceeded " + maxNodes + " nodes; the walk is no longer bounded"); + break; + } + const resolved = resolveActivationCallee(item.name, item.fromFile, item.fromSource, load); + if (resolved.kind === "external") { external.add(item.name); continue; } + if (resolved.kind === "missing") { + failures.push(item.name + ": declaration not found from " + repoRel(item.fromFile)); + continue; + } + const { declaration } = resolved; + const where = item.name + " in " + repoRel(resolved.file); + if (declaration.async) failures.push(where + ": declared async"); + if (declaration.awaitLines.length > 0) { + failures.push(where + ": body-level await at relative line " + declaration.awaitLines.join(",")); + } + const callees = declaration.body === null ? [] : collectBodyLevelCalls(declaration.body).free; + nodes.set(item.name, { + name: item.name, + file: repoRel(resolved.file), + async: declaration.async, + awaitLines: declaration.awaitLines, + callees: [...callees].sort(), + }); + for (const callee of callees) { + if (options.notWalked.has(callee)) { skipped.add(callee); continue; } + if (nodes.has(callee)) continue; + queue.push({ name: callee, fromFile: resolved.file, fromSource: resolved.source }); + } + } + return { nodes, failures, skipped, external }; +} + describe("core / Compatibility Lab boundary", () => { // Guard 1: the obvious case, a direct import. test.each(PROTECTED)("%s has no direct src/lab import", file => { @@ -990,3 +1199,155 @@ describe("activation window stays synchronous", () => { }); }); + +/** + * Depth one was the whole defect, and #4704 is the report of it. + * + * The guard above follows the direct callees of `startServer`. That catches `activateLab` becoming + * async, but `activateLab` calls installLabAutomationRuntime and startAutomationIfEnabled + * without awaiting them. Making either of those async with an await before its registration + * call leaves `activateLab` parsing as perfectly synchronous, so every assertion above stays + * green while `startServer` returns before Lab is registered — and a policy route can then be + * evaluated before its evidence provider exists, which is the one thing the window exists to + * prevent. + * + * So this block walks the chain instead of sampling its first hop. What keeps a recursive walk + * from becoming the false-positive machine the depth-one comment warned about is what it + * refuses to follow: nested functions are already skipped by collectBodyLevelCalls, so timer + * callbacks, shutdown hooks, promise continuations and the deferred route executor are not + * treated as activation edges; receiver calls are not followed, because a method that turns + * async cannot suspend its caller unless the caller awaits it, and that await is reported on + * the caller's own body; and names imported from outside this repository are classified + * automatically rather than hand-listed. + */ +describe("Lab activation stays synchronous past the first hop", () => { + const indexPath = resolve(repoRoot, "src/server/index.ts"); + const indexSource = readFileSync(indexPath, "utf8"); + const labActivationPath = resolve(repoRoot, "src/lib/lab-activation.ts"); + const orchestratorPath = resolve(repoRoot, "src/lab/automation/orchestrator.ts"); + + /** + * Free identifiers in the chain that are not repository functions. Each one is listed with + * why following it is meaningless rather than skipped silently, which is the same contract + * UNRESOLVED_CALLEES holds for the window: a name that disappears from this list without + * disappearing from the chain fails the equality assertion below. + */ + const ACTIVATION_NOT_WALKED: Record = { + String: "Language builtin. Not a repository function and not suspendable.", + Symbol: "Language builtin, used for the automation runtime owner token.", + setInterval: "Host timer. Registers the scheduler tick and returns immediately; the callback is a nested function this walk does not treat as an activation edge.", + action: "The callback parameter of withConfigLock. It is invoked synchronously, but its body is the arrow written at the call site, which is a nested function inspected there rather than here.", + mutate: "The callback parameter of mutateLabAutomationState. Same shape as action.", + release: "A lock receipt returned by acquireConfigLock/acquireStateLock. A returned closure has no declaration to resolve from the call site.", + }; + + /** + * Nodes the walk must reach. Without this the whole block could pass by walking nothing: + * a resolver regression that stopped finding `activateLab` would produce an empty graph, zero + * failures and a green suite, which is precisely the failure mode being fixed. + */ + const REQUIRED_NODES = [ + "activateLab", + "installLabAutomationRuntime", + "startAutomationIfEnabled", + "registerLabPassiveRouteLinker", + "setCompatibilityEvidenceProvider", + "createProductionLabRouteExecutor", + "setLabAutomationDispatchDeps", + "labAutomationEnabledOnDisk", + "startLabAutomationScheduler", + "loadLabAutomationConfig", + "mutateLabAutomationState", + ]; + + function walk(loadSource?: (file: string) => string, entrySource = indexSource): ActivationWalk { + return walkActivationChain({ + root: "activateLab", + entryFile: indexPath, + entrySource, + notWalked: new Set(Object.keys(ACTIVATION_NOT_WALKED)), + loadSource, + }); + } + + test("every function the activation chain calls is synchronous", () => { + const result = walk(); + + expect(result.failures).toEqual([]); + for (const name of REQUIRED_NODES) expect([...result.nodes.keys()]).toContain(name); + // A floor, not an exact count: the chain is allowed to grow, and pinning its size would + // turn an ordinary Lab refactor into a failure of this guard. + expect(result.nodes.size).toBeGreaterThan(20); + // Every classified name must still be reachable, so the list cannot accumulate entries + // that no longer describe anything. + expect([...result.skipped].sort()).toEqual(Object.keys(ACTIVATION_NOT_WALKED).sort()); + // And the chain must actually leave this repository somewhere, which is the evidence that + // the external-import classification is doing work rather than matching nothing. + expect(result.external.size).toBeGreaterThan(0); + }); + + test("a nested callee turning async is reported, and depth one cannot see it", () => { + const mutated = readFileSync(labActivationPath, "utf8") + .replace("function installLabAutomationRuntime(", "async function installLabAutomationRuntime("); + expect(mutated).toContain("async function installLabAutomationRuntime("); + + const result = walk(file => (file === labActivationPath ? mutated : readFileSync(file, "utf8"))); + expect(result.failures).toContain("installLabAutomationRuntime in src/lib/lab-activation.ts: declared async"); + + // The same mutated source, read the way the depth-one scan reads it: activateLab is still + // a plain synchronous function with no body-level await. That is the green the guard used + // to report while the window was already broken. + expect(inspectFunctionDeclaration(mutated, "activateLab")).toEqual({ + found: true, + async: false, + awaitLines: [], + }); + }); + + test("an await added inside a nested callee is reported", () => { + const mutated = readFileSync(labActivationPath, "utf8") + .replace("const previous = record.runtime;", "const previous = await record.runtime;"); + expect(mutated).toContain("await record.runtime;"); + + const result = walk(file => (file === labActivationPath ? mutated : readFileSync(file, "utf8"))); + expect(result.failures.some(failure => + failure.startsWith("installLabAutomationRuntime in src/lib/lab-activation.ts: body-level await"), + )).toBe(true); + }); + + test("a suspension three hops down is reported", () => { + // startLabAutomationScheduler sits under startAutomationIfEnabled, which sits under + // activateLab. Nothing between them awaits, so this is the shape the previous guard was + // furthest from seeing. + const mutated = readFileSync(orchestratorPath, "utf8") + .replace("export function startLabAutomationScheduler(", "export async function startLabAutomationScheduler("); + expect(mutated).toContain("export async function startLabAutomationScheduler("); + + const result = walk(file => (file === orchestratorPath ? mutated : readFileSync(file, "utf8"))); + expect(result.failures).toContain( + "startLabAutomationScheduler in src/lab/automation/orchestrator.ts: declared async", + ); + }); + + test("the arrow inspector sees what the function-only inspector cannot", () => { + // activationKey is a const arrow with a return-type annotation. The function-only + // inspector reports it missing, and a walk that treated "missing" as "fine" would skip + // every const-arrow node in the chain. + expect(inspectFunctionDeclaration("const f = (a: string): string => a;", "f").found).toBe(false); + expect(inspectActivationDeclaration("const f = (a: string): string => a;", "f")).toEqual({ + found: true, + async: false, + awaitLines: [], + body: " a", + }); + expect(inspectActivationDeclaration("const f = async (): Promise => { await g(); };", "f")).toMatchObject({ + found: true, + async: true, + }); + expect(inspectActivationDeclaration("const f = (): void => { const x = 1; };", "f")).toMatchObject({ + found: true, + async: false, + awaitLines: [], + }); + }); +}); From 2ace217bf120123f747ecbb68ba216de0a954d6d Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:21:13 +0900 Subject: [PATCH 2/3] fix(scripts): name the size exemptions after the reason they exist (#4706) [skip ci] GENERATED_PATHS exempted twelve files from every size cap, and eleven of them were hand-maintained: nine i18n catalogues, a hand-curated benchmarks snapshot, and the model-metadata generator's INPUT. Its output, src/generated/model- metadata.ts, is 108 lines and was scanned normally, so the one file the list was named after was the one file it did not describe. Exempting catalogues and data snapshots is a reasonable policy. Calling them generated is what invites the next hand-written file onto the list, because a name is a claim nothing checks. The exemption is now three exact allowlists, each carrying its own reason. GENERATED_PATHS holds only agent_pb.ts, which opens with a protoc-gen-es banner. I18N_CATALOG_PATHS holds the nine locale catalogues, exempt because they grow by one line per UI string in nine locales at once, so a cap would block every new string in the GUI rather than any oversized module. DATA_SNAPSHOT_PATHS holds the two records whose size tracks how much was recorded. EXEMPT_PATHS is their union, and the verdict and baseline field are renamed from GENERATED to EXEMPT to match. loadBaseline still accepts the old key so a branch written before the rename loads instead of failing with a shape error that explains nothing. The classification is now checkable against the files themselves: every path in GENERATED_PATHS must carry a generator banner, and no path in the other two lists may. The positive control is the generator's real output, which carries the banner, is not exempt, and is scanned under a cap. The exemption relies on --update never turning an exemption into a cap, so that is asserted directly alongside the Math.min rule that only ever lowers one. Closes #4706 --- scripts/file-size-ratchet.ts | 64 +++++++++-- tests/ci-workflows/file-size-ratchet.test.ts | 107 +++++++++++++++---- tests/fixtures/file-size-baseline.json | 10 +- 3 files changed, 147 insertions(+), 34 deletions(-) diff --git a/scripts/file-size-ratchet.ts b/scripts/file-size-ratchet.ts index d524aff7c5..6c017a8681 100644 --- a/scripts/file-size-ratchet.ts +++ b/scripts/file-size-ratchet.ts @@ -28,9 +28,26 @@ export const EXCLUDED_PREFIXES = [ export const EXCLUDED_EXACT = new Set(["bun.lock", "gui/dist"]); +/** + * Machine-generated output. Regenerating it is the only way it changes, so a line count is + * a fact about the generator rather than about anyone's editing habits. + * + * Exactly one file qualifies, and it says so on its first line + * (@generated by protoc-gen-es). The test beside this list reads that banner rather than + * trusting the name, because "generated" was doing no work here: eleven hand-maintained + * files sat on this list, and calling them generated is what invites the twelfth. + */ export const GENERATED_PATHS = [ - "scripts/model-metadata.source.json", "src/adapters/cursor/gen/agent_pb.ts", +] as const; + +/** + * Translation catalogues. Hand-written, and exempt for a different reason: they grow by one + * line per UI string in nine locales at once, so a cap would block every new string in the + * GUI rather than any oversized module. gui/src/i18n/en.ts describes itself as the TKey + * source of truth; nothing generates these. + */ +export const I18N_CATALOG_PATHS = [ "gui/src/i18n/de.ts", "gui/src/i18n/en.ts", "gui/src/i18n/fr.ts", @@ -40,19 +57,37 @@ export const GENERATED_PATHS = [ "gui/src/i18n/tr.ts", "gui/src/i18n/zh.ts", "gui/src/i18n/zh-TW.ts", +] as const; + +/** + * Hand-maintained data snapshots. Records, not code: their size tracks how much was recorded, + * and splitting one would hide provenance rather than reduce complexity. + * + * model-metadata.source.json is the generator's INPUT, which is why naming it generated was + * backwards. Its output, src/generated/model-metadata.ts, is 108 lines and is scanned normally. + */ +export const DATA_SNAPSHOT_PATHS = [ "docs-site/src/data/frontier-benchmarks.json", + "scripts/model-metadata.source.json", ] as const; +/** Every path exempt from a size cap, whatever the reason. */ +export const EXEMPT_PATHS = [ + ...GENERATED_PATHS, + ...I18N_CATALOG_PATHS, + ...DATA_SNAPSHOT_PATHS, +].sort() as readonly string[]; + export type Verdict = | "NEW_OVERSIZED" | "GREW" | "SHRANK" - | "GENERATED" + | "EXEMPT" | "UNCHANGED" | "NEW_OK"; export type Baseline = { - generated: string[]; + exempt: string[]; files: Record; }; @@ -76,9 +111,9 @@ export function isScannedPath(path: string): boolean { } export function evaluate(files: FileSize[], baseline: Baseline): Evaluation[] { - const generated = new Set(baseline.generated); + const exempt = new Set(baseline.exempt); return files.map((file) => { - if (generated.has(file.path)) return { ...file, verdict: "GENERATED" }; + if (exempt.has(file.path)) return { ...file, verdict: "EXEMPT" }; const cap = baseline.files[file.path]; if (cap === undefined) { return { ...file, verdict: file.lines >= THRESHOLD ? "NEW_OVERSIZED" : "NEW_OK" }; @@ -115,11 +150,18 @@ export function scanRepo(repoRoot: string): FileSize[] { } export function loadBaseline(text: string): Baseline { - const parsed = JSON.parse(text) as Baseline; + const raw = JSON.parse(text) as Partial & { generated?: unknown }; + // "generated" was the field's name while it also held i18n catalogues and data snapshots. + // Reading it as exempt keeps a branch written before the rename loadable instead of + // failing with a shape error that says nothing about what changed. + const parsed = { + ...raw, + exempt: Array.isArray(raw.exempt) ? raw.exempt : raw.generated, + } as Baseline; if ( !parsed || typeof parsed !== "object" - || !Array.isArray(parsed.generated) + || !Array.isArray(parsed.exempt) || typeof parsed.files !== "object" || parsed.files === null || Array.isArray(parsed.files) @@ -144,13 +186,13 @@ export function updateBaseline(current: FileSize[], baseline: Baseline, seed: bo files[path] = Math.min(cap, lines); } if (seed) { - const generated = new Set(baseline.generated); + const exempt = new Set(baseline.exempt); for (const [path, lines] of now) { - if (generated.has(path) || lines < THRESHOLD || files[path] !== undefined) continue; + if (exempt.has(path) || lines < THRESHOLD || files[path] !== undefined) continue; files[path] = lines; } } - return { generated: [...baseline.generated], files: sortRecord(files) }; + return { exempt: [...baseline.exempt], files: sortRecord(files) }; } export function formatOffenders(rows: Evaluation[]): string { @@ -166,7 +208,7 @@ if (import.meta.main) { const existed = existsSync(baselinePath); const baseline: Baseline = existed ? loadBaseline(readFileSync(baselinePath, "utf8")) - : { generated: [...GENERATED_PATHS], files: {} }; + : { exempt: [...EXEMPT_PATHS], files: {} }; const current = scanRepo(repoRoot); if (process.argv.includes("--update")) { const next = updateBaseline(current, baseline, !existed); diff --git a/tests/ci-workflows/file-size-ratchet.test.ts b/tests/ci-workflows/file-size-ratchet.test.ts index 4b6cbdb611..7c08d3e1a9 100644 --- a/tests/ci-workflows/file-size-ratchet.test.ts +++ b/tests/ci-workflows/file-size-ratchet.test.ts @@ -13,7 +13,10 @@ import { readFileSync } from "node:fs"; * Source-oracle reads go through tests/helpers/repo-root.ts (INV-TESTS-01). */ import { + DATA_SNAPSHOT_PATHS, + EXEMPT_PATHS, GENERATED_PATHS, + I18N_CATALOG_PATHS, THRESHOLD, countLines, evaluate, @@ -32,11 +35,13 @@ import { repoPath, repoRoot } from "../helpers/repo-root"; * green" test would stay green if evaluate() started returning NEW_OK for a * 2,000-line new file, as long as this tree had no such file today. * - * Five pure cases plus one repository scan. Do not add a seventh test(): - * SHRANK already covers updateBaseline (lower, drop missing, never raise, - * seed only when asked). + * Five pure cases plus one repository scan cover evaluate() and updateBaseline; + * SHRANK already covers the update rules (lower, drop missing, never raise, seed + * only when asked), so do not add another case for those. The classification + * block below asserts a different property: that each exemption is on the list + * for the reason the list claims. */ -const emptyBaseline = (): Baseline => ({ generated: [], files: {} }); +const emptyBaseline = (): Baseline => ({ exempt: [], files: {} }); const linesOf = (count: number): string => { const rows = Array.from({ length: count }, (_, i) => `line ${i}`); @@ -74,7 +79,7 @@ describe("file-size ratchet: caps", () => { // Grandfathered files may stay oversized, but they may not grow. Equality is // UNCHANGED, not SHRANK; a test that only checked isOffender() would not notice // if equality started reporting GREW. - const baseline: Baseline = { generated: [], files: { "src/config.ts": 4707 } }; + const baseline: Baseline = { exempt: [], files: { "src/config.ts": 4707 } }; const grew = evaluate([{ path: "src/config.ts", lines: 4708 }], baseline); const same = evaluate([{ path: "src/config.ts", lines: 4707 }], baseline); @@ -90,7 +95,7 @@ describe("file-size ratchet: caps", () => { // must not re-grandfather a new godfile, must not raise a cap, and must keep a // shrunken former godfile so the facade cannot grow back. const baseline: Baseline = { - generated: [], + exempt: [], files: { "src/keep.ts": 2100, "src/gone.ts": 2500, "src/small.ts": 800 }, }; const current: FileSize[] = [ @@ -117,13 +122,13 @@ describe("file-size ratchet: caps", () => { // A later --update must never raise. If it did, ratchet:update would launder GREW. const notRaised = updateBaseline( [{ path: "src/keep.ts", lines: 3000 }], - { generated: [], files: { "src/keep.ts": 2099 } }, + { exempt: [], files: { "src/keep.ts": 2099 } }, false, ); expect(notRaised.files["src/keep.ts"]).toBe(2099); // seed=true is the first-commit path only (baseline file missing). Exempt - // generated paths stay out of files even at 9000 lines. Under-threshold files + // exempt paths stay out of files even at 9000 lines. Under-threshold files // stay out so the 2,000 cap remains the policy for new modules. const seeded = updateBaseline( [ @@ -131,27 +136,27 @@ describe("file-size ratchet: caps", () => { { path: "src/fresh.ts", lines: 1800 }, { path: "gui/src/i18n/en.ts", lines: 9000 }, ], - { generated: ["gui/src/i18n/en.ts"], files: {} }, + { exempt: ["gui/src/i18n/en.ts"], files: {} }, true, ); expect(seeded.files).toEqual({ "src/old.ts": 2500 }); }); - test("GENERATED: baseline.generated 경로는 커져도 통과", () => { - // Exact paths only. A sibling under cursor/gen/ that is not in generated[] is a + test("EXEMPT: baseline.exempt 경로는 커져도 통과", () => { + // Exact paths only. A sibling under cursor/gen/ that is not in exempt[] is a // new oversized file, even though a glob would have exempted the whole directory. const path = "src/adapters/cursor/gen/agent_pb.ts"; const baseline: Baseline = { - generated: [path], + exempt: [path], files: { [path]: 100 }, }; const rows = evaluate([{ path, lines: 99_999 }], baseline); - expect(rows).toEqual([{ path, lines: 99_999, verdict: "GENERATED" }]); + expect(rows).toEqual([{ path, lines: 99_999, verdict: "EXEMPT" }]); expect(rows.filter(isOffender)).toEqual([]); const globWouldHaveCaught = evaluate( [{ path: "src/adapters/cursor/gen/hand-written.ts", lines: 2500 }], - { generated: [path], files: {} }, + { exempt: [path], files: {} }, ); expect(globWouldHaveCaught[0]?.verdict).toBe("NEW_OVERSIZED"); }); @@ -192,11 +197,11 @@ describe("file-size ratchet: repository", () => { test("저장소 스캔: 커밋된 기준선 대비 offender가 없다", () => { // Mirrors tests/ci-workflows/repo-hygiene.test.ts: git ls-files + expect([]). // An empty scan would also equal [], so scanned.length > 0 is the non-vacuous - // guard. generated[] is the committed JSON, not the script constant used alone. + // guard. exempt[] is the committed JSON, not the script constant used alone. const baseline = loadBaseline( readFileSync(repoPath("tests/fixtures/file-size-baseline.json"), "utf8"), ); - expect(baseline.generated).toEqual([...GENERATED_PATHS]); + expect(baseline.exempt).toEqual([...EXEMPT_PATHS]); const scanned = scanRepo(repoRoot()); expect(scanned.length).toBeGreaterThan(0); @@ -206,7 +211,73 @@ describe("file-size ratchet: repository", () => { const rows = evaluate(scanned, baseline); expect(rows.filter(isOffender)).toEqual([]); expect( - rows.filter((row) => row.verdict === "GENERATED").map((row) => row.path).sort(), - ).toEqual([...GENERATED_PATHS].slice().sort()); + rows.filter((row) => row.verdict === "EXEMPT").map((row) => row.path).sort(), + ).toEqual([...EXEMPT_PATHS].slice().sort()); + }); +}); + +/** + * The list said "generated" and eleven of its twelve entries were hand-written. Nothing + * failed, because nothing checked: the name was the only claim, and a name cannot be wrong + * loudly. These two cases make the claim checkable — the first against the files themselves, + * the second against the update rule the exemption relies on. + */ +describe("file-size ratchet: exemption classification", () => { + const GENERATOR_BANNER = /@generated|DO NOT EDIT|Do not edit/; + + const headOf = (path: string): string => + readFileSync(repoPath(path), "utf8").split("\n").slice(0, 12).join("\n"); + + test("분류: 세 목록은 서로소이고 합집합이 면제 목록이다", () => { + const lists = [GENERATED_PATHS, I18N_CATALOG_PATHS, DATA_SNAPSHOT_PATHS].map(list => [...list]); + const all = lists.flat(); + + // Exact allowlists: no duplicates within a list, none across two lists, and the union is + // the exemption itself. A path that drifts into two categories would be exempt for two + // contradictory reasons and reviewable under neither. + expect(new Set(all).size).toBe(all.length); + expect([...all].sort()).toEqual([...EXEMPT_PATHS]); + expect(EXEMPT_PATHS.length).toBe(12); + + // Every exemption names a file that is actually here. A stale entry exempts nothing and + // hides the fact that the policy no longer describes this tree. + for (const path of EXEMPT_PATHS) expect(readFileSync(repoPath(path), "utf8").length).toBeGreaterThan(0); + }); + + test("분류: generated로 분류된 파일만 생성기 배너를 가진다", () => { + // The oracle is the file's own first lines, not this list. agent_pb.ts opens with + // "@generated by protoc-gen-es"; that is what makes it generated, and it is the only + // exemption that can say so. + for (const path of GENERATED_PATHS) expect(GENERATOR_BANNER.test(headOf(path))).toBe(true); + for (const path of [...I18N_CATALOG_PATHS, ...DATA_SNAPSHOT_PATHS]) { + expect(GENERATOR_BANNER.test(headOf(path))).toBe(false); + } + + // Positive control on the other side: the real output of the generator whose INPUT used to + // sit on the generated list does carry the banner, and is scanned under a cap like any + // other source file. Naming the input "generated" had it exactly backwards. + expect(GENERATOR_BANNER.test(headOf("src/generated/model-metadata.ts"))).toBe(true); + expect([...EXEMPT_PATHS]).not.toContain("src/generated/model-metadata.ts"); + expect(isScannedPath("src/generated/model-metadata.ts")).toBe(true); + }); + + test("면제: --update는 면제 경로에 캡을 만들지 않고 기존 캡을 올리지도 않는다", () => { + const exemptPath = "gui/src/i18n/en.ts"; + const baseline: Baseline = { exempt: [exemptPath], files: { "src/held.ts": 2500 } }; + const current: FileSize[] = [ + { path: exemptPath, lines: 9000 }, + { path: "src/held.ts", lines: 9999 }, + ]; + + // Seeding is the only path that adds caps, and it skips exempt paths: an exemption that + // silently acquired a cap would start failing on the next line added to a catalogue. + const seeded = updateBaseline(current, baseline, true); + expect(seeded.files[exemptPath]).toBeUndefined(); + expect(seeded.exempt).toEqual([exemptPath]); + // Math.min, so a file that grew keeps the cap it had. The ratchet only ever tightens. + expect(seeded.files["src/held.ts"]).toBe(2500); + + const shrunk = updateBaseline([{ path: "src/held.ts", lines: 40 }], baseline, false); + expect(shrunk.files["src/held.ts"]).toBe(40); }); }); diff --git a/tests/fixtures/file-size-baseline.json b/tests/fixtures/file-size-baseline.json index 462abdc1f5..619f6c8ff7 100644 --- a/tests/fixtures/file-size-baseline.json +++ b/tests/fixtures/file-size-baseline.json @@ -1,7 +1,6 @@ { - "generated": [ - "scripts/model-metadata.source.json", - "src/adapters/cursor/gen/agent_pb.ts", + "exempt": [ + "docs-site/src/data/frontier-benchmarks.json", "gui/src/i18n/de.ts", "gui/src/i18n/en.ts", "gui/src/i18n/fr.ts", @@ -9,9 +8,10 @@ "gui/src/i18n/ko.ts", "gui/src/i18n/ru.ts", "gui/src/i18n/tr.ts", - "gui/src/i18n/zh.ts", "gui/src/i18n/zh-TW.ts", - "docs-site/src/data/frontier-benchmarks.json" + "gui/src/i18n/zh.ts", + "scripts/model-metadata.source.json", + "src/adapters/cursor/gen/agent_pb.ts" ], "files": { ".github/scripts/issue-quality.test.cjs": 2143, From cf851137ef0b9dbd1b8ba3d3e411236bdd740721 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:28:26 +0900 Subject: [PATCH 3/3] docs(architecture): name the modules that own the code after the facade splits (#4711) The architecture pages and structure/runtime.md still described the module ownership that existed before the facade splits of the last release train, so a reader following them landed in a file that no longer contains the code. Routing now lives in src/server/index/serve-options.ts, request preparation in src/server/responses/request-prepare.ts, and bridge conversion in src/bridge/ sse.ts and src/bridge/response-json.ts. src/bridge.ts is seven lines of re-exports. The pages named the pre-split files in every locale, so fixing only the English source would have left seven translations contradicting it. The facade paragraph also carried three counts -- seven, nine and five leaf modules -- that were already 26, 42 and 53. Counts that must be recounted on every split are a drift source rather than information, so they are gone; what replaces them is the distinction the counts were standing in for. A facade is the stable import path, not the implementation, and each step of the request flow now names the module that owns the code. structure/runtime.md gets the same correction on five ownership claims, and points at structure/transports/responses.md, which already carries the post-split owner inventory for the Responses surface. Scope is deliberately narrow: only claims about which module owns which responsibility change. No prose about behaviour is rewritten, and no locale receives a translation it did not already have. Closes #4711 --- .../content/docs/fr/reference/architecture.md | 19 ++++++------ .../content/docs/ja/reference/architecture.md | 28 +++++++++-------- .../content/docs/ko/reference/architecture.md | 26 +++++++++------- .../content/docs/reference/architecture.md | 27 +++++++++-------- .../content/docs/ru/reference/architecture.md | 28 +++++++++-------- .../content/docs/tr/reference/architecture.md | 30 +++++++++++-------- .../docs/zh-cn/reference/architecture.md | 27 +++++++++-------- .../docs/zh-tw/reference/architecture.md | 27 +++++++++-------- structure/runtime.md | 19 +++++++----- 9 files changed, 130 insertions(+), 101 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/architecture.md b/docs-site/src/content/docs/fr/reference/architecture.md index 746ea7f726..14885a808e 100644 --- a/docs-site/src/content/docs/fr/reference/architecture.md +++ b/docs-site/src/content/docs/fr/reference/architecture.md @@ -21,7 +21,8 @@ src/ ├── vision/ # service auxiliaire de vision (description et planification) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -32,19 +33,19 @@ src/ └── index.ts # public entry ``` -Trois anciens points d’entrée volumineux préservent désormais la compatibilité sous forme de façades : `codex/catalog.ts` exporte les sept modules spécialisés `codex/catalog/*.ts`, `server/management-api.ts` répartit les requêtes entre les neuf modules `server/management/*.ts`, et `server/responses.ts` exporte les cinq modules `server/responses/*.ts`. +Les anciens points d’entrée volumineux préservent désormais la compatibilité sous forme de façades : `codex/catalog.ts` exporte les modules `codex/catalog/*.ts`, `server/management-api.ts` répartit les requêtes entre les modules `server/management/*.ts`, `server/responses.ts` exporte les modules `server/responses/*.ts`, et `bridge.ts` réexporte les modules `bridge/*.ts`. Une façade est le chemin d’import stable, pas l’implémentation : chaque étape ci-dessous nomme le module qui détient le code, et `structure/transports/responses.md` contient l’inventaire complet des propriétaires de la surface Responses. ## Flux d’une requête -`server/index.ts` gère la frontière HTTP et délègue le plan de données Responses à la façade `server/responses.ts` et à ses modules `server/responses/*.ts` : +`server/index/serve-options.ts` gère la frontière HTTP et délègue le plan de données Responses à la façade `server/responses.ts` et à ses modules `server/responses/*.ts` : -1. `server/index.ts` applique CORS et l’authentification d’API, refuse les nouvelles tâches pendant le drainage et enregistre les métadonnées du cycle de vie de la requête. Il sert `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` (relayés vers une famille OpenAI en amont par `server/images.ts` pour l’outil `image_gen` intégré à Codex), `POST /v1/live` / `POST /v1/realtime/calls` (création des appels vocaux ChatGPT / Codex App et OpenAI Realtime, relayée par `server/live.ts`), les connexions WebSocket sideband sur `/v1/live/{callId}` (et `/v1/realtime?call_id=`), ainsi que la mise à niveau WebSocket facultative sur `/v1/responses`. -2. `server/responses/core.ts` décompresse et analyse le JSON, développe les entrées de mémoire locale `previous_response_id` lorsqu’elles sont disponibles, puis appelle `responses/parser.ts`. +1. `server/index/serve-options.ts` applique CORS et l’authentification d’API, refuse les nouvelles tâches pendant le drainage et enregistre les métadonnées du cycle de vie de la requête. Il sert `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` (relayés vers une famille OpenAI en amont par `server/images.ts` pour l’outil `image_gen` intégré à Codex), `POST /v1/live` / `POST /v1/realtime/calls` (création des appels vocaux ChatGPT / Codex App et OpenAI Realtime, relayée par `server/live.ts`), les connexions WebSocket sideband sur `/v1/live/{callId}` (et `/v1/realtime?call_id=`), ainsi que la mise à niveau WebSocket facultative sur `/v1/responses`. +2. `server/responses/request-prepare.ts` décompresse et analyse le JSON, développe les entrées de mémoire locale `previous_response_id` lorsqu’elles sont disponibles, puis appelle `responses/parser.ts`. 3. `router.ts` résout un identifiant simple ou `provider/model`. Le serveur détermine ensuite l’affinité du compte Codex, actualise l’authentification OAuth du fournisseur si nécessaire et applique à la route les identifiants sélectionnés. 4. Avant l’appel principal, `vision/` décrit les images pour les modèles figurant dans `noVisionModels`. En l’absence de service auxiliaire sûr, les images sont supprimées plutôt qu’envoyées à un service en amont purement textuel. 5. `server/adapter-resolve.ts` applique toute substitution de protocole propre au modèle et construit l’un des adaptateurs enregistrés. L’adaptateur Responses relaie le corps natif, Cursor exécute son transport bidirectionnel `runTurn`, et les adaptateurs traduits construisent, envoient et analysent une requête en amont. 6. Pour les modèles routés avec un outil hébergé `web_search`, `web-search/` expose une fonction synthétique, exécute la recherche réelle avec le backend configuré — le service auxiliaire OpenAI/ChatGPT ou le backend Anthropic —, renvoie les résultats au modèle routé et recommence dans la limite de boucle configurée. Cette boucle ne prend en charge que le chemin HTTP classique ; les adaptateurs qui implémentent `runTurn`, comme Cursor, la contournent et poursuivent leur propre transport. -7. `bridge.ts` produit un flux SSE Responses ou une réponse JSON. `server/request-log.ts` et `usage/` recueillent de manière bornée l’état, la latence, les libellés de fournisseur/modèle et l’utilisation estimée des jetons, sans modifier la réponse. +7. `bridge/sse.ts` / `bridge/response-json.ts` produit un flux SSE Responses ou une réponse JSON. `server/request-log.ts` et `usage/` recueillent de manière bornée l’état, la latence, les libellés de fournisseur/modèle et l’utilisation estimée des jetons, sans modifier la réponse. ## Analyseur @@ -57,7 +58,7 @@ Trois anciens points d’entrée volumineux préservent désormais la compatibil ## Pont -`bridge.ts` transforme le flux interne `AdapterEvent` de l’adaptateur en événements SSE Responses compris par Codex : +`bridge/sse.ts` transforme le flux interne `AdapterEvent` de l’adaptateur en événements SSE Responses compris par Codex : | AdapterEvent | Événements SSE Responses émis | | --- | --- | @@ -85,7 +86,7 @@ Les implémentations OAuth se trouvent dans `oauth/`. Les jetons d’accès sont ## Transport et compactage -Par défaut, `server/index.ts` sert HTTP/SSE sur `/v1/responses`. Si Codex tente une mise à niveau WebSocket de Responses alors que `websockets` vaut `false`, opencodex renvoie `426 upgrade_required` ; Codex revient alors à HTTP pour cette session. Lorsque `"websockets": true` est défini, le même point de terminaison accepte la mise à niveau et utilise le pont WebSocket. +Par défaut, `server/index/serve-options.ts` sert HTTP/SSE sur `/v1/responses`. Si Codex tente une mise à niveau WebSocket de Responses alors que `websockets` vaut `false`, opencodex renvoie `426 upgrade_required` ; Codex revient alors à HTTP pour cette session. Lorsque `"websockets": true` est défini, le même point de terminaison accepte la mise à niveau et utilise le pont WebSocket. Indépendamment de ce réglage côté client, les requêtes canoniques transmises à ChatGPT avec `stream: true` à la racine peuvent utiliser le transport WebSocket en amont de Codex avec une version stable de Bun 1.4.0 ou ultérieure. La version intégrée Bun 1.3.14, les préversions et les identités de runtime impossibles à vérifier utilisent HTTP/SSE. Les réponses WS en amont qui réussissent conservent le contrat SSE en aval et contournent `tee()` au moyen d’un relais borné à lecteur unique et avide (4 MiB par trame brute/enveloppée et une file de production de 8 MiB). Le dépassement de la file ferme la connexion en amont et émet en aval un événement terminal `response.failed`, suivi de `[DONE]`. @@ -98,7 +99,7 @@ l’ancien socket ; les requêtes admissibles suivantes ayant la même identité le nouveau socket. Les autres modèles et passerelles conservent leur politique Lite. Des métadonnées natives mal formées entraînent toujours un repli HTTP, sans modifier le corps. -Le compactage du contexte Codex fonctionne avec les modèles routés. `server/responses/compact.ts` traite `POST /v1/responses/compact` en exécutant un tour interne de synthèse routé et en renvoyant un historique compacté, tandis que `responses/parser.ts` et `bridge.ts` traitent les tours de compactage distant v2 `compaction_trigger` en émettant exactement un élément de sortie synthétique `compaction`. +Le compactage du contexte Codex fonctionne avec les modèles routés. `server/responses/compact.ts` traite `POST /v1/responses/compact` en exécutant un tour interne de synthèse routé et en renvoyant un historique compacté, tandis que `responses/parser.ts` et `bridge/sse.ts` traitent les tours de compactage distant v2 `compaction_trigger` en émettant exactement un élément de sortie synthétique `compaction`. ## Mise en cache et catalogue diff --git a/docs-site/src/content/docs/ja/reference/architecture.md b/docs-site/src/content/docs/ja/reference/architecture.md index 3500cbaf86..a803cd9784 100644 --- a/docs-site/src/content/docs/ja/reference/architecture.md +++ b/docs-site/src/content/docs/ja/reference/architecture.md @@ -21,7 +21,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -32,30 +33,33 @@ src/ └── index.ts # public entry ``` -以前の大規模なエントリーファイル 3 つは、現在は互換性 facade です。`codex/catalog.ts` は -7 個の `codex/catalog/*.ts` モジュールを、`server/management-api.ts` は 9 個の -`server/management/*.ts` モジュールを、`server/responses.ts` は 5 個の -`server/responses/*.ts` モジュールを接続します。 +大規模だったエントリーファイルは、現在は互換性 facade です。`codex/catalog.ts` は +`codex/catalog/*.ts` モジュールを、`server/management-api.ts` は +`server/management/*.ts` モジュールを、`server/responses.ts` は +`server/responses/*.ts` モジュールを、`bridge.ts` は `bridge/*.ts` モジュールを接続します。 +facade は安定した import パスであって実装ではありません。以下の各ステップは実際に +コードを所有するモジュールを示し、Responses 面の完全な所有権一覧は +`structure/transports/responses.md` にあります。 ## リクエスト処理フロー -HTTP の境界は `server/index.ts` が担い、Responses データプレーンは `server/responses.ts` facade と +HTTP の境界は `server/index/serve-options.ts` が担い、Responses データプレーンは `server/responses.ts` facade と `server/responses/*.ts` モジュールに渡します。 -1. `server/index.ts` で CORS と API 認証を確認し、終了待ち状態なら新規リクエストを拒否したのち、リクエストのライフサイクルを記録します。ここで `GET /v1/models`、`POST /v1/responses`、 +1. `server/index/serve-options.ts` で CORS と API 認証を確認し、終了待ち状態なら新規リクエストを拒否したのち、リクエストのライフサイクルを記録します。ここで `GET /v1/models`、`POST /v1/responses`、 `POST /v1/responses/compact`、`POST /v1/images/generations` / `POST /v1/images/edits` (Codex 組み込み `image_gen` ツール用 — `server/images.ts` が OpenAI 系の上流に中継)、 `POST /v1/live` / `POST /v1/realtime/calls`(ChatGPT / Codex App 音声と OpenAI Realtime の call-create、`server/live.ts` が中継)と `/v1/live/{callId}` サイドバンド WebSocket、 `/v1/responses` のオプション WebSocket アップグレードを提供します。 -2. `server/responses/core.ts` が展開し JSON を読みます。覚えておいた `previous_response_id` 入力があれば展開したのち `responses/parser.ts` に渡します。 +2. `server/responses/request-prepare.ts` が展開し JSON を読みます。覚えておいた `previous_response_id` 入力があれば展開したのち `responses/parser.ts` に渡します。 3. `router.ts` が通常のモデル id または `provider/model` id を解決します。続いて Codex アカウント affinity を決定し、必要ならプロバイダー OAuth を更新して選択された認証情報を route に適用します。 4. 本リクエストの前に `vision/` が `noVisionModels` モデル用の画像説明を作ります。安全なサイドカー経路がないときはテキスト専用の上流に画像を送らず取り除きます。 5. `server/adapter-resolve.ts` がモデル別の wire override を適用し、登録済みアダプターのいずれかを作ります。 Responses passthrough は元の body を中継し、Cursor は双方向 `runTurn` transport を使い、 残りの変換型アダプターは上流リクエストを build/fetch/parse します。 6. ルーティングモデルがホステッド `web_search` を要求すると `web-search/` が合成関数を公開します。実際の検索は ChatGPT サイドカーで実行し、結果をルーティングモデルに戻し、設定された回数の中で繰り返します。 -7. `bridge.ts` が Responses SSE または JSON を作ります。`server/request-log.ts` と `usage/` はレスポンスに触れずに終了ステータス、レイテンシー、プロバイダー/モデル、最善推定トークン使用量を記録します。 +7. `bridge/sse.ts` / `bridge/response-json.ts` が Responses SSE または JSON を作ります。`server/request-log.ts` と `usage/` はレスポンスに触れずに終了ステータス、レイテンシー、プロバイダー/モデル、最善推定トークン使用量を記録します。 ## パーサー @@ -73,7 +77,7 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン ## ブリッジ -`bridge.ts` はアダプターの内部 `AdapterEvent` ストリームを Codex が理解する Responses SSE に再変換します: +`bridge/sse.ts` はアダプターの内部 `AdapterEvent` ストリームを Codex が理解する Responses SSE に再変換します: | AdapterEvent | Responses SSE emitted | | --- | --- | @@ -95,7 +99,7 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン ## 伝送と compaction -`server/index.ts` はデフォルトで `/v1/responses` を HTTP/SSE で提供します。`websockets` が `false` の状態で Codex が Responses WebSocket アップグレードを試みると、opencodex は `426 upgrade_required` を返し、Codex はそのセッションで HTTP にフォールバックします。`"websockets": true` を設定すると同じエンドポイントがアップグレードを受け入れ WebSocket ブリッジを使います。 +`server/index/serve-options.ts` はデフォルトで `/v1/responses` を HTTP/SSE で提供します。`websockets` が `false` の状態で Codex が Responses WebSocket アップグレードを試みると、opencodex は `426 upgrade_required` を返し、Codex はそのセッションで HTTP にフォールバックします。`"websockets": true` を設定すると同じエンドポイントがアップグレードを受け入れ WebSocket ブリッジを使います。 最終送信モデルが `gpt-5.3-codex-spark` の場合、canonical ChatGPT 転送は HTTP ヘッダーと ネイティブ WS フレームのメタデータの両方で Responses Lite を明示的に無効にします。 @@ -108,7 +112,7 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン Codex コンテキスト compaction はルーティングされたモデルでも動作します。`server/responses/compact.ts` は `POST /v1/responses/compact` を内部ルーティング要約ターンとして扱い、圧縮されたヒストリーを返します。 -`responses/parser.ts` と `bridge.ts` は remote compaction v2 の `compaction_trigger` ターンを扱い、合成 `compaction` 出力項目を正確に 1 つ送ります。 +`responses/parser.ts` と `bridge/sse.ts` は remote compaction v2 の `compaction_trigger` ターンを扱い、合成 `compaction` 出力項目を正確に 1 つ送ります。 ## キャッシュとカタログ diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index d95bf391ad..31548f2ad3 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -23,7 +23,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -34,23 +35,26 @@ src/ └── index.ts # public entry ``` -기존의 대형 진입 파일 세 개는 이제 호환성 facade입니다. `codex/catalog.ts`는 7개의 -`codex/catalog/*.ts` 모듈을, `server/management-api.ts`는 9개의 `server/management/*.ts` -모듈을, `server/responses.ts`는 5개의 `server/responses/*.ts` 모듈을 연결합니다. +기존의 대형 진입 파일들은 이제 호환성 facade입니다. `codex/catalog.ts`는 +`codex/catalog/*.ts` 모듈을, `server/management-api.ts`는 `server/management/*.ts` +모듈을, `server/responses.ts`는 `server/responses/*.ts` 모듈을, `bridge.ts`는 `bridge/*.ts` +모듈을 연결합니다. facade는 안정적인 import 경로일 뿐 구현이 아닙니다. 아래 각 단계는 +실제 코드를 소유한 모듈을 가리키며, Responses 표면의 전체 소유권 목록은 +`structure/transports/responses.md`에 있습니다. ## 요청 처리 흐름 -HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `server/responses.ts` facade와 +HTTP 경계는 `server/index/serve-options.ts`가 맡고, Responses 데이터 플레인은 `server/responses.ts` facade와 `server/responses/*.ts` 모듈로 넘깁니다. -1. `server/index.ts`에서 CORS와 API 인증을 확인하고, 종료 대기 중이면 새 요청을 거부한 뒤 요청 수명 +1. `server/index/serve-options.ts`에서 CORS와 API 인증을 확인하고, 종료 대기 중이면 새 요청을 거부한 뒤 요청 수명 주기를 기록합니다. 여기서 `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` (Codex 내장 `image_gen` 도구용 — `server/images.ts`가 OpenAI 계열 업스트림으로 중계), `POST /v1/live` / `POST /v1/realtime/calls`(ChatGPT / Codex App 음성 및 OpenAI Realtime 호출 생성, `server/live.ts`가 중계)와 `/v1/live/{callId}` 사이드밴드 WebSocket, 그리고 `/v1/responses`의 선택적 WebSocket 업그레이드를 제공합니다. -2. `server/responses/core.ts`가 압축을 풀고 JSON을 읽습니다. 기억해 둔 `previous_response_id` 입력이 있으면 +2. `server/responses/request-prepare.ts`가 압축을 풀고 JSON을 읽습니다. 기억해 둔 `previous_response_id` 입력이 있으면 펼친 다음 `responses/parser.ts`로 넘깁니다. 3. `router.ts`가 일반 모델 id 또는 `provider/model` id를 해석합니다. 이어서 Codex 계정 affinity를 결정하고, 필요하면 프로바이더 OAuth를 갱신해 선택된 자격 증명을 route에 적용합니다. @@ -61,7 +65,7 @@ HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `se 나머지 변환형 어댑터는 업스트림 요청을 build/fetch/parse합니다. 6. 라우팅 모델이 호스티드 `web_search`를 요청하면 `web-search/`가 합성 함수를 노출합니다. 실제 검색은 ChatGPT 사이드카로 실행하고 결과를 라우팅 모델에 다시 넣으며, 설정된 횟수 안에서 반복합니다. -7. `bridge.ts`가 Responses SSE 또는 JSON을 만듭니다. `server/request-log.ts`와 `usage/`는 응답을 +7. `bridge/sse.ts` / `bridge/response-json.ts`가 Responses SSE 또는 JSON을 만듭니다. `server/request-log.ts`와 `usage/`는 응답을 건드리지 않은 채 종료 상태, 지연 시간, 프로바이더/모델, 최선 추정 토큰 사용량을 기록합니다. ## 파서 @@ -83,7 +87,7 @@ HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `se ## 브리지 -`bridge.ts`는 어댑터의 내부 `AdapterEvent` 스트림을 Codex가 이해하는 Responses SSE로 다시 +`bridge/sse.ts`는 어댑터의 내부 `AdapterEvent` 스트림을 Codex가 이해하는 Responses SSE로 다시 변환합니다: | AdapterEvent | Responses SSE emitted | @@ -114,7 +118,7 @@ Responses 항목 타입으로 구분됩니다 — 따라서 MCP 네임스페이 ## 전송과 compaction -`server/index.ts`는 기본적으로 `/v1/responses`를 HTTP/SSE로 제공합니다. `websockets`가 `false`인 +`server/index/serve-options.ts`는 기본적으로 `/v1/responses`를 HTTP/SSE로 제공합니다. `websockets`가 `false`인 상태에서 Codex가 Responses WebSocket 업그레이드를 시도하면 opencodex는 `426 upgrade_required`를 반환하고, Codex는 해당 세션에서 HTTP로 폴백합니다. `"websockets": true`가 설정되면 같은 엔드포인트가 업그레이드를 받아들이고 WebSocket 브리지를 사용합니다. @@ -138,7 +142,7 @@ Lite 정책을 유지합니다. 네이티브 메타데이터 형식이 잘못된 Codex 컨텍스트 compaction은 라우팅된 모델에서도 동작합니다. `server/responses/compact.ts`는 `POST /v1/responses/compact`를 내부 라우팅 요약 턴으로 처리해 압축된 히스토리를 반환합니다. -`responses/parser.ts`와 `bridge.ts`는 remote compaction v2의 `compaction_trigger` 턴을 처리해 +`responses/parser.ts`와 `bridge/sse.ts`는 remote compaction v2의 `compaction_trigger` 턴을 처리해 합성 `compaction` 출력 항목을 정확히 하나 내보냅니다. ## 캐싱과 카탈로그 diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index a9bdf48978..c04f647b72 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -23,7 +23,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -34,17 +35,19 @@ src/ └── index.ts # public entry ``` -Three formerly large entry files now preserve compatibility as facades: `codex/catalog.ts` exports -the seven focused `codex/catalog/*.ts` modules, `server/management-api.ts` dispatches to the nine -`server/management/*.ts` modules, and `server/responses.ts` exports the five -`server/responses/*.ts` modules. +Several formerly large entry files now preserve compatibility as facades: `codex/catalog.ts` exports +its focused `codex/catalog/*.ts` modules, `server/management-api.ts` dispatches to +`server/management/*.ts`, `server/responses.ts` exports `server/responses/*.ts`, and `bridge.ts` +re-exports `bridge/*.ts`. A facade is the stable import path, not the implementation: each step +below names the module that owns the code, and `structure/transports/responses.md` carries the +full owner inventory for the Responses surface. ## Request flow -`server/index.ts` owns the HTTP boundary and delegates the Responses data plane to +`server/index/serve-options.ts` owns the HTTP boundary and delegates the Responses data plane to the `server/responses.ts` facade and its `server/responses/*.ts` modules: -1. `server/index.ts` applies CORS and API authentication, rejects new work while draining, and +1. `server/index/serve-options.ts` applies CORS and API authentication, rejects new work while draining, and records request lifecycle metadata. It serves `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` (relayed to an OpenAI-family upstream by `server/images.ts` for codex's built-in `image_gen` @@ -52,7 +55,7 @@ the `server/responses.ts` facade and its `server/responses/*.ts` modules: Realtime call-create, relayed by `server/live.ts`), sideband WebSocket joins on `/v1/live/{callId}` (and `/v1/realtime?call_id=`), and the optional WebSocket upgrade on `/v1/responses`. -2. `server/responses/core.ts` decompresses and parses JSON, expands locally remembered +2. `server/responses/request-prepare.ts` decompresses and parses JSON, expands locally remembered `previous_response_id` input when available, then calls `responses/parser.ts`. 3. `router.ts` resolves a bare or `provider/model` id. The server then resolves Codex account affinity, refreshes provider OAuth when needed, and applies the selected credential to the route. @@ -65,7 +68,7 @@ the `server/responses.ts` facade and its `server/responses/*.ts` modules: executes the real search through the configured backend (the OpenAI/ChatGPT sidecar or Anthropic), feeds results back to the routed model, and repeats within the configured loop limit. This loop supports only the standard HTTP path; adapters that implement `runTurn`, such as Cursor, bypass it. -7. `bridge.ts` produces Responses SSE or JSON. `server/request-log.ts` and `usage/` collect terminal +7. `bridge/sse.ts` / `bridge/response-json.ts` produces Responses SSE or JSON. `server/request-log.ts` and `usage/` collect terminal status, latency, provider/model labels, and best-effort token usage without changing the response. ## The parser @@ -86,7 +89,7 @@ the `server/responses.ts` facade and its `server/responses/*.ts` modules: ## The bridge -`bridge.ts` turns the adapter's internal `AdapterEvent` stream back into Responses SSE that Codex +`bridge/sse.ts` turns the adapter's internal `AdapterEvent` stream back into Responses SSE that Codex understands: | AdapterEvent | Responses SSE emitted | @@ -138,7 +141,7 @@ diagnostics. ## Transport and compaction -`server/index.ts` serves HTTP/SSE on `/v1/responses` by default. If Codex attempts a Responses +`server/index/serve-options.ts` serves HTTP/SSE on `/v1/responses` by default. If Codex attempts a Responses WebSocket upgrade while `websockets` is `false`, opencodex returns `426 upgrade_required`; Codex then falls back to HTTP for that session. When `"websockets": true` is set, the same endpoint accepts the upgrade and uses the WebSocket bridge. @@ -174,7 +177,7 @@ retry after compaction. Non-streaming API callers continue to receive the provid Codex context compaction works for routed models. `server/responses/compact.ts` handles `POST /v1/responses/compact` by running an internal routed summarization turn and returning compacted -history, while `responses/parser.ts` and `bridge.ts` handle remote compaction v2 +history, while `responses/parser.ts` and `bridge/sse.ts` handle remote compaction v2 `compaction_trigger` turns by emitting exactly one synthetic `compaction` output item. ## Caching & the catalog diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index cd3f776efb..43db27e8fb 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -24,7 +24,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -35,17 +36,20 @@ src/ └── index.ts # public entry ``` -Три прежних крупных входных файла теперь служат фасадами совместимости: `codex/catalog.ts` -экспортирует семь модулей `codex/catalog/*.ts`, `server/management-api.ts` направляет запросы в -девять модулей `server/management/*.ts`, а `server/responses.ts` экспортирует пять модулей -`server/responses/*.ts`. +Прежние крупные входные файлы теперь служат фасадами совместимости: `codex/catalog.ts` +экспортирует модули `codex/catalog/*.ts`, `server/management-api.ts` направляет запросы в +модули `server/management/*.ts`, `server/responses.ts` экспортирует модули +`server/responses/*.ts`, а `bridge.ts` реэкспортирует модули `bridge/*.ts`. Фасад — это +стабильный путь импорта, а не реализация: каждый шаг ниже называет модуль, которому +принадлежит код, а полный перечень владельцев поверхности Responses находится в +`structure/transports/responses.md`. ## Поток запроса -`server/index.ts` владеет HTTP-границей и делегирует плоскость данных Responses в +`server/index/serve-options.ts` владеет HTTP-границей и делегирует плоскость данных Responses в фасад `server/responses.ts` и его модули `server/responses/*.ts`: -1. `server/index.ts` применяет CORS и аутентификацию API, отклоняет новую работу во время +1. `server/index/serve-options.ts` применяет CORS и аутентификацию API, отклоняет новую работу во время завершения (drain) и записывает метаданные жизненного цикла запроса. Он обслуживает `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST /v1/images/edits` @@ -54,7 +58,7 @@ src/ (создание голосового/Realtime-вызова ChatGPT / Codex App, ретранслируется `server/live.ts`), sideband WebSocket на `/v1/live/{callId}`, а также необязательный WebSocket-апгрейд на `/v1/responses`. -2. `server/responses/core.ts` распаковывает и парсит JSON, разворачивает локально запомненный вход +2. `server/responses/request-prepare.ts` распаковывает и парсит JSON, разворачивает локально запомненный вход `previous_response_id`, когда он доступен, затем вызывает `responses/parser.ts`. 3. `router.ts` разрешает «голый» id или id вида `provider/model`. Затем сервер определяет привязку (affinity) аккаунта Codex, при необходимости обновляет OAuth провайдера и применяет @@ -70,7 +74,7 @@ src/ предоставляет синтетическую функцию, выполняет настоящий поиск через сайдкар ChatGPT, возвращает результаты маршрутизируемой модели и повторяет это в пределах настроенного лимита цикла. -7. `bridge.ts` формирует Responses SSE или JSON. `server/request-log.ts` и `usage/` собирают +7. `bridge/sse.ts` / `bridge/response-json.ts` формирует Responses SSE или JSON. `server/request-log.ts` и `usage/` собирают итоговый статус, задержку, метки провайдера/модели и оценку использования токенов, не изменяя ответ. @@ -95,7 +99,7 @@ src/ ## Мост -`bridge.ts` превращает поток внутренних событий `AdapterEvent` адаптера обратно в Responses SSE, +`bridge/sse.ts` превращает поток внутренних событий `AdapterEvent` адаптера обратно в Responses SSE, понятный Codex: | AdapterEvent | Responses SSE emitted | @@ -146,7 +150,7 @@ loopback; настроенные записи `corsAllowOrigins` расширя ## Транспорт и compaction -`server/index.ts` по умолчанию обслуживает HTTP/SSE на `/v1/responses`. Если Codex пытается +`server/index/serve-options.ts` по умолчанию обслуживает HTTP/SSE на `/v1/responses`. Если Codex пытается выполнить WebSocket-апгрейд Responses, пока `websockets` равно `false`, opencodex возвращает `426 upgrade_required`; Codex тогда откатывается на HTTP для этой сессии. Когда установлено `"websockets": true`, та же конечная точка принимает апгрейд и использует WebSocket-мост. @@ -162,7 +166,7 @@ loopback; настроенные записи `corsAllowOrigins` расширя Compaction контекста Codex работает для маршрутизируемых моделей. `server/responses/compact.ts` обрабатывает `POST /v1/responses/compact`, выполняя внутренний маршрутизируемый ход суммаризации -и возвращая сжатую историю, а `responses/parser.ts` и `bridge.ts` обрабатывают ходы +и возвращая сжатую историю, а `responses/parser.ts` и `bridge/sse.ts` обрабатывают ходы `compaction_trigger` из remote compaction v2, генерируя ровно один синтетический выходной элемент `compaction`. diff --git a/docs-site/src/content/docs/tr/reference/architecture.md b/docs-site/src/content/docs/tr/reference/architecture.md index 5dd9b99957..f3350af415 100644 --- a/docs-site/src/content/docs/tr/reference/architecture.md +++ b/docs-site/src/content/docs/tr/reference/architecture.md @@ -24,7 +24,8 @@ src/ ├── vision/ # vizyon sidecar'ı (açıklama + plan) ├── config.ts # ~/.opencodex/config.json, varsayılanlar, PID, ortam çözümleme ├── router.ts # model kimliği → sağlayıcı + adaptör -├── bridge.ts # AdapterEvent akışı → Responses SSE / JSON +├── bridge.ts # bridge/ üzerinde cephe +├── bridge/ # AdapterEvent akışı → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # akıl yürütme çabası çevirisi, sabitleme ve katalog seviyeleri ├── responses/ │ ├── parser.ts # Responses isteği → OcxParsedRequest @@ -35,19 +36,22 @@ src/ └── index.ts # genel giriş noktası ``` -Eskiden büyük olan üç giriş dosyası artık cepheler (facades) olarak uyumluluğu -korur: `codex/catalog.ts` odaklanmış yedi `codex/catalog/*.ts` modülünü dışa -aktarır, `server/management-api.ts` dokuz `server/management/*.ts` modülüne -dağıtır ve `server/responses.ts` beş `server/responses/*.ts` modülünü dışa -aktarır. +Eskiden büyük olan giriş dosyaları artık cepheler (facades) olarak uyumluluğu +korur: `codex/catalog.ts` `codex/catalog/*.ts` modüllerini dışa aktarır, +`server/management-api.ts` `server/management/*.ts` modüllerine dağıtır, +`server/responses.ts` `server/responses/*.ts` modüllerini dışa aktarır ve `bridge.ts` +`bridge/*.ts` modüllerini yeniden dışa aktarır. Cephe, uygulamanın kendisi değil +kararlı içe aktarma yoludur: aşağıdaki her adım kodun sahibi olan modülü +adlandırır ve Responses yüzeyinin tam sahiplik envanteri +`structure/transports/responses.md` dosyasındadır. ## İstek akışı -`server/index.ts` HTTP sınırına sahiptir ve Responses veri düzlemini +`server/index/serve-options.ts` HTTP sınırına sahiptir ve Responses veri düzlemini `server/responses.ts` cephesine ve onun `server/responses/*.ts` modüllerine devreder: -1. `server/index.ts` CORS ve API kimlik doğrulamasını uygular, boşaltma +1. `server/index/serve-options.ts` CORS ve API kimlik doğrulamasını uygular, boşaltma sırasında yeni işleri reddeder ve istek yaşam döngüsü meta verilerini kaydeder. `GET /v1/models`, `POST /v1/responses`, `POST /v1/responses/compact`, `POST /v1/images/generations` / `POST @@ -58,7 +62,7 @@ devreder: `/v1/live/{callId}` (ve `/v1/realtime?call_id=`) üzerindeki yan bant WebSocket katılımlarını ve `/v1/responses` üzerindeki isteğe bağlı WebSocket yükseltmesini sunar. -2. `server/responses/core.ts` JSON'ı açar ve ayrıştırır, kullanılabilir +2. `server/responses/request-prepare.ts` JSON'ı açar ve ayrıştırır, kullanılabilir olduğunda yerel olarak hatırlanan `previous_response_id` girdisini genişletir, ardından `responses/parser.ts`'yi çağırır. 3. `router.ts` yalın veya `sağlayıcı/model` kimliğini çözer. Sunucu daha sonra @@ -75,7 +79,7 @@ devreder: `web-search/` sentetik bir fonksiyon sunar, gerçek aramayı ChatGPT sidecar'ı aracılığıyla yürütür, sonuçları yönlendirilen modele geri besler ve yapılandırılmış döngü sınırı içinde tekrarlar. -7. `bridge.ts` Responses SSE veya JSON üretir. `server/request-log.ts` ve +7. `bridge/sse.ts` / `bridge/response-json.ts` Responses SSE veya JSON üretir. `server/request-log.ts` ve `usage/` yanıtı değiştirmeden uç durumu, gecikmeyi, sağlayıcı/model etiketlerini ve en iyi çaba belirteç kullanımını toplar. @@ -102,7 +106,7 @@ ardından bir `OcxParsedRequest` oluşturur: ## Köprü (Bridge) -`bridge.ts`, adaptörün dahili `AdapterEvent` akışını Codex'in anladığı Responses +`bridge/sse.ts`, adaptörün dahili `AdapterEvent` akışını Codex'in anladığı Responses SSE'ye dönüştürür: | AdapterEvent | Yayınlanan Responses SSE | @@ -164,7 +168,7 @@ tanılamaları için `usage/` tarafından toplanır. ## Aktarım ve sıkıştırma -`server/index.ts` varsayılan olarak `/v1/responses` üzerinde HTTP/SSE sunar. +`server/index/serve-options.ts` varsayılan olarak `/v1/responses` üzerinde HTTP/SSE sunar. Codex `websockets` `false` iken bir Responses WebSocket yükseltmesi denerse opencodex `426 upgrade_required` döndürür; Codex daha sonra bu oturum için HTTP'ye geri döner. `"websockets": true` ayarlandığında aynı uç nokta @@ -182,7 +186,7 @@ değiştirilmeden HTTP'ye geri dönülmeye devam edilir. Codex bağlam sıkıştırması yönlendirilen modeller için çalışır. `server/responses/compact.ts`, dahili bir yönlendirilen özetleme turu çalıştırarak ve sıkıştırılmış geçmişi döndürerek `POST /v1/responses/compact`'ı -işlerken, `responses/parser.ts` ve `bridge.ts` tam olarak bir sentetik +işlerken, `responses/parser.ts` ve `bridge/sse.ts` tam olarak bir sentetik `compaction` çıktı öğesi yayarak uzak sıkıştırma v2 `compaction_trigger` turlarını işler. diff --git a/docs-site/src/content/docs/zh-cn/reference/architecture.md b/docs-site/src/content/docs/zh-cn/reference/architecture.md index b1edbd1e8b..8bf59e184b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -23,7 +23,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -34,24 +35,26 @@ src/ └── index.ts # public entry ``` -原先的三个大型入口文件现在是兼容性 facade:`codex/catalog.ts` 导出 7 个 -`codex/catalog/*.ts` 模块,`server/management-api.ts` 分派到 9 个 -`server/management/*.ts` 模块,而 `server/responses.ts` 导出 5 个 -`server/responses/*.ts` 模块。 +原先的大型入口文件现在是兼容性 facade:`codex/catalog.ts` 导出 +`codex/catalog/*.ts` 模块,`server/management-api.ts` 分派到 +`server/management/*.ts` 模块,`server/responses.ts` 导出 `server/responses/*.ts` +模块,而 `bridge.ts` 重新导出 `bridge/*.ts` 模块。facade 只是稳定的导入路径,而不是实现: +下面每一步都指向真正拥有代码的模块,Responses 面的完整归属清单见 +`structure/transports/responses.md`。 ## 请求流程 -`server/index.ts` 负责 HTTP 边界,并把 Responses data plane 交给 `server/responses.ts` facade +`server/index/serve-options.ts` 负责 HTTP 边界,并把 Responses data plane 交给 `server/responses.ts` facade 及其 `server/responses/*.ts` 模块: -1. `server/index.ts` 应用 CORS 和 API 认证,在 drain 期间拒绝新请求,并记录请求生命周期 +1. `server/index/serve-options.ts` 应用 CORS 和 API 认证,在 drain 期间拒绝新请求,并记录请求生命周期 metadata。它提供 `GET /v1/models`、`POST /v1/responses`、 `POST /v1/responses/compact`、`POST /v1/images/generations` / `POST /v1/images/edits` (供 Codex 内置 `image_gen` 工具使用——由 `server/images.ts` 中继到 OpenAI 系上游)、 `POST /v1/live` / `POST /v1/realtime/calls`(ChatGPT / Codex App 语音与 OpenAI Realtime 建连,由 `server/live.ts` 中继)、`/v1/live/{callId}` 旁路 WebSocket, 以及 `/v1/responses` 上可选的 WebSocket upgrade。 -2. `server/responses/core.ts` 解压并解析 JSON;如果本地记住了对应输入,则展开 +2. `server/responses/request-prepare.ts` 解压并解析 JSON;如果本地记住了对应输入,则展开 `previous_response_id`,随后调用 `responses/parser.ts`。 3. `router.ts` 解析 bare id 或 `provider/model` id。server 随后确定 Codex account affinity, 必要时刷新 provider OAuth,并把选中的 credential 应用到 route。 @@ -62,7 +65,7 @@ src/ 则构建、获取并解析上游请求。 6. 路由模型请求托管的 `web_search` 工具时,`web-search/` 会暴露一个合成函数,经 ChatGPT sidecar 执行真实搜索,把结果送回路由模型,并在配置的循环上限内重复。 -7. `bridge.ts` 生成 Responses SSE 或 JSON。`server/request-log.ts` 与 `usage/` 在不改变响应的 +7. `bridge/sse.ts` / `bridge/response-json.ts` 生成 Responses SSE 或 JSON。`server/request-log.ts` 与 `usage/` 在不改变响应的 前提下收集终止状态、延迟、provider/model 标签和尽力估算的 token usage。 ## 解析器 @@ -85,7 +88,7 @@ src/ ## 桥接器 -`bridge.ts` 把 adapter 的内部 `AdapterEvent` 流转换回 Codex 能理解的 Responses SSE: +`bridge/sse.ts` 把 adapter 的内部 `AdapterEvent` 流转换回 Codex 能理解的 Responses SSE: | AdapterEvent | 发出的 Responses SSE | | --- | --- | @@ -129,7 +132,7 @@ thread affinity 位于 `codex/` 下,不会出现在管理 API 响应中。请 ## 传输与 compaction -`server/index.ts` 默认在 `/v1/responses` 上提供 HTTP/SSE。当 `websockets` 为 `false` 而 Codex +`server/index/serve-options.ts` 默认在 `/v1/responses` 上提供 HTTP/SSE。当 `websockets` 为 `false` 而 Codex 尝试 Responses WebSocket upgrade 时,opencodex 会返回 `426 upgrade_required`,Codex 随后在该 session 中回退到 HTTP。设置 `"websockets": true` 后,同一 endpoint 会接受 upgrade 并使用 WebSocket bridge。 @@ -143,7 +146,7 @@ WebSocket bridge。 Codex context compaction 同样适用于路由模型。`server/responses/compact.ts` 处理 `POST /v1/responses/compact`,运行一次内部路由 summarization turn 并返回压缩后的历史; -`responses/parser.ts` 与 `bridge.ts` 则处理 remote compaction v2 的 `compaction_trigger` turn, +`responses/parser.ts` 与 `bridge/sse.ts` 则处理 remote compaction v2 的 `compaction_trigger` turn, 准确发出一个合成的 `compaction` 输出 item。 ## 缓存与目录 diff --git a/docs-site/src/content/docs/zh-tw/reference/architecture.md b/docs-site/src/content/docs/zh-tw/reference/architecture.md index be0b2c5003..cb246c8bd0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/architecture.md +++ b/docs-site/src/content/docs/zh-tw/reference/architecture.md @@ -23,7 +23,8 @@ src/ ├── vision/ # vision sidecar (describe + plan) ├── config.ts # ~/.opencodex/config.json, defaults, PID, env resolution ├── router.ts # model id → provider + adapter -├── bridge.ts # AdapterEvent stream → Responses SSE / JSON +├── bridge.ts # facade over bridge/ +├── bridge/ # AdapterEvent stream → Responses SSE (sse.ts) / JSON (response-json.ts) ├── reasoning-effort.ts # reasoning-effort translation, clamping, and catalog levels ├── responses/ │ ├── parser.ts # Responses request → OcxParsedRequest @@ -34,24 +35,26 @@ src/ └── index.ts # public entry ``` -原先的三個大型入口檔案現在是相容性 facade:`codex/catalog.ts` 匯出 7 個 -`codex/catalog/*.ts` 模組,`server/management-api.ts` 分派到 9 個 -`server/management/*.ts` 模組,而 `server/responses.ts` 匯出 5 個 -`server/responses/*.ts` 模組。 +原先的大型入口檔案現在是相容性 facade:`codex/catalog.ts` 匯出 +`codex/catalog/*.ts` 模組,`server/management-api.ts` 分派到 +`server/management/*.ts` 模組,`server/responses.ts` 匯出 `server/responses/*.ts` +模組,而 `bridge.ts` 重新匯出 `bridge/*.ts` 模組。facade 只是穩定的匯入路徑,而不是實作: +下面每一步都指向真正擁有程式碼的模組,Responses 面的完整歸屬清單見 +`structure/transports/responses.md`。 ## 請求流程 -`server/index.ts` 負責 HTTP 邊界,並把 Responses data plane 交給 `server/responses.ts` facade +`server/index/serve-options.ts` 負責 HTTP 邊界,並把 Responses data plane 交給 `server/responses.ts` facade 及其 `server/responses/*.ts` 模組: -1. `server/index.ts` 應用 CORS 和 API 認證,在 drain 期間拒絕新請求,並記錄請求生命週期 +1. `server/index/serve-options.ts` 應用 CORS 和 API 認證,在 drain 期間拒絕新請求,並記錄請求生命週期 metadata。它提供 `GET /v1/models`、`POST /v1/responses`、 `POST /v1/responses/compact`、`POST /v1/images/generations` / `POST /v1/images/edits` (供 Codex 內建 `image_gen` 工具使用——由 `server/images.ts` 中繼到 OpenAI 繫上遊)、 `POST /v1/live` / `POST /v1/realtime/calls`(ChatGPT / Codex App 語音與 OpenAI Realtime 建連,由 `server/live.ts` 中繼)、`/v1/live/{callId}` 旁路 WebSocket, 以及 `/v1/responses` 上可選的 WebSocket upgrade。 -2. `server/responses/core.ts` 解壓並解析 JSON;如果本機記住了對應輸入,則展開 +2. `server/responses/request-prepare.ts` 解壓並解析 JSON;如果本機記住了對應輸入,則展開 `previous_response_id`,隨後呼叫 `responses/parser.ts`。 3. `router.ts` 解析 bare id 或 `provider/model` id。server 隨後確定 Codex account affinity, 必要時重新整理 provider OAuth,並把選中的 credential 應用到 route。 @@ -62,7 +65,7 @@ src/ 則建置、取得並解析上游請求。 6. 路由模型請求託管的 `web_search` 工具時,`web-search/` 會暴露一個合成函式,經 ChatGPT sidecar 執行真實搜尋,把結果送回路由模型,並在設定的迴圈上限內重複。 -7. `bridge.ts` 生成 Responses SSE 或 JSON。`server/request-log.ts` 與 `usage/` 在不改變回應的 +7. `bridge/sse.ts` / `bridge/response-json.ts` 生成 Responses SSE 或 JSON。`server/request-log.ts` 與 `usage/` 在不改變回應的 前提下收集終止狀態、延遲、provider/model 標籤和盡力估算的 token usage。 ## 解析器 @@ -85,7 +88,7 @@ src/ ## 橋接器 -`bridge.ts` 把 adapter 的內部 `AdapterEvent` 流轉換回 Codex 能理解的 Responses SSE: +`bridge/sse.ts` 把 adapter 的內部 `AdapterEvent` 流轉換回 Codex 能理解的 Responses SSE: | AdapterEvent | 發出的 Responses SSE | | --- | --- | @@ -129,7 +132,7 @@ thread affinity 位於 `codex/` 下,不會出現在管理 API 回應中。請 ## 傳輸與 compaction -`server/index.ts` 預設在 `/v1/responses` 上提供 HTTP/SSE。當 `websockets` 為 `false` 而 Codex +`server/index/serve-options.ts` 預設在 `/v1/responses` 上提供 HTTP/SSE。當 `websockets` 為 `false` 而 Codex 嘗試 Responses WebSocket upgrade 時,opencodex 會回傳 `426 upgrade_required`,Codex 隨後在該 session 中回退到 HTTP。設定 `"websockets": true` 後,同一 endpoint 會接受 upgrade 並使用 WebSocket bridge。 @@ -144,7 +147,7 @@ WebSocket bridge。 Codex context compaction 同樣適用於路由模型。`server/responses/compact.ts` 處理 `POST /v1/responses/compact`,執行一次內部路由 summarization turn 並回傳壓縮後的歷史; -`responses/parser.ts` 與 `bridge.ts` 則處理 remote compaction v2 的 `compaction_trigger` turn, +`responses/parser.ts` 與 `bridge/sse.ts` 則處理 remote compaction v2 的 `compaction_trigger` turn, 準確發出一個合成的 `compaction` 輸出 item。 ## 快取與目錄 diff --git a/structure/runtime.md b/structure/runtime.md index 248c3011c6..63ede73c97 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -31,7 +31,7 @@ When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboar | `bin/ocx.mjs` | Published npm `bin` entry (Node shim). Resolves the bundled or explicit Bun binary before project dotenv can load, stamps its runtime provenance plus a proof-bound Anthropic parent-env snapshot, lazy-runs `bun/install.js` if only the placeholder stub is present, then execs `src/cli/index.ts` under Bun. Lets `npm install -g` work without a separately-installed Bun. The exact `system codex-cli-update` inspection namespace skips both boot repair and lazy Bun installation; missing runtime support fails closed instead of mutating state. | | `src/lib/bun-runtime.ts` | Bundled-Bun resolution: `isRealBunBinary()` (size gate vs the ~450-byte placeholder stub), `bundledBunPath()`, and `durableBunPath()` (path baked into service/shim artifacts). Durable selection accepts only the source/path pair already stamped for the running executable; it never re-reads a project-dotenv `OPENCODEX_BUN_PATH`. | | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. `restart` refuses an in-place restart requested by a CLI whose version differs from the attested `/healthz` version, because the replacement respawns from the live installation; placeholder versions (unknown/0.0.0) stay incomparable and keep the restart path. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | -| `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. | +| `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. The route table itself is built by `src/server/index/serve-options.ts`; this entry file owns the listener and the startup transaction. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/server/audio-transcriptions.ts` | Standalone multipart transcription; audio-specific key admission, bounded upload/response, stored OpenAI credential resolution and lease-bound cancellation. See [audio contracts](data-planes/inbound-compat.md#standalone-file-transcription). | | `src/server/audio-live.ts`, `src/server/audio-dictation.ts` | External voice/dictation orchestration using the existing bounded socket relay, server-owned credentials, cancellation and opaque call ownership. See [streaming audio](data-planes/inbound-compat.md#streaming-audio). | @@ -62,8 +62,10 @@ there. Feature code is grouped by responsibility: `src/generated/` is build output committed for the runtime; it is not edited by hand. -`src/server/` is split by responsibility: `index.ts` owns the listener and route ordering; -`responses.ts` owns Responses handling and compaction; `images.ts` owns the standalone Images relay; +`src/server/` is split by responsibility: `index.ts` owns the listener and the startup transaction +while `index/serve-options.ts` owns route ordering; `responses.ts` and `responses/core.ts` compose +Responses handling from the owners inventoried in [Responses transport](transports/responses.md), +and `responses/compact.ts` owns compaction; `images.ts` owns the standalone Images relay; `responses/codex-auth-error.ts` owns the shared Responses/compact Codex auth-context HTTP mapping. Model entitlement denial is a 400 request error and temporary exhaustion of every model-capable account is a retryable 429; neither is reported as an invalid API key. Images, Live, and Search @@ -194,8 +196,9 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an | `src/adapters/image.ts`, `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Image conversion for adapter ingress and Anthropic-specific normalization/limits. An image's ladder position is pinned to its own identity (content hash + media type), so appending a newer image cannot re-encode older ones and bust Anthropic's prompt prefix cache (#4532). | | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/upstream-http-error.ts` | Shared adapter execution support: turn queueing, tool-catalog nudging, client identity, upstream error normalization. | -Adapter output must stay in internal `AdapterEvent` form until `bridge.ts` converts it back to -Responses SSE or WebSocket frames. +Adapter output must stay in internal `AdapterEvent` form until `src/bridge/sse.ts` converts it back +to Responses SSE or WebSocket frames, or `src/bridge/response-json.ts` buffers it into a JSON +response. `src/bridge.ts` is the compatibility facade that re-exports both. The image/video loop bounds each hidden iteration before replay or fulfillment; see [media iteration retention](transports/inventory.md#media-iteration-retention). @@ -243,7 +246,7 @@ The shared Responses path follows the [bounded multipart recovery contract](suba ### Hosted-search continuation binding -The opt-in key-auth Responses hosted-search bridge in `src/server/responses/core.ts` captures the +The opt-in key-auth Responses hosted-search bridge in `src/server/responses/passthrough-delivery.ts` captures the request binding that served the first leg, after any permitted initial reselection. Before every continuation dispatch, after provider pacing, that binding must remain an API-key selection matching the configured entry, reference, revision, resolved key, authentication mode, and base URL; a @@ -335,7 +338,7 @@ The shared atomic replacement publisher also identifies explicit Remote Workspac Remote Workspace uses a separate, explicitly enabled server surface with structural WebSocket callbacks and awaited per-server cleanup; [its contract](remote-workspace.md) owns that integration. -Chat helper admission in `src/server/responses/core.ts` follows the +Chat helper admission in `src/server/responses/request-sidecar-auth.ts` follows the [deferred stored-main contract](providers/openai-tiers.md): only a needed Direct OpenAI helper claims stored main, after terminal vision, routed vision and search exclusions. @@ -371,7 +374,7 @@ Responses route normalization resolves provider summary defaults from the origin ## Live sideband handshake -`src/server/index.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504 and client cancellation returns 499; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. +`src/server/index/serve-options.ts` establishes the authorized upstream live sideband before accepting the client WebSocket upgrade, and `src/server/index/live-sideband.ts` implements the bounded upstream dial. `openLiveSidebandUpstream` bounds the handshake to ten seconds and retains at most 32 frames and 1 MiB of preamble within the frame limit. `src/server/ws-bridge.ts` defines the runtime handoff carrying captured frames or terminal state. Failed handshakes return 502/504 and client cancellation returns 499; exact upstream 404/410 status is unavailable from Bun's client WebSocket. Admission ownership lasts until upstream close/CLOSED, including failed upgrades and failed attachment. The ordinary Responses WebSocket exchange remains separate. ## Paginated history writer boundary