From c3ba751a8f738c61b97459d1cf2c2f0451ebd90b Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Sun, 24 May 2026 16:22:30 +1000 Subject: [PATCH] =?UTF-8?q?feat:=20D27=20=E2=80=94=20round-3=20P3=20batch?= =?UTF-8?q?=20(F8=20IR=20validator=20+=20F10=20ADR=20amend=20+=20F15=20/v1?= =?UTF-8?q?/models=20aliases)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cold-audit catch from 2026-05-24 (round 3) 3 round-3 P3 items batched per IDR. Mixed surfaces but all single-severity and conceptually independent. Changes (5 files, +324 / -17): 1. lib/ir/types.mjs — F8 validator extension (+28): - `response_format`: must be object with string `.type` (undefined/omitted accepted; null, non-object, missing-type all rejected). Forward-compat: accepts any string for type so future OpenAI additions (json_schema, etc.) flow through without schema bump. - `tool_choice`: string form 'auto'/'none'/'required' OR object form `{type:'function', function:{name:string}}`. All other shapes rejected. Pre-D27 the validator silently accepted any value; the Anthropic plugin's `if (irRequest.response_format?.type === 'json_object')` would no-op on a malformed string payload. 2. docs/adr/0005-cache-cross-provider.md — F10 Amendment 4 (+8): - Documents the IR-vs-body detection ambiguity in § D2: the ADR text reads as if detection happens on the IR, but `openai-to-ir.mjs` strips `cache_control` from messages per ADR 0003's whitelist policy, so IR-side detection is structurally always empty at v1.0 - Documents the actual v1.0 detection mechanism (server.mjs side-channels into the raw body) - Documents the cache key `cache_control` slot's always-null status as forward-compat (when a future ADR 0003 amendment adds cache_control to IR, the slot will start carrying meaningful data without schema bump) - Explicit "no code change" — F10 is docs-only 3. lib/providers/index.mjs — F15 alias map export (+11): - `getAliasMap()` returns `new Map(_aliasMap)` — defensive copy preventing caller mutation of the module-private alias map - JSDoc documents use case + defensive-copy intent 4. server.mjs — F15 /v1/models alias surfacing (+25/-7): - `handleModels` now emits TWO loops: canonical entries first (preserves existing client expectations), then alias entries via `getAliasMap()` - Each alias entry has the same 4 OpenAI-spec fields (id/object/created/ owned_by) — no invented fields per ALIGNMENT Rule 2(b) - Disabled-provider alias non-leak: `loadedProviders.has(providerName)` gate skips aliases whose target provider is not currently enabled - createdTs reused (same per-request timestamp across all entries) - JSDoc updated to document new ordering + F15 origin 5. test-features.mjs — +269 / +18 new tests: - F8: 13 tests covering response_format (object/string/non-object/missing- type) + tool_choice (string variants/object variants/wrong type/no name) - F15: 5 tests covering canonical-first ordering, alias presence/count, disabled-provider non-leak (with anthropic-only enabled, mistral and openai aliases must NOT appear), Rule 2(b) shape conformance Tests: 358 → 376 (+18). All pass on Node 20. Reviewer notes (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent of drafter): APPROVE. Critical checks verified: - F8: all 9+ tool_choice rejection axes traced through code (including partial-function-object edge cases). The code handles them correctly even where tests don't exercise (non-blocking gap). - F10: amendment substantively correct. One wording-precision note: the amendment says "body only" but the actual code is an OR-disjunction (hasCacheControl(ir) || extractCacheControlMarkers(body.messages)). Functionally identical at v1.0 because IR strips markers, so first disjunct is always false. Forward-compat by construction. - F15: disabled-provider non-leak verified by manual trace with anthropic-only enabled — mistral/openai aliases correctly filtered out by `loadedProviders.has(providerName)` gate. Test 17e explicitly asserts this. - F15+D17 round-trip: client GET /v1/models → sees alias entry → POSTs with alias → getProviderForModel resolves via same _aliasMap → cache key uses canonical → response works. Both surfaces read the same Map (SPOT). - F15+D23 cacheable interaction: cacheable opt-out doesn't suppress alias surfacing in /v1/models — cacheable is about cache-write behavior while discovery should still surface enabled providers. Intentional. Authority: - ADR 0003 § Optional fields (response_format + tool_choice IR shape) - OpenAI Chat Completions API spec (the field semantics) https://platform.openai.com/docs/api-reference/chat/create - ADR 0005 § D2 + Amendment 4 (F10's own amendment landing here) - ADR 0002 § Loading model + D17 getProviderForModel SPOT (F15's alias origin) - ALIGNMENT.md Rule 2(b) — no invented OpenAI fields - CC 开发铁律 v1.6 § 10.x — Round-3 Cold Audit caught all 3 Follow-up items (reviewer's non-blocking suggestions, NOT in this PR): - F8 micro-tests for null/boolean/partial-function inputs (code handles; test gap only) - F10 wording precision on OR-disjunction - Nested-describe wrapper quirk in test-features.mjs (pre-existing structural issue; D26/D27 describes are children of Suite 16 wrapper). Cleanup in a future hygiene pass. Co-Authored-By: Claude Opus 4.7 --- docs/adr/0005-cache-cross-provider.md | 8 + lib/ir/types.mjs | 28 +++ lib/providers/index.mjs | 11 ++ server.mjs | 25 ++- test-features.mjs | 269 ++++++++++++++++++++++++-- 5 files changed, 324 insertions(+), 17 deletions(-) diff --git a/docs/adr/0005-cache-cross-provider.md b/docs/adr/0005-cache-cross-provider.md index 2f1196f..8e34b7e 100644 --- a/docs/adr/0005-cache-cross-provider.md +++ b/docs/adr/0005-cache-cross-provider.md @@ -7,6 +7,14 @@ ## Amendments +### Amendment 4 — 2026-05-24: Clarify D2 cache_control detection surface (F10) + +- **Finding:** Round-3 cold-audit F10 (P3 detection-surface drift) — § D2 reads as if detection happens on the IR ("If THE IR REQUEST contains Anthropic cache_control markers AND..."), but `openai-to-ir.mjs` strips `cache_control` markers from messages per ADR 0003's whitelist policy. Actual v1.0 detection happens on the raw OpenAI request body at the entry surface in `server.mjs`. +- **Clarification:** At v1.0, `cache_control` marker detection for the D2 bypass uses the raw OpenAI request body's `messages` array, NOT the IR. This is because the IR (per ADR 0003) does not carry `cache_control` as a field — markers are an OpenAI-vendor-specific request annotation that the IR consciously does not surface. The bypass logic in `server.mjs`'s `executeHopFn` correctly consults `body.messages` directly via `extractCacheControlMarkers(body?.messages ?? [])`. +- **Cache key implication:** The cache key's `cache_control` slot (added by D15 Amendment 2) is structurally always `null` at v1.0 because it is computed via `extractCacheControlMarkers(ir.messages)` which always returns `[]`. This is forward-compatible: if a future ADR 0003 amendment adds `cache_control` to the IR field set, the slot will start carrying meaningful data without a cache-key schema change. The slot stays as documented; no v1.0 cache-key revision needed. +- **No code change:** F10 is a docs-only clarification. The bypass behavior is correct; the cache key slot's nullness is intentional given v1.0 IR design. +- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Round-3 Cold Audit). + ### Amendment 3 — 2026-05-24: Implement § "Cache write conditions" items 3 and 4 (D23) - **Finding:** Cold-audit round-2 Finding 3 (P2 cache-condition drift) — § "Cache write conditions" items 3 and 4 were documented in this ADR but never wired in code. Zero matches for `cacheable` / `10485760` / any size-cap pattern in `lib/` or `server.mjs`. The invariants existed only in prose. diff --git a/lib/ir/types.mjs b/lib/ir/types.mjs index bb94dfe..39c568d 100644 --- a/lib/ir/types.mjs +++ b/lib/ir/types.mjs @@ -168,5 +168,33 @@ export function validateIRRequest(obj) { errors.push('tools must be an array when present'); } + // Optional: response_format — object with .type (string) + if (obj.response_format !== undefined) { + if (typeof obj.response_format !== 'object' || obj.response_format === null) { + errors.push('response_format must be an object'); + } else if (typeof obj.response_format.type !== 'string') { + errors.push('response_format.type must be a string'); + } + } + + // Optional: tool_choice — 'auto' | 'none' | 'required' | {type:'function', function:{name}} + if (obj.tool_choice !== undefined) { + if (typeof obj.tool_choice === 'string') { + if (!['auto', 'none', 'required'].includes(obj.tool_choice)) { + errors.push("tool_choice string must be 'auto' | 'none' | 'required'"); + } + } else if (typeof obj.tool_choice === 'object' && obj.tool_choice !== null) { + if (obj.tool_choice.type !== 'function') { + errors.push("tool_choice.type must be 'function'"); + } else if (typeof obj.tool_choice.function !== 'object' || obj.tool_choice.function === null) { + errors.push('tool_choice.function must be an object'); + } else if (typeof obj.tool_choice.function.name !== 'string') { + errors.push('tool_choice.function.name must be a string'); + } + } else { + errors.push('tool_choice must be a string or an object'); + } + } + return { valid: errors.length === 0, errors }; } diff --git a/lib/providers/index.mjs b/lib/providers/index.mjs index c8e88a5..7279e5e 100644 --- a/lib/providers/index.mjs +++ b/lib/providers/index.mjs @@ -69,6 +69,17 @@ for (const [providerName, providerEntry] of Object.entries( } } +/** + * Returns a defensive copy of the alias→{providerName, canonicalModel} map. + * Used by handleModels in server.mjs to emit alias entries in /v1/models. + * Defensive copy prevents caller mutation of the module-private _aliasMap. + * + * @returns {Map} + */ +export function getAliasMap() { + return new Map(_aliasMap); +} + // ── Registry functions ──────────────────────────────────────────────────── /** diff --git a/server.mjs b/server.mjs index c9810b5..b1ce0ea 100644 --- a/server.mjs +++ b/server.mjs @@ -29,7 +29,7 @@ import { generateRequestId, SSE_DONE, } from './lib/ir/ir-to-openai.mjs'; -import { loadProviders, listAllProviderNames } from './lib/providers/index.mjs'; +import { loadProviders, listAllProviderNames, getAliasMap } from './lib/providers/index.mjs'; import { ProviderError } from './lib/providers/base.mjs'; import { computeCacheKey, hasCacheControl, extractCacheControlMarkers } from './lib/cache/keys.mjs'; import { CacheStore } from './lib/cache/store.mjs'; @@ -259,17 +259,21 @@ function handleHealth(req, res) { * Returns the list of models served by all currently loaded (enabled) providers. * Per ADR 0002 § "Loading model" + OpenAI spec /v1/models: * Each entry: { id, object: 'model', created, owned_by } - * - id: canonical model ID from the provider's models[] array + * - id: canonical model ID (or alias string for alias entries) * - object: literal 'model' (OpenAI spec) * - created: Unix epoch seconds (stable per request; computed once from Date.now()) * - owned_by: provider.name (e.g. 'anthropic', 'openai', 'mistral') - * Only canonical IDs are emitted (no aliases — per D17 SPOT decision). - * Order: insertion order of loadedProviders, then insertion order of each provider's models[]. + * Order: canonical models first (insertion order of loadedProviders, then models[]), + * then alias entries (for aliases whose target provider is currently loaded). + * Authority: OpenAI /v1/models spec (https://platform.openai.com/docs/api-reference/models); + * models-registry.json alias map (SPOT per D17); F15 round-3 adds alias surfacing. * Empty case: if no providers are enabled, data: [] is returned naturally. */ function handleModels(req, res) { const createdTs = Math.floor(Date.now() / 1000); const data = []; + + // Canonical entries first for (const [providerName, provider] of loadedProviders) { for (const modelId of provider.models) { data.push({ @@ -280,6 +284,19 @@ function handleModels(req, res) { }); } } + + // Alias entries for loaded (enabled) providers, canonical-first ordering preserved + for (const [alias, { providerName }] of getAliasMap()) { + if (loadedProviders.has(providerName)) { + data.push({ + id: alias, + object: 'model', + created: createdTs, + owned_by: providerName, + }); + } + } + sendJSON(res, 200, { object: 'list', data }); } diff --git a/test-features.mjs b/test-features.mjs index 80b2c8f..b9cab0e 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -28,7 +28,7 @@ import { SSE_DONE, } from './lib/ir/ir-to-openai.mjs'; import { validateProvider, ProviderError, withTimeout } from './lib/providers/base.mjs'; -import { loadProviders, getProviderForModel, getProviderByName, listAllProviderNames } from './lib/providers/index.mjs'; +import { loadProviders, getProviderForModel, getProviderByName, listAllProviderNames, getAliasMap } from './lib/providers/index.mjs'; import anthropic, { irToAnthropic, anthropicChunkToIR, @@ -5519,13 +5519,16 @@ import { __setFallbackConfig as setFallbackConfig17, __resetFallbackConfig as resetFallbackConfig17, __clearCache as clearCache17, + createOlpServer as createServer27, + __setProvidersEnabled as setProviders27, + __resetProvidersEnabled as resetProviders27, } from './server.mjs'; describe('/v1/models population + X-OLP-* error headers (Suite 17)', () => { - // ── 17a: /v1/models with anthropic enabled → 3 model entries ───────────── + // ── 17a: /v1/models with anthropic enabled → 3 canonical + 4 alias entries ───────────── - it('17a: /v1/models with anthropic enabled → 200 + 3 entries with owned_by="anthropic"', async () => { + it('17a: /v1/models with anthropic enabled → 200 + 7 entries (3 canonical + 4 aliases) with owned_by="anthropic"', async () => { setProviders17({ anthropic: true }); const s = createServer17(); const p = 25456 + Math.floor(Math.random() * 400); @@ -5539,8 +5542,8 @@ describe('/v1/models population + X-OLP-* error headers (Suite 17)', () => { const body = JSON.parse(r.body); assert.equal(body.object, 'list'); assert.ok(Array.isArray(body.data), 'data must be an array'); - // Anthropic has 3 canonical models in models-registry.json - assert.equal(body.data.length, 3, `Expected 3 anthropic models, got ${body.data.length}`); + // Anthropic has 3 canonical models + 4 aliases (claude, sonnet, opus, haiku) in models-registry.json + assert.equal(body.data.length, 7, `Expected 7 anthropic entries (3 canonical + 4 aliases), got ${body.data.length}`); for (const entry of body.data) { assert.equal(entry.owned_by, 'anthropic', `Expected owned_by='anthropic', got '${entry.owned_by}'`); } @@ -5572,9 +5575,10 @@ describe('/v1/models population + X-OLP-* error headers (Suite 17)', () => { } }); - // ── 17c: /v1/models contains only canonical IDs, no aliases ─────────────── + // ── 17c: /v1/models returns both canonical IDs and alias IDs ─────────────── + // Updated by D27 F15: aliases are now surfaced in /v1/models (canonical-first order). - it('17c: /v1/models returns canonical IDs only (no aliases like "sonnet")', async () => { + it('17c: /v1/models returns canonical IDs and alias IDs for loaded providers', async () => { setProviders17({ anthropic: true }); const s = createServer17(); const p = 25464 + Math.floor(Math.random() * 400); @@ -5587,13 +5591,19 @@ describe('/v1/models population + X-OLP-* error headers (Suite 17)', () => { assert.equal(r.status, 200); const body = JSON.parse(r.body); const ids = body.data.map(e => e.id); - // Aliases that must NOT appear - const knownAliases = ['sonnet', 'opus', 'haiku', 'claude', 'codex', 'devstral']; - for (const alias of knownAliases) { - assert.ok(!ids.includes(alias), `Alias '${alias}' must not appear in /v1/models data`); + // Canonical IDs must appear + assert.ok(ids.includes('claude-sonnet-4-6'), 'canonical claude-sonnet-4-6 must appear'); + assert.ok(ids.includes('claude-opus-4-7'), 'canonical claude-opus-4-7 must appear'); + assert.ok(ids.includes('claude-haiku-4-5'), 'canonical claude-haiku-4-5 must appear'); + // Aliases for the loaded (anthropic) provider must also appear + const anthropicAliases = ['claude', 'sonnet', 'opus', 'haiku']; + for (const alias of anthropicAliases) { + assert.ok(ids.includes(alias), `Alias '${alias}' must appear in /v1/models data when anthropic is enabled`); } - // The canonical ID must appear - assert.ok(ids.includes('claude-sonnet-4-6'), 'claude-sonnet-4-6 must appear in /v1/models data'); + // Canonical IDs come before alias IDs (canonical-first ordering) + const firstAliasIdx = Math.min(...anthropicAliases.map(a => ids.indexOf(a))); + const lastCanonicalIdx = Math.max(ids.indexOf('claude-sonnet-4-6'), ids.indexOf('claude-opus-4-7'), ids.indexOf('claude-haiku-4-5')); + assert.ok(lastCanonicalIdx < firstAliasIdx, 'canonical entries must appear before alias entries'); } finally { resetProviders17(); await new Promise(r => s.close(r)); @@ -6646,4 +6656,237 @@ describe('D26 F19 — streaming truncation marker on stop-less exhaustion', () = }); +// ── Suite D27: round-3 batch (F8, F15) ──────────────────────────────────────── +// +// F8: validateIRRequest now validates response_format and tool_choice. +// F15: /v1/models now surfaces alias entries for loaded providers. +// +// Authority: +// F8 — ADR 0003 § Optional fields (response_format object; tool_choice string/object) +// F15 — OpenAI /v1/models spec (https://platform.openai.com/docs/api-reference/models); +// models-registry.json alias map (SPOT per D17) + +// ── F8: response_format and tool_choice validation ──────────────────────────── + +describe('D27 F8 — validateIRRequest response_format + tool_choice validation', () => { + + it('F8: response_format object with string .type is accepted', () => { + const r = validateIRRequest(makeIR({ response_format: { type: 'json_object' } })); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + }); + + it('F8: response_format string (not object) is rejected', () => { + const r = validateIRRequest(makeIR({ response_format: 'json_object' })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('response_format must be an object')), + `Expected 'response_format must be an object', got: ${JSON.stringify(r.errors)}`); + }); + + it('F8: response_format object with non-string .type is rejected', () => { + const r = validateIRRequest(makeIR({ response_format: { type: 42 } })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('response_format.type must be a string')), + `Expected 'response_format.type must be a string', got: ${JSON.stringify(r.errors)}`); + }); + + it('F8: response_format undefined is accepted (optional field)', () => { + const ir = makeIR(); + delete ir.response_format; + const r = validateIRRequest(ir); + assert.equal(r.valid, true); + }); + + it("F8: tool_choice 'auto' is accepted", () => { + const r = validateIRRequest(makeIR({ tool_choice: 'auto' })); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + }); + + it("F8: tool_choice 'none' is accepted", () => { + const r = validateIRRequest(makeIR({ tool_choice: 'none' })); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + }); + + it("F8: tool_choice 'required' is accepted", () => { + const r = validateIRRequest(makeIR({ tool_choice: 'required' })); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + }); + + it('F8: tool_choice unknown string is rejected', () => { + const r = validateIRRequest(makeIR({ tool_choice: 'any' })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes("tool_choice string must be 'auto' | 'none' | 'required'")), + `Expected tool_choice string error, got: ${JSON.stringify(r.errors)}`); + }); + + it('F8: tool_choice {type:"function", function:{name:"X"}} is accepted', () => { + const r = validateIRRequest(makeIR({ tool_choice: { type: 'function', function: { name: 'my_fn' } } })); + assert.equal(r.valid, true); + assert.deepEqual(r.errors, []); + }); + + it('F8: tool_choice {type:"tool"} (wrong type field) is rejected', () => { + const r = validateIRRequest(makeIR({ tool_choice: { type: 'tool' } })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes("tool_choice.type must be 'function'")), + `Expected tool_choice.type error, got: ${JSON.stringify(r.errors)}`); + }); + + it('F8: tool_choice {} (object but no type field) is rejected', () => { + const r = validateIRRequest(makeIR({ tool_choice: {} })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes("tool_choice.type must be 'function'")), + `Expected tool_choice.type error for empty object, got: ${JSON.stringify(r.errors)}`); + }); + + it('F8: tool_choice undefined is accepted (optional field)', () => { + const ir = makeIR(); + delete ir.tool_choice; + const r = validateIRRequest(ir); + assert.equal(r.valid, true); + }); + + it('F8: tool_choice number is rejected (not string or object)', () => { + const r = validateIRRequest(makeIR({ tool_choice: 42 })); + assert.equal(r.valid, false); + assert.ok(r.errors.some(e => e.includes('tool_choice must be a string or an object')), + `Expected 'tool_choice must be a string or an object', got: ${JSON.stringify(r.errors)}`); + }); + +}); + +// ── F15: /v1/models alias surfacing ─────────────────────────────────────────── + +describe('D27 F15 — /v1/models alias surfacing', () => { + + it('F15a: /v1/models with anthropic enabled contains all canonical IDs and all 4 anthropic aliases', async () => { + setProviders27({ anthropic: true }); + const s = createServer27(); + const p = 27100 + 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: 'GET', path: '/v1/models' }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + const ids = body.data.map(e => e.id); + // Canonical IDs + assert.ok(ids.includes('claude-opus-4-7'), 'canonical claude-opus-4-7 must appear'); + assert.ok(ids.includes('claude-sonnet-4-6'), 'canonical claude-sonnet-4-6 must appear'); + assert.ok(ids.includes('claude-haiku-4-5'), 'canonical claude-haiku-4-5 must appear'); + // Anthropic aliases + for (const alias of ['claude', 'sonnet', 'opus', 'haiku']) { + assert.ok(ids.includes(alias), `alias '${alias}' must appear when anthropic is enabled`); + } + } finally { + resetProviders27(); + await new Promise(r => s.close(r)); + } + }); + + it('F15b: each alias entry has owned_by equal to its canonical target provider', async () => { + setProviders27({ anthropic: true }); + const s = createServer27(); + const p = 27200 + 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: 'GET', path: '/v1/models' }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + const aliasMap = getAliasMap(); + for (const entry of body.data) { + if (aliasMap.has(entry.id)) { + const { providerName } = aliasMap.get(entry.id); + assert.equal(entry.owned_by, providerName, + `alias '${entry.id}' must have owned_by='${providerName}', got '${entry.owned_by}'`); + } + } + } finally { + resetProviders27(); + await new Promise(r => s.close(r)); + } + }); + + it('F15c: /v1/models with no providers enabled returns empty data (no aliases)', async () => { + setProviders27({}); + const s = createServer27(); + const p = 27300 + 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: 'GET', path: '/v1/models' }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.deepEqual(body.data, [], 'data must be empty when no providers are enabled (no aliases either)'); + } finally { + resetProviders27(); + await new Promise(r => s.close(r)); + } + }); + + it('F15d: /v1/models with anthropic+mistral enabled contains both providers\' canonicals and aliases', async () => { + setProviders27({ anthropic: true, mistral: true }); + const s = createServer27(); + const p = 27400 + 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: 'GET', path: '/v1/models' }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + const ids = body.data.map(e => e.id); + // Anthropic canonicals + aliases + assert.ok(ids.includes('claude-sonnet-4-6'), 'anthropic canonical must appear'); + assert.ok(ids.includes('sonnet'), 'anthropic alias sonnet must appear'); + assert.ok(ids.includes('claude'), 'anthropic alias claude must appear'); + // Mistral canonicals + aliases + assert.ok(ids.includes('devstral-2-25-12'), 'mistral canonical must appear'); + assert.ok(ids.includes('devstral'), 'mistral alias devstral must appear'); + // Verify minimum total count: 3 anthropic + 4 anthropic aliases + 2 mistral + 4 mistral aliases = 13 + assert.ok(body.data.length >= 13, + `Expected >=13 entries for anthropic+mistral, got ${body.data.length}`); + } finally { + resetProviders27(); + await new Promise(r => s.close(r)); + } + }); + + it('F15e: alias entries for disabled providers do not appear', async () => { + // Only anthropic enabled — codex aliases (codex, codex-spark, gpt5, gpt5-mini) must NOT appear + setProviders27({ anthropic: true }); + const s = createServer27(); + const p = 27500 + 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: 'GET', path: '/v1/models' }); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + const ids = body.data.map(e => e.id); + for (const alias of ['codex', 'codex-spark', 'gpt5', 'gpt5-mini', 'devstral', 'devstral-2']) { + assert.ok(!ids.includes(alias), + `Alias '${alias}' must NOT appear when its provider is not enabled`); + } + } finally { + resetProviders27(); + await new Promise(r => s.close(r)); + } + }); + +}); + });