diff --git a/README.md b/README.md index 7c08b20..32df35f 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,14 @@ Trigger types, fallback safety, idempotency rules, and the full example config l _placeholder β€” full table lands as each endpoint lands._ -| Endpoint | Method | Phase | Description | -|---|---|---|---| -| `/v1/chat/completions` | POST | 1 | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. | -| `/v1/models` | GET | 1 | Lists models from `models-registry.json`. | -| `/health` | GET | 1 | Per-provider health snapshot (owner-only). | -| `/cache/stats` | GET | 5 | Cache hit rate, by-provider breakdown. | -| `/v0/management/quota` | GET | 6 | Per-provider quota / credit pool status (best-effort). | -| `/dashboard` | GET | 6 | Owner-only dashboard (localhost-bound by default). | +| Endpoint | Method | Phase | Status | Description | +|---|---|---|---|---| +| `/v1/chat/completions` | POST | 1 | βœ… Shipped | OpenAI-compatible Chat Completions entry. Internally normalized to IR, dispatched to a provider plugin, response shape converted back. | +| `/v1/models` | GET | 1 | βœ… Shipped | Lists models from `models-registry.json`. | +| `/health` | GET | 1 | βœ… Shipped | Per-provider health snapshot (owner-only). | +| `/cache/stats` | GET | 5 | πŸ“‹ Planned | Cache hit rate, by-provider breakdown. | +| `/v0/management/quota` | GET | 6 | πŸ“‹ Planned | Per-provider quota / credit pool status (best-effort). | +| `/dashboard` | GET | 6 | πŸ“‹ Planned | Owner-only dashboard (localhost-bound by default). | --- @@ -118,11 +118,36 @@ _placeholder β€” full table lands per-phase as variables are introduced._ | `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). | | `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). | +### Per-provider auth env vars + +These variables configure credential discovery for each provider plugin. Setting the correct one for your provider is usually required for OLP to make successful requests. + +**Anthropic (`claude -p`)** + +| Variable | Default behavior | Description | +|---|---|---| +| `CLAUDE_CODE_OAUTH_TOKEN` | Searches `~/.claude/.credentials.json` first, then macOS keychain (darwin only) | Directly supplies the OAuth access token. Highest-precedence override; bypasses all credential-discovery logic. Useful in CI/headless environments where the keychain is unavailable. | + +**OpenAI Codex (`codex exec --json`)** + +| Variable | Default behavior | Description | +|---|---|---| +| `OPENAI_CODEX_AUTH_PATH` | `~/.codex/auth.json` (or `$CODEX_HOME/auth.json`) | Overrides the full path to the Codex auth artifact. When set, no other path is tried; missing or malformed file returns null (no auth). Useful for CI test fixtures. | +| `CODEX_HOME` | `~/.codex` | Overrides the Codex home directory. `$CODEX_HOME/auth.json` becomes the default auth path. Ignored when `OPENAI_CODEX_AUTH_PATH` is set explicitly. | + +**Mistral Vibe (`vibe --prompt`)** + +| Variable | Default behavior | Description | +|---|---|---| +| `MISTRAL_API_KEY` | Reads `$VIBE_HOME/.env` (default `~/.vibe/.env`) | Directly supplies the Mistral API key. Highest-precedence override per DOCS-2; bypasses `.env` file lookup. | +| `MISTRAL_VIBE_AUTH_PATH` | `~/.vibe/.env` (or `$VIBE_HOME/.env`) | Overrides the full path to the Vibe `.env` auth file. Evaluated only when `MISTRAL_API_KEY` is not set. When set, no other path is tried; missing or malformed file returns null (no auth). | +| `VIBE_HOME` | `~/.vibe` | Overrides the Vibe home directory. `$VIBE_HOME/.env` becomes the default auth path. Ignored when `MISTRAL_API_KEY` or `MISTRAL_VIBE_AUTH_PATH` is set explicitly. | + > **πŸ“‹ Planned (Phase 2) β€” not yet read by the codebase:** > - `OLP_HOME` (`~/.olp`) β€” Config, providers, keys, cache, logs root. Currently hardcoded to `~/.olp/config.json` in `loadFallbackConfigSync`; the env override path is a Phase 2 config-layer deliverable. > - `OLP_LOG_LEVEL` (`info`) β€” Log level filter (`error`/`warn`/`info`/`debug`). `logEvent` currently writes unconditionally; level filtering is a Phase 2 observability deliverable. -Further variables (per-provider auth path overrides, cache size limits, fallback-engine knobs) land with the relevant phase. See also the [Implementation status](#implementation-status-as-of-2026-05-24) table. +See also the [Implementation status](#implementation-status-as-of-2026-05-24) table. --- diff --git a/docs/adr/0002-plugin-architecture.md b/docs/adr/0002-plugin-architecture.md index 9be9652..78cb127 100644 --- a/docs/adr/0002-plugin-architecture.md +++ b/docs/adr/0002-plugin-architecture.md @@ -7,6 +7,14 @@ ## Amendments +### Amendment 4 β€” 2026-05-24: Ratify `contractVersion` as a required Provider contract field (D32 F5) + +- **Finding:** Round-4 cold-audit F5 (P3 governance omission) β€” `lib/providers/base.mjs` `validateProvider` enforces `p.contractVersion === '1.0'` and all three shipped plugins declare it, but the Provider contract field list in Β§ Decision (lines ~63-74) does not include `contractVersion`. It was mentioned only in Β§ Mitigations as a forward-looking note ("The contract is versioned. v1.0 is the subset in this ADR; future additions … require ADR amendment plus a contract-version bump. Old provider plugins continue to declare `contractVersion: '1.0'`…"), not as a required field. This is the same class of documentation–implementation gap as Amendment 1 (`maxSpawnTimeMs` retroactive sync). +- **Change:** Add `contractVersion: '1.0'` as the 10th required field in the Provider contract field list in Β§ Decision. The `hints` block moves from 9th to 10th entry to keep the list logically ordered (name β†’ displayName β†’ models β†’ auth β†’ spawn β†’ estimateCost β†’ quotaStatus β†’ healthCheck β†’ contractVersion β†’ hints). `validateProvider` in `base.mjs` already enforces it; this amendment is a retroactive documentation sync, not a code change. +- **Semantics:** `contractVersion` is a required string field set to `'1.0'` for all v0.1 plugins. Its purpose is to allow the loader to detect plugins authored against an older or newer contract version when OLP's contract evolves. A future contract revision (v1.1 or v2.0) will increment this value and update this ADR accordingly. +- **Authority:** `lib/providers/base.mjs` `validateProvider` function β€” live enforcement as of v0.1 bootstrap. All three shipped plugins (`anthropic.mjs`, `codex.mjs`, `mistral.mjs`) already export `contractVersion: '1.0'`. +- **Procedural mechanism:** CC 开发铁律 v1.6 Β§ 10.x (Round-4 Cold Audit caught it as F5). Parallel to Amendment 1 (D11 `maxSpawnTimeMs` retroactive sync). + ### Amendment 3 β€” 2026-05-24: Add `cacheable` to Provider contract hints (D23) - **Finding:** Cold-audit round-2 Finding 3 (P2 contract/cache-condition drift) β€” ADR 0005 Β§ "Cache write conditions" item 3 references `hints.cacheable` but the field was never named in this ADR's Provider contract hints list. `validateProvider` did not enforce it; no plugin declared it; no consumer read it. The field existed only in prose. @@ -71,6 +79,7 @@ Every provider plugin exports an object conforming to: - `estimateCost: (request) => { inputTokens, outputTokensEstimate, currency, usd }` β€” best-effort, may return null - `quotaStatus: async (authContext) => { available, percentUsed, resetsAt, pool }` β€” best-effort, null if unretrievable - `healthCheck: async () => { ok, latencyMs, error? }` β€” startup and `/health` endpoint use this +- `contractVersion: '1.0'` β€” required string identifying the Provider contract version the plugin was authored against. Set to `'1.0'` for all v0.1 plugins. `validateProvider` in `base.mjs` enforces this field. Future contract revisions will increment the value; the loader uses it to detect stale plugins. See Amendment 4. (D32 F5) - `hints: { requiresTTY, concurrentSpawnSafe, maxConcurrent, maxSpawnTimeMs, cacheable }` β€” fingerprint, concurrency, timeout, and cache hints: - `requiresTTY` β€” boolean; whether the provider CLI requires a TTY to produce non-interactive output (e.g., some CLIs suppress JSON output unless forced with a flag or a TTY is present). - `concurrentSpawnSafe` β€” boolean; whether the provider CLI is safe to spawn concurrently under the same auth context without rate-limit or session collisions. diff --git a/docs/adr/0003-intermediate-representation.md b/docs/adr/0003-intermediate-representation.md index 6597fbf..da419bc 100644 --- a/docs/adr/0003-intermediate-representation.md +++ b/docs/adr/0003-intermediate-representation.md @@ -7,6 +7,14 @@ ## Amendments +### Amendment 2 β€” 2026-05-24: Correct model-mapping example; document verbatim-pass-through design (D32 F2) + +- **Finding:** Round-4 cold-audit F2 (P3 ADR example vs implementation drift) β€” Β§ Decision "Required fields" item `model` reads: "The provider plugin maps this to the provider-native model identifier (e.g., `claude-sonnet-4-6` β†’ `claude-sonnet-4-6-20260301` for Anthropic)." This is WRONG per the D17 SPOT decision (commit `cb86807`): OLP does NOT perform a model-alias mapping inside the provider plugin. `irRequest.model` is passed verbatim to the provider CLI (`claude -p --model `, `codex exec --model `, etc.); each provider's CLI resolves its own aliases natively per its documented behaviour. +- **Decision:** Update the `model` field description in Β§ Decision to reflect the actual verbatim-pass-through design. The erroneous example (`claude-sonnet-4-6` β†’ `claude-sonnet-4-6-20260301`) implied OLP maintained an explicit alias map, which it does not. See also inline update in Β§ Decision below. +- **Authority:** D17 SPOT decision (commit `cb86807`) β€” "provider plugin passes `irRequest.model` verbatim; CLI alias resolution is the provider's responsibility." ADR 0003 Β§ Decision is updated in-place to match; the original v1.0 text is preserved as a struck annotation. +- **Forward note:** v1.x may add an explicit OLP-side mapping step if any provider's CLI drops alias support or if a unified OLP-owned alias layer is needed across providers. That change requires a further ADR 0003 amendment. +- **Procedural mechanism:** CC 开发铁律 v1.6 Β§ 10.x (Round-4 Cold Audit caught it as F2). + ### Amendment 1 β€” 2026-05-24: Remove __irRoundTripTest() claim; document substitute test strategy (D31 F5) - **Finding:** Round-3 cold-audit F5 (P2 ADR-claim vs implementation drift) β€” Β§ Mitigations names a `__irRoundTripTest()` export on every provider plugin as the structural counter-measure against silent lossy translation. ZERO plugins export this function; the export-name was an early-design aspiration that does not fit the actual plugin shape (spawn-wrappers with asymmetric requestβ†’CLI-args+stdin and response-streamβ†’IR, not symmetric `irToNative`+`nativeToIR` functions that could be tested in isolation via a round-trip fixture). @@ -48,7 +56,7 @@ OLP defines an Intermediate Representation (IR) as the canonical internal shape **Required fields:** - `messages[]` β€” each with `role`, `content`, optional `name`, optional `tool_calls`, optional `tool_call_id`. Roles supported: `system`, `user`, `assistant`, `tool`. -- `model` β€” the user-requested model string. The provider plugin maps this to the provider-native model identifier (e.g., `claude-sonnet-4-6` β†’ `claude-sonnet-4-6-20260301` for Anthropic). +- `model` β€” the user-requested model string. v0.1: passed verbatim to the provider CLI (`claude -p --model `, `codex exec --model `, etc.); each provider's CLI accepts its own aliases natively per its docs (D17 SPOT decision, commit `cb86807`). *(The original v1.0 text incorrectly stated the plugin maps this to a provider-native identifier β€” corrected by Amendment 2.)* v1.x may add an explicit OLP-side mapping step if any provider's CLI drops alias support. - `stream` β€” boolean. SSE expected on the entry side iff true. **Optional fields:** diff --git a/lib/fallback/engine.mjs b/lib/fallback/engine.mjs index 52c2e2e..f33969d 100644 --- a/lib/fallback/engine.mjs +++ b/lib/fallback/engine.mjs @@ -32,7 +32,8 @@ import { computeIRRequestHash } from '../cache/keys.mjs'; * - SPAWN_FAILED β†’ hard trigger (provider CLI failed) * - CLI_NOT_FOUND β†’ hard trigger (binary missing) * - AUTH_MISSING β†’ NOT a hard trigger (user-config failure; user must fix) - * - OUTPUT_PARSE_ERROR β†’ hard trigger + * - OUTPUT_PARSE_ERROR β†’ removed (D32 F4): no plugin emits it; dead code. + * Re-add via ADR 0004 amendment if needed. * * @type {Record} */ @@ -42,7 +43,6 @@ const HARD_TRIGGER_CODES = { SPAWN_FAILED: true, 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 }; diff --git a/lib/providers/base.mjs b/lib/providers/base.mjs index 5eadfaa..b50db50 100644 --- a/lib/providers/base.mjs +++ b/lib/providers/base.mjs @@ -145,7 +145,8 @@ export const PROVIDER_ERROR_CODES = /** @type {const} */ ([ 'RATE_LIMITED', 'CLI_NOT_FOUND', 'SPAWN_FAILED', - 'OUTPUT_PARSE_ERROR', + // OUTPUT_PARSE_ERROR removed (D32 F4): no plugin emits it; dead code. + // Re-add via ADR 0004 amendment if a future plugin surfaces parse failures. 'SPAWN_TIMEOUT', // ADR 0004 Β§ Trigger taxonomy bullet 4: spawn timeout is a hard trigger ]); diff --git a/lib/providers/codex.mjs b/lib/providers/codex.mjs index 7a5b368..07c8de5 100644 --- a/lib/providers/codex.mjs +++ b/lib/providers/codex.mjs @@ -292,27 +292,32 @@ export function codexChunkToIR(rawNDJSONLine) { if (!event || typeof event !== 'object') return null; // Error event: type === 'error' or error field present + // A4: defensive β€” error shape unconfirmed; D7 will pin actual field names if (event.type === 'error' || (event.error && typeof event.error === 'string')) { const errMsg = event.error ?? event.message ?? 'codex error'; return { type: 'error', error: errMsg }; } // Stop event: type === 'stop' or done === true + // A4: defensive β€” stop shape unconfirmed; D7 will pin whether 'done' field exists if (event.type === 'stop' || event.done === true) { return { type: 'stop', finish_reason: 'stop' }; } // Delta/content event: has a content string field + // A4: defensive β€” primary delta shape assumed from Codex CLI Β§ --json docs if (typeof event.content === 'string') { return { type: 'delta', content: event.content }; } // delta field (alternative shape): { type: 'delta', delta: '...' } + // A4: defensive β€” alternative shape not confirmed; D7 will probe real stdout if (event.type === 'delta' && typeof event.delta === 'string') { return { type: 'delta', content: event.delta }; } // Output_text event shape (possible alternative): { type: 'output_text', text: '...' } + // A4: defensive β€” text field shape not confirmed; D7 will probe real stdout if (typeof event.text === 'string') { return { type: 'delta', content: event.text }; } diff --git a/server.mjs b/server.mjs index b1ce0ea..5ed778a 100644 --- a/server.mjs +++ b/server.mjs @@ -238,6 +238,31 @@ function olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus = 'miss', fa }; } +/** + * Returns the standard 5-header OLP diagnostic set for pre-chain error paths + * (no provider was attempted yet). Used by sendError call sites that occur + * before chain construction (415 wrong Content-Type, 400 bad JSON, 400 IR + * validation failure, 503 no-enabled-providers). + * + * Per F8 (D32 round-4 cold-audit): ADR 0004 Β§ Observability requires the full + * 5-header set on every response; early error paths must emit canonical + * "no provider attempted" defaults. + * + * @param {object} opts + * @param {number} opts.startMs β€” request start timestamp (Date.now()) + * @param {string|null|undefined} [opts.model] β€” ir.model if IR was parsed; undefined/null otherwise + * @returns {Record} + */ +function olpErrorHeaders({ startMs, model }) { + return { + 'X-OLP-Provider-Used': 'none', + 'X-OLP-Model-Used': model ?? 'unknown', + 'X-OLP-Fallback-Hops': '0', + 'X-OLP-Cache': 'bypass', + 'X-OLP-Latency-Ms': String(Date.now() - startMs), + }; +} + // ── Route handlers ──────────────────────────────────────────────────────── /** @@ -320,7 +345,7 @@ async function handleChatCompletions(req, res) { const ct = req.headers['content-type'] ?? ''; if (!ct.includes('application/json')) { return sendError(res, 415, 'Content-Type must be application/json', 'invalid_request_error', - { 'X-OLP-Latency-Ms': String(Date.now() - startMs) }); + olpErrorHeaders({ startMs })); } let body; @@ -328,7 +353,7 @@ async function handleChatCompletions(req, res) { body = await readJSON(req); } catch (e) { return sendError(res, e.statusCode ?? 400, e.message, 'invalid_request_error', - { 'X-OLP-Latency-Ms': String(Date.now() - startMs) }); + olpErrorHeaders({ startMs })); } // Translate OpenAI β†’ IR (ADR 0003) @@ -338,7 +363,7 @@ async function handleChatCompletions(req, res) { } catch (e) { if (e instanceof BadRequestError) { return sendError(res, 400, e.message, 'invalid_request_error', - { 'X-OLP-Latency-Ms': String(Date.now() - startMs) }); + olpErrorHeaders({ startMs })); } throw e; } @@ -365,6 +390,7 @@ async function handleChatCompletions(req, res) { res, 503, `No enabled providers for model ${ir.model}. See README Β§ Supported Providers.`, 'no_enabled_provider', + olpErrorHeaders({ startMs, model: ir.model }), ); } @@ -558,7 +584,8 @@ async function handleChatCompletions(req, res) { if (!streamPlugin) { // Provider disappeared between chain build and here (edge case). - return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider'); + return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider', + olpErrorHeaders({ startMs, model: ir.model })); } const streamHeaders = olpHeaders({ @@ -676,7 +703,8 @@ async function handleChatCompletions(req, res) { error: e.message, }); if (!res.headersSent) { - sendError(res, 502, e.message ?? 'Provider error', 'provider_error'); + sendError(res, 502, e.message ?? 'Provider error', 'provider_error', + olpHeaders({ providerUsed: streamProvider, modelUsed: streamModel, startMs, cacheStatus: 'miss', fallbackHops: 0 })); } else { res.end(); } @@ -693,7 +721,8 @@ async function handleChatCompletions(req, res) { } catch (e) { // executeWithFallback throws only on programming errors (empty chain). logEvent('error', 'fallback_engine_error', { error: e.message }); - return sendError(res, 500, 'Internal server error', 'internal_error'); + return sendError(res, 500, 'Internal server error', 'internal_error', + olpErrorHeaders({ startMs, model: ir.model })); } const { diff --git a/test-features.mjs b/test-features.mjs index fa0fcb8..9b70d86 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -3891,10 +3891,8 @@ describe('Fallback engine β€” trigger taxonomy (D9)', () => { assert.equal(evaluateHardTriggers(err), false); }); - it('evaluateHardTriggers: ProviderError OUTPUT_PARSE_ERROR β†’ fires', () => { - const err = new ProviderError('Parse error', 'OUTPUT_PARSE_ERROR'); - assert.equal(evaluateHardTriggers(err), true); - }); + // D32 F4: OUTPUT_PARSE_ERROR removed from PROVIDER_ERROR_CODES and + // HARD_TRIGGER_CODES β€” no plugin emits it; it was dead code. Test removed. it('evaluateHardTriggers: generic Error with no statusCode β†’ does NOT fire', () => { const err = new Error('Something went wrong'); @@ -6142,16 +6140,16 @@ describe('/v1/models population + X-OLP-* error headers (Suite 17)', () => { } }); - // ── 17g: pre-routing 400 (invalid JSON) carries X-OLP-Latency-Ms ───────── + // ── 17g: pre-routing 400 (invalid JSON) carries full 5 X-OLP-* headers ────── + // Updated by D32 F8: olpErrorHeaders() now emits all 5 headers with + // "no provider attempted" defaults (provider='none', model='unknown', + // hops=0, cache='bypass') on pre-chain error paths. Test updated to assert + // the full set rather than only X-OLP-Latency-Ms. - it('17g: pre-routing 400 (invalid JSON body) carries X-OLP-Latency-Ms', async () => { - // Pre-routing errors inside handleChatCompletions (invalid JSON body, wrong - // Content-Type, IR parse failure) have access to startMs and emit - // X-OLP-Latency-Ms to allow latency instrumentation even when there is no - // provider context. The other 4 X-OLP-* headers are omitted (no provider was - // attempted). This is consistent with ADR 0004 Β§ Observability which requires - // the full set for chain-exhausted paths; partial emission on pre-route errors - // is the minimum viable observability for those paths. + it('17g: pre-routing 400 (invalid JSON body) carries all 5 X-OLP-* headers with no-provider defaults', async () => { + // Pre-chain errors inside handleChatCompletions now emit the full 5-header set + // via olpErrorHeaders() with canonical "no provider attempted" defaults per + // ADR 0004 Β§ Observability (D32 F8). setProviders17({}); const s = createServer17(); await new Promise((resolve, reject) => { @@ -6177,15 +6175,102 @@ describe('/v1/models population + X-OLP-* error headers (Suite 17)', () => { req.end(); }); assert.equal(result.status, 400, `Expected 400 for invalid JSON body`); - // X-OLP-Latency-Ms must be present (D18 pre-routing observability) + // All 5 X-OLP-* headers must be present (D32 F8 olpErrorHeaders) assert.ok(result.headers['x-olp-latency-ms'] !== undefined, - 'Pre-routing 400 error must carry X-OLP-Latency-Ms for latency instrumentation'); + 'Pre-routing 400 must carry X-OLP-Latency-Ms'); const latencyMs = parseInt(result.headers['x-olp-latency-ms'], 10); assert.ok(!isNaN(latencyMs) && latencyMs >= 0, - `X-OLP-Latency-Ms must be a non-negative integer, got: ${result.headers['x-olp-latency-ms']}`); - // The other 4 headers are NOT present (no provider context) - assert.ok(result.headers['x-olp-provider-used'] === undefined, - 'Pre-routing 400 must NOT carry X-OLP-Provider-Used (no provider context)'); + `X-OLP-Latency-Ms must be non-negative integer, got: ${result.headers['x-olp-latency-ms']}`); + assert.equal(result.headers['x-olp-provider-used'], 'none', + 'Pre-routing 400 must carry X-OLP-Provider-Used: none (no provider attempted)'); + assert.equal(result.headers['x-olp-model-used'], 'unknown', + 'Pre-routing 400 must carry X-OLP-Model-Used: unknown (IR not parsed yet)'); + assert.equal(result.headers['x-olp-fallback-hops'], '0', + 'Pre-routing 400 must carry X-OLP-Fallback-Hops: 0'); + assert.equal(result.headers['x-olp-cache'], 'bypass', + 'Pre-routing 400 must carry X-OLP-Cache: bypass'); + } finally { + resetProviders17(); + await new Promise(r => s.close(r)); + } + }); + + // ── 17h: 415 wrong Content-Type carries all 5 X-OLP-* headers ────────��───── + // D32 F8: olpErrorHeaders on 415 pre-chain path. + + it('17h: 415 wrong Content-Type carries all 5 X-OLP-* headers with no-provider defaults', async () => { + setProviders17({}); + const s = createServer17(); + await new Promise((resolve, reject) => { + s.listen(0, '127.0.0.1', resolve); + s.once('error', reject); + }); + const p = s.address().port; + try { + const result = await new Promise((resolve, reject) => { + const req = httpRequest({ + hostname: '127.0.0.1', + port: p, + method: 'POST', + path: '/v1/chat/completions', + headers: { 'Content-Type': 'text/plain', 'Content-Length': '2' }, + }, res => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })); + }); + req.on('error', reject); + req.write('{}'); + req.end(); + }); + assert.equal(result.status, 415, `Expected 415 for wrong Content-Type, got ${result.status}`); + assert.equal(result.headers['x-olp-provider-used'], 'none', + '415 must carry X-OLP-Provider-Used: none'); + assert.equal(result.headers['x-olp-model-used'], 'unknown', + '415 must carry X-OLP-Model-Used: unknown'); + assert.equal(result.headers['x-olp-fallback-hops'], '0', + '415 must carry X-OLP-Fallback-Hops: 0'); + assert.equal(result.headers['x-olp-cache'], 'bypass', + '415 must carry X-OLP-Cache: bypass'); + assert.ok(result.headers['x-olp-latency-ms'] !== undefined, + '415 must carry X-OLP-Latency-Ms'); + } finally { + resetProviders17(); + await new Promise(r => s.close(r)); + } + }); + + // ── 17i: 503 no-enabled-providers carries all 5 X-OLP-* headers ─────────── + // D32 F8: olpErrorHeaders on 503 no-chain path. X-OLP-Model-Used reflects + // the requested model (IR was parsed successfully before chain lookup). + + it('17i: 503 no-enabled-providers carries all 5 X-OLP-* headers with model from IR', async () => { + setProviders17({}); + const s = createServer17(); + await new Promise((resolve, reject) => { + s.listen(0, '127.0.0.1', resolve); + s.once('error', reject); + }); + const p = s.address().port; + 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 for no enabled providers, got ${r.status}`); + assert.equal(r.headers['x-olp-provider-used'], 'none', + '503 must carry X-OLP-Provider-Used: none'); + // Model is known (IR was parsed) so model string is propagated + assert.equal(r.headers['x-olp-model-used'], 'claude-sonnet-4-6', + '503 must carry X-OLP-Model-Used reflecting the requested model'); + assert.equal(r.headers['x-olp-fallback-hops'], '0', + '503 must carry X-OLP-Fallback-Hops: 0'); + assert.equal(r.headers['x-olp-cache'], 'bypass', + '503 must carry X-OLP-Cache: bypass'); + assert.ok(r.headers['x-olp-latency-ms'] !== undefined, + '503 must carry X-OLP-Latency-Ms'); } finally { resetProviders17(); await new Promise(r => s.close(r));