From 5a75e57ff7812ae7524b02b086fa94b0c0900dbe Mon Sep 17 00:00:00 2001 From: Bet4 <0xbet4@gmail.com> Date: Tue, 18 Aug 2026 08:24:51 +0800 Subject: [PATCH 1/2] fix(grok): switch to Responses backend and backfill required annotations Grok CLI was pinned to api_backend = "chat_completions" because opencodex emitted response.heartbeat as a typed SSE event. That is not a valid Responses variant, so Grok-build's strict enum deserializer crashed with "unknown variant response.heartbeat". The keep-alive now emits an SSE comment line instead, which re-arms the idle timer without triggering deserialization on any client. With heartbeats fixed, Grok can finally use the Responses passthrough path. This gives Grok clients the same protocol fidelity Codex already enjoys and removes the chat to responses translation layer from the hot path. Some upstream relays (e.g. sub2api) omit annotations on output_text content parts even though the Responses spec marks it as a required Vec field. Strict clients, including Grok-build's async-openai fork, fail with "missing field annotations". A new stateless SSE/JSON backfill adds annotations: [] on any output_text part that lacks it, on both the streaming and bounded-JSON passthrough paths. The rewrite is unconditional and safe for all clients because the field is always valid on the wire. The /v1/responses handler now surfaces grok-tagged requests as surface=grok in the log context, matching the chat-completions handler. Stale comments referencing grok-build's decoder and the old chat_completions pin have been corrected in the tests. --- src/bridge.ts | 10 +- src/grok/inject.ts | 2 +- src/server/index.ts | 1 + src/server/responses/core.ts | 9 +- .../responses/responses-field-backfill.ts | 173 ++++++++++++++++++ tests/bridge.test.ts | 34 +++- tests/chat-completions-endpoint.test.ts | 7 +- tests/grok-config-inject.test.ts | 2 +- tests/grok-orphan-adoption.test.ts | 6 +- tests/responses-field-backfill.test.ts | 173 ++++++++++++++++++ 10 files changed, 392 insertions(+), 25 deletions(-) create mode 100644 src/server/responses/responses-field-backfill.ts create mode 100644 tests/responses-field-backfill.test.ts diff --git a/src/bridge.ts b/src/bridge.ts index c34e91734a..2bcce69dc2 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -325,10 +325,12 @@ export function bridgeToResponsesSSE( clearOwnedWatchdog(); }; // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an - // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored - // (responses.rs `_ => Ok(None)`). Emit a parser-ignored `response.heartbeat` whenever the + // eventsource_stream; ANY received bytes re-arm it. An SSE comment line (a line starting + // with `:`) is discarded by every eventsource parser without producing an event, so it + // keeps the wire alive without triggering deserialization. Emit a comment line whenever the // *wire* has been silent, even if invisible adapter heartbeats are still flowing (web-search - // buffering + raw-byte progress). Upstream activity only resets the stall watchdog. + // buffering + raw-byte progress). Upstream activity only resets the stall watchdog. Parity + // with the passthrough relay's `: opencodex keepalive` (relay.ts). let upstreamActivity = false; let wireActivity = false; let beat: unknown; @@ -395,7 +397,7 @@ export function bridgeToResponsesSSE( ...(endTurn !== undefined ? { end_turn: endTurn } : {}), }); - const heartbeatFrame = encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n'); + const heartbeatFrame = encoder.encode(': opencodex heartbeat\n\n'); let stallTicks = 0; const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec); const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs); diff --git a/src/grok/inject.ts b/src/grok/inject.ts index 9304d923ca..fee7e60180 100644 --- a/src/grok/inject.ts +++ b/src/grok/inject.ts @@ -324,7 +324,7 @@ export function buildGrokManagedBlock( `[model.${alias}]`, `model = ${tomlString(model.id)}`, `base_url = ${tomlString(baseUrl)}`, - 'api_backend = "chat_completions"', + 'api_backend = "responses"', 'api_key = "opencodex-loopback"', `name = ${tomlString(model.name ?? `OCX ${model.id}`)}`, // Best-effort attribution tag for the usage dashboard. Upstream Grok sends diff --git a/src/server/index.ts b/src/server/index.ts index 87ece913e1..b63151e201 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1201,6 +1201,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server`, not `Option`). Some + * upstream relays omit it when there are no annotations, which is technically + * spec-non-compliant. Strict deserializers — any client that follows the + * schema without `#[serde(default)]` on that field — fail with + * `missing field `annotations`` when the field is absent. + * + * This rewrite scans every SSE event for output_text content parts — whether + * they appear in item.content[], part, or response.output[].content[] — and + * adds annotations: [] if missing. + * + * Stateless: no retained buffers, no lifecycle tracking, no fail-closed. + * Existing values are always authoritative; only absent fields are added. + * The field is always valid on the wire, so adding it when absent is safe + * for all clients including Codex CLI/App. + */ + +import { + replaceSseDataPayload, + sseDataPayload, + type SseBlockRewrite, +} from "../sse-payload-rewrite"; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** + * Backfill annotations: [] on an output_text content part if missing. + * Returns the same object reference if no change is needed. + */ +function backfillOutputTextPart(part: Record): Record { + if (part.type !== "output_text") return part; + // Only add annotations when the key is entirely absent — preserve any + // existing value (even null or a malformed type) so we never overwrite + // what the upstream actually sent. + if ("annotations" in part) return part; + return { ...part, annotations: [] }; +} + +/** + * Walk a content array and backfill output_text parts. + * Returns the same array reference if nothing changed. + */ +function backfillContentArray(content: unknown): unknown { + if (!Array.isArray(content)) return content; + let changed = false; + const repaired = content.map((part) => { + if (!isPlainObject(part)) return part; + const next = backfillOutputTextPart(part); + if (next !== part) changed = true; + return next; + }); + return changed ? repaired : content; +} + +/** + * Walk an output item and backfill output_text parts in its content. + * Returns the same object reference if nothing changed. + */ +function backfillOutputItem(item: unknown): unknown { + if (!isPlainObject(item)) return item; + const content = item.content; + const repaired = backfillContentArray(content); + if (repaired === content) return item; + return { ...item, content: repaired }; +} + +/** + * Walk a response object's output[] and backfill output_text parts. + * Returns the same object reference if nothing changed. + */ +function backfillResponseOutput(response: unknown): unknown { + if (!isPlainObject(response)) return response; + const output = response.output; + if (!Array.isArray(output)) return response; + let changed = false; + const repaired = output.map((item) => { + if (!isPlainObject(item)) return item; + const next = backfillOutputItem(item); + if (next !== item) changed = true; + return next; + }); + return changed ? { ...response, output: repaired } : response; +} + +/** + * Statelessly rewrite one SSE event: backfill annotations + * on any output_text content part found in the event payload. + */ +function rewriteEvent(event: Record): Record { + const type = typeof event.type === "string" ? event.type : ""; + let next = event; + let changed = false; + + // output_item.added / output_item.done: item.content[] -> output_text parts + if ((type === "response.output_item.added" || type === "response.output_item.done") + && isPlainObject(event.item)) { + const item = backfillOutputItem(event.item); + if (item !== event.item) { + next = { ...next, item }; + changed = true; + } + } + + // content_part.added / content_part.done: part -> output_text + if ((type === "response.content_part.added" || type === "response.content_part.done") + && isPlainObject(event.part)) { + const part = backfillOutputTextPart(event.part); + if (part !== event.part) { + next = { ...next, part }; + changed = true; + } + } + + // response.created / in_progress / completed / incomplete / failed: + // response.output[].content[] -> output_text parts + if (isPlainObject(event.response)) { + const response = backfillResponseOutput(event.response); + if (response !== event.response) { + next = { ...next, response }; + changed = true; + } + } + + return changed ? next : event; +} + +/** + * Create a stateless SSE block rewrite that backfills annotations and + * on output_text content parts. Unconditional: the field is a required + * canonical Responses field, so adding it when absent is safe for all + * clients. + */ +export function createResponsesFieldBackfillBlockRewrite(): SseBlockRewrite { + const rewrite: SseBlockRewrite = (block: string): readonly string[] => { + const payload = sseDataPayload(block); + if (payload === null) return [block]; + let event: unknown; + try { + event = JSON.parse(payload); + } catch { + return [block]; + } + if (!isPlainObject(event)) return [block]; + const rewritten = rewriteEvent(event); + if (rewritten === event) return [block]; + return [replaceSseDataPayload(block, JSON.stringify(rewritten))]; + }; + return rewrite; +} + +/** + * Backfill annotations on a non-streaming Responses JSON + * object. Mirrors the SSE block rewrite for the bounded-JSON passthrough + * path. Returns the original string if no change is needed. + */ +export function backfillResponsesFieldsJson(payload: string): string { + let response: unknown; + try { + response = JSON.parse(payload); + } catch { + return payload; + } + if (!isPlainObject(response)) return payload; + const repaired = backfillResponseOutput(response); + if (repaired === response) return payload; + return JSON.stringify(repaired); +} diff --git a/tests/bridge.test.ts b/tests/bridge.test.ts index b28a5c68cd..8c92b4ef9d 100644 --- a/tests/bridge.test.ts +++ b/tests/bridge.test.ts @@ -729,7 +729,7 @@ describe("Responses bridge reasoning and usage parity", () => { // Regression for the Cursor parallel-tool-call stall: while the upstream silently assembles tool // calls, the adapter emits `heartbeat` events. They must keep the stall watchdog alive (no // upstream_stall_timeout). Adapter heartbeats themselves are not translated into Responses - // protocol items; wire keepalives use a separate `response.heartbeat` frame (see next test). + // protocol items; wire keepalives use a separate SSE comment line (see next test). // // resolveStallTimeoutSec ceils to a minimum of 1s, so sub-second stallTimeoutSec values cannot // prove the reset. Drive the beat loop through a test clock seam and run adapter-only progress @@ -803,10 +803,11 @@ describe("Responses bridge reasoning and usage parity", () => { expect(frames.some(f => f.data.type === "heartbeat")).toBe(false); }); - test("wire response.heartbeat keeps firing while only adapter heartbeats flow", async () => { + test("wire keepalive comment keeps firing while only adapter heartbeats flow", async () => { // Issue #521: web-search buffers semantic events and yields invisible adapter heartbeats from // raw-byte progress. Those must not suppress wire keepalives, or Codex Desktop idle-timeouts - // (~5 min) while OCX still considers the upstream alive. + // (~5 min) while OCX still considers the upstream alive. The wire keepalive is an SSE comment + // line (": opencodex heartbeat") so it never triggers deserialization on any client. const heartbeatMs = 50; const stallTimeoutSec = 1; const cycles = 4; @@ -842,7 +843,7 @@ describe("Responses bridge reasoning and usage parity", () => { yield { type: "done" }; } - const framesPromise = collectSse(bridgeToResponsesSSE( + const stream = bridgeToResponsesSSE( adapterHeartbeatsOnly(), "model", undefined, @@ -851,7 +852,8 @@ describe("Responses bridge reasoning and usage parity", () => { undefined, heartbeatMs, { stallTimeoutSec, timers }, - )); + ); + const rawTextPromise = new Response(stream).text(); await flush(); for (let i = 0; i < cycles; i++) { @@ -860,12 +862,24 @@ describe("Responses bridge reasoning and usage parity", () => { releaseDelay(); await flush(); } + const rawText = await rawTextPromise; + const frames: { event?: string; data: Record }[] = []; + for (const frame of rawText.split("\n\n")) { + const trimmed = frame.trim(); + if (!trimmed || trimmed === "data: [DONE]") continue; + const lines = trimmed.split("\n"); + const event = lines.find(l => l.startsWith("event: "))?.slice(7); + const dataLine = lines.find(l => l.startsWith("data: ")); + // Skip comment-only frames (e.g. ": opencodex heartbeat"); they have no data + // line and must not become fake deserializable events. + if (!dataLine) continue; + frames.push({ event, data: JSON.parse(dataLine?.slice(6) ?? "{}") as Record }); + } - const frames = await framesPromise; - const wireHeartbeats = frames.filter(f => - f.event === "response.heartbeat" && f.data.type === "response.heartbeat" - ); - expect(wireHeartbeats.length).toBeGreaterThan(1); + // Wire keepalives are SSE comment lines (": opencodex heartbeat") — they keep the + // idle timer alive without producing a typed event any client must deserialize. + const keepaliveCount = (rawText.match(/^: opencodex heartbeat$/gm) ?? []).length; + expect(keepaliveCount).toBeGreaterThan(1); expect(frames.some(f => f.event === "response.completed")).toBe(true); expect(frames.some(f => (f.data.response as Record | undefined)?.incomplete_details)).toBe(false); // Reject every adapter-shaped heartbeat payload, regardless of event name or field count. diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index c536b905a0..752851a29a 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -349,10 +349,9 @@ test("chatCompletionsUsage always emits detail objects with zero defaults", () = }); test("responsesSseToChatCompletionsSse consumes response.heartbeat without forwarding a raw frame", async () => { - // grok-build's strict Responses decoder dies on unknown variants (response.heartbeat), - // which is why the injected Grok config pins api_backend = "chat_completions". This - // regression pins the safety property: heartbeats never surface as raw frames here — - // at most a valid role chunk is emitted. + // Upstream responses SSE may contain heartbeat events (SSE comment keep-alive in + // bridge.ts, but some upstreams emit them as typed frames). The chat-completions + // converter must drop them rather than forwarding raw Responses-vocab frames. const { responsesSseToChatCompletionsSse } = budgetedChatOutbound(await import("../src/chat/outbound")); const upstream = new Response([ `event: response.heartbeat\ndata: ${JSON.stringify({ type: "response.heartbeat" })}\n\n`, diff --git a/tests/grok-config-inject.test.ts b/tests/grok-config-inject.test.ts index 6e89f44c1d..65b2319624 100644 --- a/tests/grok-config-inject.test.ts +++ b/tests/grok-config-inject.test.ts @@ -70,7 +70,7 @@ describe("Grok config injection", () => { const table = block.slice(block.indexOf("[model.ocx-cursor-grok-4-5]")); expect(table).toContain('model = "cursor/grok-4.5"'); expect(table).toContain('base_url = "http://127.0.0.1:10190/v1"'); - expect(table).toContain('api_backend = "chat_completions"'); + expect(table).toContain('api_backend = "responses"'); expect(table).toContain('api_key = "opencodex-loopback"'); expect(table).toContain("context_window = 500000"); }); diff --git a/tests/grok-orphan-adoption.test.ts b/tests/grok-orphan-adoption.test.ts index 2520904089..aba8af6e27 100644 --- a/tests/grok-orphan-adoption.test.ts +++ b/tests/grok-orphan-adoption.test.ts @@ -45,7 +45,7 @@ describe("Grok orphan adoption (#511)", () => { "[model.ocx-gpt-5-6-sol]", 'model = "gpt-5.6-sol"', 'base_url = "http://127.0.0.1:10100/v1"', - 'api_backend = "chat_completions"', + 'api_backend = "responses"', 'api_key = "opencodex-loopback"', 'name = "OCX gpt-5.6-sol"', "", @@ -140,7 +140,7 @@ describe("Grok orphan adoption (#511)", () => { expect(content).toContain("[ui]"); expect(content).toContain('theme = "dark"'); // No key from the removed table leaked into [ui]. - expect(content).not.toContain('api_backend = "chat_completions"\ntheme'); + expect(content).not.toContain('api_backend = "responses"\ntheme'); }); // F5: an orphan with no replacement stays, and its reference is not rewritten to @@ -272,7 +272,7 @@ describe("Grok orphan adoption (#511)", () => { "[model.ocx-gpt-5-6-sol]", // stale generation: dead port 'model = "gpt-5.6-sol"', 'base_url = "http://127.0.0.1:4179/v1"', - 'api_backend = "chat_completions"', + 'api_backend = "responses"', 'api_key = "opencodex-loopback"', "context_window = 372000", "", diff --git a/tests/responses-field-backfill.test.ts b/tests/responses-field-backfill.test.ts new file mode 100644 index 0000000000..8071323e5a --- /dev/null +++ b/tests/responses-field-backfill.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "bun:test"; +import { + createResponsesFieldBackfillBlockRewrite, + backfillResponsesFieldsJson, +} from "../src/server/responses/responses-field-backfill"; + +const rewrite = createResponsesFieldBackfillBlockRewrite(); + +function apply(block: string): string[] { + return [...rewrite(block)]; +} + +function sseBlock(data: Record): string { + return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function parseData(blocks: string[]): Record[] { + return blocks.map((b) => { + const match = b.match(/^data: (.+)$/m); + return JSON.parse(match![1]); + }); +} + +describe("responses-field-backfill", () => { + test("adds annotations to output_item.done message content", () => { + const event = { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hello" }], + }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + expect(parsed.item.content[0].annotations).toEqual([]); + expect(parsed.item.content[0].text).toBe("hello"); + }); + + test("preserves existing annotations", () => { + const existing = [{ type: "url_citation", url: "https://example.com" }]; + const event = { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hi", annotations: existing }], + }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + expect(parsed.item.content[0].annotations).toEqual(existing); + }); + + test("adds annotations to content_part.added", () => { + const event = { + type: "response.content_part.added", + item_id: "msg_1", + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "" }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + expect(parsed.part.annotations).toEqual([]); + }); + test("adds annotations to response.completed output items", () => { + const event = { + type: "response.completed", + sequence_number: 42, + response: { + id: "resp_1", + object: "response", + status: "completed", + model: "grok-4.5", + output: [ + { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "answer" }], + }, + ], + usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + input_tokens_details: { cached_tokens: 0 }, + output_tokens_details: { reasoning_tokens: 0 }, + }, + }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + expect(parsed.response.output[0].content[0].annotations).toEqual([]); + }); + + test("does not modify events without output_text parts", () => { + const event = { + type: "response.output_item.added", + output_index: 0, + item: { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "do_thing", + arguments: "{}", + }, + }; + const result = apply(sseBlock(event)); + expect(result).toHaveLength(1); + expect(result[0]).toBe(sseBlock(event)); + }); + + test("handles multiple content parts with mixed types", () => { + const event = { + type: "response.output_item.done", + output_index: 0, + item: { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [ + { type: "output_text", text: "first" }, + { type: "refusal", refusal: "no" }, + { type: "output_text", text: "second", annotations: [] }, + ], + }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + expect(parsed.item.content[0].annotations).toEqual([]); + expect(parsed.item.content[1]).not.toHaveProperty("annotations"); + expect(parsed.item.content[2].annotations).toEqual([]); + }); + + test("backfillResponsesFieldsJson adds missing annotations and preserves existing", () => { + const response = { + id: "resp_1", + object: "response", + status: "completed", + output: [ + { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [ + { type: "output_text", text: "no annotations" }, + { type: "output_text", text: "has annotations", annotations: [{ type: "url_citation", url: "https://example.com" }] }, + { type: "output_text", text: "null annotations", annotations: null }, + { type: "output_text", text: "malformed annotations", annotations: "not-an-array" }, + { type: "output_text", text: "object annotations", annotations: { unexpected: true } }, + ], + }, + ], + }; + const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as typeof response; + expect(result.output[0].content[0].annotations).toEqual([]); + expect(result.output[0].content[1].annotations).toEqual([{ type: "url_citation", url: "https://example.com" }]); + expect(result.output[0].content[2].annotations).toBeNull(); + expect(result.output[0].content[3].annotations).toBe("not-an-array"); + expect(result.output[0].content[4].annotations).toEqual({ unexpected: true }); + }); +}); From 82311ba8f196eed64595dba3bd75f4ca86a86317 Mon Sep 17 00:00:00 2001 From: Bet4 <0xbet4@gmail.com> Date: Tue, 18 Aug 2026 09:16:06 +0800 Subject: [PATCH 2/2] docs(test): reflect comment-line keep-alive and responses backend Keep-alives are now SSE comment lines (': opencodex heartbeat') instead of response.heartbeat events, so the bridge-lifecycle RC3 test and the transport architecture docs no longer describe a parser-ignored response.heartbeat event. The grok-build guides' api_backend examples were still chat_completions; they now match the Responses backend the proxy emits. --- .../src/content/docs/fr/guides/grok-build.md | 11 +++-------- .../src/content/docs/fr/reference/architecture.md | 2 +- docs-site/src/content/docs/guides/grok-build.md | 9 ++------- .../src/content/docs/ja/guides/grok-build.md | 8 +++----- .../src/content/docs/ja/reference/architecture.md | 2 +- .../src/content/docs/ko/guides/grok-build.md | 7 +++---- .../src/content/docs/ko/reference/architecture.md | 8 +++++--- .../src/content/docs/reference/architecture.md | 11 ++++++----- .../src/content/docs/ru/guides/grok-build.md | 12 +++--------- .../src/content/docs/ru/reference/architecture.md | 10 ++++++---- .../src/content/docs/tr/guides/grok-build.md | 14 +++----------- .../src/content/docs/tr/reference/architecture.md | 5 +++-- .../src/content/docs/zh-cn/guides/grok-build.md | 7 +++---- .../content/docs/zh-cn/reference/architecture.md | 10 ++++++---- .../src/content/docs/zh-tw/guides/grok-build.md | 7 +++---- .../content/docs/zh-tw/reference/architecture.md | 10 ++++++---- structure/04_transports-and-sidecars.md | 15 ++++++++------- tests/bridge-lifecycle.test.ts | 7 +++++-- 18 files changed, 70 insertions(+), 85 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/grok-build.md b/docs-site/src/content/docs/fr/guides/grok-build.md index 69f4e056c0..709542ad31 100644 --- a/docs-site/src/content/docs/fr/guides/grok-build.md +++ b/docs-site/src/content/docs/fr/guides/grok-build.md @@ -18,7 +18,7 @@ en `~/.grok/config.toml` : [model.ocx-gpt-5-6-sol] model = "gpt-5.6-sol" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" name = "OCX gpt-5.6-sol" # ... one [model.ocx-*] table per visible model ... @@ -104,7 +104,7 @@ tables par modèle avec **champs directs**, en dehors des marqueurs `# >>> openc [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" ``` @@ -115,7 +115,7 @@ composez et utilisez votre jeton d'entrée : [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 -api_backend = "chat_completions" +api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" ``` @@ -129,11 +129,6 @@ l'identifiant `grok-4.5`. Les alias générés évitent entièrement les points ## Limitations connues -- **Réponses backend et keep-alives:** opencodex émet un `response.heartbeat` keep-alive - dans les flux `/v1/responses` pendant les périodes de silence en amont. Le décodeur Responses de Grok Build - rejette les types d'événements inconnus, donc un modèle `api_backend = "responses"` configuré manuellement - peut échouer à mi-tour sur des amonts lents. Le code PIN des entrées enregistrées automatiquement - `api_backend = "chat_completions"`, qui ne fait jamais apparaître les images de battements de cœur bruts. - **Installé par le service `ocx restart` :** le proxy en cours d'exécution possède l'autorisation de redémarrage et la vidange coordination, tandis que le gestionnaire de service installé lance le remplacement après l'ancien processus sorties. La supervision du service reste installée. Lors de l'enregistrement automatique en boucle, le bloc géré diff --git a/docs-site/src/content/docs/fr/reference/architecture.md b/docs-site/src/content/docs/fr/reference/architecture.md index e57f2dc76a..5d7e2777d8 100644 --- a/docs-site/src/content/docs/fr/reference/architecture.md +++ b/docs-site/src/content/docs/fr/reference/architecture.md @@ -73,7 +73,7 @@ Trois anciens points d’entrée volumineux préservent désormais la compatibil | `done` | `response.completed` (avec l’utilisation) | | `error` | `response.failed` (avec `last_error`) | -Le pont émet également un **signal de maintien en vie** (RC3) : lorsque le service en amont reste silencieux, il envoie toutes les 2 secondes un événement SSE `response.heartbeat`, ignoré par l’analyseur, afin de réarmer la minuterie d’inactivité de Codex. Le **délai maximal de blocage** est de 300 secondes par défaut (`stallTimeoutSec`). Une fois ce délai atteint, le service en amont est interrompu et `response.incomplete` est émis avec le motif `upstream_stall_timeout`, ce qui empêche une connexion bloquée d’immobiliser Codex indéfiniment. +Le pont émet également un **signal de maintien en vie** (RC3) : lorsque le service en amont reste silencieux, il envoie toutes les 2 secondes une ligne de commentaire SSE (`: opencodex heartbeat`), ignorée par l’analyseur, afin de réarmer la minuterie d’inactivité de Codex. Une ligne de commentaire est ignorée par tous les analyseurs eventsource sans produire d’événement, donc les décodeurs Responses stricts ne voient jamais de variante inconnue. Le **délai maximal de blocage** est de 300 secondes par défaut (`stallTimeoutSec`). Une fois ce délai atteint, le service en amont est interrompu et `response.incomplete` est émis avec le motif `upstream_stall_timeout`, ce qui empêche une connexion bloquée d’immobiliser Codex indéfiniment. Les appels d’outils sont répartis entre trois types d’éléments Responses à l’aide de la table des espaces de noms, de l’ensemble des outils libres et de l’ensemble des outils de recherche capturés par l’analyseur. Les espaces de noms MCP, les outils libres tels que `apply_patch` et les appels `tool_search` exécutés par le client peuvent ainsi effectuer un aller-retour complet. Une variante `buildResponseJSON()` produit à partir des mêmes événements un objet de réponse unique hors flux. diff --git a/docs-site/src/content/docs/guides/grok-build.md b/docs-site/src/content/docs/guides/grok-build.md index 75dfadf84e..08a1073805 100644 --- a/docs-site/src/content/docs/guides/grok-build.md +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -104,7 +104,7 @@ per-model tables with **direct fields**, outside the `# >>> opencodex managed bl [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" ``` @@ -115,7 +115,7 @@ dial and use your admission token: [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 -api_backend = "chat_completions" +api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" ``` @@ -129,11 +129,6 @@ the id `grok-4.5`. Generated aliases avoid dots entirely for this reason. ## Known limitations -- **Responses backend and keep-alives:** opencodex emits a `response.heartbeat` keep-alive - on `/v1/responses` streams during upstream silence. Grok Build's Responses decoder - rejects unknown event types, so a manually configured `api_backend = "responses"` model - can fail mid-turn on slow upstreams. The auto-registered entries pin - `api_backend = "chat_completions"`, which never surfaces raw heartbeat frames. - **Service-installed `ocx restart`:** the running proxy owns restart authorization and drain coordination, while the installed service manager launches the replacement after the old process exits. Service supervision remains installed. On loopback auto-registration, the managed block diff --git a/docs-site/src/content/docs/ja/guides/grok-build.md b/docs-site/src/content/docs/ja/guides/grok-build.md index 6dc6b2f2e4..e68af728ef 100644 --- a/docs-site/src/content/docs/ja/guides/grok-build.md +++ b/docs-site/src/content/docs/ja/guides/grok-build.md @@ -14,7 +14,7 @@ opencodex はローカル ポート上で OpenAI 互換の `POST /v1/chat/comple [model.ocx-gpt-5-6-sol] model = "gpt-5.6-sol" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" name = "OCX gpt-5.6-sol" # ... one [model.ocx-*] table per visible model ... @@ -56,7 +56,7 @@ Grok Build では、ループバックでもカスタム モデルに対して [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" ``` @@ -66,7 +66,7 @@ api_key = "opencodex-loopback" [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 -api_backend = "chat_completions" +api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" ``` @@ -76,8 +76,6 @@ api_key = "your-OPENCODEX_API_AUTH_TOKEN" ## 既知の制限事項 -- **バックエンドとキープアライブの応答:** opencodex は `response.heartbeat` キープアライブを発行します -アップストリーム沈黙中の `/v1/responses` ストリーム。 Grok Build の Responses デコーダは未知のイベント タイプを拒否するため、手動で構成された `api_backend = "responses"` モデルは低速なアップストリームではターン中に失敗する可能性があります。自動登録されたエントリは `api_backend = "chat_completions"` をピン留めしますが、生のハートビート フレームが表示されることはありません。 - **サービスでインストールされた `ocx restart`:** 実行中のプロキシが再起動の認可とドレインの調整を担当し、古いプロセスの終了後はインストール済みのサービス マネージャーが置換プロセスを起動します。サービス監視は維持されます。ループバックの自動登録を使用している場合に限り、マネージド ブロックもハンドオフ中に維持されます。非ループバック構成では Grok 設定を手動管理します。同じポートで、別の ID 検証済みプロセスが正常になったことを確認した場合にのみ成功します。 - **構成読み取りタイミング:** 最初に opencodex を起動し、その後 `grok` を起動します。 予測可能な結果。 Grok Build は `~/.grok/config.toml` を監視し、`[model]` テーブルが実際に変更されると (内容で比較すると約 1 秒のデバウンス) 再ロードするため、更新されたブロックは再起動せずに開いているセッションに到達します。 Grok が解析した内容を確認するには、`grok inspect` を実行します。ロードされた設定ソースがリストされ、拒否されたフィールドについて警告が表示されます。解決されたモデルのリストは出力されません。単一の TOML エラーがユーザー設定レイヤー「全体」を無効にすることに注意してください。これが、opencodex がファイルをアトミックに書き込む理由です。Grok は書きかけの設定を決して認識しません。 diff --git a/docs-site/src/content/docs/ja/reference/architecture.md b/docs-site/src/content/docs/ja/reference/architecture.md index d1f436e3c6..d6eb85f9fb 100644 --- a/docs-site/src/content/docs/ja/reference/architecture.md +++ b/docs-site/src/content/docs/ja/reference/architecture.md @@ -89,7 +89,7 @@ HTTP の境界は `server/index.ts` が担い、Responses データプレーン | `done` | `response.completed`(usage 付き) | | `error` | `response.failed`(`last_error` 付き) | -ブリッジは **ハートビートキープアライブ**(RC3)も実行します。上流からデータが来ないとき 2 秒ごとにパーサーが無視する `response.heartbeat` SSE イベントを送り、Codex のアイドルタイマーを再開します。デフォルトの **stall deadline** は 300 秒(`stallTimeoutSec`)です。この時間を超えると上流を中断し、理由が `upstream_stall_timeout` の `response.incomplete` を送り、接続が延々とぶら下がらないようにします。 +ブリッジは **ハートビートキープアライブ**(RC3)も実行します。上流からデータが来ないとき 2 秒ごとにパーサーが無視する `: opencodex heartbeat` SSE コメント行を送り、Codex のアイドルタイマーを再開します。コメント行はイベントを生成せずに任意の eventsource パーサーに破棄されるため、厳格な Responses デコーダは未知のバリアントを決して見ません。デフォルトの **stall deadline** は 300 秒(`stallTimeoutSec`)です。この時間を超えると上流を中断し、理由が `upstream_stall_timeout` の `response.incomplete` を送り、接続が延々とぶら下がらないようにします。 ツール呼び出しはパーサーが取得した名前空間マップ、freeform 集合、tool-search 集合を使って 3 種類の Responses 項目タイプに振り分けます — そのため MCP 名前空間、`apply_patch` スタイルの freeform ツール、クライアントが実行する `tool_search` がすべてラウンドトリップします。`buildResponseJSON()` 変種は同じイベントから単一の非ストリーミングレスポンスオブジェクトを生成します。 diff --git a/docs-site/src/content/docs/ko/guides/grok-build.md b/docs-site/src/content/docs/ko/guides/grok-build.md index 79bd048367..1f6b09ecca 100644 --- a/docs-site/src/content/docs/ko/guides/grok-build.md +++ b/docs-site/src/content/docs/ko/guides/grok-build.md @@ -14,7 +14,7 @@ opencodex는 로컬 포트에서 OpenAI 호환 `POST /v1/chat/completions`(및 ` [model.ocx-gpt-5-6-sol] model = "gpt-5.6-sol" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" name = "OCX gpt-5.6-sol" # ... one [model.ocx-*] table per visible model ... @@ -52,7 +52,7 @@ Grok Build는 루프백에서도 사용자 정의 모델에 비어 있지 않은 [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" ``` @@ -62,7 +62,7 @@ api_key = "opencodex-loopback" [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 -api_backend = "chat_completions" +api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" ``` @@ -72,7 +72,6 @@ api_key = "your-OPENCODEX_API_AUTH_TOKEN" ## 알려진 제한 -- **Responses 백엔드와 keep-alive:** 상위 업스트림이 조용한 동안 opencodex는 `/v1/responses` 스트림에 `response.heartbeat` keep-alive를 보냅니다. Grok Build의 Responses 디코더는 알 수 없는 이벤트 타입을 거부하므로, 수동으로 설정한 `api_backend = "responses"` 모델은 느린 업스트림에서 턴 도중 실패할 수 있습니다. 자동 등록된 항목은 `api_backend = "chat_completions"`로 고정되며, 원시 heartbeat 프레임을 노출하지 않습니다. - **서비스 설치된 `ocx restart`:** 실행 중인 프록시는 재시작 권한 확인과 드레인 조정을 담당하고, 기존 프로세스가 종료된 뒤 설치된 서비스 관리자가 교체 프로세스를 시작합니다. 서비스 감독은 그대로 유지됩니다. 루프백 자동 등록을 사용하는 경우에만 관리 블록도 핸드오프 동안 유지되며, 비루프백 배포에서는 Grok 설정을 수동으로 관리합니다. 같은 포트에서 신원이 확인된 다른 프로세스가 정상 상태가 된 뒤에만 명령이 성공합니다. - **설정 읽기 시점:** 가장 예측 가능한 결과를 얻으려면 opencodex를 먼저 시작하고 그다음 `grok`를 실행합니다. Grok Build는 `~/.grok/config.toml`을 감시하다가 `[model]` 테이블이 실제로 바뀔 때 다시 불러옵니다(내용을 기준으로 비교하는 약 1초 디바운스). 그래서 새로 고친 블록은 재시작 없이 열린 세션에도 들어갑니다. Grok가 무엇을 파싱했는지 확인하려면 `grok inspect`를 실행합니다. 이 명령은 로드한 설정 원본을 나열하고 거부한 필드가 있으면 경고합니다. 해석된 모델 목록은 출력하지 않습니다. TOML 오류 하나만으로도 사용자 설정 레이어 전체가 무효가 되므로, opencodex가 파일을 원자적으로 쓰는 이유도 여기에 있습니다. Grok는 절반만 써진 설정을 보지 않습니다. - **카탈로그 업데이트:** 펜스 블록은 주입 시점의 카탈로그를 반영합니다. 공급자나 모델을 추가한 뒤에는 `ocx ensure`를 실행하거나 프록시를 재시작해 갱신합니다. diff --git a/docs-site/src/content/docs/ko/reference/architecture.md b/docs-site/src/content/docs/ko/reference/architecture.md index 1554597a66..dfae968b5c 100644 --- a/docs-site/src/content/docs/ko/reference/architecture.md +++ b/docs-site/src/content/docs/ko/reference/architecture.md @@ -101,9 +101,11 @@ HTTP 경계는 `server/index.ts`가 맡고, Responses 데이터 플레인은 `se | `error` | `response.failed` (with `last_error`) | 브리지는 **하트비트 킵얼라이브**(RC3)도 실행합니다. 업스트림에서 데이터가 오지 않을 때 2초마다 -파서가 무시하는 `response.heartbeat` SSE 이벤트를 보내 Codex의 유휴 타이머를 다시 시작합니다. -기본 **stall deadline**은 300초(`stallTimeoutSec`)입니다. 이 시간을 넘기면 업스트림을 중단하고 -이유가 `upstream_stall_timeout`인 `response.incomplete`를 내보내 연결이 끝없이 매달리지 않게 합니다. +파서가 무시하는 `: opencodex heartbeat` SSE 주석 줄을 보내 Codex의 유휴 타이머를 다시 시작합니다. +주석 줄은 이벤트를 생성하지 않고 모든 eventsource 파서에 의해 버려지므로, 엄격한 Responses 디코더는 +알 수 없는 variant를 절대 보지 못합니다. 기본 **stall deadline**은 300초(`stallTimeoutSec`)입니다. +이 시간을 넘기면 업스트림을 중단하고 이유가 `upstream_stall_timeout`인 `response.incomplete`를 +내보내 연결이 끝없이 매달리지 않게 합니다. 툴 호출은 파서가 캡처한 네임스페이스 맵, freeform 집합, tool-search 집합을 사용하여 세 가지 Responses 항목 타입으로 구분됩니다 — 따라서 MCP 네임스페이스, `apply_patch` 스타일의 freeform diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 4ed8b67617..180c043a88 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -103,11 +103,12 @@ understands: | `done` | `response.completed` (with usage) | | `error` | `response.failed` (with `last_error`) | -The bridge also runs a **heartbeat keep-alive** (RC3): during upstream silence, it emits a -parser-ignored `response.heartbeat` SSE event every 2 seconds to re-arm Codex's idle timer. The -default **stall deadline** is 300 seconds (`stallTimeoutSec`); reaching it aborts the upstream and emits -`response.incomplete` with reason `upstream_stall_timeout`, preventing a hung connection from blocking -Codex indefinitely. +The bridge also runs a **heartbeat keep-alive** (RC3): during upstream silence, it emits an SSE +comment line (`: opencodex heartbeat`) every 2 seconds to re-arm Codex's idle timer. Comment lines +are discarded by every eventsource parser without producing an event, so strict Responses decoders +never see an unknown variant. The default **stall deadline** is 300 seconds (`stallTimeoutSec`); +reaching it aborts the upstream and emits `response.incomplete` with reason +`upstream_stall_timeout`, preventing a hung connection from blocking Codex indefinitely. Tool calls are disambiguated into three Responses item types using the namespace map, the freeform set, and the tool-search set captured by the parser — so MCP namespaces, `apply_patch`-style freeform diff --git a/docs-site/src/content/docs/ru/guides/grok-build.md b/docs-site/src/content/docs/ru/guides/grok-build.md index 096bb7aa39..bd8ac09d7c 100644 --- a/docs-site/src/content/docs/ru/guides/grok-build.md +++ b/docs-site/src/content/docs/ru/guides/grok-build.md @@ -18,7 +18,7 @@ Grok Build — вручную редактировать конфигураци [model.ocx-gpt-5-6-sol] model = "gpt-5.6-sol" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" name = "OCX gpt-5.6-sol" # ... one [model.ocx-*] table per visible model ... @@ -83,7 +83,7 @@ admission token, а управляемый блок не может безопа [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" ``` @@ -94,7 +94,7 @@ api_key = "opencodex-loopback" [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 -api_backend = "chat_completions" +api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" ``` @@ -107,12 +107,6 @@ api_key = "your-OPENCODEX_API_AUTH_TOKEN" ## Известные ограничения -- **Responses backend и keep-alive:** во время тишины upstream opencodex посылает keep-alive - `response.heartbeat` в потоках `/v1/responses`. Декодер Responses в Grok Build отвергает - неизвестные типы событий, поэтому вручную настроенная модель с - `api_backend = "responses"` может оборваться посреди хода на медленных upstream. Автоматически - зарегистрированные записи жёстко используют `api_backend = "chat_completions"`, где сырые - heartbeat-кадры никогда не видны. - **`ocx restart` при установленной службе:** работающий прокси сам управляет drain и заменой, поэтому supervision службы и managed block сохраняются. Команда завершается успешно только после того, как на том же порту станет здоровым другой процесс с проверенной идентичностью. diff --git a/docs-site/src/content/docs/ru/reference/architecture.md b/docs-site/src/content/docs/ru/reference/architecture.md index 569652c6a4..03258e5949 100644 --- a/docs-site/src/content/docs/ru/reference/architecture.md +++ b/docs-site/src/content/docs/ru/reference/architecture.md @@ -113,10 +113,12 @@ src/ | `error` | `response.failed` (с `last_error`) | Мост также выполняет **heartbeat keep-alive** (RC3): пока вышестоящая сторона молчит, он каждые -2 секунды генерирует игнорируемое парсером SSE-событие `response.heartbeat`, чтобы перезапускать -таймер простоя Codex. **Дедлайн зависания** по умолчанию — 300 секунд (`stallTimeoutSec`); по его -достижении запрос к вышестоящей стороне прерывается и генерируется `response.incomplete` с -причиной `upstream_stall_timeout`, что не даёт зависшему соединению блокировать Codex бесконечно. +2 секунды генерирует комментарий-строку SSE (`: opencodex heartbeat`), чтобы перезапускать +таймер простоя Codex. Комментарий отбрасывается любым eventsource-парсером без создания события, +поэтому строгие декодеры Responses никогда не видят неизвестный вариант. **Дедлайн зависания** по +умолчанию — 300 секунд (`stallTimeoutSec`); по его достижении запрос к вышестоящей стороне +прерывается и генерируется `response.incomplete` с причиной `upstream_stall_timeout`, что не +даёт зависшему соединению блокировать Codex бесконечно. Вызовы инструментов различаются между тремя типами элементов Responses с помощью карты пространств имён, множества freeform и множества tool-search, зафиксированных парсером — поэтому diff --git a/docs-site/src/content/docs/tr/guides/grok-build.md b/docs-site/src/content/docs/tr/guides/grok-build.md index 44e4e324a7..94b669874e 100644 --- a/docs-site/src/content/docs/tr/guides/grok-build.md +++ b/docs-site/src/content/docs/tr/guides/grok-build.md @@ -19,7 +19,7 @@ gerekmez. [model.ocx-gpt-5-6-sol] model = "gpt-5.6-sol" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" name = "OCX gpt-5.6-sol" # ... görünür model başına bir [model.ocx-*] tablosu ... @@ -111,7 +111,7 @@ işaretçilerinin dışına **doğrudan alanlarla** model başına tablolar ekle [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" ``` @@ -122,7 +122,7 @@ Ağ üzerinden erişilebilen bir proxy için `base_url`'i `grok`'un gerçekten [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://192.168.1.10:10100/v1" # 127.0.0.1 değil, erişilebilir ana bilgisayar -api_backend = "chat_completions" +api_backend = "responses" api_key = "OPENCODEX_API_AUTH_TOKEN_DEGERINIZ" ``` @@ -137,13 +137,6 @@ adlar bu nedenle noktalardan tamamen kaçınır. ## Bilinen sınırlamalar -- **Responses arka ucu ve canlı tutmalar (keep-alives):** opencodex, yukarı akış - sessizliği sırasında `/v1/responses` akışlarında bir `response.heartbeat` - canlı tutma yayar. Grok Build'in Responses kod çözücüsü bilinmeyen olay - türlerini reddeder, bu nedenle manuel olarak yapılandırılmış bir `api_backend - = "responses"` modeli yavaş yukarı akışlarda tur ortasında başarısız olabilir. - Otomatik olarak kaydedilen girdiler, ham kalp atışı çerçevelerini asla - göstermeyen `api_backend = "chat_completions"` değerini sabitler. - **Servis kurulu `ocx restart`:** çalışan proxy yeniden başlatma yetkilendirmesine ve tahliye koordinasyonuna sahiptir, kurulu servis yöneticisi ise eski süreç çıktıktan sonra yenisini başlatır. Servis denetimi @@ -167,4 +160,3 @@ adlar bu nedenle noktalardan tamamen kaçınır. yansıtır. Sağlayıcılar veya modeller ekledikten sonra yenilemek için `ocx ensure` çalıştırın (veya proxy'yi yeniden başlatın). - diff --git a/docs-site/src/content/docs/tr/reference/architecture.md b/docs-site/src/content/docs/tr/reference/architecture.md index 6a76545abb..131c85e3e1 100644 --- a/docs-site/src/content/docs/tr/reference/architecture.md +++ b/docs-site/src/content/docs/tr/reference/architecture.md @@ -122,7 +122,9 @@ SSE'ye dönüştürür: Köprü ayrıca bir **kalp atışı canlı tutması (heartbeat keep-alive)** çalıştırır (RC3): yukarı akış sessizliği sırasında Codex'in boşta kalma zamanlayıcısını yeniden kurmak için her 2 saniyede bir ayrıştırıcı tarafından yok sayılan -`response.heartbeat` SSE olayı yayar. Varsayılan **durma süresi sınırı** 300 +`: opencodex heartbeat` SSE yorum satırı yayar. Yorum satırı, olay üretmeden her +eventsource ayrıştırıcısı tarafından atılır, böylece katı Responses kod çözücüleri +asla bilinmeyen bir varyant görmez. Varsayılan **durma süresi sınırı** 300 saniyedir (`stallTimeoutSec`); bu sınıra ulaşılması yukarı akışı iptal eder ve `upstream_stall_timeout` nedeni ile `response.incomplete` yayar, böylece askıda kalan bir bağlantının Codex'i süresiz olarak engellemesi önlenir. @@ -219,4 +221,3 @@ Dahili model `types.ts` içinde yer alır: `OcxParsedRequest`, `OcxContext`, `namespacedToolName()` ve `modelInList()` (`noVisionModels` / `noReasoningModels` için toleranslı `:size` etiketi eşleştirmesi). - diff --git a/docs-site/src/content/docs/zh-cn/guides/grok-build.md b/docs-site/src/content/docs/zh-cn/guides/grok-build.md index ffd9e43254..766e8f81b1 100644 --- a/docs-site/src/content/docs/zh-cn/guides/grok-build.md +++ b/docs-site/src/content/docs/zh-cn/guides/grok-build.md @@ -14,7 +14,7 @@ opencodex 在本地端口提供一个与 OpenAI 兼容的 `POST /v1/chat/complet [model.ocx-gpt-5-6-sol] model = "gpt-5.6-sol" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" name = "OCX gpt-5.6-sol" # ... one [model.ocx-*] table per visible model ... @@ -52,7 +52,7 @@ grok -m ocx-anthropic-claude-opus-4-8 -p "hello" [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" ``` @@ -62,7 +62,7 @@ api_key = "opencodex-loopback" [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 -api_backend = "chat_completions" +api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" ``` @@ -72,7 +72,6 @@ api_key = "your-OPENCODEX_API_AUTH_TOKEN" ## 已知限制 -- **Responses 后端与保活:** opencodex 在 `/v1/responses` 流上、上游静默期间会发送 `response.heartbeat` 保活事件。Grok Build 的 Responses 解码器会拒绝未知事件类型,因此手动配置为 `api_backend = "responses"` 的模型在上游较慢时可能会在对话中途失败。自动注册的条目会固定为 `api_backend = "chat_completions"`,这样就不会暴露原始的心跳帧。 - **服务安装后的 `ocx restart`:** 运行中的代理负责重启授权和排空协调;旧进程退出后,由已安装的服务管理器启动替换进程。服务监督始终保留。仅在 loopback 自动注册模式下,受管理区块也会在交接期间保留;非 loopback 部署使用手动管理的 Grok 配置。只有确认同一端口上出现另一个经过身份验证且健康的进程后,命令才会成功。 - **配置读取时机:** 先启动 opencodex,再启动 `grok`,结果最可预测。Grok Build 会监视 `~/.grok/config.toml`,并在 `[model]` 表实际发生变化时重新加载(大约一秒的防抖,按内容比较),因此刷新后的区块可以在无需重启的情况下进入已打开的会话。要确认 Grok 解析到了什么,可以运行 `grok inspect`:它会列出已加载的配置来源,并提示被拒绝的字段,但不会打印最终解析出的模型列表。注意,单个 TOML 错误会使*整个*用户配置层失效,这也是 opencodex 以原子方式写入文件的原因——Grok 不会看到半写入的配置。 - **目录更新:** 有边界线的区块反映的是注入时的目录状态。添加提供方或模型后,运行 `ocx ensure`(或重启代理)以刷新它。 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 926a6d096f..44c443f94d 100644 --- a/docs-site/src/content/docs/zh-cn/reference/architecture.md +++ b/docs-site/src/content/docs/zh-cn/reference/architecture.md @@ -101,10 +101,12 @@ src/ | `done` | `response.completed`(带 usage) | | `error` | `response.failed`(带 `last_error`) | -桥接器还会运行**心跳保活**(RC3):上游没有数据时,每 2 秒发送一次解析器会忽略的 -`response.heartbeat` SSE event,以重新启动 Codex 的空闲计时器。默认**停滞截止时间**为 300 秒 -(`stallTimeoutSec`);达到该时限后会中止上游,并发出 reason 为 -`upstream_stall_timeout` 的 `response.incomplete`,避免挂起的连接无限期阻塞 Codex。 +桥接器还会运行**心跳保活**(RC3):上游没有数据时,每 2 秒发送一个 SSE 注释行 +(`: opencodex heartbeat`)来重新启动 Codex 的空闲计时器。注释行会被每个 +eventsource 解析器丢弃而不会产生任何事件,因此严格的 Responses 解码器永远不会 +遇到未知 variant。默认**停滞截止时间**为 300 秒(`stallTimeoutSec`);达到该时限后 +会中止上游,并发出 reason 为 `upstream_stall_timeout` 的 `response.incomplete`, +避免挂起的连接无限期阻塞 Codex。 解析器捕获的命名空间映射、freeform 集合与 tool-search 集合会把工具调用区分为三种 Responses item,因此 MCP 命名空间、`apply_patch` 风格的 freeform 工具和客户端执行的 `tool_search` 都能 diff --git a/docs-site/src/content/docs/zh-tw/guides/grok-build.md b/docs-site/src/content/docs/zh-tw/guides/grok-build.md index 32ad708527..364f92c3fd 100644 --- a/docs-site/src/content/docs/zh-tw/guides/grok-build.md +++ b/docs-site/src/content/docs/zh-tw/guides/grok-build.md @@ -14,7 +14,7 @@ opencodex 在本機埠提供 OpenAI 相容的 `POST /v1/chat/completions`(以 [model.ocx-gpt-5-6-sol] model = "gpt-5.6-sol" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" name = "OCX gpt-5.6-sol" # ... one [model.ocx-*] table per visible model ... @@ -64,7 +64,7 @@ Codex 行為一致。原生 GPT-5.6 條目則分開處理:它們保留並暴 [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" ``` @@ -74,7 +74,7 @@ api_key = "opencodex-loopback" [model.ocx-opus] model = "anthropic/claude-opus-4-8" base_url = "http://192.168.1.10:10100/v1" # the reachable host, not 127.0.0.1 -api_backend = "chat_completions" +api_backend = "responses" api_key = "your-OPENCODEX_API_AUTH_TOKEN" ``` @@ -84,7 +84,6 @@ api_key = "your-OPENCODEX_API_AUTH_TOKEN" ## 已知限制 -- **Responses 後端與 keep-alive:** opencodex 會在上游靜默期間,於 `/v1/responses` 串流上發出 `response.heartbeat` keep-alive。Grok Build 的 Responses 解碼器會拒絕未知的事件類型,因此手動設定 `api_backend = "responses"` 的模型,可能在上游較慢時於回合中途失敗。自動註冊的項目會固定為 `api_backend = "chat_completions"`,不會露出原始 heartbeat 框架。 - **以服務安裝的 `ocx restart`:** 當 opencodex 在服務管理員下執行時,`ocx restart` 目前會停止服務並以非受管程序取代——服務持續性(自動重啟、開機啟動)會遺失,直到下次 `ocx service` 設定;若該非受管程序死亡,受管理區塊可能指向已死的代理程式,直到下一次 `ocx start`/`ocx ensure` 重新整理它。 - **設定讀取時機:** 先啟動 opencodex,再啟動 `grok`,結果最可預期。Grok Build 會監看 `~/.grok/config.toml`,並在 `[model]` 表格實際變更時重新載入(約一秒 debounce,依內容比對),因此重新整理後的區塊可在不重啟的情況下到達開啟中的工作階段。若要確認 Grok 解析了什麼,執行 `grok inspect`:它會列出已載入的設定來源,並對任何被拒絕的欄位發出警告。它不會印出解析後的模型清單。請注意,單一 TOML 錯誤會使*整個*使用者設定層失效,這也是 opencodex 以原子方式寫入檔案的原因——Grok 永遠看不到半寫入的設定。 - **目錄更新:** 圍欄區塊反映注入當下的目錄。新增供應商或模型後,請執行 `ocx ensure`(或重啟代理程式)以重新整理它。 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 3ca807a461..ca5e5eab88 100644 --- a/docs-site/src/content/docs/zh-tw/reference/architecture.md +++ b/docs-site/src/content/docs/zh-tw/reference/architecture.md @@ -101,10 +101,12 @@ src/ | `done` | `response.completed`(帶 usage) | | `error` | `response.failed`(帶 `last_error`) | -橋接器還會執行**心跳保活**(RC3):上游沒有資料時,每 2 秒傳送一次解析器會忽略的 -`response.heartbeat` SSE event,以重新啟動 Codex 的空閒計時器。預設**停滯截止時間**為 300 秒 -(`stallTimeoutSec`);達到該時限後會中止上游,併發出 reason 為 -`upstream_stall_timeout` 的 `response.incomplete`,避免掛起的連線無限期阻塞 Codex。 +橋接器還會執行**心跳保活**(RC3):上游沒有資料時,每 2 秒傳送一個 SSE 註解行 +(`: opencodex heartbeat`)來重新啟動 Codex 的空閒計時器。註解行會被每個 +eventsource 解析器丟棄而不會產生任何事件,因此嚴格的 Responses 解碼器永遠不會 +遇到未知 variant。預設**停滯截止時間**為 300 秒(`stallTimeoutSec`);達到該時限後 +會中止上游,並發出 reason 為 `upstream_stall_timeout` 的 `response.incomplete`, +避免掛起的連線無限期阻塞 Codex。 解析器捕獲的名稱空間對映、freeform 集合與 tool-search 集合會把工具呼叫區分為三種 Responses item,因此 MCP 名稱空間、`apply_patch` 風格的 freeform 工具和用戶端執行的 `tool_search` 都能 diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index f3287a65f7..d36164aa56 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -323,13 +323,14 @@ frame rather than always emitting `response.completed`. If the response status i ## Heartbeat and stall deadline -The HTTP/SSE bridge emits `response.heartbeat` events during upstream silence to re-arm Codex's idle -timer (Codex's default `stream_idle_timeout` is 300 s and ANY SSE event re-arms it). Those -bridge-enqueued keepalive frames do NOT count as activity for the bridge's own watchdog: a bounded -stall deadline (default 300 s, configurable via `stallTimeoutSec`, checked on the 2 s heartbeat tick) -closes the stream with `response.incomplete` / `upstream_stall_timeout` and cancels the upstream -request if no real adapter events arrive. Adapter-yielded `{ type: "heartbeat" }` events DO reset -the watchdog. +The HTTP/SSE bridge emits an SSE comment-line keep-alive (`: opencodex heartbeat`) during upstream +silence to re-arm Codex's idle timer (Codex's default `stream_idle_timeout` is 300 s and ANY SSE +bytes re-arm it). A comment line is discarded by every eventsource parser without producing an event, +so strict Responses decoders never see an unknown variant. Those bridge-enqueued keepalive frames do +NOT count as activity for the bridge's own watchdog: a bounded stall deadline (default 300 s, +configurable via `stallTimeoutSec`, checked on the 2 s heartbeat tick) closes the stream with +`response.incomplete` / `upstream_stall_timeout` and cancels the upstream request if no real +adapter events arrive. Adapter-yielded `{ type: "heartbeat" }` events DO reset the watchdog. Top-level `emptyCompletionRetry: true` opts Responses turns into one identical replay when a successful upstream completion contains neither output text nor a tool call. The default is off diff --git a/tests/bridge-lifecycle.test.ts b/tests/bridge-lifecycle.test.ts index 08d491395f..47d77166bd 100644 --- a/tests/bridge-lifecycle.test.ts +++ b/tests/bridge-lifecycle.test.ts @@ -246,7 +246,7 @@ describe("bridge stream lifecycle (RC1 / RC2)", () => { expect(aborted).toBe(true); }); - test("RC3: emits a parser-ignored response.heartbeat during upstream silence", async () => { + test("RC3: emits an SSE comment keep-alive (no response.heartbeat event) during upstream silence", async () => { // heartbeatMs = 10 so the keep-alive fires quickly; hangs() goes silent after one delta. const stream = bridgeToResponsesSSE(hangs(), "routed/model", undefined, undefined, undefined, undefined, 10); const reader = stream.getReader(); @@ -258,7 +258,10 @@ describe("bridge stream lifecycle (RC1 / RC2)", () => { if (value) text += dec.decode(value, { stream: true }); } await reader.cancel(); - expect(text).toContain("response.heartbeat"); + // Keep-alives are SSE comment lines, not typed events: any client parser discards + // them without deserializing, so strict Responses decoders stay alive and quiet. + expect(text).toContain(": opencodex heartbeat\n\n"); + expect(text).not.toContain("event: response.heartbeat"); }); test("RC3: configurable stall timeout emits response.incomplete after deadline", async () => {