feat+docs+test: D40 — X-OLP-Fallback-Detail header (issue #7)

ADR 0004 § Decision § Chain advancement step 4 promised a per-hop
failure detail debug header `X-OLP-Fallback-Detail`. From D9 through
D39 the engine logged per-hop events (D28 added correlation fields)
but never surfaced the failure trail on the response. D40 fulfills
the promise via Option A — ungated v0.1 emission. Phase 2 will
re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands.

**Per-hop tuple collection** (lib/fallback/engine.mjs)

executeWithFallback now collects `fallbackDetail` — an array of per-hop
tuples — and returns it on EVERY return shape (success / client-error /
AUTH_MISSING / non-trigger / chain-exhausted). Soft-trigger skipped
hops are also recorded with `trigger_type: 'soft'` (currently dead-
code-by-config per ADR 0004 Amendment 2; shape forward-compatible).

Tuple shape (reuses D28 log-event field shapes so logs and the header
pivot on the same keys):
```
{
  hop: <0-indexed hop number>,
  provider: <provider name>,
  model: <model name from IR>,
  code: <ProviderError code, engine-synthetic SOFT_TRIGGER, or 'UNKNOWN'>,
  error_message: <truncated to 200 chars with ellipsis on overflow>,
  trigger_type: 'hard' | 'soft' | 'client_error' | 'auth_missing' | 'unclassified'
}
```

**Header serialiser** (server.mjs)

- `FALLBACK_DETAIL_BYTE_CAP = 4096` (UTF-8 bytes).
- `serializeFallbackDetailHeader(fallbackDetail)` exported. Returns
  `null` for empty/null/undefined → caller omits the header.
- `jsonStringifyAscii` escapes every non-ASCII code point as `\uXXXX`
  to satisfy RFC 7230 §3.2.6 field-vchar (Node's HTTP header validator
  rejects multi-byte UTF-8). The D38 CONCURRENCY_LIMIT synthesised
  message contains a U+2014 em dash — without this escape, every
  CONCURRENCY_LIMIT response crashed at writeHead with
  "Invalid character in header content". The regression test pins
  this exact string.
- 4KB cap algorithm: builds candidates as `[...slice(0, kept), sentinel]`
  and measures the FULL serialised length (including sentinel) before
  comparing against the cap, so the result is guaranteed under cap.
  Tail tuples dropped one at a time. Sentinel form:
  `{ truncated: true, omitted_hops: N }`.

**Header emission** (server.mjs)

- `withFallbackDetailHeader(base, fallbackDetail)` wraps the base
  header object; emits the header only when serialiser returns
  non-null.
- Emitted on chain-exhausted, non-trigger-error, client-error,
  AUTH_MISSING, and success-with-prior-failure paths.
- ABSENT on clean primary success — verified by a dedicated HTTP
  integration test.

**Tests** (test-features.mjs): 452 → 468 (+16):

- Engine-level tuple shape: 2-hop/exhausted, 2-hop/success-with-prior-
  failure, 1-hop/success (empty array), 1-hop/fail, non-ProviderError-
  yields-UNKNOWN, 500-char-message → 200-char-with-ellipsis, client
  error → 1 tuple + client_error trigger type
- Serialiser: empty/null → null, small array round-trip, >4KB cap
  with `{truncated:true,omitted_hops:N}` sentinel, RFC 7230 newline/CR
  escaping, non-ASCII escaping (em dash regression guard for the D38
  CONCURRENCY_LIMIT synthesised message)
- HTTP integration: clean-1-hop-success (header absent), 2-hop-
  exhausted (2 tuples on the wire), 2-hop-success-with-prior-failure
  (1 tuple on the wire)

Pre-commit fold-in (per evidence-first checkpoint #4):

- **Reviewer Suggestion #2**: `jsonStringifyAscii` regex character
  class `[U+0080-U+FFFF]` contains an invisible U+0080 boundary marker
  that editors render as nothing, making the line easy to misread as
  the empty class `[-...]`. Folded in a 5-line comment block above
  the function body explaining the literal byte range and citing
  RFC 7230 §3.2.6.

Two reviewer suggestions not folded:
- **Suggestion #1**: AUTH_MISSING tuple path lacks a dedicated D40
  test. Code is structurally correct (tuple pushed before early-
  return); low priority. Future polish.
- **Suggestion #3**: defensive `err.code != null` guard. Extremely
  low priority — PROVIDER_ERROR_CODES is a closed enum with no
  falsy values.

**ADR amendments** (docs/adr/0004-fallback-engine.md)

- Amendment 5 added at top of amendments stack — full tuple schema,
  cap behavior, RFC 7230 hygiene, ungated v0.1 rationale, Phase 2
  follow-up.
- § Chain advancement step 4 updated to remove TBD / not-yet-
  implemented qualifiers and cross-reference Amendment 5.
- § Observability headers section gains the X-OLP-Fallback-Detail
  schema as IMPLEMENTED at v0.1.

**CHANGELOG.md** — D40 sub-entry appended under existing D38/D39
entries in Unreleased section. No package.json bump per
phase_rolling_mode.

Authority:
- ADR 0004 § Decision § Chain advancement step 4 — D40 fulfils
  the promise
- ADR 0004 Amendment 5 (this commit) — implementation contract
- ADR 0004 § Observability headers — updated to IMPLEMENTED state
- D18 (5 standard X-OLP-* headers) — D40 builds on this convention
- D28 (per-hop structured log fields) — D40 reuses the field shapes
- GitHub issue #7 — closed by this commit
- CC 开发铁律 v1.6 § 10.x — fresh-context opus reviewer independent
- CLAUDE.md release_kit_overlay phase_rolling_mode — no version bump

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus,
independent of drafter): APPROVE. Critical depth checks:
- Tuple pushed once per hop, BEFORE client-error / AUTH_MISSING
  early-return branches — both paths include the failing tuple
- fallbackDetail returned on all 5 return paths (verified line by line)
- Cap algorithm measures FULL serialised length (with sentinel)
  before comparing — no overshoot
- RFC 7230 compliance verified end-to-end: imported the function
  in Node, confirmed em-dash → —, unescaped form throws
  "Invalid character in header content"
- Serialiser handles null / undefined / [] gracefully → header
  absent on clean primary success (HTTP test pins this)
- Hygiene: 0 hits for personal markers, home paths, tokens
- 468/468 tests pass in reviewer's independent npm test run

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 21:44:11 +10:00
co-authored by Claude Opus 4.7
parent bdfea6884b
commit 04f797f917
5 changed files with 683 additions and 10 deletions
+429
View File
@@ -5586,6 +5586,219 @@ describe('Fallback engine — HTTP integration (D9)', () => {
}
});
// ── D40 HTTP integration tests (issue #7): X-OLP-Fallback-Detail on the wire ─
it('D40: 1-hop chain succeeds → X-OLP-Fallback-Detail header is absent (no failures to report)', async () => {
__clearCache();
__setSpawnImpl(makeSuccessSpawn('D40 clean success'));
try {
const r = await fetch({
port,
method: 'POST',
path: '/v1/chat/completions',
body: {
model: 'claude-sonnet-4-6',
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 10,
},
});
assert.equal(r.status, 200, `Expected 200, got ${r.status}: ${r.body.slice(0, 200)}`);
assert.equal(r.headers['x-olp-fallback-hops'], '0');
assert.equal(
r.headers['x-olp-fallback-detail'],
undefined,
`Header must be absent on clean primary success, got: ${r.headers['x-olp-fallback-detail']}`,
);
} finally {
__resetSpawnImpl();
}
});
it('D40: 2-hop chain, both fail → response carries X-OLP-Fallback-Detail with 2 tuples', async () => {
__clearCache();
__setFallbackConfig({
chains: {
'claude-sonnet-4-6': [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
],
},
soft_triggers: {},
});
// Anthropic mock: exits non-zero with stderr message → SPAWN_FAILED
__setSpawnImpl(function (_bin, _args, _opts) {
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.stdin = {
write: () => {},
end: () => {
setImmediate(() => {
proc.stderr.emit('data', Buffer.from('anthropic-stderr\n'));
proc.stdout.emit('end');
proc.stderr.emit('end');
proc.emit('close', 1, null);
});
},
};
proc.killed = false;
proc.kill = () => {};
return proc;
});
// Codex (openai) mock: also fails → exhausted chain
codexSetSpawnImpl(function (_bin, _args, _opts) {
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.stdin = {
write: () => {},
end: () => {
setImmediate(() => {
proc.stderr.emit('data', Buffer.from('codex-stderr\n'));
proc.stdout.emit('end');
proc.stderr.emit('end');
proc.emit('close', 1, null);
});
},
};
proc.killed = false;
proc.kill = () => {};
return proc;
});
try {
const r = await fetch({
port,
method: 'POST',
path: '/v1/chat/completions',
body: {
model: 'claude-sonnet-4-6',
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 10,
},
});
assert.ok(r.status >= 400 && r.status < 600, `Expected error status, got ${r.status}`);
assert.ok(
r.headers['x-olp-fallback-detail'] !== undefined,
`Expected X-OLP-Fallback-Detail header on exhausted chain, headers: ${JSON.stringify(r.headers)}`,
);
const parsed = JSON.parse(r.headers['x-olp-fallback-detail']);
assert.ok(Array.isArray(parsed), 'Header must JSON-parse to array');
assert.equal(parsed.length, 2, `Expected 2 tuples on 2-hop exhaustion, got ${parsed.length}`);
assert.equal(parsed[0].hop, 0);
assert.equal(parsed[0].provider, 'anthropic');
assert.equal(parsed[0].code, 'SPAWN_FAILED');
assert.equal(parsed[0].trigger_type, 'hard');
assert.equal(parsed[1].hop, 1);
assert.equal(parsed[1].provider, 'openai');
} finally {
__resetFallbackConfig();
__resetSpawnImpl();
codexResetSpawnImpl();
}
});
it('D40: 2-hop chain, primary fails + secondary succeeds → success response carries X-OLP-Fallback-Detail with 1 tuple (the failed primary)', async () => {
__clearCache();
const { writeFileSync, unlinkSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join: pathJoin } = await import('node:path');
const tmpAuthFile = pathJoin(tmpdir(), `olp-test-d40-success-with-detail-${Date.now()}.json`);
writeFileSync(tmpAuthFile, JSON.stringify({ accessToken: 'fake-d40-codex-token' }), 'utf8');
const savedCodexAuthPath = process.env.OPENAI_CODEX_AUTH_PATH;
process.env.OPENAI_CODEX_AUTH_PATH = tmpAuthFile;
__setFallbackConfig({
chains: {
'claude-sonnet-4-6': [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
],
},
soft_triggers: {},
});
// Anthropic mock: SPAWN_FAILED (no chunks)
__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('end');
proc.stderr.emit('end');
proc.emit('close', 1, null);
});
},
};
proc.killed = false;
proc.kill = () => {};
return proc;
});
// Codex (openai) mock: succeeds
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":"D40 success-after-fail"}\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;
});
try {
const r = await fetch({
port,
method: 'POST',
path: '/v1/chat/completions',
body: {
model: 'claude-sonnet-4-6',
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 10,
},
});
assert.equal(r.status, 200, `Expected 200 from fallback success, got ${r.status}: ${r.body.slice(0, 300)}`);
assert.equal(r.headers['x-olp-fallback-hops'], '1');
assert.equal(r.headers['x-olp-provider-used'], 'openai');
assert.ok(
r.headers['x-olp-fallback-detail'] !== undefined,
`Expected X-OLP-Fallback-Detail on success-with-prior-failure, got headers: ${JSON.stringify(r.headers)}`,
);
const parsed = JSON.parse(r.headers['x-olp-fallback-detail']);
assert.equal(parsed.length, 1, `Expected 1 tuple (the failed primary), got ${parsed.length}`);
assert.equal(parsed[0].hop, 0);
assert.equal(parsed[0].provider, 'anthropic');
assert.equal(parsed[0].code, 'SPAWN_FAILED');
assert.equal(parsed[0].trigger_type, 'hard');
} finally {
__resetFallbackConfig();
__resetSpawnImpl();
codexResetSpawnImpl();
if (savedCodexAuthPath !== undefined) {
process.env.OPENAI_CODEX_AUTH_PATH = savedCodexAuthPath;
} else {
delete process.env.OPENAI_CODEX_AUTH_PATH;
}
try { unlinkSync(tmpAuthFile); } catch { /* ignore */ }
}
});
});
// ── Suite 13g: D28 round-3 F2 — structured log observability fields ─────────
@@ -5901,6 +6114,222 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
});
// ── D40: X-OLP-Fallback-Detail header (issue #7) ─────────────────────────
//
// Tests for D40 fold the per-hop fallback failure detail into a response
// header. Authority: ADR 0004 § Decision § Chain advancement step 4 +
// § Observability headers.
import {
serializeFallbackDetailHeader,
FALLBACK_DETAIL_BYTE_CAP,
} from './server.mjs';
describe('D40 — X-OLP-Fallback-Detail header (issue #7)', () => {
// ── Engine: fallbackDetail returned in FallbackResult ─────────────────
it('engine: 2-hop chain, both fail → fallbackDetail has 2 tuples with correct shape', async () => {
const errA = new ProviderError('Anthropic spawn failed', 'SPAWN_FAILED');
const errB = new ProviderError('Codex spawn timeout', 'SPAWN_TIMEOUT');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
];
const hopFn = async (provider) => {
if (provider === 'anthropic') throw errA;
throw errB;
};
const result = await executeWithFallback(chain, makeIR({ model: 'claude-sonnet-4-6' }), hopFn);
assert.equal(result.chunks, null, 'Expected exhausted chain');
assert.ok(Array.isArray(result.fallbackDetail), 'fallbackDetail must be array');
assert.equal(result.fallbackDetail.length, 2);
assert.deepEqual(result.fallbackDetail[0], {
hop: 0, provider: 'anthropic', model: 'claude-sonnet-4-6',
code: 'SPAWN_FAILED', error_message: 'Anthropic spawn failed', trigger_type: 'hard',
});
assert.deepEqual(result.fallbackDetail[1], {
hop: 1, provider: 'openai', model: 'gpt-5.5',
code: 'SPAWN_TIMEOUT', error_message: 'Codex spawn timeout', trigger_type: 'hard',
});
});
it('engine: 2-hop chain, primary fails + secondary succeeds → fallbackDetail has 1 tuple (the failed primary)', async () => {
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
];
const hopFn = async (provider) => {
if (provider === 'anthropic') throw err;
return [{ type: 'stop', finish_reason: 'stop' }];
};
const result = await executeWithFallback(chain, makeIR({ model: 'claude-sonnet-4-6' }), hopFn);
assert.ok(Array.isArray(result.chunks), 'Expected success chunks');
assert.equal(result.fallbackHops, 1);
assert.equal(result.fallbackDetail.length, 1);
assert.equal(result.fallbackDetail[0].hop, 0);
assert.equal(result.fallbackDetail[0].provider, 'anthropic');
assert.equal(result.fallbackDetail[0].code, 'SPAWN_FAILED');
assert.equal(result.fallbackDetail[0].trigger_type, 'hard');
});
it('engine: 1-hop chain succeeds → fallbackDetail is empty array', async () => {
const chain = [{ provider: 'anthropic', model: 'claude-sonnet-4-6' }];
const hopFn = async () => [{ type: 'stop', finish_reason: 'stop' }];
const result = await executeWithFallback(chain, makeIR({ model: 'claude-sonnet-4-6' }), hopFn);
assert.ok(Array.isArray(result.chunks));
assert.deepEqual(result.fallbackDetail, [], 'Empty fallbackDetail on clean primary success');
});
it('engine: 1-hop chain fails → fallbackDetail has 1 tuple', async () => {
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [{ provider: 'anthropic', model: 'claude-sonnet-4-6' }];
const hopFn = async () => { throw err; };
const result = await executeWithFallback(chain, makeIR({ model: 'claude-sonnet-4-6' }), hopFn);
assert.equal(result.chunks, null);
assert.equal(result.fallbackDetail.length, 1);
assert.equal(result.fallbackDetail[0].hop, 0);
assert.equal(result.fallbackDetail[0].code, 'SPAWN_FAILED');
});
it('engine: non-ProviderError exception → tuple code is "UNKNOWN"', async () => {
const err = new Error('Something unexpected'); // no .code field
const chain = [{ provider: 'anthropic', model: 'claude-sonnet-4-6' }];
const hopFn = async () => { throw err; };
const result = await executeWithFallback(chain, makeIR({ model: 'claude-sonnet-4-6' }), hopFn);
assert.equal(result.fallbackDetail.length, 1);
assert.equal(result.fallbackDetail[0].code, 'UNKNOWN');
assert.equal(result.fallbackDetail[0].error_message, 'Something unexpected');
assert.equal(result.fallbackDetail[0].trigger_type, 'non_trigger');
});
it('engine: 500-char error message → tuple.error_message is truncated to 200 chars ending in ellipsis', async () => {
const longMsg = 'x'.repeat(500);
const err = new ProviderError(longMsg, 'SPAWN_FAILED');
const chain = [{ provider: 'anthropic', model: 'claude-sonnet-4-6' }];
const hopFn = async () => { throw err; };
const result = await executeWithFallback(chain, makeIR({ model: 'claude-sonnet-4-6' }), hopFn);
const tuple = result.fallbackDetail[0];
assert.equal(tuple.error_message.length, 200, 'Truncated message must be exactly 200 chars');
assert.equal(tuple.error_message.slice(-1), '…', 'Truncated message must end with ellipsis');
assert.equal(tuple.error_message.slice(0, 199), 'x'.repeat(199), 'First 199 chars preserved');
});
it('engine: client error 400 → fallbackDetail has 1 tuple with trigger_type "client_error" (no advance)', async () => {
const err = Object.assign(new Error('Bad request'), { statusCode: 400 });
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
];
const hopFn = async (provider) => {
if (provider === 'anthropic') throw err;
return [{ type: 'stop', finish_reason: 'stop' }];
};
const result = await executeWithFallback(chain, makeIR({ model: 'claude-sonnet-4-6' }), hopFn);
assert.equal(result.fallbackDetail.length, 1, 'Client error stops chain — only 1 tuple');
assert.equal(result.fallbackDetail[0].trigger_type, 'client_error');
});
// ── serializeFallbackDetailHeader: cap + RFC 7230 hygiene ─────────────
it('serialize: empty array → null (no header emitted)', () => {
assert.equal(serializeFallbackDetailHeader([]), null);
});
it('serialize: null/undefined → null (no header emitted)', () => {
assert.equal(serializeFallbackDetailHeader(null), null);
assert.equal(serializeFallbackDetailHeader(undefined), null);
});
it('serialize: small array → JSON.stringified array under cap', () => {
const detail = [{
hop: 0, provider: 'anthropic', model: 'claude-sonnet-4-6',
code: 'SPAWN_FAILED', error_message: 'oops', trigger_type: 'hard',
}];
const v = serializeFallbackDetailHeader(detail);
assert.ok(typeof v === 'string');
assert.ok(Buffer.byteLength(v, 'utf8') <= FALLBACK_DETAIL_BYTE_CAP);
const parsed = JSON.parse(v);
assert.deepEqual(parsed, detail);
});
it('serialize: array exceeding 4KB → truncated with { truncated:true, omitted_hops:N } sentinel; total stays <= 4096 bytes', () => {
// Build many tuples each just under the cap individually but cumulatively
// over the cap. Each tuple here is roughly 250 bytes serialised.
const baseMsg = 'x'.repeat(180); // leaves ~20 chars for JSON braces/key padding
const tuples = [];
for (let i = 0; i < 50; i++) {
tuples.push({
hop: i,
provider: 'anthropic',
model: 'claude-sonnet-4-6',
code: 'SPAWN_FAILED',
error_message: baseMsg,
trigger_type: 'hard',
});
}
const v = serializeFallbackDetailHeader(tuples);
assert.ok(typeof v === 'string');
assert.ok(
Buffer.byteLength(v, 'utf8') <= FALLBACK_DETAIL_BYTE_CAP,
`Header value must be <= ${FALLBACK_DETAIL_BYTE_CAP} bytes, got ${Buffer.byteLength(v, 'utf8')}`,
);
const parsed = JSON.parse(v);
assert.ok(Array.isArray(parsed), 'Must JSON-parse to array');
const tail = parsed[parsed.length - 1];
assert.equal(tail.truncated, true, 'Last entry must be the truncation sentinel');
assert.equal(typeof tail.omitted_hops, 'number');
assert.ok(tail.omitted_hops > 0, 'omitted_hops must be positive when truncation fired');
assert.equal(
parsed.length - 1 + tail.omitted_hops,
tuples.length,
'kept tuples + omitted_hops must equal total original tuples',
);
});
it('serialize: JSON.stringify escapes newlines in error_message (RFC 7230 hygiene — no raw CR/LF in header)', () => {
const detail = [{
hop: 0, provider: 'anthropic', model: 'claude-sonnet-4-6',
code: 'UNKNOWN', error_message: 'line1\nline2\rline3', trigger_type: 'non_trigger',
}];
const v = serializeFallbackDetailHeader(detail);
assert.ok(typeof v === 'string');
// Raw \n / \r in a header value would break RFC 7230. JSON.stringify escapes them.
assert.equal(v.indexOf('\n'), -1, 'Header value must not contain raw newline');
assert.equal(v.indexOf('\r'), -1, 'Header value must not contain raw CR');
// Parsed back, the original message round-trips correctly
assert.equal(JSON.parse(v)[0].error_message, 'line1\nline2\rline3');
});
it('serialize: non-ASCII characters in error_message (e.g. em dash U+2014) → escaped as \\uXXXX so Node HTTP header validator accepts the value', () => {
// The D38 CONCURRENCY_LIMIT synthesised error message in server.mjs contains
// an em dash. Without \uXXXX escaping, res.writeHead would throw
// "Invalid character in header content" because Node's HTTP layer rejects
// multi-byte UTF-8 in header values (RFC 7230 §3.2.6 field-vchar = ASCII VCHAR).
const detail = [{
hop: 0, provider: 'anthropic', model: 'claude-sonnet-4-6',
code: 'CONCURRENCY_LIMIT',
error_message: 'provider anthropic at maxConcurrent (2) — advancing to next hop',
trigger_type: 'hard',
}];
const v = serializeFallbackDetailHeader(detail);
assert.ok(typeof v === 'string');
// Every byte must be ASCII (0x00-0x7F) — verified via Buffer round-trip equality.
const utf8Len = Buffer.byteLength(v, 'utf8');
assert.equal(utf8Len, v.length, 'Header value must be pure ASCII (1 byte per char)');
for (let i = 0; i < v.length; i++) {
assert.ok(v.charCodeAt(i) < 0x80, `Char at ${i} (${v[i]}) must be ASCII`);
}
// JSON.parse must still round-trip the original em dash.
assert.equal(
JSON.parse(v)[0].error_message,
'provider anthropic at maxConcurrent (2) — advancing to next hop',
);
});
});
// ── Suite 14: providers.enabled config wiring ──────────────────────────────
//
// Tests that: