Skip to content

Commit 32aed6f

Browse files
committed
fix(review): registry fail-loud, error-bypass parse, detail symmetry
- init: sentinel pre-check makes registerAll retry fail loud instead of piling partial state; honest no-rollback comment (throw is fatal). - wire: isErrorEnvelope string path now parses data: JSON with shared object rules + anchored event check, so assistant text mentioning error literals no longer bypasses translation. - chat-responses: image detail reuses normalizeImageDetail (original->high, symmetric with responses->chat); unnamed-custom first-wins collapse documented (Go parity, unreferenceable). - docs: single-terminal usage assumption + thinking case-insensitivity notes (both zero live impact: collector coalesces; 'none' adds no prompt line). - tests: 5 new cases (fail-loud, 12-pair, string FP, detail, Disabled). - Verification: typecheck clean, 22 suites / 347 tests green.
1 parent be972dc commit 32aed6f

7 files changed

Lines changed: 120 additions & 10 deletions

File tree

‎src/converters/chat-messages/request.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ export function resolveMessagesReasoningLevel(
7474
* Thinking -> reasoning_effort, extending anthropicThinkingToReasoningEffort
7575
* with adaptive/auto variants (Go claude_openai_request.go:57-98 core):
7676
* adaptive/auto + output_config.effort|effort passthrough, none/disabled -> none.
77+
* NOTE vs legacy: type matching is case-insensitive here (legacy was
78+
* exact-match). The only newly-accepted shape is mixed-case Disabled (which
79+
* yields 'none', and 'none' adds no prompt line per buildSystemPrompt), plus
80+
* mixed-case Adaptive/Auto (collapsed to the legacy fallback by
81+
* resolveMessagesReasoningLevel on the live leg). Lowercase spec input —
82+
* the only shape clients send — behaves exactly as legacy.
7783
*/
7884
function messagesThinkingToReasoningEffort(thinking: unknown): string | null {
7985
if (!thinking || typeof thinking !== 'object' || Array.isArray(thinking)) return null;

‎src/converters/chat-messages/response.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ export function convertMessagesResponseToChatNonStream(
117117
* Stateful chat-chunk -> Anthropic-SSE translator (one instance per stream).
118118
* Emits message_start / content_block_start(content text=0, tool_use=1..) /
119119
* content_block_delta / content_block_stop / message_delta / message_stop.
120+
* Single-terminal assumption: usage is read from the finish_reason chunk
121+
* only; a trailing usage-only chunk after completion is ignored (the gateway
122+
* collector coalesces finish + usage into one terminal chunk).
120123
* TODO(P4): thinking-delta events + signature passthrough + multi-choice.
121124
*/
122125
export function createChatToMessagesStreamTranslator(model: string, messageId?: string) {

‎src/converters/chat-responses/request.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,10 @@ function responsesToolToChatTool(tool: unknown): Record<string, unknown> | null
8585
// `custom` may be a name string or a descriptor object. Unlike
8686
// function tools (dropped when unnamed — validators reject empty
8787
// names), custom tools always fall back to 'custom_tool' because the
88-
// shape itself implies a callable.
88+
// shape itself implies a callable. NOTE: several unnamed customs
89+
// therefore share that one name and collapse via first-wins dedup
90+
// in pushTool — accepted: an unnamed declaration cannot be referenced
91+
// by any call, so nothing routable is lost.
8992
const custom = t['custom'];
9093
const customName = typeof custom === 'object' && custom !== null ? str(asRecord(custom)['name']) : str(custom);
9194
const name = str(t['name']) || customName || 'custom_tool';
@@ -431,11 +434,13 @@ export function convertChatRequestToResponses(model: string, body: unknown, stre
431434
const part = asRecord(p);
432435
if (str(part['type']) === 'image_url') {
433436
const iu = asRecord(part['image_url']);
434-
// Preserve detail (Go request.go:207-209); responses side
435-
// carries it on the input_image item itself.
437+
// Preserve detail via the shared normalizer (Go
438+
// request.go:207-209); responses side carries it on the
439+
// input_image item itself. original->high, like the
440+
// responses->chat direction.
436441
const imageItem: Record<string, unknown> = { type: 'input_image', image_url: str(iu['url']) };
437-
const detail = str(iu['detail']).toLowerCase().trim();
438-
if (detail === 'auto' || detail === 'low' || detail === 'high') imageItem['detail'] = detail;
442+
const detail = normalizeImageDetail(iu['detail']);
443+
if (detail) imageItem['detail'] = detail;
439444
parts.push(imageItem);
440445
} else {
441446
parts.push({ type: 'input_text', text: str(part['text']) });

‎src/converters/chat-responses/response.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ export interface ResponsesStreamEvent {
154154
* per-tool-call output_item.added/args-delta/done, message text delta, and
155155
* response.completed deferred until the terminal chunk carrying finish_reason
156156
* (and late usage when present).
157+
* Single-terminal assumption: usage is read from the finish_reason chunk
158+
* only; a trailing usage-only chunk after completion is ignored (the gateway
159+
* collector coalesces finish + usage into one terminal chunk).
157160
*/
158161
export function createChatToResponsesStreamTranslator(model: string, responseId?: string) {
159162
const id = responseId || newResponseId();

‎src/converters/init.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import type { TranslatorRegistry } from './registry.js';
1717
import { defaultTranslatorRegistry } from './registry.js';
18+
import { FormatOpenAI, FormatOpenAIResponse } from './formats.js';
1819
import { registerChatResponsesPair } from './chat-responses/init.js';
1920
import { registerChatMessagesPair } from './chat-messages/init.js';
2021
import { registerResponsesMessagesPair } from './responses-messages/init.js';
@@ -25,10 +26,20 @@ const initializedRegistries = new WeakSet<TranslatorRegistry>();
2526

2627
export function registerAllTranslatorPairs(registry: TranslatorRegistry = defaultTranslatorRegistry()): void {
2728
if (initializedRegistries.has(registry)) return;
28-
// NOTE: mark initialized only AFTER all four succeed — a throwing
29-
// register must not poison the cache into a half-wired (6/12) no-op.
29+
// Fail fast (before mutating) when a retry lands on an already-wired
30+
// registry: per-pair register() throws on duplicates midway, which would
31+
// otherwise add more partial state on top of a previous partial failure.
32+
// NOTE: the WeakSet mark below happens only AFTER all four succeed, so a
33+
// throwing register never poisons the cache into a half-wired no-op — but
34+
// the registry map itself may still hold that run's predecessors (each
35+
// single register() call is atomic, the batch has no rollback). Callers
36+
// must treat a throw as fatal (boot fails loud) and must not keep serving
37+
// from the half-wired registry.
3038
// Per-pair register* on a shared registry is not supported; always use
3139
// registerAll (fresh registries in tests may use per-pair directly).
40+
if (registry.hasRequestTransformer(FormatOpenAIResponse, FormatOpenAI)) {
41+
throw new Error('[translators] duplicate request transformer openai-response->openai');
42+
}
3243
registerChatResponsesPair(registry);
3344
registerChatMessagesPair(registry);
3445
registerResponsesMessagesPair(registry);

‎src/converters/wire.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,42 @@ export function ensureTranslatorsRegistered(registry: TranslatorRegistry = defau
4141
*/
4242
export function isErrorEnvelope(payload: unknown): boolean {
4343
if (typeof payload === 'string') {
44-
return payload.includes('event: error') || payload.includes('"type":"error"') || payload.includes('"type": "error"') || payload.includes('response.failed');
44+
// SSE `event:` fields are line-anchored: only a real error event
45+
// matches (assistant text merely mentioning errors must not bypass).
46+
if (/^event:\s*error(\s|$)/m.test(payload)) return true;
47+
// Inspect JSON data payloads with the same object rules below, so a
48+
// valid delta whose text contains error literals still translates.
49+
const tryRecord = (text: string): boolean => {
50+
try {
51+
const parsed: unknown = JSON.parse(text);
52+
return !!parsed && typeof parsed === 'object' && !Array.isArray(parsed) && isErrorRecord(asRecord(parsed));
53+
} catch {
54+
return false;
55+
}
56+
};
57+
for (const line of payload.split('\n')) {
58+
const trimmed = line.trim();
59+
if (!trimmed.startsWith('data:')) continue;
60+
const jsonText = trimmed.slice(5).trim();
61+
if (!jsonText || jsonText === '[DONE]') continue;
62+
if (tryRecord(jsonText)) return true;
63+
}
64+
// Bare JSON string without an SSE envelope.
65+
const trimmed = payload.trim();
66+
if (trimmed.startsWith('{') && tryRecord(trimmed)) return true;
67+
return false;
4568
}
4669
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
47-
const r = asRecord(payload);
70+
return isErrorRecord(asRecord(payload));
71+
}
72+
73+
/**
74+
* Object-shape error check shared by the object and string (parsed SSE)
75+
* paths: wrapped {error}, error/response.failed type or event, and the bare
76+
* TransformedUpstreamErrorBody ({message, type} with none of the
77+
* valid-payload markers choices/output/content/data).
78+
*/
79+
function isErrorRecord(r: Record<string, unknown>): boolean {
4880
// Wrapped envelopes: {error: {...}} / {error: 'msg'}. Falsy sentinels
4981
// (''/0/false) are not error envelopes.
5082
const wrapped = r['error'];

‎tests/translator-phase-fixes.test.js‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { TranslatorRegistry } from '../src/converters/registry.js';
22
import { TranslatorPipeline } from '../src/converters/pipeline.js';
3+
import { registerAllTranslatorPairs } from '../src/converters/init.js';
4+
import { FormatOpenAI, FormatOpenAIResponse } from '../src/converters/formats.js';
5+
import { isErrorEnvelope } from '../src/converters/wire.js';
36
import { makeId, normalizeArgs, num, asArray, str } from '../src/converters/json.js';
47
import {
58
usageToChat,
@@ -11,7 +14,8 @@ import {
1114
convertChatResponseToResponsesNonStream,
1215
convertResponsesResponseToChatNonStream,
1316
} from '../src/converters/chat-responses/response.js';
14-
import { convertChatRequestToMessages } from '../src/converters/chat-messages/request.js';
17+
import { convertChatRequestToMessages, convertMessagesRequestToChat } from '../src/converters/chat-messages/request.js';
18+
import { convertChatRequestToResponses } from '../src/converters/chat-responses/request.js';
1519
import {
1620
convertMessagesRequestToResponses,
1721
convertResponsesRequestToMessages,
@@ -166,3 +170,49 @@ describe('Phase4 text-core downgrade pinned (P0)', () => {
166170
expect(deltas.some((e) => e.type === 'response.output_text.delta')).toBe(true);
167171
});
168172
});
173+
174+
describe('PR review follow-ups', () => {
175+
test('registerAll retry on a wired registry fails loud (no silent half-wire)', () => {
176+
const r = new TranslatorRegistry();
177+
r.register(FormatOpenAIResponse, FormatOpenAI, (model, body) => ({ ...body, model }), {
178+
nonStream: (m, _o, _t, b) => b,
179+
});
180+
expect(() => registerAllTranslatorPairs(r)).toThrow(/duplicate/);
181+
});
182+
183+
test('registerAll on a fresh registry wires all 12 pairs', () => {
184+
const r = new TranslatorRegistry();
185+
expect(() => registerAllTranslatorPairs(r)).not.toThrow();
186+
expect(r.size()).toEqual({ requests: 12, responses: 12 });
187+
});
188+
189+
test('isErrorEnvelope string path: error markers match, assistant text does not', () => {
190+
expect(isErrorEnvelope('event: error\ndata: {"type":"error"}')).toBe(true);
191+
expect(isErrorEnvelope('data: {"type":"response.failed"}')).toBe(true);
192+
expect(isErrorEnvelope('{"type":"error","error":{"message":"x"}}')).toBe(true);
193+
expect(isErrorEnvelope('data: {"type":"response.output_text.delta"}')).toBe(false);
194+
// Valid delta whose text mentions error literals must still translate.
195+
expect(isErrorEnvelope('data: {"choices":[{"delta":{"content":"she explained \\"type\\":\\"error\\" handling"}}]}')).toBe(false);
196+
expect(isErrorEnvelope('data: [DONE]')).toBe(false);
197+
});
198+
199+
test('chat->responses image detail original normalizes to high (symmetric)', () => {
200+
const out = convertChatRequestToResponses('m', {
201+
messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:x', detail: 'original' } }] }],
202+
}, false);
203+
expect(out.input[0].content).toEqual([{ type: 'input_image', image_url: 'data:x', detail: 'high' }]);
204+
const missing = convertChatRequestToResponses('m', {
205+
messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:x' } }] }],
206+
}, false);
207+
expect(missing.input[0].content).toEqual([{ type: 'input_image', image_url: 'data:x' }]);
208+
});
209+
210+
test('thinking type matching is case-insensitive (Disabled -> none)', () => {
211+
const out = convertMessagesRequestToChat('m', {
212+
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
213+
thinking: { type: 'Disabled' },
214+
}, false);
215+
// 'none' adds no prompt line downstream; legacy exact-match gave null.
216+
expect(out.reasoning_effort).toBe('none');
217+
});
218+
});

0 commit comments

Comments
 (0)