feat: D27 — round-3 P3 batch (F8 IR validator + F10 ADR amend + F15 /v1/models aliases)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 16:22:30 +10:00
co-authored by Claude Opus 4.7
parent a281d3e424
commit c3ba751a8f
5 changed files with 324 additions and 17 deletions
+8
View File
@@ -7,6 +7,14 @@
## Amendments ## 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) ### 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. - **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.
+28
View File
@@ -168,5 +168,33 @@ export function validateIRRequest(obj) {
errors.push('tools must be an array when present'); 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 }; return { valid: errors.length === 0, errors };
} }
+11
View File
@@ -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<string, { providerName: string, canonicalModel: string }>}
*/
export function getAliasMap() {
return new Map(_aliasMap);
}
// ── Registry functions ──────────────────────────────────────────────────── // ── Registry functions ────────────────────────────────────────────────────
/** /**
+21 -4
View File
@@ -29,7 +29,7 @@ import {
generateRequestId, generateRequestId,
SSE_DONE, SSE_DONE,
} from './lib/ir/ir-to-openai.mjs'; } 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 { ProviderError } from './lib/providers/base.mjs';
import { computeCacheKey, hasCacheControl, extractCacheControlMarkers } from './lib/cache/keys.mjs'; import { computeCacheKey, hasCacheControl, extractCacheControlMarkers } from './lib/cache/keys.mjs';
import { CacheStore } from './lib/cache/store.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. * Returns the list of models served by all currently loaded (enabled) providers.
* Per ADR 0002 § "Loading model" + OpenAI spec /v1/models: * Per ADR 0002 § "Loading model" + OpenAI spec /v1/models:
* Each entry: { id, object: 'model', created, owned_by } * 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) * - object: literal 'model' (OpenAI spec)
* - created: Unix epoch seconds (stable per request; computed once from Date.now()) * - created: Unix epoch seconds (stable per request; computed once from Date.now())
* - owned_by: provider.name (e.g. 'anthropic', 'openai', 'mistral') * - owned_by: provider.name (e.g. 'anthropic', 'openai', 'mistral')
* Only canonical IDs are emitted (no aliases — per D17 SPOT decision). * Order: canonical models first (insertion order of loadedProviders, then models[]),
* Order: insertion order of loadedProviders, then insertion order of each provider's 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. * Empty case: if no providers are enabled, data: [] is returned naturally.
*/ */
function handleModels(req, res) { function handleModels(req, res) {
const createdTs = Math.floor(Date.now() / 1000); const createdTs = Math.floor(Date.now() / 1000);
const data = []; const data = [];
// Canonical entries first
for (const [providerName, provider] of loadedProviders) { for (const [providerName, provider] of loadedProviders) {
for (const modelId of provider.models) { for (const modelId of provider.models) {
data.push({ 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 }); sendJSON(res, 200, { object: 'list', data });
} }
+256 -13
View File
@@ -28,7 +28,7 @@ import {
SSE_DONE, SSE_DONE,
} from './lib/ir/ir-to-openai.mjs'; } from './lib/ir/ir-to-openai.mjs';
import { validateProvider, ProviderError, withTimeout } from './lib/providers/base.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, { import anthropic, {
irToAnthropic, irToAnthropic,
anthropicChunkToIR, anthropicChunkToIR,
@@ -5519,13 +5519,16 @@ import {
__setFallbackConfig as setFallbackConfig17, __setFallbackConfig as setFallbackConfig17,
__resetFallbackConfig as resetFallbackConfig17, __resetFallbackConfig as resetFallbackConfig17,
__clearCache as clearCache17, __clearCache as clearCache17,
createOlpServer as createServer27,
__setProvidersEnabled as setProviders27,
__resetProvidersEnabled as resetProviders27,
} from './server.mjs'; } from './server.mjs';
describe('/v1/models population + X-OLP-* error headers (Suite 17)', () => { 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 }); setProviders17({ anthropic: true });
const s = createServer17(); const s = createServer17();
const p = 25456 + Math.floor(Math.random() * 400); 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); const body = JSON.parse(r.body);
assert.equal(body.object, 'list'); assert.equal(body.object, 'list');
assert.ok(Array.isArray(body.data), 'data must be an array'); assert.ok(Array.isArray(body.data), 'data must be an array');
// Anthropic has 3 canonical models in models-registry.json // Anthropic has 3 canonical models + 4 aliases (claude, sonnet, opus, haiku) in models-registry.json
assert.equal(body.data.length, 3, `Expected 3 anthropic models, got ${body.data.length}`); assert.equal(body.data.length, 7, `Expected 7 anthropic entries (3 canonical + 4 aliases), got ${body.data.length}`);
for (const entry of body.data) { for (const entry of body.data) {
assert.equal(entry.owned_by, 'anthropic', `Expected owned_by='anthropic', got '${entry.owned_by}'`); 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 }); setProviders17({ anthropic: true });
const s = createServer17(); const s = createServer17();
const p = 25464 + Math.floor(Math.random() * 400); 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); assert.equal(r.status, 200);
const body = JSON.parse(r.body); const body = JSON.parse(r.body);
const ids = body.data.map(e => e.id); const ids = body.data.map(e => e.id);
// Aliases that must NOT appear // Canonical IDs must appear
const knownAliases = ['sonnet', 'opus', 'haiku', 'claude', 'codex', 'devstral']; assert.ok(ids.includes('claude-sonnet-4-6'), 'canonical claude-sonnet-4-6 must appear');
for (const alias of knownAliases) { assert.ok(ids.includes('claude-opus-4-7'), 'canonical claude-opus-4-7 must appear');
assert.ok(!ids.includes(alias), `Alias '${alias}' must not appear in /v1/models data`); 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 // Canonical IDs come before alias IDs (canonical-first ordering)
assert.ok(ids.includes('claude-sonnet-4-6'), 'claude-sonnet-4-6 must appear in /v1/models data'); 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 { } finally {
resetProviders17(); resetProviders17();
await new Promise(r => s.close(r)); 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));
}
});
});
}); });