mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user