mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
feat: Phase 6c — claude stream-json port + --system-prompt + opus 4.8 (#103)
* 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) <noreply@anthropic.com> * 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 § <flag> (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) <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## v3.16.4 — 2026-05-13
|
||||||
|
|
||||||
### Refactor — port-literal SPOT + CI guardrail
|
### Refactor — port-literal SPOT + CI guardrail
|
||||||
|
|||||||
@@ -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
|
installed and logged in (`claude auth status`). Install missing pieces
|
||||||
using my system's package manager.
|
using my system's package manager.
|
||||||
2. git clone the repo, cd in, and run `node setup.mjs`.
|
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.
|
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.
|
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).
|
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.
|
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`.
|
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.
|
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
|
chmod +x ocp-connect
|
||||||
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
||||||
3. Follow any IDE-specific manual hints it prints.
|
3. Follow any IDE-specific manual hints it prints.
|
||||||
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 4 models.
|
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 5 models.
|
||||||
5. Tell me to reload my shell + restart any IDE that was already running.
|
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.
|
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:**
|
**Verify:**
|
||||||
```bash
|
```bash
|
||||||
curl http://127.0.0.1:3456/v1/models
|
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
|
#### 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)
|
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||||
|
|
||||||
Testing API access...
|
Testing API access...
|
||||||
✓ API accessible (4 models available)
|
✓ API accessible (5 models available)
|
||||||
|
|
||||||
Shell config:
|
Shell config:
|
||||||
✓ .bashrc
|
✓ .bashrc
|
||||||
@@ -344,6 +344,7 @@ OCP Connect v1.3.0
|
|||||||
✓ OpenClaw configured
|
✓ OpenClaw configured
|
||||||
Provider: ocp
|
Provider: ocp
|
||||||
Models:
|
Models:
|
||||||
|
• ocp/claude-opus-4-8
|
||||||
• ocp/claude-opus-4-7
|
• ocp/claude-opus-4-7
|
||||||
• ocp/claude-opus-4-6
|
• ocp/claude-opus-4-6
|
||||||
• ocp/claude-sonnet-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
|
## 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
|
## Available Models
|
||||||
|
|
||||||
| Model ID | Notes |
|
| Model ID | Notes |
|
||||||
|----------|-------|
|
|----------|-------|
|
||||||
| `claude-opus-4-7` | Most capable (default for `opus` alias) |
|
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||||
| `claude-opus-4-6` | Previous Opus, retained for pinning |
|
| `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-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
||||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -2,6 +2,14 @@
|
|||||||
"$schema": "./models.schema.json",
|
"$schema": "./models.schema.json",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"models": [
|
"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",
|
"id": "claude-opus-4-7",
|
||||||
"displayName": "Claude Opus 4.7",
|
"displayName": "Claude Opus 4.7",
|
||||||
@@ -36,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"opus": "claude-opus-4-7",
|
"opus": "claude-opus-4-8",
|
||||||
"sonnet": "claude-sonnet-4-6",
|
"sonnet": "claude-sonnet-4-6",
|
||||||
"haiku": "claude-haiku-4-5-20251001"
|
"haiku": "claude-haiku-4-5-20251001"
|
||||||
},
|
},
|
||||||
|
|||||||
+275
-81
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
|
* 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.
|
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||||
*
|
*
|
||||||
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
|
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
|
||||||
@@ -122,6 +122,137 @@ function resolveClaude() {
|
|||||||
process.exit(1);
|
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 ───────────────────────────────────────────────────────
|
// ── Configuration ───────────────────────────────────────────────────────
|
||||||
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
||||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT), 10);
|
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || String(DEFAULT_PORT), 10);
|
||||||
@@ -381,17 +512,29 @@ checkAuth();
|
|||||||
const authCheckInterval = setInterval(checkAuth, 600000);
|
const authCheckInterval = setInterval(checkAuth, 600000);
|
||||||
|
|
||||||
// ── Build CLI arguments ─────────────────────────────────────────────────
|
// ── Build CLI arguments ─────────────────────────────────────────────────
|
||||||
function buildCliArgs(cliModel, sessionInfo) {
|
// Phase 6c port (2026-05-30): removed `-p` / `--output-format text`.
|
||||||
const args = ["-p", "--model", cliModel, "--output-format", "text"];
|
// Now uses `--output-format stream-json --verbose --no-session-persistence
|
||||||
|
// --system-prompt <OCP_SYSTEM_PROMPT_WRAPPER + client system messages>`.
|
||||||
// Session handling
|
//
|
||||||
if (sessionInfo?.resume) {
|
// Authority: claude CLI § --output-format stream-json, § --verbose,
|
||||||
args.push("--resume", sessionInfo.uuid);
|
// § --no-session-persistence, § --system-prompt (ported from OLP, verified v2.1.104;
|
||||||
} else if (sessionInfo?.uuid) {
|
// behavior stable through v2.1.158).
|
||||||
args.push("--session-id", sessionInfo.uuid);
|
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||||
} else {
|
//
|
||||||
args.push("--no-session-persistence");
|
// 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
|
// Permissions
|
||||||
if (SKIP_PERMISSIONS) {
|
if (SKIP_PERMISSIONS) {
|
||||||
@@ -400,11 +543,6 @@ function buildCliArgs(cliModel, sessionInfo) {
|
|||||||
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// System prompt
|
|
||||||
if (SYSTEM_PROMPT) {
|
|
||||||
args.push("--append-system-prompt", SYSTEM_PROMPT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// MCP config
|
// MCP config
|
||||||
if (MCP_CONFIG) {
|
if (MCP_CONFIG) {
|
||||||
args.push("--mcp-config", MCP_CONFIG);
|
args.push("--mcp-config", MCP_CONFIG);
|
||||||
@@ -475,8 +613,15 @@ function getModelTier(cliModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
// Builds CLI args, spawns the process, and sets up timeouts.
|
||||||
// timeouts. Returns context object or throws synchronously.
|
// 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) {
|
function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
throw new Error(`concurrency limit reached (${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.activeRequests++;
|
||||||
stats.totalRequests++;
|
stats.totalRequests++;
|
||||||
|
|
||||||
let sessionInfo = null;
|
// Phase 6c: always serialize full conversation via stdin (no session resume).
|
||||||
let prompt;
|
// 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 ──
|
// messagesToPrompt skips system messages now that they go via --system-prompt.
|
||||||
// sessionKey namespaces the Map key by keyName to prevent cross-caller collision
|
// Filter them out before calling to avoid double-injection.
|
||||||
// when two callers with different API keys share the same conversationId string.
|
const nonSystemMessages = messages.filter(m => m.role !== "system");
|
||||||
const sessionKey = _sessionKey(conversationId, keyName);
|
const prompt = messagesToPrompt(nonSystemMessages);
|
||||||
if (sessionKey && sessions.has(sessionKey)) {
|
|
||||||
const session = sessions.get(sessionKey);
|
|
||||||
session.lastUsed = Date.now();
|
|
||||||
sessionInfo = { uuid: session.uuid, resume: true };
|
|
||||||
stats.sessionHits++;
|
|
||||||
|
|
||||||
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++;
|
stats.oneOffRequests++;
|
||||||
prompt = messagesToPrompt(messages);
|
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 };
|
const env = { ...process.env };
|
||||||
delete env.CLAUDECODE;
|
delete env.CLAUDECODE;
|
||||||
@@ -563,15 +687,10 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
proc.once("exit", cleanup);
|
proc.once("exit", cleanup);
|
||||||
|
|
||||||
function handleSessionFailure() {
|
function handleSessionFailure() {
|
||||||
if (sessionInfo?.resume && sessionKey) {
|
// Phase 6c: session resume (--resume/--session-id) is no longer used;
|
||||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
// OCP always passes full context via stdin. No session state to clean up.
|
||||||
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
if (conversationId) {
|
||||||
sessions.delete(sessionKey);
|
logEvent("warn", "session_failure", { mode: "stateless", conversationId: conversationId.slice(0, 12) + "...", action: "none" });
|
||||||
} 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" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,9 +726,14 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
|
// ── 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.
|
// No pool = no crash loops, no stale workers, no degraded states.
|
||||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
// 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) {
|
function callClaude(model, messages, conversationId, keyName) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let ctx;
|
let ctx;
|
||||||
@@ -620,12 +744,30 @@ function callClaude(model, messages, conversationId, keyName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
||||||
let stdout = "";
|
let lineBuffer = "";
|
||||||
|
let assembledText = "";
|
||||||
|
let isFirstDelta = true;
|
||||||
|
let resultEventSeen = false;
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
|
||||||
proc.stdout.on("data", (d) => {
|
proc.stdout.on("data", (d) => {
|
||||||
markFirstByte();
|
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));
|
proc.stderr.on("data", (d) => (stderr += d));
|
||||||
|
|
||||||
@@ -633,17 +775,19 @@ function callClaude(model, messages, conversationId, keyName) {
|
|||||||
activeProcesses.delete(proc);
|
activeProcesses.delete(proc);
|
||||||
const elapsed = Date.now() - t0;
|
const elapsed = Date.now() - t0;
|
||||||
cleanup();
|
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);
|
recordModelError(cliModel, false);
|
||||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
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();
|
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 {
|
} else {
|
||||||
recordModelSuccess(cliModel, elapsed);
|
recordModelSuccess(cliModel, elapsed);
|
||||||
breakerRecordSuccess(cliModel);
|
breakerRecordSuccess(cliModel);
|
||||||
logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
logEvent("info", "claude_ok", { model: cliModel, chars: assembledText.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||||
resolve(stdout.trim());
|
resolve(assembledText);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -685,9 +829,14 @@ function startHeartbeat(res, intervalMs, sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
||||||
// Pipes stdout from the claude process directly to SSE chunks as they arrive.
|
// Pipes stdout from the claude process as SSE chunks as they arrive.
|
||||||
// Each data chunk becomes a proper SSE event with delta content in real time.
|
// 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.
|
// 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 = {}) {
|
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
const created = Math.floor(Date.now() / 1000);
|
const created = Math.floor(Date.now() / 1000);
|
||||||
@@ -704,6 +853,9 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
let headersSent = false;
|
let headersSent = false;
|
||||||
let totalChars = 0;
|
let totalChars = 0;
|
||||||
let cachedContent = ""; // accumulate for cache write-back
|
let cachedContent = ""; // accumulate for cache write-back
|
||||||
|
let lineBuffer = "";
|
||||||
|
let isFirstDelta = true;
|
||||||
|
let resultEventSeen = false;
|
||||||
|
|
||||||
function ensureHeaders() {
|
function ensureHeaders() {
|
||||||
if (res.writableEnded || res.destroyed) return false;
|
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
|
// D4 (spec 2026-04-25): eagerly send SSE headers post-spawn so the
|
||||||
// heartbeat started in the next statement (Task 1.3) covers the
|
// heartbeat started in the next statement covers the pre-first-byte silent window.
|
||||||
// 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.
|
|
||||||
ensureHeaders();
|
ensureHeaders();
|
||||||
const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, convId);
|
const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, convId);
|
||||||
|
|
||||||
proc.stdout.on("data", (d) => {
|
proc.stdout.on("data", (d) => {
|
||||||
markFirstByte();
|
markFirstByte();
|
||||||
const text = d.toString();
|
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) {
|
||||||
|
// content_block_delta text — forward as SSE delta
|
||||||
|
const text = parsed.text;
|
||||||
totalChars += text.length;
|
totalChars += text.length;
|
||||||
if (CACHE_TTL > 0) cachedContent += text;
|
if (CACHE_TTL > 0) cachedContent += text;
|
||||||
|
isFirstDelta = false;
|
||||||
|
|
||||||
if (!ensureHeaders()) return;
|
if (!ensureHeaders()) continue;
|
||||||
|
|
||||||
// Stream each chunk as it arrives from the CLI process
|
|
||||||
sendSSE(res, {
|
sendSSE(res, {
|
||||||
id, object: "chat.completion.chunk", created, model,
|
id, object: "chat.completion.chunk", created, model,
|
||||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||||
}, hb);
|
}, 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));
|
proc.stderr.on("data", (d) => (stderr += d));
|
||||||
@@ -755,7 +943,9 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
cleanup();
|
cleanup();
|
||||||
const elapsed = Date.now() - t0;
|
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);
|
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 }); }
|
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) });
|
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||||
@@ -782,6 +972,9 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 (!headersSent) ensureHeaders();
|
||||||
if (!res.writableEnded && !res.destroyed) {
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
sendSSE(res, {
|
sendSSE(res, {
|
||||||
@@ -792,6 +985,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
res.end();
|
res.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on("error", (err) => {
|
proc.on("error", (err) => {
|
||||||
@@ -1820,7 +2014,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
|||||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
||||||
console.log(`---`);
|
console.log(`---`);
|
||||||
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
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(` CC uses: MCP protocol (in-process) → persistent session`);
|
||||||
console.log(` Both can run simultaneously on the same machine.`);
|
console.log(` Both can run simultaneously on the same machine.`);
|
||||||
|
|
||||||
|
|||||||
@@ -951,6 +951,248 @@ await asyncTest("doctor --check oauth + 200 with null body → fix_service", asy
|
|||||||
assert.equal(result.fail_count, 1);
|
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 ──
|
// ── Cleanup ──
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user