Files
puter/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts
T
404oops 9b67a2b1dd Add AI response normalization controls
Introduces a new `normalize` option for chat completions, plus release-date based default normalization (post-2026-09-01) to coerce provider-native outputs into a consistent OpenAI-style shape. Adds shared normalization utilities, extensive driver/provider consistency tests, and controller safeguards that pin provider-native output where route-specific translators are used. Also wires the option through puter.js (`chat` options and `puter.ai.normalize` default), updates AI/chat response docs and examples, and resolves related TypeScript typing issues reflected in the typecheck baseline.
2026-08-28 01:08:47 +02:00

278 lines
9.4 KiB
TypeScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import type { IChatMessageResult } from '../types.js';
import {
isPostCutoffRelease,
needsOpenAICoercion,
normalizeResultToOpenAI,
} from './normalizeToOpenAI.js';
// Pure data transforms — inputs in, shapes out, no mocks needed.
const claudeResult = (
content: unknown[],
stop_reason = 'end_turn',
): IChatMessageResult => ({
message: {
id: 'msg_test',
type: 'message',
role: 'assistant',
model: 'claude-sonnet-5',
content,
stop_reason,
stop_sequence: null,
},
usage: { input_tokens: 3, output_tokens: 5 },
finish_reason: 'stop',
});
// ── isPostCutoffRelease ─────────────────────────────────────────────
describe('isPostCutoffRelease', () => {
it('is true on the cutoff date and after', () => {
expect(isPostCutoffRelease('2026-09-01')).toBe(true);
expect(isPostCutoffRelease('2027-01-15')).toBe(true);
});
it('is false before the cutoff', () => {
expect(isPostCutoffRelease('2026-08-31')).toBe(false);
expect(isPostCutoffRelease('2025-01-01')).toBe(false);
});
it('handles month-precision catalog dates', () => {
expect(isPostCutoffRelease('2026-09')).toBe(true);
expect(isPostCutoffRelease('2026-08')).toBe(false);
});
it('treats missing or unparseable dates as pre-cutoff', () => {
expect(isPostCutoffRelease(undefined)).toBe(false);
expect(isPostCutoffRelease('')).toBe(false);
expect(isPostCutoffRelease('soon')).toBe(false);
});
});
// ── needsOpenAICoercion ─────────────────────────────────────────────
describe('needsOpenAICoercion', () => {
it('flags Anthropic message envelopes and block arrays', () => {
expect(
needsOpenAICoercion({ type: 'message', content: 'x' }),
).toBe(true);
expect(
needsOpenAICoercion({
role: 'assistant',
content: [{ type: 'text', text: 'x' }],
}),
).toBe(true);
expect(needsOpenAICoercion('bare string')).toBe(true);
});
it('passes OpenAI-shaped messages through', () => {
expect(
needsOpenAICoercion({ role: 'assistant', content: 'hello' }),
).toBe(false);
expect(
needsOpenAICoercion({
role: 'assistant',
content: null,
tool_calls: [],
}),
).toBe(false);
expect(needsOpenAICoercion(undefined)).toBe(false);
expect(needsOpenAICoercion(null)).toBe(false);
});
});
// ── normalizeResultToOpenAI ─────────────────────────────────────────
describe('normalizeResultToOpenAI', () => {
it('returns an already-OpenAI-shaped result by reference', () => {
const res: IChatMessageResult = {
message: { role: 'assistant', content: 'hi', refusal: null },
usage: { input_tokens: 1, output_tokens: 1 },
finish_reason: 'stop',
};
expect(normalizeResultToOpenAI(res)).toBe(res);
});
it('leaves a responses-API-shaped message untouched', () => {
const res: IChatMessageResult = {
message: {
role: 'assistant',
content: 'text',
reasoning: null,
refusal: null,
},
usage: { input_tokens: 1, output_tokens: 1 },
finish_reason: 'stop',
};
expect(normalizeResultToOpenAI(res)).toBe(res);
});
it('leaves a string-content message with images untouched', () => {
const res: IChatMessageResult = {
message: {
role: 'assistant',
content: 'here is your image',
images: [{ type: 'image_url', image_url: { url: 'data:x' } }],
},
usage: { input_tokens: 1, output_tokens: 1 },
finish_reason: 'stop',
};
expect(normalizeResultToOpenAI(res)).toBe(res);
});
it('joins text blocks into a string content', () => {
const out = normalizeResultToOpenAI(
claudeResult([
{ type: 'text', text: 'Hello' },
{ type: 'text', text: ', world' },
]),
);
expect(out.message).toEqual({
role: 'assistant',
content: 'Hello, world',
refusal: null,
});
expect(out.finish_reason).toBe('stop');
});
it('wraps a bare-string message', () => {
const out = normalizeResultToOpenAI({
message: 'plain',
usage: { input_tokens: 1, output_tokens: 1 },
finish_reason: 'stop',
});
expect(out.message).toEqual({
role: 'assistant',
content: 'plain',
refusal: null,
});
});
it.each([
['end_turn', 'stop'],
['stop_sequence', 'stop'],
['max_tokens', 'length'],
['tool_use', 'tool_calls'],
['refusal', 'content_filter'],
])('maps stop_reason %s to finish_reason %s', (stop_reason, expected) => {
const out = normalizeResultToOpenAI(
claudeResult([{ type: 'text', text: 'x' }], stop_reason),
);
expect(out.finish_reason).toBe(expected);
});
it('keeps the existing finish_reason for unknown stop_reasons', () => {
const out = normalizeResultToOpenAI(
claudeResult([{ type: 'text', text: 'x' }], 'pause_turn'),
);
expect(out.finish_reason).toBe('stop');
});
it('converts tool_use blocks into OpenAI tool_calls with stringified arguments', () => {
const out = normalizeResultToOpenAI(
claudeResult(
[
{ type: 'text', text: 'calling' },
{
type: 'tool_use',
id: 'toolu_1',
name: 'get_weather',
input: { city: 'Paris' },
},
],
'tool_use',
),
);
expect(out.message.content).toBe('calling');
expect(out.message.tool_calls).toEqual([
{
id: 'toolu_1',
type: 'function',
function: {
name: 'get_weather',
arguments: '{"city":"Paris"}',
},
},
]);
expect(out.finish_reason).toBe('tool_calls');
});
it('uses null content for tool-only turns', () => {
const out = normalizeResultToOpenAI(
claudeResult(
[
{
type: 'tool_use',
id: 'toolu_2',
name: 'noop',
input: {},
},
],
'tool_use',
),
);
expect(out.message.content).toBeNull();
expect(out.message.tool_calls).toHaveLength(1);
});
it('joins thinking blocks into message.reasoning', () => {
const out = normalizeResultToOpenAI(
claudeResult([
{ type: 'thinking', thinking: 'step one. ', signature: 's1' },
{ type: 'thinking', thinking: 'step two.', signature: 's2' },
{ type: 'text', text: 'answer' },
]),
);
expect(out.message.reasoning).toBe('step one. step two.');
expect(out.message.content).toBe('answer');
});
it('drops redacted_thinking, compaction, and unknown blocks', () => {
const out = normalizeResultToOpenAI({
...claudeResult([
{ type: 'redacted_thinking', data: 'ENC' },
{ type: 'compaction', content: 'ENC2' },
{ type: 'server_tool_use', id: 'x', name: 'y', input: {} },
{ type: 'text', text: 'visible' },
]),
compaction: { type: 'compaction', encrypted_content: 'ENC2' },
});
expect(out.message).toEqual({
role: 'assistant',
content: 'visible',
refusal: null,
});
// The top-level compaction artifact survives coercion.
expect(out.compaction).toEqual({
type: 'compaction',
encrypted_content: 'ENC2',
});
});
it('preserves usage untouched', () => {
const res = claudeResult([{ type: 'text', text: 'x' }]);
const out = normalizeResultToOpenAI(res);
expect(out.usage).toBe(res.usage);
});
});