mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-15 01:35:37 +00:00
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.
278 lines
9.4 KiB
TypeScript
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);
|
|
});
|
|
});
|