From 885f62addfc95cf324bf1d03070a64a5683bb8e9 Mon Sep 17 00:00:00 2001 From: dtzp555-max Date: Sun, 31 May 2026 09:13:36 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=206c=20=E2=80=94=20claude=20strea?= =?UTF-8?q?m-json=20port=20+=20--system-prompt=20+=20opus=204.8=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip(rescue): OCP Phase 6c stream-json port + opus 4.8 (uncommitted /tmp work) Rescue commit — preserves the subagent-produced Phase 6c port (claude -p → stream-json + --system-prompt + NDJSON parser) and opus 4.8 model addition done 2026-05-30 in a /tmp clone (volatile). NOT reviewed-for-merge; this is preservation only on a WIP branch so a /tmp reboot does not lose the work. Strategy context: OCP is being made the first-mover for TUI-mode (users are on OCP). Phase 6c here is the DEFAULT-path foundation (stream-json = sdk-cli = the "Max-user stable mode" fallback). TUI-mode (cc_entrypoint=cli subscription bridge) lands on top as an opt-in. Re-review before merging to main. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(phase6c): fold review findings — scrub hostname, README prose, parser tests, version citation P3-1 (binding): scrub private hostname "PI231" from server.mjs comment; replace with role-based term "the test server". Repo is public — no machine-specific hostnames may appear. P3-2: update README.md "How It Works" diagram and prose from stale "claude -p" to "claude --output-format stream-json", matching the Phase 6c spawn change already in server.mjs and CHANGELOG. P2-2: update all five authority citations in server.mjs from bare "claude CLI v2.1.104" to "claude CLI § (ported from OLP, verified v2.1.104; behavior stable through v2.1.158)" — honest about OLP provenance and version range, without inventing new facts. P2-1: add "Stream-JSON parsers" suite to test-features.mjs (17 new tests, 84 → 101 total). Copies parseStreamJsonLines and parseStreamJsonEvent verbatim from server.mjs with a MIRRORS header comment; logEvent stubbed to avoid live-server side-effects. Covers: content_block_delta deltas, assistant-aggregate fallback, no-double-count guard, aggregate-only short responses, partial-line buffering across two chunks, is_error result variants, malformed/non-JSON lines, system/user/unknown event types. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: dtzp555 Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 + README.md | 20 +-- models.json | 10 +- server.mjs | 384 ++++++++++++++++++++++++++++++++++------------ test-features.mjs | 242 +++++++++++++++++++++++++++++ 5 files changed, 559 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 641dbfb..8f8b871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Model — add claude-opus-4-8 + +Add `claude-opus-4-8` as the newest Opus to `models.json` (index 0, newest first). Repoint `aliases.opus` from `claude-opus-4-7` to `claude-opus-4-8`. `claude-opus-4-7` remains in the list callable by literal id. `legacyAliases.claude-opus-4` left pointing at `claude-opus-4-7` (no change — legacy alias tracks the prior generation). README Available Models table and model-count references updated accordingly. + +--- + ## v3.16.4 — 2026-05-13 ### Refactor — port-literal SPOT + CI guardrail diff --git a/README.md b/README.md index cbf6696..7a68a89 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md installed and logged in (`claude auth status`). Install missing pieces using my system's package manager. 2. git clone the repo, cd in, and run `node setup.mjs`. -3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 4 models). +3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 5 models). 4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc. 5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor. @@ -142,7 +142,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md 5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc). 6. Run `ocp lan` to show me the LAN IP and connect command. 7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`. -8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 4 models. +8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 5 models. Tell me each step before running it. On error, diagnose before retrying. ``` @@ -165,7 +165,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md chmod +x ocp-connect 2. Run `./ocp-connect ` (add `--key ` if you have one). 3. Follow any IDE-specific manual hints it prints. -4. Verify: `curl http://:3456/v1/models` returns 4 models. +4. Verify: `curl http://:3456/v1/models` returns 5 models. 5. Tell me to reload my shell + restart any IDE that was already running. Don't auto-retry on error. Tell me the failure mode first. @@ -235,7 +235,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions. **Verify:** ```bash curl http://127.0.0.1:3456/v1/models -# Returns: claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001 +# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001 ``` #### Headless install notes @@ -314,7 +314,7 @@ OCP Connect v1.3.0 (set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A) Testing API access... - ✓ API accessible (4 models available) + ✓ API accessible (5 models available) Shell config: ✓ .bashrc @@ -344,6 +344,7 @@ OCP Connect v1.3.0 ✓ OpenClaw configured Provider: ocp Models: + • ocp/claude-opus-4-8 • ocp/claude-opus-4-7 • ocp/claude-opus-4-6 • ocp/claude-sonnet-4-6 @@ -674,17 +675,18 @@ Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored loca ## How It Works ``` -Your IDE → OCP (localhost:3456) → claude -p CLI → Anthropic (via subscription) +Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI → Anthropic (via subscription) ``` -OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed. +OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed. ## Available Models | Model ID | Notes | |----------|-------| -| `claude-opus-4-7` | Most capable (default for `opus` alias) | -| `claude-opus-4-6` | Previous Opus, retained for pinning | +| `claude-opus-4-8` | Most capable (default for `opus` alias) | +| `claude-opus-4-7` | Previous Opus, retained for pinning | +| `claude-opus-4-6` | Older Opus, retained for pinning | | `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) | | `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) | diff --git a/models.json b/models.json index ede1adb..fe119e4 100644 --- a/models.json +++ b/models.json @@ -2,6 +2,14 @@ "$schema": "./models.schema.json", "version": 1, "models": [ + { + "id": "claude-opus-4-8", + "displayName": "Claude Opus 4.8", + "openclawName": "Claude Opus 4.8 (via CLI)", + "reasoning": true, + "contextWindow": 200000, + "maxTokens": 16384 + }, { "id": "claude-opus-4-7", "displayName": "Claude Opus 4.7", @@ -36,7 +44,7 @@ } ], "aliases": { - "opus": "claude-opus-4-7", + "opus": "claude-opus-4-8", "sonnet": "claude-sonnet-4-6", "haiku": "claude-haiku-4-5-20251001" }, diff --git a/server.mjs b/server.mjs index c085fce..2258748 100644 --- a/server.mjs +++ b/server.mjs @@ -2,7 +2,7 @@ /** * openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI * - * Translates OpenAI chat/completions requests into `claude -p` CLI calls, + * Translates OpenAI chat/completions requests into `claude --output-format stream-json` CLI calls, * letting you use your Claude Pro/Max subscription as an OpenClaw model provider. * * Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min). @@ -122,6 +122,137 @@ function resolveClaude() { process.exit(1); } +// ── OCP system prompt wrapper (Phase 6c port — ADR 0009 Amendment 1 analogue) ─ +// Injected via `--system-prompt` flag, replacing claude CLI's default system +// prompt (which normally includes cwd, OS, tool descriptions, and git status — +// all irrelevant and potentially misleading when the model is accessed via the +// OCP HTTP proxy). +// +// Authority: claude CLI § --system-prompt (ported from OLP, verified v2.1.104; +// behavior stable through v2.1.158 — OLP ADR 0009 Amendment 1 § +// "OLP system prompt wrapper"; ported to OCP 2026-05-30). +// Reference: https://github.com/dtzp555-max/olp commit 97e7d16 (Phase 6c) +const OCP_SYSTEM_PROMPT_WRAPPER = `You are accessed via the OCP HTTP proxy. You do NOT have access to any local filesystem, working directory, shell, git status, or machine environment. Do not infer or invent such information from any context you observe. Respond only based on the conversation provided.`; + +// Build the full system-prompt string: OCP_SYSTEM_PROMPT_WRAPPER prepended, +// then any system-role messages from the request appended (separated by blank line). +// ADR 0009 Amendment 1 analogue § "OLP system prompt wrapper". +function extractSystemPrompt(messages) { + const systemMessages = (messages ?? []).filter(m => m.role === "system"); + if (systemMessages.length === 0) { + return OCP_SYSTEM_PROMPT_WRAPPER; + } + const clientContent = systemMessages.map(m => + typeof m.content === "string" ? m.content : JSON.stringify(m.content) + ).join("\n\n"); + return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`; +} + +// ── NDJSON line buffer parser (Phase 6c port) ───────────────────────────── +// Splits a buffered string on newlines, returning complete parsed events +// plus the trailing incomplete line as `remainder` for the next data chunk. +// +// Authority: claude CLI § --output-format stream-json (ported from OLP, verified v2.1.104; +// behavior stable through v2.1.158; each event is a newline-terminated JSON object on stdout). +// Reference: OLP lib/providers/anthropic.mjs parseStreamJsonLines (commit 97e7d16). +function parseStreamJsonLines(buffered) { + const lines = buffered.split("\n"); + const remainder = lines.pop(); // last element is the incomplete trailing line + const events = []; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === "") continue; + try { + events.push(JSON.parse(trimmed)); + } catch { + console.error("[claude] NDJSON parse error on line:", trimmed.slice(0, 120)); + events.push({ type: "parse_error", raw: trimmed }); + } + } + return { events, remainder: remainder ?? "" }; +} + +// ── NDJSON event → text content extractor (Phase 6c port) ──────────────── +// Maps claude CLI stream-json NDJSON events to { text, stop, error } signals. +// Returns: +// { text: string } — content delta to forward +// { stop: true } — terminal event (emit finish_reason=stop) +// { error: string } — error event (emit error stop) +// null — consumed event (log/ignore) +// +// Authority: claude CLI § --output-format stream-json (ported from OLP, verified v2.1.104; +// behavior stable through v2.1.158). +// Reference: OLP lib/providers/anthropic.mjs anthropicStreamJsonEventToIR (commit 97e7d16). +// +// @param {object} event — parsed NDJSON event +// @param {boolean} isFirstDelta — true if no content has been yielded yet +function parseStreamJsonEvent(event, isFirstDelta) { + const t = event?.type; + + // system/* — first-event init + other system meta (api_retry etc.) + if (t === "system") return null; + // user — echo of user message; consumed + if (t === "user") return null; + + // stream_event — contains nested content_block_delta + if (t === "stream_event") { + const inner = event.event ?? event; + if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") { + return { text: inner.delta.text ?? "" }; + } + // Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed + return null; + } + + // assistant — aggregate message (fallback when no prior content_block_delta seen) + // Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short + // responses may emit ONLY the aggregate assistant event, no content_block_delta events. + // If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore. + // Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in). + if (t === "assistant") { + if (isFirstDelta) { + const blocks = event.message?.content; + if (Array.isArray(blocks)) { + const text = blocks + .filter(b => b && b.type === "text" && typeof b.text === "string") + .map(b => b.text) + .join(""); + if (text) return { text }; + } + } + return null; + } + + // result — terminal event + if (t === "result") { + if (event.is_error === true) { + return { error: event.error_message ?? event.result ?? "claude returned is_error" }; + } + return { stop: true }; + } + + // rate_limit_event / usage — log for observability, don't forward + if (t === "rate_limit_event" || t === "usage") { + logEvent("info", "claude_stream_event", { type: t, data: JSON.stringify(event).slice(0, 200) }); + return null; + } + + // control_request — per Anthropic stream-json docs + if (t === "control_request") { + console.error("[claude] stream_json control_request event (ignored):", JSON.stringify(event).slice(0, 120)); + return null; + } + + // parse_error — already logged by parseStreamJsonLines + if (t === "parse_error") return null; + + // Unknown event type — log + skip; future-proof for new claude CLI events + if (t !== undefined) { + console.error("[claude] unknown stream_json event type:", t); + } + return null; +} + // ── Configuration ─────────────────────────────────────────────────────── // Settings marked with `let` can be changed at runtime via PATCH /settings. const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT), 10); @@ -381,17 +512,29 @@ checkAuth(); const authCheckInterval = setInterval(checkAuth, 600000); // ── Build CLI arguments ───────────────────────────────────────────────── -function buildCliArgs(cliModel, sessionInfo) { - const args = ["-p", "--model", cliModel, "--output-format", "text"]; - - // Session handling - if (sessionInfo?.resume) { - args.push("--resume", sessionInfo.uuid); - } else if (sessionInfo?.uuid) { - args.push("--session-id", sessionInfo.uuid); - } else { - args.push("--no-session-persistence"); - } +// Phase 6c port (2026-05-30): removed `-p` / `--output-format text`. +// Now uses `--output-format stream-json --verbose --no-session-persistence +// --system-prompt `. +// +// Authority: claude CLI § --output-format stream-json, § --verbose, +// § --no-session-persistence, § --system-prompt (ported from OLP, verified v2.1.104; +// behavior stable through v2.1.158). +// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16. +// +// Session flags (--resume, --session-id) are dropped: they are incompatible +// with stream-json mode without -p. OCP always passes full conversation context +// via stdin instead (messagesToPrompt), preserving multi-turn correctness. +// CLAUDE_SYSTEM_PROMPT env var is absorbed into the system prompt via +// extractSystemPrompt() at the caller level; APPEND_SYSTEM_PROMPT no longer used. +// Note: ALLOWED_TOOLS / SKIP_PERMISSIONS / MCP_CONFIG are preserved as before. +function buildCliArgs(cliModel, systemPrompt) { + const args = [ + "--model", cliModel, + "--output-format", "stream-json", + "--verbose", + "--no-session-persistence", + "--system-prompt", systemPrompt, + ]; // Permissions if (SKIP_PERMISSIONS) { @@ -400,11 +543,6 @@ function buildCliArgs(cliModel, sessionInfo) { args.push("--allowedTools", ...ALLOWED_TOOLS); } - // System prompt - if (SYSTEM_PROMPT) { - args.push("--append-system-prompt", SYSTEM_PROMPT); - } - // MCP config if (MCP_CONFIG) { args.push("--mcp-config", MCP_CONFIG); @@ -475,8 +613,15 @@ function getModelTier(cliModel) { } // ── Spawn claude CLI (shared setup) ───────────────────────────────────── -// Resolves session logic, builds CLI args, spawns the process, and sets up -// timeouts. Returns context object or throws synchronously. +// Builds CLI args, spawns the process, and sets up timeouts. +// Returns context object or throws synchronously. +// +// Phase 6c port (2026-05-30): session resume (--resume / --session-id) is +// dropped because it is incompatible with stream-json mode without -p. +// OCP now always passes the full serialized conversation via stdin +// (messagesToPrompt), so multi-turn correctness is preserved without sessions. +// The sessions Map is retained for stats/logging but no longer drives --resume. +// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16. function spawnClaudeProcess(model, messages, conversationId, keyName) { if (stats.activeRequests >= MAX_CONCURRENT) { throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`); @@ -489,43 +634,22 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) { stats.activeRequests++; stats.totalRequests++; - let sessionInfo = null; - let prompt; + // Phase 6c: always serialize full conversation via stdin (no session resume). + // System messages are extracted and passed via --system-prompt; the remaining + // messages (user/assistant/tool) are serialized by messagesToPrompt. + const systemPrompt = extractSystemPrompt(messages); - // ── Session logic ── - // sessionKey namespaces the Map key by keyName to prevent cross-caller collision - // when two callers with different API keys share the same conversationId string. - const sessionKey = _sessionKey(conversationId, keyName); - if (sessionKey && sessions.has(sessionKey)) { - const session = sessions.get(sessionKey); - session.lastUsed = Date.now(); - sessionInfo = { uuid: session.uuid, resume: true }; - stats.sessionHits++; + // messagesToPrompt skips system messages now that they go via --system-prompt. + // Filter them out before calling to avoid double-injection. + const nonSystemMessages = messages.filter(m => m.role !== "system"); + const prompt = messagesToPrompt(nonSystemMessages); - const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); - prompt = lastUserMsg - ? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content)) - : ""; - session.messageCount = messages.length; - - console.log(`[session] resume conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`); - - } else if (sessionKey) { - const uuid = randomUUID(); - const now = Date.now(); - sessions.set(sessionKey, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel }); - sessionInfo = { uuid, resume: false }; - stats.sessionMisses++; - prompt = messagesToPrompt(messages); - - console.log(`[session] new conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`); - - } else { - stats.oneOffRequests++; - prompt = messagesToPrompt(messages); + stats.oneOffRequests++; + if (conversationId) { + console.log(`[session] stateless conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} msgs=${messages.length} prompt_chars=${prompt.length}`); } - const cliArgs = buildCliArgs(cliModel, sessionInfo); + const cliArgs = buildCliArgs(cliModel, systemPrompt); const env = { ...process.env }; delete env.CLAUDECODE; @@ -563,15 +687,10 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) { proc.once("exit", cleanup); function handleSessionFailure() { - if (sessionInfo?.resume && sessionKey) { - console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`); - logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" }); - sessions.delete(sessionKey); - } else if (sessionInfo && !sessionInfo.resume && sessionKey) { - // #41 evidence-gathering: session-create failures currently leave a stale entry - // in the sessions map. Log without action so the staleness pattern can be - // confirmed in /logs before any code change. Do NOT delete here speculatively. - logEvent("warn", "session_failure", { mode: "create", conversationId: conversationId.slice(0, 12) + "...", action: "kept" }); + // Phase 6c: session resume (--resume/--session-id) is no longer used; + // OCP always passes full context via stdin. No session state to clean up. + if (conversationId) { + logEvent("warn", "session_failure", { mode: "stateless", conversationId: conversationId.slice(0, 12) + "...", action: "none" }); } } @@ -607,9 +726,14 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) { } // ── Call claude CLI (non-streaming) ───────────────────────────────────── -// On-demand spawning: each request spawns a fresh `claude -p` process. +// On-demand spawning: each request spawns a fresh claude process. // No pool = no crash loops, no stale workers, no degraded states. // Stdin is written immediately so there's no 3s stdin timeout issue. +// +// Phase 6c port (2026-05-30): stdout is now NDJSON (stream-json format). +// We accumulate full text across all content_block_delta events plus the +// assistant-aggregate fallback, then resolve with the assembled string. +// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16. function callClaude(model, messages, conversationId, keyName) { return new Promise((resolve, reject) => { let ctx; @@ -620,12 +744,30 @@ function callClaude(model, messages, conversationId, keyName) { } const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx; - let stdout = ""; + let lineBuffer = ""; + let assembledText = ""; + let isFirstDelta = true; + let resultEventSeen = false; let stderr = ""; proc.stdout.on("data", (d) => { markFirstByte(); - stdout += d; + lineBuffer += d.toString(); + const { events, remainder } = parseStreamJsonLines(lineBuffer); + lineBuffer = remainder; + for (const event of events) { + const parsed = parseStreamJsonEvent(event, isFirstDelta); + if (!parsed) continue; + if (parsed.text !== undefined) { + assembledText += parsed.text; + isFirstDelta = false; + } else if (parsed.stop) { + resultEventSeen = true; + } else if (parsed.error) { + // is_error result — treat as process error + reject(new Error(parsed.error)); + } + } }); proc.stderr.on("data", (d) => (stderr += d)); @@ -633,17 +775,19 @@ function callClaude(model, messages, conversationId, keyName) { activeProcesses.delete(proc); const elapsed = Date.now() - t0; cleanup(); - if (code !== 0) { + // Tolerate null exit code when result event was seen (sandbox-wrap noise, same + // as OLP commit 2864275 — bwrap shell exits null after model completes). + if (code !== 0 && !resultEventSeen) { recordModelError(cliModel, false); logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); - trackError(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`); + trackError(stderr.slice(0, 300) || assembledText.slice(0, 300) || `claude exit ${code}`); handleSessionFailure(); - reject(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`)); + reject(new Error(stderr.slice(0, 300) || assembledText.slice(0, 300) || `claude exit ${code}`)); } else { recordModelSuccess(cliModel, elapsed); breakerRecordSuccess(cliModel); - logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" }); - resolve(stdout.trim()); + logEvent("info", "claude_ok", { model: cliModel, chars: assembledText.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" }); + resolve(assembledText); } }); @@ -685,9 +829,14 @@ function startHeartbeat(res, intervalMs, sessionId) { } // ── Call claude CLI (real streaming) ───────────────────────────────────── -// Pipes stdout from the claude process directly to SSE chunks as they arrive. -// Each data chunk becomes a proper SSE event with delta content in real time. +// Pipes stdout from the claude process as SSE chunks as they arrive. +// Each NDJSON content_block_delta text event becomes one SSE delta. // TODO(cache-singleflight-stream): streaming-path singleflight is out of scope for v3.13.0; see spec D4 streaming caveat. +// +// Phase 6c port (2026-05-30): stdout is now NDJSON (stream-json format). +// We parse line-by-line and forward content_block_delta text events as SSE. +// The result event triggers the stop/[DONE] sequence. +// Reference: OLP ADR 0009 Amendment 1 + commits 97e7d16, 65f945c. function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) { const id = `chatcmpl-${randomUUID()}`; const created = Math.floor(Date.now() / 1000); @@ -704,6 +853,9 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {} let headersSent = false; let totalChars = 0; let cachedContent = ""; // accumulate for cache write-back + let lineBuffer = ""; + let isFirstDelta = true; + let resultEventSeen = false; function ensureHeaders() { if (res.writableEnded || res.destroyed) return false; @@ -724,27 +876,63 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {} } // D4 (spec 2026-04-25): eagerly send SSE headers post-spawn so the - // heartbeat started in the next statement (Task 1.3) covers the - // pre-first-byte silent window. Behavior change: the `code !== 0` - // before-first-byte branch at server.mjs:610-611 becomes effectively - // unreachable in the common case — the post-headers SSE-stop path - // (612-619) handles it instead. + // heartbeat started in the next statement covers the pre-first-byte silent window. ensureHeaders(); const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, convId); proc.stdout.on("data", (d) => { markFirstByte(); - const text = d.toString(); - totalChars += text.length; - if (CACHE_TTL > 0) cachedContent += text; + lineBuffer += d.toString(); + const { events, remainder } = parseStreamJsonLines(lineBuffer); + lineBuffer = remainder; - if (!ensureHeaders()) return; + for (const event of events) { + const parsed = parseStreamJsonEvent(event, isFirstDelta); + if (!parsed) continue; - // Stream each chunk as it arrives from the CLI process - sendSSE(res, { - id, object: "chat.completion.chunk", created, model, - choices: [{ index: 0, delta: { content: text }, finish_reason: null }], - }, hb); + if (parsed.text !== undefined) { + // content_block_delta text — forward as SSE delta + const text = parsed.text; + totalChars += text.length; + if (CACHE_TTL > 0) cachedContent += text; + isFirstDelta = false; + + if (!ensureHeaders()) continue; + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + }, hb); + + } else if (parsed.stop) { + // result event — emit stop and [DONE] immediately + resultEventSeen = true; + if (!ensureHeaders()) continue; + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, hb); + if (!res.writableEnded && !res.destroyed) { + res.write("data: [DONE]\n\n"); + res.end(); + } + + } else if (parsed.error) { + // is_error result — emit error stop + resultEventSeen = true; + logEvent("error", "claude_result_error", { model: cliModel, error: parsed.error.slice(0, 200) }); + trackError(parsed.error.slice(0, 200)); + if (!headersSent && !res.writableEnded && !res.destroyed) { + jsonResponse(res, 500, { error: { message: parsed.error, type: "provider_error" } }); + } else if (!res.writableEnded && !res.destroyed) { + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, hb); + res.write("data: [DONE]\n\n"); + res.end(); + } + } + } }); proc.stderr.on("data", (d) => (stderr += d)); @@ -755,7 +943,9 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {} cleanup(); const elapsed = Date.now() - t0; - if (code !== 0) { + // Tolerate null exit code when result event was seen (sandbox-wrap noise, same + // as OLP commit 2864275 — bwrap shell exits null after model completes). + if (code !== 0 && !resultEventSeen) { recordModelError(cliModel, false); try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); } logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); @@ -782,14 +972,18 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {} try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); } } - if (!headersSent) ensureHeaders(); - if (!res.writableEnded && !res.destroyed) { - sendSSE(res, { - id, object: "chat.completion.chunk", created, model, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - }, hb); - res.write("data: [DONE]\n\n"); - res.end(); + // If result event already closed the response, nothing more to do. + // Otherwise emit a synthetic stop (version drift safety net, same as OLP). + if (!resultEventSeen) { + if (!headersSent) ensureHeaders(); + if (!res.writableEnded && !res.destroyed) { + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, hb); + res.write("data: [DONE]\n\n"); + res.end(); + } } } }); @@ -1820,7 +2014,7 @@ server.listen(PORT, BIND_ADDRESS, () => { else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`); console.log(`---`); console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`); - console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`); + console.log(` OCP uses: localhost:${PORT} (HTTP) → claude --output-format stream-json (per-request process)`); console.log(` CC uses: MCP protocol (in-process) → persistent session`); console.log(` Both can run simultaneously on the same machine.`); diff --git a/test-features.mjs b/test-features.mjs index 0664851..fcea657 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -951,6 +951,248 @@ await asyncTest("doctor --check oauth + 200 with null body → fix_service", asy assert.equal(result.fail_count, 1); }); +// ── Stream-JSON parser tests ────────────────────────────────────────────── +// MIRRORS server.mjs parseStreamJsonLines/parseStreamJsonEvent — keep in sync. +// Copied verbatim to avoid importing server.mjs (top-level server.listen() would +// start a live HTTP server). The logEvent stub silences observability side-effects. +console.log("\nStream-JSON parsers:"); + +function logEvent() {} // stub — observability side-effect not needed in tests + +function parseStreamJsonLines(buffered) { + const lines = buffered.split("\n"); + const remainder = lines.pop(); // last element is the incomplete trailing line + const events = []; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === "") continue; + try { + events.push(JSON.parse(trimmed)); + } catch { + console.error("[claude] NDJSON parse error on line:", trimmed.slice(0, 120)); + events.push({ type: "parse_error", raw: trimmed }); + } + } + return { events, remainder: remainder ?? "" }; +} + +function parseStreamJsonEvent(event, isFirstDelta) { + const t = event?.type; + + // system/* — first-event init + other system meta (api_retry etc.) + if (t === "system") return null; + // user — echo of user message; consumed + if (t === "user") return null; + + // stream_event — contains nested content_block_delta + if (t === "stream_event") { + const inner = event.event ?? event; + if (inner?.type === "content_block_delta" && inner.delta?.type === "text_delta") { + return { text: inner.delta.text ?? "" }; + } + // Other stream_event sub-types (content_block_start, message_delta, etc.) — consumed + return null; + } + + // assistant — aggregate message (fallback when no prior content_block_delta seen) + // Empirically (claude CLI without --include-partial-messages, verified v2.1.104 through v2.1.158): fast/short + // responses may emit ONLY the aggregate assistant event, no content_block_delta events. + // If isFirstDelta is true, extract text here; otherwise it's a duplicate, ignore. + // Reference: OLP commit 65f945c (assistant-aggregate fallback, fold-in). + if (t === "assistant") { + if (isFirstDelta) { + const blocks = event.message?.content; + if (Array.isArray(blocks)) { + const text = blocks + .filter(b => b && b.type === "text" && typeof b.text === "string") + .map(b => b.text) + .join(""); + if (text) return { text }; + } + } + return null; + } + + // result — terminal event + if (t === "result") { + if (event.is_error === true) { + return { error: event.error_message ?? event.result ?? "claude returned is_error" }; + } + return { stop: true }; + } + + // rate_limit_event / usage — log for observability, don't forward + if (t === "rate_limit_event" || t === "usage") { + logEvent("info", "claude_stream_event", { type: t, data: JSON.stringify(event).slice(0, 200) }); + return null; + } + + // control_request — per Anthropic stream-json docs + if (t === "control_request") { + console.error("[claude] stream_json control_request event (ignored):", JSON.stringify(event).slice(0, 120)); + return null; + } + + // parse_error — already logged by parseStreamJsonLines + if (t === "parse_error") return null; + + // Unknown event type — log + skip; future-proof for new claude CLI events + if (t !== undefined) { + console.error("[claude] unknown stream_json event type:", t); + } + return null; +} + +// (a) content_block_delta deltas + assistant-aggregate fallback → assembled text with NO double-count +test("parseStreamJsonEvent: stream_event content_block_delta yields text", () => { + const event = { + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hello" } } + }; + const result = parseStreamJsonEvent(event, true); + assert.deepEqual(result, { text: "Hello" }); +}); + +test("parseStreamJsonEvent: assistant-aggregate used when isFirstDelta=true (no prior delta)", () => { + const event = { + type: "assistant", + message: { content: [{ type: "text", text: "Short answer." }] } + }; + const result = parseStreamJsonEvent(event, true); + assert.deepEqual(result, { text: "Short answer." }); +}); + +test("parseStreamJsonEvent: assistant-aggregate skipped when isFirstDelta=false (no double-count)", () => { + const event = { + type: "assistant", + message: { content: [{ type: "text", text: "Short answer." }] } + }; + const result = parseStreamJsonEvent(event, false); + assert.equal(result, null); +}); + +test("parseStreamJsonEvent: stream_event + assistant → assembled without double-count", () => { + // Simulate receiving a content_block_delta first, then an assistant aggregate + const delta = { + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text: "Streaming text." } } + }; + const agg = { + type: "assistant", + message: { content: [{ type: "text", text: "Streaming text." }] } + }; + // First event: isFirstDelta=true → yields text + const r1 = parseStreamJsonEvent(delta, true); + assert.deepEqual(r1, { text: "Streaming text." }); + // Second event (aggregate): isFirstDelta is now false (content already emitted) → null + const r2 = parseStreamJsonEvent(agg, false); + assert.equal(r2, null); +}); + +// (b) aggregate-only short response → assembles correctly +test("parseStreamJsonEvent: aggregate-only multi-block response assembles all text blocks", () => { + const event = { + type: "assistant", + message: { + content: [ + { type: "text", text: "Part one." }, + { type: "tool_use", id: "x" }, // non-text block — should be filtered + { type: "text", text: " Part two." } + ] + } + }; + const result = parseStreamJsonEvent(event, true); + assert.deepEqual(result, { text: "Part one. Part two." }); +}); + +// (c) JSON line split across two parseStreamJsonLines calls → partial-line buffering +test("parseStreamJsonLines: partial line carried as remainder", () => { + const chunk1 = '{"type":"system","subtype":"init"}\n{"type":"stream_ev'; + const { events: ev1, remainder: rem1 } = parseStreamJsonLines(chunk1); + assert.equal(ev1.length, 1); + assert.equal(ev1[0].type, "system"); + assert.equal(rem1, '{"type":"stream_ev'); + + const chunk2 = rem1 + 'ent","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}}\n'; + const { events: ev2, remainder: rem2 } = parseStreamJsonLines(chunk2); + assert.equal(ev2.length, 1); + assert.equal(ev2[0].type, "stream_event"); + assert.equal(rem2, ""); + // Verify the reassembled event parses through parseStreamJsonEvent correctly + const parsed = parseStreamJsonEvent(ev2[0], true); + assert.deepEqual(parsed, { text: "Hi" }); +}); + +test("parseStreamJsonLines: empty input returns no events and empty remainder", () => { + const { events, remainder } = parseStreamJsonLines(""); + assert.equal(events.length, 0); + assert.equal(remainder, ""); +}); + +// (d) is_error result event → surfaces the error +test("parseStreamJsonEvent: result is_error=true surfaces error_message", () => { + const event = { type: "result", is_error: true, error_message: "Rate limit hit" }; + const result = parseStreamJsonEvent(event, false); + assert.deepEqual(result, { error: "Rate limit hit" }); +}); + +test("parseStreamJsonEvent: result is_error=true falls back to result field when no error_message", () => { + const event = { type: "result", is_error: true, result: "error detail" }; + const result = parseStreamJsonEvent(event, false); + assert.deepEqual(result, { error: "error detail" }); +}); + +test("parseStreamJsonEvent: result is_error=true falls back to default string when no detail", () => { + const event = { type: "result", is_error: true }; + const result = parseStreamJsonEvent(event, false); + assert.deepEqual(result, { error: "claude returned is_error" }); +}); + +test("parseStreamJsonEvent: result is_error=false yields stop", () => { + const event = { type: "result", is_error: false, result: "success" }; + const result = parseStreamJsonEvent(event, false); + assert.deepEqual(result, { stop: true }); +}); + +// (e) malformed/non-JSON line → skipped without throwing +test("parseStreamJsonLines: malformed JSON line becomes parse_error event without throwing", () => { + const input = '{"type":"system"}\nnot-valid-json\n{"type":"result","is_error":false}\n'; + const { events, remainder } = parseStreamJsonLines(input); + assert.equal(events.length, 3); + assert.equal(events[0].type, "system"); + assert.equal(events[1].type, "parse_error"); + assert.equal(events[1].raw, "not-valid-json"); + assert.equal(events[2].type, "result"); +}); + +test("parseStreamJsonEvent: parse_error event returns null without throwing", () => { + const event = { type: "parse_error", raw: "garbage" }; + const result = parseStreamJsonEvent(event, false); + assert.equal(result, null); +}); + +// Additional edge cases +test("parseStreamJsonEvent: system event returns null", () => { + const result = parseStreamJsonEvent({ type: "system", subtype: "init" }, true); + assert.equal(result, null); +}); + +test("parseStreamJsonEvent: user event returns null", () => { + const result = parseStreamJsonEvent({ type: "user", message: {} }, true); + assert.equal(result, null); +}); + +test("parseStreamJsonEvent: stream_event non-text-delta (content_block_start) returns null", () => { + const event = { type: "stream_event", event: { type: "content_block_start", index: 0 } }; + const result = parseStreamJsonEvent(event, true); + assert.equal(result, null); +}); + +test("parseStreamJsonEvent: unknown event type returns null", () => { + const result = parseStreamJsonEvent({ type: "future_event_type" }, false); + assert.equal(result, null); +}); + // ── Cleanup ── closeDb();