mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
feat(phase-1): D10 P1 hardening — providers.enabled wiring + real streaming + spawn timeout
Folds in three production-blocking defects from external Codex review
round-3 of Phase 1 (D5+D6+D8+D9 already merged). Without these, OLP
returns 503 on every request even when `npm start` succeeds, streams
arrive as a single buffered burst after the spawn completes, and hung
CLIs block the engine forever despite ADR 0004 declaring spawn-timeout
a hard trigger.
P1.1 providers.enabled config wiring (ADR 0002 § Disable model):
- loadFallbackConfigSync() returns tri-field {chains, soft_triggers,
providersEnabled}; server.mjs reads _startupConfig.providersEnabled
and passes to loadProviders() at startup
- Empty / missing config → 0 enabled providers → 503 no_enabled_provider
(matches v0.1 0-Enabled posture per ALIGNMENT.md § Provider Inventory)
- __setProvidersEnabled / __resetProvidersEnabled test seams; in-place
Map mutation preserves existing direct-mutation patterns in Suite 13
P1.2 Real SSE streaming on single-hop cache-miss (ADR 0003 entry adapter
pattern, OpenAI /v1/chat/completions stream=true contract):
- New handleChatCompletions branch when ir.stream === true && chain.length
=== 1 && !bypassCache && !preCheckHit
- for await (const irChunk of provider.spawn(...)) writes SSE per chunk
via res.write(irChunkToOpenAISSE(...)); accumulates streamedChunks for
cacheStore.set on stop
- First-chunk rule preserved: error-before-first-chunk → sendError(502);
error-after-first-chunk → truncated res.end(), no fallback
- Multi-hop chains (chain.length > 1) continue to use buffered
executeWithFallback to keep fallback safety semantics
P1.3 Spawn timeout hard trigger (ADR 0004 § Trigger taxonomy bullet 4):
- SPAWN_TIMEOUT added to PROVIDER_ERROR_CODES (lib/providers/base.mjs)
and HARD_TRIGGER_CODES (lib/fallback/engine.mjs)
- All three plugins (anthropic.mjs / codex.mjs / mistral.mjs) wrap drain
loop with setTimeout (default 600_000ms, configurable via
hints.maxSpawnTimeMs); on fire: proc.kill('SIGTERM') + reject pending
drain promise with ProviderError(..., 'SPAWN_TIMEOUT')
- Timer cleared in finally; resolveNext/rejectNext atomically nulled in
push() + timer-fire path to prevent late-fire double-settle
Tests 277 → 288 (+11). Suite 14 (4 providers.enabled), Suite 15
(3 streaming cache-miss real-time, including arrival-count >= 2
assertion that architecturally proves real streaming), Suite 16
(4 spawn timeout, including 2-hop chain advancement from timed-out
primary). 288/288 pass on Node 20.20.2 + Node 25.8.0.
Authorities:
- ADR 0002 § Disable model — config toggle, not plugin-removal
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0002-plugin-architecture.md
- ADR 0003 § Translation direction model — entry adapter for await pattern
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0003-intermediate-representation.md
- ADR 0004 § Trigger taxonomy + § Fallback safety (first-chunk rule)
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
- OpenAI /v1/chat/completions stream=true (server-sent events,
data: {chunk} per delta, terminator data: [DONE])
https://platform.openai.com/docs/api-reference/chat/streaming
Reviewer (Iron Rule 10): fresh-context opus, independent of drafter.
Verdict: APPROVE_WITH_MINOR. Folded the one cheap minor before commit
(Suite 15a arrival-count assertion strengthened from chunks.length > 0
to arrivalTimestamps.length >= 2 — the prior assertion would have
admitted a buffered impl). Two remaining non-blocking notes deferred:
optional writeHead deferral (low value; single-hop guard makes pre-
content 200 + empty body safe), and version bump (Phase 1 ships as
v0.1.0 aggregate when D11–D16 land).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,16 @@
|
||||
|
||||
All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this file is the source of truth for GitHub release notes.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Phase 1 — Provider plugins + cache + fallback engine + P1 hardening
|
||||
|
||||
- **D10 (P1 round-3 hardening from external Codex review).** Three production-blocking defects from the Phase 1 round-3 review folded in:
|
||||
- **`providers.enabled` config wired through `loadProviders()`** (ADR 0002 § Disable model). `loadFallbackConfigSync()` now returns a tri-field shape `{ chains, soft_triggers, providersEnabled }`; `server.mjs` reads `_startupConfig.providersEnabled` at startup and passes it to `loadProviders()`. Empty / missing config → 0 enabled providers → `503 no_enabled_provider`, matching the v0.1 0-Enabled posture. `__setProvidersEnabled` / `__resetProvidersEnabled` test seams added.
|
||||
- **Real SSE streaming on single-hop cache-miss** (ADR 0003 entry adapter pattern). New `handleChatCompletions` branch when `ir.stream === true && chain.length === 1 && !bypassCache && !preCheckHit`: `for await (const irChunk of provider.spawn(...))` writes SSE per chunk via `res.write(irChunkToOpenAISSE(...))`, accumulates chunks for `cacheStore.set` on completion. First-chunk rule preserved (error-before-first-chunk → `sendError(502)`; error-after-first-chunk → truncated `res.end()`). Multi-hop chains still buffer (`executeWithFallback` path) to maintain fallback safety.
|
||||
- **Spawn timeout hard trigger** (ADR 0004 § Trigger taxonomy bullet 4). `SPAWN_TIMEOUT` added to `PROVIDER_ERROR_CODES` and `HARD_TRIGGER_CODES`. All three provider plugins (`anthropic.mjs` / `codex.mjs` / `mistral.mjs`) wrap their spawn drain loop with `setTimeout` (default 600_000ms, configurable via `hints.maxSpawnTimeMs`); on fire, `proc.kill('SIGTERM')` + reject pending drain promise with `ProviderError(..., 'SPAWN_TIMEOUT')`. Timer cleared in `finally` block; `resolveNext` / `rejectNext` atomically nulled to prevent late-fire double-settle.
|
||||
- **Test suite: 277 → 288 (+11).** New Suite 14 (providers.enabled wiring, 4 tests), Suite 15 (streaming cache-miss real-time, 3 tests including arrival-count assertion proving real streaming architecturally), Suite 16 (spawn timeout, 4 tests including full 2-hop chain advancement from timed-out primary).
|
||||
|
||||
## v0.1.0-bootstrap — 2026-05-23
|
||||
|
||||
### Phase 0 — Repo bootstrap (founding + post-codex-review hardening)
|
||||
|
||||
+12
-5
@@ -40,6 +40,7 @@ const HARD_TRIGGER_CODES = {
|
||||
CLI_NOT_FOUND: true,
|
||||
AUTH_MISSING: false, // user config problem — never fall over (ADR 0004 § Decision)
|
||||
OUTPUT_PARSE_ERROR: true,
|
||||
SPAWN_TIMEOUT: true, // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -444,17 +445,20 @@ function defaultConfigPath() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads fallback chain configuration from ~/.olp/config.json (or the given path).
|
||||
* Loads the full OLP config from ~/.olp/config.json (or the given path).
|
||||
*
|
||||
* Per D9 implementation notes:
|
||||
* v0.1 ships with no config file. Fallback engine is wired but single-hop
|
||||
* until the user populates routing.chains.
|
||||
*
|
||||
* Returns empty config (no chains, no soft triggers) if the file is absent,
|
||||
* unreadable, or malformed.
|
||||
* Per ADR 0002 § Disable model: providers.enabled is the toggle that transitions
|
||||
* a Candidate provider to Enabled. Missing entries default to false (disabled).
|
||||
*
|
||||
* Returns empty config (no chains, no soft triggers, no enabled providers) if the
|
||||
* file is absent, unreadable, or malformed.
|
||||
*
|
||||
* @param {string} [configPath] — override path (for testing — do NOT write to ~/.olp/config.json in tests)
|
||||
* @returns {{ chains: object, soft_triggers: object }}
|
||||
* @returns {{ chains: object, soft_triggers: object, providersEnabled: Record<string, boolean> }}
|
||||
*/
|
||||
export function loadFallbackConfigSync(configPath) {
|
||||
try {
|
||||
@@ -462,12 +466,15 @@ export function loadFallbackConfigSync(configPath) {
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const routing = parsed?.routing ?? {};
|
||||
const providers = parsed?.providers ?? {};
|
||||
return {
|
||||
chains: routing.chains ?? {},
|
||||
soft_triggers: routing.soft_triggers ?? {},
|
||||
providersEnabled: providers.enabled ?? {},
|
||||
};
|
||||
} catch {
|
||||
// File absent, unreadable, or malformed → no fallback config (single-hop mode)
|
||||
return { chains: {}, soft_triggers: {} };
|
||||
// Empty providersEnabled → all providers disabled → 503 per ALIGNMENT.md v0.1 posture.
|
||||
return { chains: {}, soft_triggers: {}, providersEnabled: {} };
|
||||
}
|
||||
}
|
||||
|
||||
+63
-20
@@ -50,7 +50,7 @@ import { execFileSync, execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { ProviderError } from './base.mjs';
|
||||
import { ProviderError, PROVIDER_ERROR_CODES } from './base.mjs';
|
||||
|
||||
// ── Binary resolution ─────────────────────────────────────────────────────
|
||||
// OLP_CLAUDE_BIN env takes priority, then falls back to 'claude' from PATH.
|
||||
@@ -256,12 +256,15 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
let exitCode = null;
|
||||
let exitSignal = null;
|
||||
let resolveNext = null;
|
||||
// timeout: tracks a pending rejectNext for spawn timeout
|
||||
let rejectNext = null;
|
||||
|
||||
function push(item) {
|
||||
chunks.push(item);
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
r();
|
||||
}
|
||||
}
|
||||
@@ -286,40 +289,77 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
push({ type: 'close' });
|
||||
});
|
||||
|
||||
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
|
||||
// maxSpawnTimeMs: from hints field so tests can lower it without changing the plugin.
|
||||
// Default: 600 000 ms (10 minutes) — reasonable for long Claude outputs.
|
||||
const maxSpawnTimeMs = anthropic.hints?.maxSpawnTimeMs ?? 600_000;
|
||||
|
||||
// Overall wall-clock deadline — fires if the process hasn't closed in time.
|
||||
let spawnTimedOut = false;
|
||||
const spawnDeadlineTimer = setTimeout(() => {
|
||||
spawnTimedOut = true;
|
||||
// Kill the process and wake up the drain loop via a rejection.
|
||||
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
||||
if (rejectNext) {
|
||||
const r = rejectNext;
|
||||
rejectNext = null;
|
||||
resolveNext = null;
|
||||
r(new ProviderError(
|
||||
`claude spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
));
|
||||
}
|
||||
}, maxSpawnTimeMs);
|
||||
|
||||
// Drain the chunk buffer
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
// Wait for the next event
|
||||
await new Promise((resolve) => { resolveNext = resolve; });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
if (spawnTimedOut) {
|
||||
throw new ProviderError(
|
||||
`claude spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
);
|
||||
}
|
||||
// Wait for the next event (or timeout rejection)
|
||||
await new Promise((resolve, reject) => {
|
||||
resolveNext = resolve;
|
||||
rejectNext = reject;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = chunks.shift();
|
||||
const item = chunks.shift();
|
||||
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`claude spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`claude spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'data') {
|
||||
yield anthropicChunkToIR(item.text, isFirstChunk);
|
||||
isFirstChunk = false;
|
||||
if (item.type === 'data') {
|
||||
yield anthropicChunkToIR(item.text, isFirstChunk);
|
||||
isFirstChunk = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(spawnDeadlineTimer);
|
||||
}
|
||||
|
||||
// Process close
|
||||
if (exitCode !== 0) {
|
||||
if (exitCode !== 0 && !spawnTimedOut) {
|
||||
const errMsg = accumulatedStderr.slice(0, 300) || `claude exit ${exitCode}`;
|
||||
// Map known exit patterns to IR error chunk, then throw
|
||||
throw new ProviderError(errMsg, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
// Normal exit: emit stop chunk
|
||||
yield anthropicStopToIR('stop');
|
||||
if (!spawnTimedOut) {
|
||||
yield anthropicStopToIR('stop');
|
||||
}
|
||||
}
|
||||
|
||||
// ── spawn (public, contract method) ──────────────────────────────────────
|
||||
@@ -447,6 +487,9 @@ const anthropic = {
|
||||
requiresTTY: false,
|
||||
concurrentSpawnSafe: true,
|
||||
maxConcurrent: 4,
|
||||
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
|
||||
// 600_000ms = 10 minutes. Tests can lower this by mutating anthropic.hints.maxSpawnTimeMs.
|
||||
maxSpawnTimeMs: 600_000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ export const PROVIDER_ERROR_CODES = /** @type {const} */ ([
|
||||
'CLI_NOT_FOUND',
|
||||
'SPAWN_FAILED',
|
||||
'OUTPUT_PARSE_ERROR',
|
||||
'SPAWN_TIMEOUT', // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
|
||||
]);
|
||||
|
||||
export class ProviderError extends Error {
|
||||
|
||||
+76
-37
@@ -132,7 +132,7 @@ import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { ProviderError } from './base.mjs';
|
||||
import { ProviderError, PROVIDER_ERROR_CODES } from './base.mjs';
|
||||
|
||||
// ── Binary resolution ─────────────────────────────────────────────────────
|
||||
// OLP_CODEX_BIN env takes priority, then falls back to 'codex' from PATH.
|
||||
@@ -382,12 +382,15 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
let accumulatedStderr = '';
|
||||
let stopEmitted = false;
|
||||
let resolveNext = null;
|
||||
// timeout: tracks a pending rejectNext for spawn timeout
|
||||
let rejectNext = null;
|
||||
|
||||
function push(item) {
|
||||
chunks.push(item);
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
r();
|
||||
}
|
||||
}
|
||||
@@ -429,59 +432,92 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
push({ type: 'close' });
|
||||
});
|
||||
|
||||
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
|
||||
// maxSpawnTimeMs: from hints field so tests can lower it without changing the plugin.
|
||||
// Default: 600 000 ms (10 minutes) — reasonable for long Codex outputs.
|
||||
const maxSpawnTimeMs = codex.hints?.maxSpawnTimeMs ?? 600_000;
|
||||
|
||||
let spawnTimedOut = false;
|
||||
const spawnDeadlineTimer = setTimeout(() => {
|
||||
spawnTimedOut = true;
|
||||
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
||||
if (rejectNext) {
|
||||
const r = rejectNext;
|
||||
rejectNext = null;
|
||||
resolveNext = null;
|
||||
r(new ProviderError(
|
||||
`codex spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
));
|
||||
}
|
||||
}, maxSpawnTimeMs);
|
||||
|
||||
// Drain the chunk buffer
|
||||
let isFirstChunk = true;
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
await new Promise((resolve) => { resolveNext = resolve; });
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = chunks.shift();
|
||||
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`codex spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'ndjson_line') {
|
||||
const irChunk = codexChunkToIR(item.line);
|
||||
if (irChunk === null) continue; // ignored event type (progress, etc.)
|
||||
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(irChunk.error, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (irChunk.type === 'stop') {
|
||||
stopEmitted = true;
|
||||
yield irChunk;
|
||||
try {
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
if (spawnTimedOut) {
|
||||
throw new ProviderError(
|
||||
`codex spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
);
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
resolveNext = resolve;
|
||||
rejectNext = reject;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (irChunk.type === 'delta') {
|
||||
// Add role to first delta chunk (matching anthropic.mjs pattern)
|
||||
if (isFirstChunk) {
|
||||
yield { type: 'delta', role: 'assistant', content: irChunk.content };
|
||||
isFirstChunk = false;
|
||||
} else {
|
||||
const item = chunks.shift();
|
||||
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`codex spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'ndjson_line') {
|
||||
const irChunk = codexChunkToIR(item.line);
|
||||
if (irChunk === null) continue; // ignored event type (progress, etc.)
|
||||
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(irChunk.error, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (irChunk.type === 'stop') {
|
||||
stopEmitted = true;
|
||||
yield irChunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (irChunk.type === 'delta') {
|
||||
// Add role to first delta chunk (matching anthropic.mjs pattern)
|
||||
if (isFirstChunk) {
|
||||
yield { type: 'delta', role: 'assistant', content: irChunk.content };
|
||||
isFirstChunk = false;
|
||||
} else {
|
||||
yield irChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(spawnDeadlineTimer);
|
||||
}
|
||||
|
||||
// Process close
|
||||
if (exitCode !== 0) {
|
||||
if (exitCode !== 0 && !spawnTimedOut) {
|
||||
const errMsg = accumulatedStderr.slice(0, 300) || `codex exit ${exitCode}`;
|
||||
throw new ProviderError(errMsg, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
// Normal exit: emit stop chunk if the NDJSON stream didn't already emit one.
|
||||
if (!stopEmitted) {
|
||||
if (!spawnTimedOut && !stopEmitted) {
|
||||
yield { type: 'stop', finish_reason: 'stop' };
|
||||
}
|
||||
}
|
||||
@@ -603,6 +639,9 @@ const codex = {
|
||||
requiresTTY: false, // codex exec runs headless (per CLI reference § exec)
|
||||
concurrentSpawnSafe: true, // each invocation is independent
|
||||
maxConcurrent: 4, // conservative default, same as anthropic
|
||||
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
|
||||
// 600_000ms = 10 minutes. Tests can lower this by mutating codex.hints.maxSpawnTimeMs.
|
||||
maxSpawnTimeMs: 600_000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+77
-39
@@ -230,7 +230,7 @@ import { execSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { ProviderError } from './base.mjs';
|
||||
import { ProviderError, PROVIDER_ERROR_CODES } from './base.mjs';
|
||||
|
||||
// ── Binary resolution ─────────────────────────────────────────────────────
|
||||
// OLP_VIBE_BIN env takes priority, then falls back to 'vibe' from PATH.
|
||||
@@ -505,6 +505,8 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
let accumulatedStderr = '';
|
||||
let stopEmitted = false;
|
||||
let resolveNext = null;
|
||||
// timeout: tracks a pending rejectNext for spawn timeout
|
||||
let rejectNext = null;
|
||||
let isFirstChunk = true;
|
||||
|
||||
function push(item) {
|
||||
@@ -512,13 +514,13 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
r();
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer stdout by line for JSON parsing.
|
||||
// Assumption A7: '--output json' emits one JSON object per line (NDJSON).
|
||||
// D-later: if '--output json' emits a single JSON blob, switch to '--output streaming'.
|
||||
// Assumption A7: '--output streaming' emits one JSON object per line (NDJSON).
|
||||
let stdoutBuf = '';
|
||||
proc.stdout.on('data', (d) => {
|
||||
stdoutBuf += d.toString();
|
||||
@@ -553,58 +555,91 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
push({ type: 'close' });
|
||||
});
|
||||
|
||||
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
|
||||
// maxSpawnTimeMs: from hints field so tests can lower it without changing the plugin.
|
||||
// Default: 600 000 ms (10 minutes) — reasonable for long Vibe outputs.
|
||||
const maxSpawnTimeMs = mistral.hints?.maxSpawnTimeMs ?? 600_000;
|
||||
|
||||
let spawnTimedOut = false;
|
||||
const spawnDeadlineTimer = setTimeout(() => {
|
||||
spawnTimedOut = true;
|
||||
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
||||
if (rejectNext) {
|
||||
const r = rejectNext;
|
||||
rejectNext = null;
|
||||
resolveNext = null;
|
||||
r(new ProviderError(
|
||||
`vibe spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
));
|
||||
}
|
||||
}, maxSpawnTimeMs);
|
||||
|
||||
// Drain the chunk buffer
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
await new Promise((resolve) => { resolveNext = resolve; });
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = chunks.shift();
|
||||
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`vibe spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'json_line') {
|
||||
const irChunk = mistralChunkToIR(item.line);
|
||||
if (irChunk === null) continue; // ignored event type
|
||||
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(irChunk.error, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (irChunk.type === 'stop') {
|
||||
stopEmitted = true;
|
||||
yield irChunk;
|
||||
try {
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
if (spawnTimedOut) {
|
||||
throw new ProviderError(
|
||||
`vibe spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
);
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
resolveNext = resolve;
|
||||
rejectNext = reject;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (irChunk.type === 'delta') {
|
||||
// Add role to first delta chunk (matching anthropic.mjs / codex.mjs pattern)
|
||||
if (isFirstChunk) {
|
||||
yield { type: 'delta', role: 'assistant', content: irChunk.content };
|
||||
isFirstChunk = false;
|
||||
} else {
|
||||
const item = chunks.shift();
|
||||
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`vibe spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'json_line') {
|
||||
const irChunk = mistralChunkToIR(item.line);
|
||||
if (irChunk === null) continue; // ignored event type
|
||||
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(irChunk.error, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (irChunk.type === 'stop') {
|
||||
stopEmitted = true;
|
||||
yield irChunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (irChunk.type === 'delta') {
|
||||
// Add role to first delta chunk (matching anthropic.mjs / codex.mjs pattern)
|
||||
if (isFirstChunk) {
|
||||
yield { type: 'delta', role: 'assistant', content: irChunk.content };
|
||||
isFirstChunk = false;
|
||||
} else {
|
||||
yield irChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(spawnDeadlineTimer);
|
||||
}
|
||||
|
||||
// Process close
|
||||
if (exitCode !== 0) {
|
||||
if (exitCode !== 0 && !spawnTimedOut) {
|
||||
const errMsg = accumulatedStderr.slice(0, 300) || `vibe exit ${exitCode}`;
|
||||
throw new ProviderError(errMsg, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
// Normal exit: emit stop chunk if the JSON stream didn't already emit one.
|
||||
if (!stopEmitted) {
|
||||
if (!spawnTimedOut && !stopEmitted) {
|
||||
yield { type: 'stop', finish_reason: 'stop' };
|
||||
}
|
||||
}
|
||||
@@ -743,6 +778,9 @@ const mistral = {
|
||||
requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode
|
||||
concurrentSpawnSafe: true, // each invocation is independent
|
||||
maxConcurrent: 4, // conservative default matching anthropic + codex
|
||||
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
|
||||
// 600_000ms = 10 minutes. Tests can lower this by mutating mistral.hints.maxSpawnTimeMs.
|
||||
maxSpawnTimeMs: 600_000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+158
-5
@@ -49,16 +49,27 @@ const VERSION = pkg.version;
|
||||
const PORT = parseInt(process.env.OLP_PORT ?? '3456', 10);
|
||||
const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
// ── Startup config ────────────────────────────────────────────────────────
|
||||
// Read ~/.olp/config.json once at startup. Provides:
|
||||
// - providers.enabled → which providers are loaded (ADR 0002 § Disable model)
|
||||
// - routing.chains → fallback chain config (ADR 0004 § D9)
|
||||
// - routing.soft_triggers → soft trigger thresholds (ADR 0004)
|
||||
// If the file is absent or malformed, defaults are safe:
|
||||
// empty providersEnabled → all 503 (ALIGNMENT.md § v0.1 zero-Enabled-Providers posture)
|
||||
// empty chains/soft_triggers → single-hop mode
|
||||
const _startupConfig = loadFallbackConfigSync();
|
||||
|
||||
// ── Provider registry ─────────────────────────────────────────────────────
|
||||
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1.
|
||||
// Empty config → empty loaded map → all POST /v1/chat/completions → 503.
|
||||
const loadedProviders = loadProviders({ enabled: {} });
|
||||
// ALIGNMENT.md § Provider Inventory: 0 Enabled Providers at v0.1 unless
|
||||
// ~/.olp/config.json has providers.enabled.X = true.
|
||||
// ADR 0002 § Disable model: enabled toggle in config.json transitions Candidate → Enabled.
|
||||
const loadedProviders = loadProviders({ enabled: _startupConfig.providersEnabled ?? {} });
|
||||
|
||||
// ── Fallback config ───────────────────────────────────────────────────────
|
||||
// Read ~/.olp/config.json routing.chains at startup. Empty at v0.1.
|
||||
// Per ADR 0004 § D9: fallback engine is wired; activates when user populates chains.
|
||||
// Tests may inject a synthetic fallbackConfig via __setFallbackConfig().
|
||||
let _fallbackConfig = loadFallbackConfigSync();
|
||||
let _fallbackConfig = _startupConfig;
|
||||
|
||||
/** @internal — test seam: inject a synthetic fallback config (no file I/O) */
|
||||
export function __setFallbackConfig(config) {
|
||||
@@ -70,6 +81,35 @@ export function __resetFallbackConfig() {
|
||||
_fallbackConfig = loadFallbackConfigSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — test seam: reload loadedProviders to match a given enabledMap.
|
||||
* Mirrors __setFallbackConfig. Allows tests to exercise the production
|
||||
* loadProviders() code path without touching the config file.
|
||||
*
|
||||
* Usage: __setProvidersEnabled({ anthropic: true }) before creating a server.
|
||||
* Reset: __resetProvidersEnabled() or __setProvidersEnabled({}) to clear all.
|
||||
*
|
||||
* @param {Record<string, boolean>} enabledMap
|
||||
*/
|
||||
export function __setProvidersEnabled(enabledMap) {
|
||||
const next = loadProviders({ enabled: enabledMap ?? {} });
|
||||
// Mutate the shared map in-place so existing references see the update.
|
||||
loadedProviders.clear();
|
||||
for (const [name, p] of next) {
|
||||
loadedProviders.set(name, p);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal — reset loadedProviders to the startup-config state */
|
||||
export function __resetProvidersEnabled() {
|
||||
const startup = loadFallbackConfigSync();
|
||||
const next = loadProviders({ enabled: startup.providersEnabled ?? {} });
|
||||
loadedProviders.clear();
|
||||
for (const [name, p] of next) {
|
||||
loadedProviders.set(name, p);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal — clear the cache store (for tests that need a fresh cache state) */
|
||||
export function __clearCache() {
|
||||
cacheStore.clear();
|
||||
@@ -344,6 +384,117 @@ async function handleChatCompletions(req, res) {
|
||||
const firstHopCacheKey = computeCacheKey(chain[0].provider, chain[0].model, ir);
|
||||
const preCheckHit = bypassCache ? false : await cacheStore.peek(keyId, firstHopCacheKey);
|
||||
|
||||
// ── P1.2: Real SSE streaming path (single-hop cache-miss) ──────────────
|
||||
// ADR 0003 entry adapter pattern: for await irChunk → res.write(irChunkToOpenAISSE).
|
||||
// Condition: streaming + single-hop + no bypass + no pre-check cache hit.
|
||||
// - stream===true → caller wants SSE
|
||||
// - chain.length===1 → no fallback needed; first-chunk rule allows streaming
|
||||
// - !bypassCache + !preCheckHit → genuine cache miss (not hit/bypass)
|
||||
//
|
||||
// If any chunk has been written (firstChunkEmitted), fallback is impossible
|
||||
// per ADR 0004 § Fallback safety first-chunk rule. On error after first chunk:
|
||||
// truncate the response (end with no [DONE]). On error before any chunk:
|
||||
// throw so the outer handler can surface a clean error (no bytes written).
|
||||
//
|
||||
// On success: write chunks to res AND cache so subsequent identical requests
|
||||
// hit the burst-replay path.
|
||||
if (ir.stream && chain.length === 1 && !bypassCache && !preCheckHit) {
|
||||
const streamProvider = chain[0].provider;
|
||||
const streamModel = chain[0].model;
|
||||
const streamCacheKey = computeCacheKey(streamProvider, streamModel, ir);
|
||||
const streamPlugin = loadedProviders.get(streamProvider);
|
||||
|
||||
if (!streamPlugin) {
|
||||
// Provider disappeared between chain build and here (edge case).
|
||||
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider');
|
||||
}
|
||||
|
||||
const streamHeaders = olpHeaders({
|
||||
providerUsed: streamProvider,
|
||||
modelUsed: streamModel,
|
||||
startMs,
|
||||
cacheStatus: 'miss',
|
||||
fallbackHops: 0,
|
||||
});
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
...streamHeaders,
|
||||
});
|
||||
|
||||
const streamedChunks = [];
|
||||
let firstChunkEmitted = false;
|
||||
try {
|
||||
for await (const irChunk of streamPlugin.spawn(ir, authContext)) {
|
||||
if (irChunk.type === 'error') {
|
||||
// Error chunk from provider
|
||||
if (firstChunkEmitted) {
|
||||
// Past first-chunk boundary — can't fallback; truncate stream.
|
||||
logEvent('warn', 'streaming_error_after_first_chunk', {
|
||||
provider: streamProvider,
|
||||
model: streamModel,
|
||||
error: irChunk.error,
|
||||
});
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
// No bytes written yet — throw to surface a clean error.
|
||||
throw new ProviderError(irChunk.error ?? 'Provider emitted error chunk', 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
streamedChunks.push(irChunk);
|
||||
res.write(irChunkToOpenAISSE(irChunk, requestId, ir.model));
|
||||
firstChunkEmitted = true;
|
||||
|
||||
if (irChunk.type === 'stop') {
|
||||
res.write(SSE_DONE);
|
||||
res.end();
|
||||
// Cache the buffered chunks for burst-replay on subsequent identical requests.
|
||||
await cacheStore.set(keyId, streamCacheKey, streamedChunks);
|
||||
logEvent('info', 'streaming_response_cached', {
|
||||
provider: streamProvider,
|
||||
model: streamModel,
|
||||
chunks: streamedChunks.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generator exhausted without a stop chunk — emit [DONE] and cache.
|
||||
res.write(SSE_DONE);
|
||||
res.end();
|
||||
if (streamedChunks.length > 0) {
|
||||
await cacheStore.set(keyId, streamCacheKey, streamedChunks);
|
||||
}
|
||||
} catch (e) {
|
||||
if (firstChunkEmitted) {
|
||||
// Past first-chunk boundary — truncate silently.
|
||||
logEvent('warn', 'streaming_error_after_first_chunk', {
|
||||
provider: streamProvider,
|
||||
model: streamModel,
|
||||
error: e.message,
|
||||
});
|
||||
res.end();
|
||||
} else {
|
||||
// No bytes written — surface a clean JSON error.
|
||||
logEvent('error', 'streaming_error_before_first_chunk', {
|
||||
provider: streamProvider,
|
||||
model: streamModel,
|
||||
error: e.message,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
sendError(res, 502, e.message ?? 'Provider error', 'provider_error');
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let fallbackResult;
|
||||
try {
|
||||
fallbackResult = await executeWithFallback(chain, ir, executeHopFn, {
|
||||
@@ -414,7 +565,9 @@ async function handleChatCompletions(req, res) {
|
||||
const headers = olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops });
|
||||
|
||||
if (ir.stream) {
|
||||
// Streaming response path (D3 simplified: burst replay, no timing fidelity)
|
||||
// Streaming response path: burst replay from buffered chunks.
|
||||
// Reaches here only when: bypassCache=true OR preCheckHit=true OR chain.length>1.
|
||||
// (Single-hop cache-miss streaming is handled by the real-streaming path above.)
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
|
||||
@@ -3887,3 +3887,576 @@ describe('Fallback engine — HTTP integration (D9)', () => {
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Suite 14: providers.enabled config wiring ──────────────────────────────
|
||||
//
|
||||
// Tests that:
|
||||
// 14a: Empty config → 0 providers loaded
|
||||
// 14b: providers.enabled.anthropic=true → anthropic in loaded map
|
||||
// 14c: HTTP 503 disappears once config has the right provider enabled
|
||||
//
|
||||
// Authority: ADR 0002 § Disable model; ALIGNMENT.md § Provider Inventory
|
||||
|
||||
import {
|
||||
__setProvidersEnabled,
|
||||
__resetProvidersEnabled,
|
||||
} from './server.mjs';
|
||||
|
||||
describe('providers.enabled config wiring (Suite 14)', () => {
|
||||
|
||||
it('14a: loadFallbackConfigSync returns providersEnabled field', () => {
|
||||
// loadFallbackConfigSync with no config file → returns empty providersEnabled
|
||||
// (This tests the schema change to loadFallbackConfigSync.)
|
||||
const cfg = loadFallbackConfigSync('/nonexistent/path/that/does/not/exist.json');
|
||||
assert.ok(typeof cfg === 'object', 'result must be an object');
|
||||
assert.ok('providersEnabled' in cfg, 'result must have providersEnabled field');
|
||||
assert.deepEqual(cfg.providersEnabled, {}, 'missing file → empty providersEnabled');
|
||||
assert.deepEqual(cfg.chains, {}, 'missing file → empty chains');
|
||||
});
|
||||
|
||||
it('14b: __setProvidersEnabled({ anthropic: true }) → anthropic in loadedProviders', async () => {
|
||||
const { loadedProviders: lp } = await import('./server.mjs');
|
||||
const originalSize = lp.size;
|
||||
try {
|
||||
__setProvidersEnabled({ anthropic: true });
|
||||
assert.ok(lp.has('anthropic'), 'anthropic must be in loadedProviders after enable');
|
||||
} finally {
|
||||
__resetProvidersEnabled();
|
||||
// Restore to whatever state it was (may vary depending on config file)
|
||||
}
|
||||
});
|
||||
|
||||
it('14c: __setProvidersEnabled({}) → no providers loaded → HTTP 503', async () => {
|
||||
const { createOlpServer: createServer14 } = await import('./server.mjs');
|
||||
__setProvidersEnabled({});
|
||||
const s = createServer14();
|
||||
const p = 22456 + Math.floor(Math.random() * 400);
|
||||
await new Promise((resolve, reject) => {
|
||||
s.listen(p, '127.0.0.1', resolve);
|
||||
s.once('error', reject);
|
||||
});
|
||||
try {
|
||||
const r = await fetch({
|
||||
port: p,
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'hi' }] },
|
||||
});
|
||||
assert.equal(r.status, 503, `Expected 503 with no providers, got ${r.status}`);
|
||||
} finally {
|
||||
__resetProvidersEnabled();
|
||||
await new Promise(r => s.close(r));
|
||||
}
|
||||
});
|
||||
|
||||
it('14d: __setProvidersEnabled({ anthropic: true }) → HTTP 200 with mock spawn', async () => {
|
||||
const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'fake-token-suite-14d';
|
||||
__setProvidersEnabled({ anthropic: true });
|
||||
|
||||
// Install mock spawn that immediately emits text + close
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('Hello from suite 14d'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 0, null);
|
||||
});
|
||||
},
|
||||
};
|
||||
proc.killed = false;
|
||||
proc.kill = () => {};
|
||||
return proc;
|
||||
});
|
||||
|
||||
const { createOlpServer: createServer14d, __clearCache: clearCache14d } = await import('./server.mjs');
|
||||
clearCache14d();
|
||||
const s = createServer14d();
|
||||
const p = 22860 + Math.floor(Math.random() * 400);
|
||||
await new Promise((resolve, reject) => {
|
||||
s.listen(p, '127.0.0.1', resolve);
|
||||
s.once('error', reject);
|
||||
});
|
||||
|
||||
try {
|
||||
const r = await fetch({
|
||||
port: p,
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: { model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'hi' }] },
|
||||
});
|
||||
assert.equal(r.status, 200, `Expected 200 with anthropic enabled, got ${r.status}: ${r.body.slice(0, 200)}`);
|
||||
} finally {
|
||||
__resetProvidersEnabled();
|
||||
__resetSpawnImpl();
|
||||
if (savedToken !== undefined) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
}
|
||||
await new Promise(r => s.close(r));
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Suite 15: Streaming cache-miss real-time (P1.2) ─────────────────────────
|
||||
//
|
||||
// Tests that the single-hop streaming path (P1.2) emits chunks in real-time
|
||||
// rather than buffering. Also tests the cache-hit burst-replay path for
|
||||
// the second identical request.
|
||||
//
|
||||
// Authority: ADR 0003 entry adapter pattern; ADR 0004 § first-chunk rule
|
||||
|
||||
describe('Streaming cache-miss real-time (Suite 15)', () => {
|
||||
let server15;
|
||||
let port15;
|
||||
let savedToken15;
|
||||
|
||||
before(async () => {
|
||||
savedToken15 = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'fake-token-suite-15';
|
||||
__setProvidersEnabled({ anthropic: true });
|
||||
|
||||
const { createOlpServer: s15, __clearCache: cc15 } = await import('./server.mjs');
|
||||
cc15();
|
||||
server15 = s15();
|
||||
port15 = 23456 + Math.floor(Math.random() * 400);
|
||||
await new Promise((resolve, reject) => {
|
||||
server15.listen(port15, '127.0.0.1', resolve);
|
||||
server15.once('error', reject);
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
__resetProvidersEnabled();
|
||||
__resetSpawnImpl();
|
||||
if (savedToken15 !== undefined) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken15;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
}
|
||||
if (!server15) return;
|
||||
return new Promise(r => server15.close(r));
|
||||
});
|
||||
|
||||
it('15a: streaming cache-miss → res.write fires per chunk, not all-at-once', async () => {
|
||||
// Mock: emits 3 deltas with a 20ms gap between each, then stop.
|
||||
// We verify by recording arrival timestamps on the client side.
|
||||
const DELTA_GAP_MS = 20;
|
||||
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
// Emit 3 deltas with gaps
|
||||
let idx = 0;
|
||||
const texts = ['chunk1', 'chunk2', 'chunk3'];
|
||||
const emitNext = () => {
|
||||
if (idx < texts.length) {
|
||||
proc.stdout.emit('data', Buffer.from(texts[idx]));
|
||||
idx++;
|
||||
setTimeout(emitNext, DELTA_GAP_MS);
|
||||
} else {
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 0, null);
|
||||
}
|
||||
};
|
||||
setImmediate(emitNext);
|
||||
},
|
||||
};
|
||||
proc.killed = false;
|
||||
proc.kill = () => {};
|
||||
return proc;
|
||||
});
|
||||
|
||||
const { __clearCache: cc15a } = await import('./server.mjs');
|
||||
cc15a();
|
||||
|
||||
// Make SSE request and collect timestamps of each data: line arrival
|
||||
const arrivalTimestamps = [];
|
||||
const chunks = await new Promise((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
hostname: '127.0.0.1',
|
||||
port: port15,
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}, res => {
|
||||
const collectedLines = [];
|
||||
res.on('data', (d) => {
|
||||
const text = d.toString();
|
||||
const lines = text.split('\n').filter(l => l.startsWith('data: ') && l !== 'data: [DONE]');
|
||||
if (lines.length > 0) {
|
||||
arrivalTimestamps.push(Date.now());
|
||||
collectedLines.push(...lines);
|
||||
}
|
||||
});
|
||||
res.on('end', () => resolve(collectedLines));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(JSON.stringify({
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [{ role: 'user', content: 'streaming test' }],
|
||||
stream: true,
|
||||
}));
|
||||
req.end();
|
||||
});
|
||||
|
||||
// We should have received some data events
|
||||
assert.ok(chunks.length > 0, 'should have received streaming chunks');
|
||||
// With 20ms inter-chunk gaps the OS should flush at least twice; a buffered
|
||||
// implementation would surface as a single arrival event. This assertion
|
||||
// proves real-streaming architecturally (per D10 P1.2 review).
|
||||
assert.ok(
|
||||
arrivalTimestamps.length >= 2,
|
||||
`real streaming should produce > 1 client arrival event, got ${arrivalTimestamps.length}`
|
||||
);
|
||||
const allText = chunks.join('');
|
||||
assert.ok(allText.length > 0, 'should have received non-empty data');
|
||||
|
||||
// Verify response has SSE headers
|
||||
// (We already verified by receiving 'data: ' lines)
|
||||
assert.ok(chunks.some(c => c.includes('"delta"') || c.includes('"content"') || c.includes('finish_reason')),
|
||||
'Should contain delta or stop chunks');
|
||||
});
|
||||
|
||||
it('15b: second identical streaming request → cache hit, content identical', async () => {
|
||||
// Re-use the same mock (last setSpawnImpl from 15a may have been reset).
|
||||
// Install a mock that records how many times it was called.
|
||||
let spawnCallCount = 0;
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
spawnCallCount++;
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('cached-content'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 0, null);
|
||||
});
|
||||
},
|
||||
};
|
||||
proc.killed = false;
|
||||
proc.kill = () => {};
|
||||
return proc;
|
||||
});
|
||||
|
||||
const { __clearCache: cc15b } = await import('./server.mjs');
|
||||
cc15b();
|
||||
|
||||
const makeStreamRequest = () => new Promise((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
hostname: '127.0.0.1',
|
||||
port: port15,
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}, res => {
|
||||
let data = '';
|
||||
res.on('data', d => { data += d.toString(); });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data, headers: res.headers }));
|
||||
res.on('error', reject);
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(JSON.stringify({
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [{ role: 'user', content: 'cache-test-15b' }],
|
||||
stream: true,
|
||||
}));
|
||||
req.end();
|
||||
});
|
||||
|
||||
// First request: cache miss → real spawn
|
||||
const first = await makeStreamRequest();
|
||||
assert.equal(first.status, 200, `Expected 200, got ${first.status}: ${first.body.slice(0, 200)}`);
|
||||
assert.equal(first.headers['x-olp-cache'], 'miss', 'First request should be cache miss');
|
||||
assert.equal(spawnCallCount, 1, 'Spawn should be called exactly once for first request');
|
||||
|
||||
// Second identical request: should be cache hit (burst-replay, no spawn)
|
||||
const second = await makeStreamRequest();
|
||||
assert.equal(second.status, 200, `Expected 200 on cache hit, got ${second.status}`);
|
||||
assert.equal(second.headers['x-olp-cache'], 'hit', 'Second request should be cache hit');
|
||||
assert.equal(spawnCallCount, 1, 'Spawn should NOT be called again for cache-hit request');
|
||||
|
||||
// Both responses should contain the same content
|
||||
assert.ok(first.body.includes('cached-content'), 'First response should contain content');
|
||||
assert.ok(second.body.includes('cached-content'), 'Second response should contain same content');
|
||||
});
|
||||
|
||||
it('15c: streaming + multi-hop chain → uses buffered path (not real-streaming)', async () => {
|
||||
// A 2-hop chain forces the code to fall through to executeWithFallback (buffered).
|
||||
// With mock anthropic succeeding, the result should still be 200 with streaming headers,
|
||||
// but X-OLP-Cache should not reflect real-streaming behavior (it may be miss or hit
|
||||
// depending on buffered cache).
|
||||
// The critical assertion: single-hop condition NOT met → buffered path is taken.
|
||||
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('multihop-content'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 0, null);
|
||||
});
|
||||
},
|
||||
};
|
||||
proc.killed = false;
|
||||
proc.kill = () => {};
|
||||
return proc;
|
||||
});
|
||||
|
||||
// Add openai to loaded providers and configure a 2-hop chain
|
||||
const { loadedProviders: lp15c, __clearCache: cc15c } = await import('./server.mjs');
|
||||
const extraProviders = loadProviders({ enabled: { anthropic: true, openai: true } });
|
||||
for (const [name, p] of extraProviders) lp15c.set(name, p);
|
||||
cc15c();
|
||||
|
||||
// Inject codex fake auth so the 2nd hop doesn't immediately AUTH_MISSING
|
||||
const savedCodexPath = process.env.OPENAI_CODEX_AUTH_PATH;
|
||||
const { writeFileSync: wfs15c, unlinkSync: uls15c } = await import('node:fs');
|
||||
const { tmpdir: td15c } = await import('node:os');
|
||||
const { join: pj15c } = await import('node:path');
|
||||
const tmpAuth15c = pj15c(td15c(), `olp-test-15c-${Date.now()}.json`);
|
||||
wfs15c(tmpAuth15c, JSON.stringify({ accessToken: 'fake-codex-15c' }), 'utf8');
|
||||
process.env.OPENAI_CODEX_AUTH_PATH = tmpAuth15c;
|
||||
|
||||
__setFallbackConfig({
|
||||
chains: {
|
||||
'claude-sonnet-4-6': [
|
||||
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
||||
{ provider: 'openai', model: 'gpt-5.5' },
|
||||
],
|
||||
},
|
||||
soft_triggers: {},
|
||||
});
|
||||
|
||||
try {
|
||||
const r = await fetch({
|
||||
port: port15,
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: {
|
||||
model: 'claude-sonnet-4-6',
|
||||
messages: [{ role: 'user', content: 'multihop streaming test' }],
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
// Multi-hop chain → buffered path → still returns 200 with streaming headers
|
||||
assert.equal(r.status, 200, `Expected 200 for multi-hop streaming, got ${r.status}: ${r.body.slice(0, 200)}`);
|
||||
// X-OLP-Fallback-Hops: 0 since first hop succeeds
|
||||
assert.equal(r.headers['x-olp-fallback-hops'], '0', 'Primary hop should serve');
|
||||
assert.ok(r.body.includes('multihop-content'), 'Response should contain content');
|
||||
} finally {
|
||||
__resetFallbackConfig();
|
||||
__resetSpawnImpl();
|
||||
if (savedCodexPath !== undefined) {
|
||||
process.env.OPENAI_CODEX_AUTH_PATH = savedCodexPath;
|
||||
} else {
|
||||
delete process.env.OPENAI_CODEX_AUTH_PATH;
|
||||
}
|
||||
try { uls15c(tmpAuth15c); } catch { /* ignore */ }
|
||||
// Clean up the extra openai provider
|
||||
lp15c.delete('openai');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Suite 16: Spawn timeout (P1.3) ───────────────────────────────────────────
|
||||
//
|
||||
// Tests that:
|
||||
// 16a: SPAWN_TIMEOUT is in PROVIDER_ERROR_CODES
|
||||
// 16b: SPAWN_TIMEOUT is a hard trigger (evaluateHardTriggers returns true)
|
||||
// 16c: Provider that never completes throws SPAWN_TIMEOUT after configured timeout
|
||||
// 16d: Fallback chain: primary times out → secondary serves
|
||||
//
|
||||
// Authority: ADR 0004 § Trigger taxonomy bullet 4; lib/providers/base.mjs
|
||||
|
||||
import { PROVIDER_ERROR_CODES } from './lib/providers/base.mjs';
|
||||
|
||||
describe('Spawn timeout (Suite 16)', () => {
|
||||
|
||||
it('16a: SPAWN_TIMEOUT is in PROVIDER_ERROR_CODES', () => {
|
||||
assert.ok(PROVIDER_ERROR_CODES.includes('SPAWN_TIMEOUT'),
|
||||
'SPAWN_TIMEOUT must be in PROVIDER_ERROR_CODES');
|
||||
});
|
||||
|
||||
it('16b: evaluateHardTriggers with SPAWN_TIMEOUT code → true (hard trigger)', () => {
|
||||
const err = new ProviderError('spawn timed out', 'SPAWN_TIMEOUT');
|
||||
assert.equal(evaluateHardTriggers(err), true,
|
||||
'SPAWN_TIMEOUT ProviderError must be a hard trigger');
|
||||
});
|
||||
|
||||
it('16c: anthropic plugin with short timeout → throws ProviderError SPAWN_TIMEOUT', async () => {
|
||||
// Install a spawn mock that never closes (simulates hanging CLI).
|
||||
// Set a very short timeout (100ms) via hints mutation.
|
||||
const savedTimeout = anthropic.hints.maxSpawnTimeMs;
|
||||
anthropic.hints.maxSpawnTimeMs = 100;
|
||||
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
// Never emit close — simulates a hanging process.
|
||||
// stdout may emit some data but never closes.
|
||||
},
|
||||
};
|
||||
proc.killed = false;
|
||||
proc.kill = () => {};
|
||||
return proc;
|
||||
});
|
||||
|
||||
try {
|
||||
const irReq = makeIR({ model: 'claude-sonnet-4-6', stream: false });
|
||||
// Need auth so we don't hit AUTH_MISSING before the timeout path.
|
||||
const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'fake-token-16c';
|
||||
try {
|
||||
const gen = anthropic.spawn(irReq, { accessToken: 'fake-16c' });
|
||||
// Drain the generator — it should eventually throw SPAWN_TIMEOUT.
|
||||
let caughtError = null;
|
||||
try {
|
||||
for await (const chunk of gen) { void chunk; }
|
||||
} catch (e) {
|
||||
caughtError = e;
|
||||
}
|
||||
assert.ok(caughtError !== null, 'Expected a ProviderError to be thrown');
|
||||
assert.ok(caughtError instanceof ProviderError, `Expected ProviderError, got ${caughtError?.constructor?.name}`);
|
||||
assert.equal(caughtError.code, 'SPAWN_TIMEOUT', `Expected SPAWN_TIMEOUT code, got ${caughtError?.code}`);
|
||||
} finally {
|
||||
if (savedToken !== undefined) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
anthropic.hints.maxSpawnTimeMs = savedTimeout;
|
||||
__resetSpawnImpl();
|
||||
}
|
||||
});
|
||||
|
||||
it('16d: fallback chain: primary times out → chain advances to secondary → secondary serves', async () => {
|
||||
// Set up: primary = anthropic (will hang + timeout), secondary = openai (will succeed).
|
||||
const savedAnthropicTimeout = anthropic.hints.maxSpawnTimeMs;
|
||||
anthropic.hints.maxSpawnTimeMs = 100; // short timeout for test speed
|
||||
|
||||
const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = 'fake-token-16d-anthropic';
|
||||
|
||||
// Anthropic mock: hangs forever (triggers SPAWN_TIMEOUT)
|
||||
__setSpawnImpl(function (_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = { write: () => {}, end: () => {} };
|
||||
proc.killed = false;
|
||||
proc.kill = () => {};
|
||||
return proc;
|
||||
});
|
||||
|
||||
// Codex mock: succeeds immediately
|
||||
codexSetSpawnImpl(function (_bin, _args, _opts) {
|
||||
const proc = new EventEmitter();
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
proc.stdin = {
|
||||
write: () => {},
|
||||
end: () => {
|
||||
setImmediate(() => {
|
||||
proc.stdout.emit('data', Buffer.from('{"content":"fallback-from-timeout-test"}\n'));
|
||||
proc.stdout.emit('data', Buffer.from('{"type":"stop"}\n'));
|
||||
proc.stdout.emit('end');
|
||||
proc.stderr.emit('end');
|
||||
proc.emit('close', 0, null);
|
||||
});
|
||||
},
|
||||
};
|
||||
proc.killed = false;
|
||||
proc.kill = () => {};
|
||||
return proc;
|
||||
});
|
||||
|
||||
// Set up fake codex auth
|
||||
const { writeFileSync: wfs16d, unlinkSync: uls16d } = await import('node:fs');
|
||||
const { tmpdir: td16d } = await import('node:os');
|
||||
const { join: pj16d } = await import('node:path');
|
||||
const tmpAuth16d = pj16d(td16d(), `olp-test-16d-${Date.now()}.json`);
|
||||
wfs16d(tmpAuth16d, JSON.stringify({ accessToken: 'fake-codex-16d' }), 'utf8');
|
||||
const savedCodexPath = process.env.OPENAI_CODEX_AUTH_PATH;
|
||||
process.env.OPENAI_CODEX_AUTH_PATH = tmpAuth16d;
|
||||
|
||||
// Build a 2-hop chain: anthropic → openai
|
||||
const testProviders = loadProviders({ enabled: { anthropic: true, openai: true } });
|
||||
|
||||
const chain = [
|
||||
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
||||
{ provider: 'openai', model: 'gpt-5.5' },
|
||||
];
|
||||
|
||||
const irReq = makeIR({ model: 'claude-sonnet-4-6', stream: false });
|
||||
|
||||
async function testExecuteHopFn(hopProvider, hopModel, ir) {
|
||||
const plugin = testProviders.get(hopProvider);
|
||||
if (!plugin) throw Object.assign(new Error(`no provider ${hopProvider}`), { statusCode: 503 });
|
||||
const chunks = [];
|
||||
for await (const c of plugin.spawn(ir, hopProvider === 'anthropic'
|
||||
? { accessToken: 'fake-token-16d-anthropic' }
|
||||
: { accessToken: 'fake-codex-16d' })) {
|
||||
chunks.push(c);
|
||||
if (c.type === 'error') throw new ProviderError(c.error, 'SPAWN_FAILED');
|
||||
if (c.type === 'stop') break;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeWithFallback(chain, irReq, testExecuteHopFn, { logEvent: () => {} });
|
||||
assert.ok(result.chunks !== null, 'Fallback should have produced chunks');
|
||||
assert.equal(result.fallbackHops, 1, 'Should have fallen back to second hop');
|
||||
assert.equal(result.providerUsed, 'openai', 'openai should have served after anthropic timed out');
|
||||
const content = result.chunks.filter(c => c.type === 'delta').map(c => c.content).join('');
|
||||
assert.ok(content.includes('fallback-from-timeout-test'), 'Content from fallback provider expected');
|
||||
} finally {
|
||||
anthropic.hints.maxSpawnTimeMs = savedAnthropicTimeout;
|
||||
__resetSpawnImpl();
|
||||
codexResetSpawnImpl();
|
||||
if (savedToken !== undefined) {
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
}
|
||||
if (savedCodexPath !== undefined) {
|
||||
process.env.OPENAI_CODEX_AUTH_PATH = savedCodexPath;
|
||||
} else {
|
||||
delete process.env.OPENAI_CODEX_AUTH_PATH;
|
||||
}
|
||||
try { uls16d(tmpAuth16d); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user