From 6b4f5b01f1beb7edefb1b22280602213b3585438 Mon Sep 17 00:00:00 2001 From: 404oops Date: Thu, 27 Aug 2026 00:08:02 +0200 Subject: [PATCH] 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. --- .../puterai/PuterAIController.test.ts | 6 + .../controllers/puterai/PuterAIController.ts | 10 + src/backend/core/context.ts | 5 + .../ai-chat/ChatCompletionDriver.test.ts | 286 ++++++++ .../drivers/ai-chat/ChatCompletionDriver.ts | 48 +- .../drivers/ai-chat/providers/ChatProvider.ts | 3 + .../ai-chat/providers/FakeChatProvider.ts | 11 +- .../providers/azure/AzureChatProvider.ts | 2 +- .../providers/azure/AzureResponsesProvider.ts | 4 +- .../providers/claude/ClaudeProvider.ts | 2 +- .../ai-chat/providers/groq/GroqAIProvider.ts | 2 +- .../providers/mistral/MistralAiProvider.ts | 75 ++- .../moonshot/MoonshotProvider.test.ts | 3 +- .../providers/ollama/OllamaProvider.ts | 8 +- .../openai/OpenAiChatCompletionsProvider.ts | 2 +- .../openai/OpenAiChatResponsesProvider.ts | 6 +- .../openrouter/OpenRouterProvider.ts | 5 +- .../providers/providerConsistency.test.ts | 632 ++++++++++++++++++ .../providers/together/TogetherAIProvider.ts | 2 +- src/backend/drivers/ai-chat/types.ts | 16 +- .../drivers/ai-chat/utils/OpenAIUtil.js | 25 +- .../drivers/ai-chat/utils/compaction.js | 23 +- .../ai-chat/utils/modelRouting.test.ts | 1 + .../ai-chat/utils/normalizeToOpenAI.test.ts | 277 ++++++++ .../ai-chat/utils/normalizeToOpenAI.ts | 183 +++++ src/backend/drivers/ai-ocr/OCRDriver.ts | 6 +- .../ai-speech2speech/VoiceChangerDriver.ts | 3 +- .../openai/OpenAISpeechToTextProvider.ts | 4 +- .../providers/xai/XAISpeechToTextProvider.ts | 6 +- src/backend/drivers/ai-tts/TTSDriver.ts | 18 +- src/docs/src/AI/chat.md | 38 +- src/docs/src/Objects/chatresponse.md | 16 +- .../examples/ai-claude-cache-control.html | 4 +- src/puter-js/src/modules/ai/ai.test.js | 41 ++ src/puter-js/src/modules/ai/chat.js | 11 + src/puter-js/src/modules/ai/index.js | 12 + src/puter-js/src/modules/ai/types.js | 9 + src/puter-js/tests/api/suites/ai.suite.ts | 24 + tools/typecheck-baseline.json | 31 - 39 files changed, 1756 insertions(+), 104 deletions(-) create mode 100644 src/backend/drivers/ai-chat/providers/providerConsistency.test.ts create mode 100644 src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts create mode 100644 src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts diff --git a/src/backend/controllers/puterai/PuterAIController.test.ts b/src/backend/controllers/puterai/PuterAIController.test.ts index 321a33e5c..d87445416 100644 --- a/src/backend/controllers/puterai/PuterAIController.test.ts +++ b/src/backend/controllers/puterai/PuterAIController.test.ts @@ -369,6 +369,9 @@ describe('PuterAIController.openaiChatCompletions', () => { ]); expect(completeArgs.stream).toBe(false); expect(completeArgs.provider).toBe('openai-completion'); + // Wire routes translate shapes themselves; the driver is pinned + // provider-native so the release-date cutoff can't change them. + expect(completeArgs.normalize).toBe(false); // Response shape matches OpenAI's /v1/chat/completions wire format. const body = captured.body as Record; @@ -532,6 +535,7 @@ describe('PuterAIController.openaiCompletions', () => { expect(completeArgs.messages).toEqual([ { role: 'user', content: 'hello there' }, ]); + expect(completeArgs.normalize).toBe(false); const body = captured.body as Record; expect(body.object).toBe('text_completion'); @@ -595,6 +599,7 @@ describe('PuterAIController.openaiResponses', () => { role: 'system', content: 'be brief', }); + expect(completeArgs.normalize).toBe(false); // `input` becomes a user message after the system one. expect(completeArgs.messages[1]).toEqual({ role: 'user', @@ -662,6 +667,7 @@ describe('PuterAIController.anthropicMessages', () => { content: 'be helpful', }); expect(completeArgs.provider).toBe('claude'); + expect(completeArgs.normalize).toBe(false); const body = captured.body as Record; expect(body.type).toBe('message'); diff --git a/src/backend/controllers/puterai/PuterAIController.ts b/src/backend/controllers/puterai/PuterAIController.ts index 87d8a541b..f338a7271 100644 --- a/src/backend/controllers/puterai/PuterAIController.ts +++ b/src/backend/controllers/puterai/PuterAIController.ts @@ -332,6 +332,10 @@ export class PuterAIController extends PuterController { messages: body.messages, model: toStringOrEmpty(body.model), stream, + // This route does its own wire translation; pin the driver to the + // provider-native shape so the release-date cutoff can't change + // what the translators below receive. + normalize: false, ...(body.tools ? { tools: body.tools as unknown[] } : {}), ...(body.temperature !== undefined ? { temperature: Number(body.temperature) } @@ -491,6 +495,8 @@ export class PuterAIController extends PuterController { messages, model: toStringOrEmpty(body.model), stream, + // Pinned provider-native — this route translates the shape itself. + normalize: false, ...(body.temperature !== undefined ? { temperature: Number(body.temperature) } : {}), @@ -630,6 +636,8 @@ export class PuterAIController extends PuterController { messages, model: toStringOrEmpty(body.model), stream, + // Pinned provider-native — this route translates the shape itself. + normalize: false, ...(body.tools ? { tools: body.tools as unknown[] } : {}), ...(body.tool_choice ? { tool_choice: body.tool_choice } : {}), ...(body.parallel_tool_calls !== undefined @@ -970,6 +978,8 @@ export class PuterAIController extends PuterController { messages: normalizedMessages, model: toStringOrEmpty(body.model), stream, + // Pinned provider-native — this route translates the shape itself. + normalize: false, ...(tools ? { tools } : {}), ...(body.temperature !== undefined ? { temperature: Number(body.temperature) } diff --git a/src/backend/core/context.ts b/src/backend/core/context.ts index e1941fde3..d775190dc 100644 --- a/src/backend/core/context.ts +++ b/src/backend/core/context.ts @@ -56,6 +56,11 @@ export interface KnownContextFields { req: Request; /** A unique id for this request — useful for structured logging / tracing. */ requestId: string; + /** + * The driver name the caller addressed (set by DriverController for + * `/drivers/call` dispatch); drivers read it to pick a provider. + */ + driverName: string; } // -- Context store --------------------------------------------------- diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts index 8975d3907..af97792fa 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts @@ -823,6 +823,292 @@ describe('ChatCompletionDriver.complete normalization', () => { }); }); +// ── OpenAI-shape normalization ────────────────────────────────────── + +describe('ChatCompletionDriver.complete OpenAI-shape normalization', () => { + // An Anthropic-native provider result, as ClaudeProvider returns it. + const claudeShaped = (stop_reason = 'end_turn') => + ({ + message: { + id: 'msg_1', + type: 'message', + role: 'assistant', + model: 'post-cutoff', + content: [{ type: 'text', text: 'hi there' }], + stop_reason, + stop_sequence: null, + }, + usage: { input_tokens: 1, output_tokens: 2 }, + finish_reason: 'stop', + }) as never; + + const zeroCost = { + costs_currency: 'usd-cents', + costs: { 'input-tokens': 0, 'output-tokens': 0 }, + max_tokens: 8192, + }; + + // Driver whose catalog carries a model on each side of the cutoff. + const makeCutoffDriver = async () => { + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ + { id: 'post-cutoff', release_date: '2026-09-01', ...zeroCost }, + { id: 'pre-cutoff', release_date: '2026-08-31', ...zeroCost }, + ] as never); + return await makeDriver(); + }; + + type NormalizedResult = { + message: { + role: string; + content: unknown; + tool_calls?: unknown[]; + }; + finish_reason: string; + normalized?: boolean; + via_ai_chat_service: boolean; + }; + + it('coerces to the OpenAI shape when `normalize: true`, on any model', async () => { + vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce( + claudeShaped('max_tokens'), + ); + + const res = (await withTestActor(() => + driver.complete({ + model: 'fake', // date-less — only the flag triggers coercion + messages: [{ role: 'user', content: 'hi' }], + normalize: true, + }), + )) as NormalizedResult; + + expect(res.normalized).toBe(true); + expect(res.via_ai_chat_service).toBe(true); + expect(res.message).toEqual({ + role: 'assistant', + content: 'hi there', + refusal: null, + }); + expect(res.finish_reason).toBe('length'); + }); + + it('coerces by default for a model released on/after the cutoff', async () => { + const d = await makeCutoffDriver(); + vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce( + claudeShaped(), + ); + + const res = (await withTestActor(() => + d.complete({ + model: 'post-cutoff', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as NormalizedResult; + + expect(res.normalized).toBe(true); + expect(res.message.content).toBe('hi there'); + expect(res.finish_reason).toBe('stop'); + }); + + it('leaves a pre-cutoff model provider-native by default', async () => { + const d = await makeCutoffDriver(); + vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce( + claudeShaped(), + ); + + const res = (await withTestActor(() => + d.complete({ + model: 'pre-cutoff', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as NormalizedResult; + + expect(res.normalized).toBeUndefined(); + expect(res.message.content).toEqual([ + { type: 'text', text: 'hi there' }, + ]); + expect(res.finish_reason).toBe('stop'); + }); + + it('leaves a date-less model provider-native by default', async () => { + const res = (await withTestActor(() => + driver.complete({ + model: 'fake', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as NormalizedResult; + + expect(res.normalized).toBeUndefined(); + expect(Array.isArray(res.message.content)).toBe(true); + }); + + it('`normalize: false` forces provider-native on a post-cutoff model', async () => { + const d = await makeCutoffDriver(); + vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce( + claudeShaped(), + ); + + const res = (await withTestActor(() => + d.complete({ + model: 'post-cutoff', + messages: [{ role: 'user', content: 'hi' }], + normalize: false, + }), + )) as NormalizedResult; + + expect(res.normalized).toBeUndefined(); + expect(res.message.content).toEqual([ + { type: 'text', text: 'hi there' }, + ]); + }); + + it('`normalize: true` beats the legacy `response.normalize` flag', async () => { + vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce( + claudeShaped(), + ); + + const res = (await withTestActor(() => + driver.complete({ + model: 'fake', + messages: [{ role: 'user', content: 'hi' }], + normalize: true, + response: { normalize: true }, + }), + )) as NormalizedResult; + + // OpenAI shape, not the legacy block shape. + expect(res.normalized).toBe(true); + expect(res.message.content).toBe('hi there'); + }); + + it('`normalize: false` beats both the legacy flag and the cutoff', async () => { + const d = await makeCutoffDriver(); + vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce( + claudeShaped(), + ); + + const res = (await withTestActor(() => + d.complete({ + model: 'post-cutoff', + messages: [{ role: 'user', content: 'hi' }], + normalize: false, + response: { normalize: true }, + }), + )) as NormalizedResult; + + expect(res.normalized).toBeUndefined(); + expect(res.message.content).toEqual([ + { type: 'text', text: 'hi there' }, + ]); + }); + + it('the legacy `response.normalize` still wins over the cutoff when `normalize` is unset', async () => { + const d = await makeCutoffDriver(); + vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce( + claudeShaped(), + ); + + const res = (await withTestActor(() => + d.complete({ + model: 'post-cutoff', + messages: [{ role: 'user', content: 'hi' }], + response: { normalize: true }, + }), + )) as NormalizedResult; + + // Legacy block shape, not the OpenAI string shape. + expect(res.normalized).toBe(true); + expect(res.message.content).toEqual([ + { type: 'text', text: 'hi there' }, + ]); + }); + + it('converts tool_use blocks into OpenAI tool_calls when coercing', async () => { + vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({ + message: { + type: 'message', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_9', + name: 'lookup', + input: { q: 'x' }, + }, + ], + stop_reason: 'tool_use', + }, + usage: { input_tokens: 1, output_tokens: 1 }, + finish_reason: 'stop', + } as never); + + const res = (await withTestActor(() => + driver.complete({ + model: 'fake', + messages: [{ role: 'user', content: 'hi' }], + normalize: true, + }), + )) as NormalizedResult; + + expect(res.message.content).toBeNull(); + expect(res.message.tool_calls).toEqual([ + { + id: 'toolu_9', + type: 'function', + function: { name: 'lookup', arguments: '{"q":"x"}' }, + }, + ]); + expect(res.finish_reason).toBe('tool_calls'); + }); + + it('does not touch streaming results', async () => { + const res = await withTestActor(() => + driver.complete({ + model: 'fake', + messages: [{ role: 'user', content: 'hi' }], + stream: true, + normalize: true, + }), + ); + + expect(res).toMatchObject({ + dataType: 'stream', + content_type: 'application/x-ndjson', + }); + // Drain so the fake provider's populator finishes cleanly. + await collectStream( + (res as unknown as { stream: Readable }).stream, + ); + }); + + it('a blocked prompt rerouted to fake-chat keeps its historical native shape', async () => { + // Catalog with a post-cutoff model plus the `fake` reroute target + // (mocking `models` replaces the whole catalog). + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ + { id: 'post-cutoff', release_date: '2026-09-01', ...zeroCost }, + { id: 'fake', aliases: [], ...zeroCost }, + ] as never); + const d = await makeDriver(); + vi.spyOn(server.clients.event, 'emitAndWait').mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (key, data: any) => { + if (key === 'ai.prompt.validate') data.allow = false; + }, + ); + + const res = (await withTestActor(() => + d.complete({ + // The user asked for a post-cutoff model, but the reroute + // lands on the date-less `fake` model — no coercion. + model: 'post-cutoff', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as NormalizedResult; + + expect(res.normalized).toBeUndefined(); + expect(Array.isArray(res.message.content)).toBe(true); + }); +}); + // ── Fallback / error envelope ─────────────────────────────────────── describe('ChatCompletionDriver.complete fallback and error envelope', () => { diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 5e4d98d17..92ba9cc37 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -57,6 +57,7 @@ import { XAIProvider } from './providers/xai/XAIProvider.js'; import { ZAIProvider } from './providers/zai/ZAIProvider.js'; import type { IChatCompleteResult, + IChatMessageResult, IChatModel, IChatProvider, ICompleteArguments, @@ -71,6 +72,10 @@ import { isIdentityKey, normalizeModelKey, } from './utils/modelRouting.js'; +import { + isPostCutoffRelease, + normalizeResultToOpenAI, +} from './utils/normalizeToOpenAI.js'; import { costKeys, isFreeModel } from './utils/pricing.js'; import { isRouteUnhealthy, @@ -671,16 +676,43 @@ export class ChatCompletionDriver extends PuterDriver { providerUsed: model.id, }); - if (args.response?.normalize && 'message' in res && res.message) { - return { - ...res, - message: normalize_single_message(res.message), - normalized: true, - via_ai_chat_service: true, - }; + // Response-format precedence: an explicit per-call `normalize` wins in + // both directions; the legacy `response.normalize` (internal + // block-format normalization) applies only when the new flag is + // absent; otherwise the release-date cutoff decides. The coercer is + // idempotent, so already-OpenAI-shaped results (most providers, or a + // Claude model served through a reseller fallback) pass through. + if ('message' in res && res.message) { + // `'message' in res` doesn't narrow the result union for TS. + const messageRes = res as IChatMessageResult; + if (args.normalize === true) { + return { + ...normalizeResultToOpenAI(messageRes), + normalized: true, + via_ai_chat_service: true, + }; + } + if (args.normalize !== false) { + if (args.response?.normalize) { + return { + ...messageRes, + message: normalize_single_message(messageRes.message), + normalized: true, + via_ai_chat_service: true, + }; + } + if (isPostCutoffRelease(model.release_date)) { + return { + ...normalizeResultToOpenAI(messageRes), + normalized: true, + via_ai_chat_service: true, + }; + } + } } - return { ...res, via_ai_chat_service: true }; + // Streaming results returned above; only message results reach here. + return { ...(res as IChatMessageResult), via_ai_chat_service: true }; } // Compute per-token cost in microcents (1 cent = 1_000_000 microCents). diff --git a/src/backend/drivers/ai-chat/providers/ChatProvider.ts b/src/backend/drivers/ai-chat/providers/ChatProvider.ts index 9c83dee28..9f24cab55 100644 --- a/src/backend/drivers/ai-chat/providers/ChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/ChatProvider.ts @@ -41,4 +41,7 @@ export class ChatProvider implements IChatProvider { async complete(_arg: ICompleteArguments): Promise { throw new Error('Method not implemented.'); } + checkModeration(_text: string): void { + // No moderation by default; providers override when they support it. + } } diff --git a/src/backend/drivers/ai-chat/providers/FakeChatProvider.ts b/src/backend/drivers/ai-chat/providers/FakeChatProvider.ts index 2f94debb5..05d268f43 100644 --- a/src/backend/drivers/ai-chat/providers/FakeChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/FakeChatProvider.ts @@ -20,7 +20,12 @@ import dedent from 'dedent'; import { LoremIpsum } from 'lorem-ipsum'; import { AIChatStream } from '../utils/Streaming.js'; -import { IChatProvider, ICompleteArguments, PuterMessage } from '../types.js'; +import { + IChatModel, + IChatProvider, + ICompleteArguments, + PuterMessage, +} from '../types.js'; export class FakeChatProvider implements IChatProvider { checkModeration(_text: string) { @@ -31,7 +36,9 @@ export class FakeChatProvider implements IChatProvider { return 'fake'; } - async models() { + // Annotated (rather than inferred) so test mocks of this method accept + // any IChatModel field, not just the ones the fake catalog happens to use. + async models(): Promise { return [ { id: 'fake', diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts index 811401869..12e860686 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts @@ -226,7 +226,7 @@ export class AzureChatProvider implements IChatProvider { ? { verbosity: requestedVerbosity } : {}), }), - } as ChatCompletionCreateParams; + } as unknown as ChatCompletionCreateParams; const completion = await this.#openAi.chat.completions.create(completionParams); diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts index d6d3c0f65..9e49330ad 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts @@ -156,7 +156,7 @@ export class AzureResponsesProvider implements IChatProvider { if (tools) { // Unravel tools to OpenAI Responses API format // eslint-disable-next-line @typescript-eslint/no-explicit-any - tools = (tools as any).map((e) => { + tools = (tools as any[]).map((e) => { if (e.type === 'function') { const tool = e.function; tool.type = 'function'; @@ -232,7 +232,7 @@ export class AzureResponsesProvider implements IChatProvider { : {}), }), ...(supportsReasoningControls && reasoning ? { reasoning } : {}), - } as ResponseCreateParams; + } as unknown as ResponseCreateParams; const completion = await this.#openAi.responses.create(completionParams); diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts index e1a2ada3b..2f01c7968 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts @@ -501,7 +501,7 @@ export class ClaudeProvider implements IChatProvider { } const finalMessage = await completion .finalMessage() - .catch(() => null); + .catch((): null => null); if (finalMessage) { const finalUsage = this.#usageFormatterUtil( finalMessage.usage as Usage | BetaUsage, diff --git a/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts b/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts index 4710f6da6..3783df61c 100644 --- a/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts @@ -84,7 +84,7 @@ export class GroqAIProvider implements IChatProvider { return OpenAIUtil.handle_completion_output({ deviations: { - index_usage_from_stream_chunk: (chunk) => + index_usage_from_stream_chunk: (chunk: unknown) => // x_groq contains usage details for streamed responses (chunk as { x_groq?: { usage?: CompletionUsage } }).x_groq ?.usage, diff --git a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts index 19e7fb471..756e5833e 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts @@ -30,6 +30,15 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { MISTRAL_MODELS } from './models.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +// Mistral's finish reasons mapped to the OpenAI vocabulary; values without +// an OpenAI analog (e.g. `error`) pass through unmapped. +const MISTRAL_FINISH_REASON_MAP: Record = { + stop: 'stop', + length: 'length', + model_length: 'length', + tool_calls: 'tool_calls', +}; + export class MistralAIProvider implements IChatProvider { #client: Mistral; @@ -123,24 +132,78 @@ export class MistralAIProvider implements IChatProvider { temperature, }); + // The Mistral SDK speaks camelCase (`finishReason`, `toolCalls`, + // object-typed `arguments`); remap each choice to the OpenAI wire + // shape so the result matches every other provider's. + if (!stream) { + const choices = + (completion as ChatCompletionResponse).choices ?? []; + for (const choice of choices as unknown as Record< + string, + unknown + >[]) { + if ( + choice.finish_reason === undefined && + typeof choice.finishReason === 'string' + ) { + choice.finish_reason = + MISTRAL_FINISH_REASON_MAP[choice.finishReason] ?? + choice.finishReason; + } + delete choice.finishReason; + const message = choice.message as + | (Record & { + toolCalls?: { + id?: string; + function?: { name?: string; arguments?: unknown }; + }[]; + }) + | undefined; + if ( + message && + message.tool_calls === undefined && + Array.isArray(message.toolCalls) + ) { + message.tool_calls = message.toolCalls.map((tc) => ({ + id: tc.id, + type: 'function', + function: { + name: tc.function?.name, + arguments: + typeof tc.function?.arguments === 'string' + ? tc.function.arguments + : JSON.stringify( + tc.function?.arguments ?? {}, + ), + }, + })); + } + if (message) delete message.toolCalls; + } + } + return await OpenAIUtil.handle_completion_output({ deviations: { - index_usage_from_stream_chunk: (chunk) => { + index_usage_from_stream_chunk: (chunk: { + usage?: Record; + }) => { if (!chunk.usage) return; - const snake_usage = {}; + const snake_usage: Record = {}; for (const key in chunk.usage) { const snakeKey = key .replace(/([A-Z])/g, '_$1') .toLowerCase(); - snake_usage[snakeKey] = chunk.usage[key]; + snake_usage[snakeKey] = chunk.usage[key]!; } return snake_usage; }, - chunk_but_like_actually: (chunk) => (chunk as any).data, - index_tool_calls_from_stream_choice: (choice) => - (choice.delta as any).toolCalls, + chunk_but_like_actually: (chunk: unknown) => + (chunk as any).data, + index_tool_calls_from_stream_choice: (choice: { + delta?: unknown; + }) => (choice.delta as any).toolCalls, coerce_completion_usage: ( completion: ChatCompletionResponse, ) => ({ diff --git a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts index a4a9ae120..844698218 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts @@ -81,7 +81,8 @@ vi.mock('openai', () => { // ── imageHandling stub ────────────────────────────────────────────── const { inlineHttpImageUrlsMock } = vi.hoisted(() => ({ - inlineHttpImageUrlsMock: vi.fn(async () => {}), + // Declared with the real function's arity so `mock.calls[0][0]` is typed. + inlineHttpImageUrlsMock: vi.fn(async (_messages: unknown) => {}), })); vi.mock('./imageHandling.js', () => ({ diff --git a/src/backend/drivers/ai-chat/providers/ollama/OllamaProvider.ts b/src/backend/drivers/ai-chat/providers/ollama/OllamaProvider.ts index 6332bfa7d..4a2affda0 100644 --- a/src/backend/drivers/ai-chat/providers/ollama/OllamaProvider.ts +++ b/src/backend/drivers/ai-chat/providers/ollama/OllamaProvider.ts @@ -26,10 +26,11 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { IChatModel, IChatProvider, ICompleteArguments } from '../../types.js'; import { ChatCompletionCreateParams } from 'openai/resources/index.js'; /** - * OllamaService class - Provides integration with Ollama's API for chat completions - * Extends BaseService to implement the puter-chat-completion interface. - * Handles model management, message adaptation, streaming responses, + * OllamaService class - Provides integration with Ollama's API for chat + * completions Extends BaseService to implement the puter-chat-completion + * interface. Handles model management, message adaptation, streaming responses, * and usage tracking for Ollama's language models. + * * @extends BaseService */ export class OllamaChatProvider implements IChatProvider { @@ -182,6 +183,7 @@ export class OllamaChatProvider implements IChatProvider { /** * Returns the default model identifier for the Ollama service + * * @returns {string} The default model ID 'gpt-oss:20b' */ getDefaultModel() { diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts index 315042e80..652a064ac 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts @@ -206,7 +206,7 @@ export class OpenAiChatProvider implements IChatProvider { ? { verbosity: requestedVerbosity } : {}), }), - } as ChatCompletionCreateParams; + } as unknown as ChatCompletionCreateParams; const completion = await this.#openAi.chat.completions.create(completionParams); diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts index 7df7910bf..4993cc7a3 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts @@ -70,7 +70,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider { * Each model object includes an ID and cost details (currency, tokens, * input/output rates). */ - models(extra_params) { + models(extra_params?: { no_restrictions?: boolean }) { if (extra_params?.no_restrictions) { return OPEN_AI_MODELS; } @@ -152,7 +152,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider { if (tools) { // Unravel tools to OpenAI Responses API format - tools = (tools as any).map((e) => { + tools = (tools as any[]).map((e) => { if (e.type === 'function') { const tool = e.function; tool.type = 'function'; @@ -228,7 +228,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider { : {}), }), ...(supportsReasoningControls && reasoning ? { reasoning } : {}), - } as ResponseCreateParams; + } as unknown as ResponseCreateParams; // console.log("completion params: ", completionParams) const completion = diff --git a/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts b/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts index 74a254418..c673cb600 100644 --- a/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts @@ -29,6 +29,7 @@ import type { IChatModel, IChatProvider, IChatCompleteResult, + ICompleteArguments, } from '../../types.js'; import { OPEN_ROUTER_MODEL_OVERRIDES } from './modelOverrides.js'; @@ -95,7 +96,7 @@ export class OpenRouterProvider implements IChatProvider { tools, max_tokens, temperature, - }): Promise { + }: ICompleteArguments): Promise { const modelUsed = (await this.models()).find((m) => [m.id, ...(m.aliases || [])].includes(model), @@ -201,7 +202,7 @@ export class OpenRouterProvider implements IChatProvider { return trackedUsage; } else { // custom open router logic because they're pricing are weird - const trackedUsage = { + const trackedUsage: Record = { prompt: (usage.prompt_tokens ?? 0) - (usage.prompt_tokens_details?.cached_tokens ?? 0), diff --git a/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts b/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts new file mode 100644 index 000000000..d4f266579 --- /dev/null +++ b/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts @@ -0,0 +1,632 @@ +/* + * 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 . + */ + +/** + * Cross-provider output-consistency contract. + * + * Every chat provider is driven through its real `complete()` against a + * mocked upstream, and the result — after the same `normalizeResultToOpenAI` + * pass the driver applies — must be shaped identically regardless of which + * vendor served it: + * + * - `message.role === 'assistant'`, `message.content` string (or null for + * tool-only turns), OpenAI-shaped `message.tool_calls` + * - `finish_reason` from the OpenAI vocabulary (`stop`, `length`, + * `tool_calls`, `content_filter`) + * - reasoning exposed as a `reasoning` string, never `reasoning_content` + * - no camelCase wire leftovers (`toolCalls`, `finishReason`) + * - `usage` is an object of numbers (key names are metering-specific and + * intentionally NOT part of this contract) + * + * A provider that forwards its vendor's dialect unconverted fails here. + */ + +import { describe, expect, it, vi } from 'vitest'; + +import type { MeteringService } from '../../../services/metering/MeteringService.js'; +import { withTestActor } from '../../integrationTestUtil.js'; +import type { + IChatMessageResult, + IChatModel, + IChatProvider, +} from '../types.js'; +import { + needsOpenAICoercion, + normalizeResultToOpenAI, +} from '../utils/normalizeToOpenAI.js'; + +import { AlibabaProvider } from './alibaba/AlibabaProvider.js'; +import { AzureChatProvider } from './azure/AzureChatProvider.js'; +import { AzureResponsesProvider } from './azure/AzureResponsesProvider.js'; +import { BytePlusProvider } from './byteplus/BytePlusProvider.js'; +import { ClaudeProvider } from './claude/ClaudeProvider.js'; +import { DeepSeekProvider } from './deepseek/DeepSeekProvider.js'; +import { FakeChatProvider } from './FakeChatProvider.js'; +import { GeminiChatProvider } from './gemini/GeminiChatProvider.js'; +import { GroqAIProvider } from './groq/GroqAIProvider.js'; +import { InfronProvider } from './infron/InfronProvider.js'; +import { MetaProvider } from './meta/MetaProvider.js'; +import { MiniMaxProvider } from './minimax/MiniMaxProvider.js'; +import { MistralAIProvider } from './mistral/MistralAiProvider.js'; +import { MoonshotProvider } from './moonshot/MoonshotProvider.js'; +import { NeuralwattProvider } from './neuralwatt/NeuralwattProvider.js'; +import { OllamaChatProvider } from './ollama/OllamaProvider.js'; +import { OpenAiChatProvider } from './openai/OpenAiChatCompletionsProvider.js'; +import { OpenAiResponsesChatProvider } from './openai/OpenAiChatResponsesProvider.js'; +import { OpenRouterProvider } from './openrouter/OpenRouterProvider.js'; +import { TogetherAIProvider } from './together/TogetherAIProvider.js'; +import { XAIProvider } from './xai/XAIProvider.js'; +import { ZAIProvider } from './zai/ZAIProvider.js'; + +// ── Upstream SDK mocks ────────────────────────────────────────────── +// One create-mock per wire dialect; every provider speaking that dialect +// shares it, which is the point: same upstream bytes in, same Puter shape out. + +const { + chatCreateMock, + responsesCreateMock, + mistralCompleteMock, + anthropicCreateMock, +} = vi.hoisted(() => ({ + chatCreateMock: vi.fn(), + responsesCreateMock: vi.fn(), + mistralCompleteMock: vi.fn(), + anthropicCreateMock: vi.fn(), +})); + +vi.mock('openai', () => { + const OpenAICtor = vi.fn().mockImplementation(function ( + this: Record, + ) { + this.chat = { completions: { create: chatCreateMock } }; + this.responses = { create: responsesCreateMock }; + }); + return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } }; +}); + +vi.mock('groq-sdk', () => ({ + default: vi.fn().mockImplementation(function ( + this: Record, + ) { + this.chat = { completions: { create: chatCreateMock } }; + }), +})); + +vi.mock('together-ai', () => ({ + Together: vi.fn().mockImplementation(function ( + this: Record, + ) { + this.chat = { completions: { create: chatCreateMock } }; + }), +})); + +vi.mock('@mistralai/mistralai', () => ({ + Mistral: vi.fn().mockImplementation(function ( + this: Record, + ) { + this.chat = { complete: mistralCompleteMock, stream: vi.fn() }; + }), +})); + +vi.mock('@anthropic-ai/sdk', () => { + const AnthropicCtor = vi.fn().mockImplementation(function ( + this: Record, + ) { + this.messages = { create: anthropicCreateMock, stream: vi.fn() }; + this.beta = { + messages: { create: anthropicCreateMock, stream: vi.fn() }, + files: { delete: vi.fn() }, + }; + }); + return { default: AnthropicCtor, Anthropic: AnthropicCtor }; +}); + +// ── Harness ───────────────────────────────────────────────────────── + +const TEXT = 'Hello from the model.'; +const REASONING = 'Chain of thought summary.'; +const TOOL_ARGS = '{"city":"Paris"}'; + +const metering = () => + ({ utilRecordUsageObject: vi.fn() }) as unknown as MeteringService; +const stores = { fsEntry: {}, s3Object: {} } as never; +const fsService = {} as never; + +// Superset of every cost key any provider's usage calculator multiplies by, +// so the canonical catalog works for all of them. +const canonicalModel = (id: string): IChatModel => ({ + id, + aliases: [], + costs_currency: 'usd-cents', + costs: { + prompt: 1, + completion: 1, + input: 1, + output: 1, + prompt_tokens: 1, + completion_tokens: 1, + cached_tokens: 1, + input_cache_read: 1, + request: 1, + 'input-tokens': 1, + 'output-tokens': 1, + input_tokens: 1, + output_tokens: 1, + }, + max_tokens: 1024, +}); + +type Dialect = 'chat' | 'responses' | 'mistral' | 'anthropic' | 'fake'; + +interface ProviderCase { + name: string; + dialect: Dialect; + make: () => IChatProvider; +} + +const PROVIDERS: ProviderCase[] = [ + { + name: 'alibaba', + dialect: 'chat', + make: () => + new AlibabaProvider({ apiKey: 'k' } as never, metering()), + }, + { + name: 'azure-chat', + dialect: 'chat', + make: () => + new AzureChatProvider(metering(), stores, fsService, { + apiKey: 'k', + apiURL: 'https://azure.test', + }), + }, + { + name: 'azure-responses', + dialect: 'responses', + make: () => + new AzureResponsesProvider(metering(), stores, fsService, { + apiKey: 'k', + apiURL: 'https://azure.test', + }), + }, + { + name: 'byteplus', + dialect: 'chat', + make: () => + new BytePlusProvider({ apiKey: 'k' } as never, metering()), + }, + { + name: 'claude', + dialect: 'anthropic', + make: () => + new ClaudeProvider(metering(), stores, fsService, { + apiKey: 'k', + }), + }, + { + name: 'deepseek', + dialect: 'chat', + make: () => new DeepSeekProvider({ apiKey: 'k' }, metering()), + }, + { + name: 'fake', + dialect: 'fake', + make: () => new FakeChatProvider(), + }, + { + name: 'gemini', + dialect: 'chat', + make: () => new GeminiChatProvider(metering(), { apiKey: 'k' }), + }, + { + name: 'groq', + dialect: 'chat', + make: () => new GroqAIProvider({ apiKey: 'k' }, metering()), + }, + { + name: 'infron', + dialect: 'chat', + make: () => + new InfronProvider( + { apiKey: 'k', apiBaseUrl: 'https://infron.test' }, + metering(), + ), + }, + { + name: 'meta', + dialect: 'chat', + make: () => + new MetaProvider(metering(), stores, fsService, { + apiKey: 'k', + } as never), + }, + { + name: 'minimax', + dialect: 'chat', + make: () => + new MiniMaxProvider({ apiKey: 'k' } as never, metering()), + }, + { + name: 'mistral', + dialect: 'mistral', + make: () => new MistralAIProvider({ apiKey: 'k' }, metering()), + }, + { + name: 'moonshot', + dialect: 'chat', + make: () => new MoonshotProvider({ apiKey: 'k' }, metering()), + }, + { + name: 'neuralwatt', + dialect: 'chat', + make: () => + new NeuralwattProvider( + { apiKey: 'k', apiBaseUrl: 'https://neuralwatt.test' }, + metering(), + ), + }, + { + name: 'ollama', + dialect: 'chat', + make: () => + new OllamaChatProvider( + { apiBaseUrl: 'http://ollama.test' }, + metering(), + ), + }, + { + name: 'openai-chat', + dialect: 'chat', + make: () => + new OpenAiChatProvider(metering(), stores, fsService, { + apiKey: 'k', + }), + }, + { + name: 'openai-responses', + dialect: 'responses', + make: () => + new OpenAiResponsesChatProvider(metering(), stores, fsService, { + apiKey: 'k', + }), + }, + { + name: 'openrouter', + dialect: 'chat', + make: () => + new OpenRouterProvider({ apiKey: 'k' }, metering()), + }, + { + name: 'together', + dialect: 'chat', + make: () => new TogetherAIProvider({ apiKey: 'k' }, metering()), + }, + { + name: 'xai', + dialect: 'chat', + make: () => new XAIProvider({ apiKey: 'k' }, metering()), + }, + { + name: 'zai', + dialect: 'chat', + make: () => new ZAIProvider({ apiKey: 'k' } as never, metering()), + }, +]; + +// ── Per-dialect upstream fixtures ─────────────────────────────────── + +const chatUsage = { prompt_tokens: 3, completion_tokens: 5 }; + +const fixtures: Record< + Exclude, + { text: () => unknown; tool: () => unknown; reasoning?: () => unknown } +> = { + chat: { + text: () => ({ + choices: [ + { + message: { role: 'assistant', content: TEXT, refusal: null }, + finish_reason: 'stop', + }, + ], + usage: chatUsage, + }), + tool: () => ({ + choices: [ + { + message: { + role: 'assistant', + content: null, + refusal: null, + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'get_weather', + arguments: TOOL_ARGS, + }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: chatUsage, + }), + // DeepSeek wire convention, spoken by several OpenAI-compatible + // vendors: reasoning arrives as `reasoning_content`. + reasoning: () => ({ + choices: [ + { + message: { + role: 'assistant', + content: TEXT, + refusal: null, + reasoning_content: REASONING, + }, + finish_reason: 'stop', + }, + ], + usage: chatUsage, + }), + }, + responses: { + text: () => ({ + output_text: TEXT, + output: [ + { + type: 'message', + id: 'msg_1', + role: 'assistant', + content: [{ type: 'output_text', text: TEXT }], + }, + ], + usage: { + input_tokens: 3, + output_tokens: 5, + input_tokens_details: { cached_tokens: 0 }, + }, + }), + tool: () => ({ + output_text: '', + output: [ + { + type: 'function_call', + id: 'fc_1', + call_id: 'call_1', + name: 'get_weather', + arguments: TOOL_ARGS, + }, + ], + usage: { + input_tokens: 3, + output_tokens: 5, + input_tokens_details: { cached_tokens: 0 }, + }, + }), + reasoning: () => ({ + output_text: TEXT, + output: [ + { + type: 'reasoning', + id: 'rs_1', + summary: [{ type: 'summary_text', text: REASONING }], + }, + { + type: 'message', + id: 'msg_1', + role: 'assistant', + content: [{ type: 'output_text', text: TEXT }], + }, + ], + usage: { + input_tokens: 3, + output_tokens: 5, + input_tokens_details: { cached_tokens: 0 }, + }, + }), + }, + mistral: { + text: () => ({ + choices: [ + { + message: { role: 'assistant', content: TEXT }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 3, completionTokens: 5 }, + }), + tool: () => ({ + choices: [ + { + message: { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'get_weather', + // Mistral's SDK can hand arguments back + // as a parsed object. + arguments: { city: 'Paris' }, + }, + }, + ], + }, + finishReason: 'tool_calls', + }, + ], + usage: { promptTokens: 3, completionTokens: 5 }, + }), + }, + anthropic: { + text: () => ({ + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: TEXT }], + stop_reason: 'end_turn', + usage: { input_tokens: 3, output_tokens: 5 }, + }), + tool: () => ({ + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'get_weather', + input: { city: 'Paris' }, + }, + ], + stop_reason: 'tool_use', + usage: { input_tokens: 3, output_tokens: 5 }, + }), + reasoning: () => ({ + id: 'msg_1', + type: 'message', + role: 'assistant', + content: [ + { type: 'thinking', thinking: REASONING, signature: 'sig' }, + { type: 'text', text: TEXT }, + ], + stop_reason: 'end_turn', + usage: { input_tokens: 3, output_tokens: 5 }, + }), + }, +}; + +const armUpstream = (dialect: Dialect, kind: 'text' | 'tool' | 'reasoning') => { + if (dialect === 'fake') return; + const fixture = fixtures[dialect][kind]; + if (!fixture) throw new Error(`${dialect} has no ${kind} fixture`); + const value = fixture(); + if (dialect === 'responses') responsesCreateMock.mockResolvedValueOnce(value); + else if (dialect === 'mistral') mistralCompleteMock.mockResolvedValueOnce(value); + else if (dialect === 'anthropic') anthropicCreateMock.mockResolvedValueOnce(value); + else chatCreateMock.mockResolvedValueOnce(value); +}; + +const run = async (pc: ProviderCase, kind: 'text' | 'tool' | 'reasoning') => { + const provider = pc.make(); + const model = provider.getDefaultModel(); + if (pc.dialect !== 'fake') { + vi.spyOn(provider, 'models').mockImplementation( + () => [canonicalModel(model)] as never, + ); + } + armUpstream(pc.dialect, kind); + const res = (await withTestActor(() => + provider.complete({ + messages: [{ role: 'user', content: 'hi' }], + model, + stream: false, + } as never), + )) as IChatMessageResult; + // The same pass ChatCompletionDriver applies with `normalize: true`. + return normalizeResultToOpenAI(res); +}; + +// The equalized contract every provider must satisfy, whatever its vendor +// dialect was. +const expectEqualized = (res: IChatMessageResult) => { + expect(res.message).toBeTruthy(); + const message = res.message as Record; + + expect(needsOpenAICoercion(message)).toBe(false); + expect(message.role).toBe('assistant'); + expect( + typeof message.content === 'string' || message.content === null, + ).toBe(true); + + // No vendor-dialect leftovers on the result or the message. + for (const leftover of ['toolCalls', 'finishReason', 'reasoning_content']) { + expect(leftover in message, `message.${leftover} leaked`).toBe(false); + expect( + leftover in (res as unknown as Record), + `result.${leftover} leaked`, + ).toBe(false); + } + + expect(['stop', 'length', 'tool_calls', 'content_filter']).toContain( + res.finish_reason, + ); + + if (message.reasoning !== undefined) { + expect(typeof message.reasoning).toBe('string'); + } + + expect(res.usage).toBeTypeOf('object'); + for (const [key, value] of Object.entries( + res.usage as Record, + )) { + expect(typeof value, `usage.${key} must be a number`).toBe('number'); + } +}; + +// ── The matrix ────────────────────────────────────────────────────── + +describe.each(PROVIDERS)('provider consistency: $name', (pc) => { + it('equalizes a plain text completion', async () => { + const res = await run(pc, 'text'); + expectEqualized(res); + if (pc.dialect !== 'fake') { + expect(res.message.content).toBe(TEXT); + expect(res.finish_reason).toBe('stop'); + } else { + expect(typeof res.message.content).toBe('string'); + expect((res.message.content as string).length).toBeGreaterThan(0); + } + }); + + if (pc.dialect !== 'fake') { + it('equalizes a tool-call completion', async () => { + const res = await run(pc, 'tool'); + expectEqualized(res); + expect(res.finish_reason).toBe('tool_calls'); + // Tool-only turns carry no text; OpenAI uses null, some + // vendors an empty string — both read as "no content". + expect( + res.message.content === null || res.message.content === '', + ).toBe(true); + const toolCalls = res.message.tool_calls as unknown[]; + expect(toolCalls).toHaveLength(1); + // `canonical_id` (Responses round-trip handle) is the one + // permitted extra attribute. + expect(toolCalls[0]).toMatchObject({ + id: 'call_1', + type: 'function', + function: { name: 'get_weather', arguments: TOOL_ARGS }, + }); + }); + } + + if (pc.dialect !== 'fake' && fixtures[pc.dialect].reasoning) { + it('exposes reasoning as a plain `reasoning` string', async () => { + const res = await run(pc, 'reasoning'); + expectEqualized(res); + expect(res.message.content).toBe(TEXT); + expect(res.message.reasoning).toBe(REASONING); + }); + } +}); diff --git a/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts b/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts index 8558a08ab..8cc9603a2 100644 --- a/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts @@ -25,7 +25,7 @@ import { IChatModel, IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; -const TOGETHER_AI_CHAT_COST_MAP = { +const TOGETHER_AI_CHAT_COST_MAP: Record = { prompt_tokens: 'input', completion_tokens: 'output', }; diff --git a/src/backend/drivers/ai-chat/types.ts b/src/backend/drivers/ai-chat/types.ts index a5bd4aa42..92dcb95cd 100644 --- a/src/backend/drivers/ai-chat/types.ts +++ b/src/backend/drivers/ai-chat/types.ts @@ -92,12 +92,7 @@ export interface ICompleteArguments { truncation?: 'auto' | 'disabled' | undefined; background?: boolean; service_tier?: - | 'auto' - | 'default' - | 'flex' - | 'scale' - | 'priority' - | undefined; + 'auto' | 'default' | 'flex' | 'scale' | 'priority' | undefined; max_tokens?: number; temperature?: number; reasoning?: { effort: 'low' | 'medium' | 'high' } | undefined; @@ -106,6 +101,15 @@ export interface ICompleteArguments { verbosity?: 'concise' | 'detailed' | undefined; moderation?: boolean; custom?: unknown; + /** + * Response-format control for non-streaming results. `true` coerces the + * result to the OpenAI `choices[0]` shape (string `message.content`, + * `message.tool_calls`, mapped `finish_reason`); `false` forces the + * provider-native shape. Left undefined, the legacy `response.normalize` + * flag applies if set; otherwise models released on or after + * [[OPENAI_SHAPE_CUTOFF]] (2026-09-01) are coerced by default. + */ + normalize?: boolean; response?: { normalize?: boolean; }; diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js index 430832b85..6bd975d4d 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js @@ -541,6 +541,12 @@ export const handle_completion_output = async ( output_tokens: completion_usage.completion_tokens, }; + // Providers following the DeepSeek wire convention return + // `reasoning_content`; expose it as Puter's `reasoning` key here so every + // provider's message carries the same attribute (the streaming path does + // the equivalent rename on deltas). + normalizeReasoningContent(ret); + const mod_text = completion.choices[0].message.content; if (moderate && mod_text !== null) { const moderation_result = await moderate(mod_text); @@ -561,9 +567,14 @@ export const handle_completion_output = async ( /** * @param {object} params + * @param {Record} [params.deviations] + * @param {boolean} [params.stream] + * @param {any} params.completion + * @param {((text: string) => Promise<{ flagged: boolean }>) | undefined} [params.moderate] * @param {(args: { * usage: import('openai/resources/completions.mjs').CompletionUsage; * }) => unknown} params.usage_calculator + * @param {() => Promise} [params.finally_fn] * @returns {ReturnType} */ export const handle_completion_output_responses_api = async ({ @@ -624,12 +635,22 @@ export const handle_completion_output_responses_api = async ({ }); } + // Reasoning models return `reasoning` output items; their human-readable + // text only exists when the caller requested summaries via + // `reasoning: { summary: ... }` (raw chain-of-thought is never returned). + const reasoningText = output + .filter((item) => item?.type === 'reasoning') + .flatMap((item) => (Array.isArray(item.summary) ? item.summary : [])) + .map((part) => (typeof part?.text === 'string' ? part.text : '')) + .join(''); + const ret = { - finish_reason: 'stop', + finish_reason: responseToolCalls.length ? 'tool_calls' : 'stop', index: 0, message: { content: completion.output_text, - reasoning: null, // Fix later to add proper reasoning + // String-or-absent, matching every other provider's `reasoning`. + ...(reasoningText ? { reasoning: reasoningText } : {}), refusal: null, role: 'assistant', ...(responseToolCalls.length diff --git a/src/backend/drivers/ai-chat/utils/compaction.js b/src/backend/drivers/ai-chat/utils/compaction.js index 9ab764bb0..222541c9f 100644 --- a/src/backend/drivers/ai-chat/utils/compaction.js +++ b/src/backend/drivers/ai-chat/utils/compaction.js @@ -29,7 +29,7 @@ /** * @param {boolean | { trigger_tokens?: number } | undefined} compaction - * @returns {{ enabled: boolean, trigger_tokens?: number }} + * @returns {{ enabled: boolean; trigger_tokens?: number }} */ const readCompaction = (compaction) => { if (compaction === true) return { enabled: true }; @@ -48,8 +48,11 @@ const readCompaction = (compaction) => { * Build OpenAI Responses `context_management` from the neutral opt-in. A raw * `context_management` passthrough (already in OpenAI shape) wins. * - * @param {{ compaction?: boolean | { trigger_tokens?: number }, context_management?: unknown }} args - * @returns {Array<{ type: 'compaction', compact_threshold?: number }> | undefined} + * @param {{ + * compaction?: boolean | { trigger_tokens?: number }; + * context_management?: unknown; + * }} args + * @returns {{ type: 'compaction'; compact_threshold?: number }[] | undefined} */ export const toOpenAiContextManagement = (args) => { if (args.context_management !== undefined) { @@ -71,8 +74,11 @@ export const toOpenAiContextManagement = (args) => { * Build Anthropic `context_management` (beta `compact-2026-01-12`) from the * neutral opt-in. A raw `context_management` passthrough wins. * - * @param {{ compaction?: boolean | { trigger_tokens?: number }, context_management?: unknown }} args - * @returns {{ edits: Array> } | undefined} + * @param {{ + * compaction?: boolean | { trigger_tokens?: number }; + * context_management?: unknown; + * }} args + * @returns {{ edits: Record[] } | undefined} */ export const toAnthropicContextManagement = (args) => { if (args.context_management !== undefined) { @@ -100,7 +106,10 @@ export const toAnthropicContextManagement = (args) => { /** * Whether the request opted into inline compaction by any route. * - * @param {{ compaction?: boolean | { trigger_tokens?: number }, context_management?: unknown }} args + * @param {{ + * compaction?: boolean | { trigger_tokens?: number }; + * context_management?: unknown; + * }} args */ export const wantsCompaction = (args) => args.context_management !== undefined || @@ -109,7 +118,7 @@ export const wantsCompaction = (args) => /** * Whether the (normalized) message list carries a round-tripped compaction * artifact. Such a request must route through a compaction-capable surface even - * if it didn't request *new* compaction — chat.completions can't represent a + * if it didn't request _new_ compaction — chat.completions can't represent a * compaction content block, and Anthropic needs its compaction beta to accept * one as input. * diff --git a/src/backend/drivers/ai-chat/utils/modelRouting.test.ts b/src/backend/drivers/ai-chat/utils/modelRouting.test.ts index c6a0e6b02..abc36f431 100644 --- a/src/backend/drivers/ai-chat/utils/modelRouting.test.ts +++ b/src/backend/drivers/ai-chat/utils/modelRouting.test.ts @@ -51,6 +51,7 @@ const resoldModel = ( input_cost_key: 'prompt', output_cost_key: 'completion', costs: { tokens: 1_000_000, prompt: promptCost, completion: 100 }, + max_tokens: 8192, provider, }) as IChatModel; diff --git a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts new file mode 100644 index 000000000..393dc8df5 --- /dev/null +++ b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts @@ -0,0 +1,277 @@ +/* + * 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 . + */ + +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); + }); +}); diff --git a/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts new file mode 100644 index 000000000..39c685db1 --- /dev/null +++ b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts @@ -0,0 +1,183 @@ +/** + * 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/](https://www.gnu.org/licenses/). + */ + +/** + * Coercion of provider-native (Anthropic-style) completion results into the + * OpenAI `choices[0]` shape the other providers already return through + * `OpenAIUtil.handle_completion_output`: a string `message.content`, + * OpenAI-style `message.tool_calls`, and a real `finish_reason`. + * + * The coercer is idempotent — a result that is already OpenAI-shaped passes + * through by reference — so the driver can apply it uniformly regardless of + * which provider (or fallback route) served the request. + */ + +import type { IChatMessageResult } from '../types.js'; + +/** + * Models released on or after this date return OpenAI-shaped responses by + * default; callers opt out per call with `normalize: false`. + */ +export const OPENAI_SHAPE_CUTOFF = '2026-09-01'; + +const CUTOFF_MS = Date.parse(OPENAI_SHAPE_CUTOFF); + +/** + * Whether a model's release date puts it under the normalize-by-default policy. + * Catalogs are inconsistent about precision (`'2026-09'` and `'2026-09-13'` + * both occur), so dates are compared as timestamps rather than strings; a + * missing or unparseable date counts as pre-cutoff. + */ +export const isPostCutoffRelease = (release_date?: string): boolean => { + if (!release_date) return false; + const ms = Date.parse(release_date); + return Number.isFinite(ms) && ms >= CUTOFF_MS; +}; + +/** + * Whether a result message is provider-native and needs coercing, as opposed to + * already carrying the OpenAI shape (string-or-null `content`, optional + * `tool_calls`), which must pass through untouched — including extra fields + * like Gemini's `images` or the Responses API's `reasoning`. + */ +export const needsOpenAICoercion = (message: unknown): boolean => { + if (typeof message === 'string') return true; + if (!message || typeof message !== 'object') return false; + const m = message as Record; + // The Anthropic SDK's message envelope self-identifies. + if (m.type === 'message') return true; + // A content-block array is the provider-native shape even without the + // envelope marker (e.g. the fake provider's fixture messages). + return Array.isArray(m.content); +}; + +const STOP_REASON_TO_FINISH_REASON: Record = { + end_turn: 'stop', + stop_sequence: 'stop', + max_tokens: 'length', + tool_use: 'tool_calls', + refusal: 'content_filter', +}; + +const mapStopReason = ( + stop_reason: unknown, + fallback: string | undefined, +): string => { + if (typeof stop_reason === 'string') { + const mapped = STOP_REASON_TO_FINISH_REASON[stop_reason]; + if (mapped) return mapped; + } + return fallback ?? 'stop'; +}; + +type OpenAIToolCall = { + id: unknown; + type: 'function'; + function: { name: unknown; arguments: string }; +}; + +/** + * Coerce a completion result to the OpenAI `choices[0]` shape. + * + * Returns `res` by reference when the message is already OpenAI-shaped. + * Otherwise rebuilds `message` (text blocks joined into a string `content`, + * `tool_use` blocks into `tool_calls`, `thinking` blocks into `reasoning`) and + * remaps `finish_reason` from the Anthropic `stop_reason`. Everything else on + * the result — `usage`, the top-level `compaction` artifact — passes through + * unchanged. The caller owns the `normalized` marker. + */ +export const normalizeResultToOpenAI = ( + res: IChatMessageResult, +): IChatMessageResult => { + if (!needsOpenAICoercion(res.message)) return res; + + if (typeof res.message === 'string') { + return { + ...res, + message: { + role: 'assistant', + content: res.message, + refusal: null, + }, + finish_reason: res.finish_reason ?? 'stop', + }; + } + + const native = res.message as Record; + const blocks = Array.isArray(native.content) + ? (native.content as unknown[]) + : []; + + const textParts: string[] = []; + const reasoningParts: string[] = []; + const toolCalls: OpenAIToolCall[] = []; + + for (const block of blocks) { + if (!block || typeof block !== 'object') continue; + const b = block as Record; + switch (b.type) { + case 'text': + if (typeof b.text === 'string') textParts.push(b.text); + break; + case 'thinking': + if (typeof b.thinking === 'string') { + reasoningParts.push(b.thinking); + } + break; + case 'tool_use': + toolCalls.push({ + id: b.id, + type: 'function', + function: { + name: b.name, + arguments: + typeof b.input === 'string' + ? b.input + : JSON.stringify(b.input ?? {}), + }, + }); + break; + // `redacted_thinking` is encrypted, and `compaction` already + // rides the result's top-level `compaction` field; both — and + // any block type introduced later — are dropped rather than + // leaked into a shape that has nowhere to put them. + default: + break; + } + } + + // Null content alongside tool_calls mirrors OpenAI's own convention for + // tool-only turns. + const content = textParts.length > 0 ? textParts.join('') : null; + const reasoning = + reasoningParts.length > 0 ? reasoningParts.join('') : undefined; + + return { + ...res, + message: { + role: 'assistant', + content, + refusal: null, + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + ...(reasoning !== undefined ? { reasoning } : {}), + }, + finish_reason: mapStopReason(native.stop_reason, res.finish_reason), + }; +}; diff --git a/src/backend/drivers/ai-ocr/OCRDriver.ts b/src/backend/drivers/ai-ocr/OCRDriver.ts index 676dc4f6e..52e0fa7c9 100644 --- a/src/backend/drivers/ai-ocr/OCRDriver.ts +++ b/src/backend/drivers/ai-ocr/OCRDriver.ts @@ -132,11 +132,9 @@ export class OCRDriver extends PuterDriver { const providers = this.config.providers ?? {}; const textract = providers['aws-textract'] as - | Record - | undefined; + Record | undefined; const textractAws = (textract?.aws ?? textract) as - | Record - | undefined; + Record | undefined; const textractAccessKey = textractAws?.access_key as string | undefined; const textractSecretKey = textractAws?.secret_key as string | undefined; const textractRegion = diff --git a/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts b/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts index c6231ea9a..8621e98d2 100644 --- a/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts +++ b/src/backend/drivers/ai-speech2speech/VoiceChangerDriver.ts @@ -87,8 +87,7 @@ export class VoiceChangerDriver extends PuterDriver { override onServerStart() { const elevenlabs = this.config.providers?.elevenlabs as - | Record - | undefined; + Record | undefined; this.#apiKey = (elevenlabs?.apiKey as string | undefined) ?? diff --git a/src/backend/drivers/ai-speech2txt/providers/openai/OpenAISpeechToTextProvider.ts b/src/backend/drivers/ai-speech2txt/providers/openai/OpenAISpeechToTextProvider.ts index add76a82d..848f61873 100644 --- a/src/backend/drivers/ai-speech2txt/providers/openai/OpenAISpeechToTextProvider.ts +++ b/src/backend/drivers/ai-speech2txt/providers/openai/OpenAISpeechToTextProvider.ts @@ -273,12 +273,12 @@ export class OpenAISpeechToTextProvider extends SpeechToTextProvider { const result = translate ? await this.#openai.audio.translations.create( - payload as Parameters< + payload as unknown as Parameters< OpenAI['audio']['translations']['create'] >[0], ) : await this.#openai.audio.transcriptions.create( - payload as Parameters< + payload as unknown as Parameters< OpenAI['audio']['transcriptions']['create'] >[0], ); diff --git a/src/backend/drivers/ai-speech2txt/providers/xai/XAISpeechToTextProvider.ts b/src/backend/drivers/ai-speech2txt/providers/xai/XAISpeechToTextProvider.ts index 33f7d4d6f..e346a228e 100644 --- a/src/backend/drivers/ai-speech2txt/providers/xai/XAISpeechToTextProvider.ts +++ b/src/backend/drivers/ai-speech2txt/providers/xai/XAISpeechToTextProvider.ts @@ -181,7 +181,11 @@ export class XAISpeechToTextProvider extends SpeechToTextProvider { formData.append('url', args.file as string); } else { // File must be the last field per xAI docs - const blob = new Blob([fileBuffer!], { type: mimeType }); + // Copy into a plain Uint8Array — Node's Buffer type doesn't + // satisfy the DOM BlobPart signature. + const blob = new Blob([new Uint8Array(fileBuffer!)], { + type: mimeType, + }); formData.append('file', blob, filename); } diff --git a/src/backend/drivers/ai-tts/TTSDriver.ts b/src/backend/drivers/ai-tts/TTSDriver.ts index 29dd45ed8..bee62755d 100644 --- a/src/backend/drivers/ai-tts/TTSDriver.ts +++ b/src/backend/drivers/ai-tts/TTSDriver.ts @@ -266,8 +266,7 @@ export class TTSDriver extends PuterDriver { } const elevenlabs = providers['elevenlabs'] as - | Record - | undefined; + Record | undefined; const elevenKey = (elevenlabs?.apiKey as string | undefined) ?? (elevenlabs?.api_key as string | undefined) ?? @@ -278,8 +277,7 @@ export class TTSDriver extends PuterDriver { apiKey: elevenKey, apiBaseUrl: elevenlabs?.apiBaseUrl as string | undefined, defaultVoiceId: elevenlabs?.defaultVoiceId as - | string - | undefined, + string | undefined, }); } catch (e) { console.warn( @@ -290,11 +288,9 @@ export class TTSDriver extends PuterDriver { } const polly = providers['aws-polly'] as - | Record - | undefined; + Record | undefined; const pollyAws = (polly?.aws ?? polly) as - | Record - | undefined; + Record | undefined; const pollyAccessKey = pollyAws?.access_key as string | undefined; const pollySecretKey = pollyAws?.secret_key as string | undefined; const pollyRegion = @@ -323,8 +319,7 @@ export class TTSDriver extends PuterDriver { #registerGeminiProvider(providers: Record) { const m = this.services.metering; const gemini = (providers['gemini'] ?? providers['gemini-tts']) as - | Record - | undefined; + Record | undefined; const geminiKey = (gemini?.apiKey as string | undefined) ?? (gemini?.api_key as string | undefined) ?? @@ -346,8 +341,7 @@ export class TTSDriver extends PuterDriver { #registerXAIProvider(providers: Record) { const m = this.services.metering; const xai = (providers['xai'] ?? providers['xai-tts']) as - | Record - | undefined; + Record | undefined; const xaiKey = (xai?.apiKey as string | undefined) ?? (xai?.api_key as string | undefined) ?? diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md index 288bcb61f..a11a35433 100755 --- a/src/docs/src/AI/chat.md +++ b/src/docs/src/AI/chat.md @@ -35,6 +35,7 @@ An object containing the following properties: - `tools` (Array) (Optional) - Function definitions the AI can call. See [Function Calling](#function-calling) for details. - `reasoning_effort` / `reasoning.effort` (String) (Optional) - Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, and `xhigh`. Lower values give faster responses with less reasoning. OpenAI models and Meta's Muse Spark models only; Muse Spark always reasons, so `none` is ignored for it. - `verbosity` / `text.verbosity` (String) (Optional) - Controls how long or short responses are. Supported values: `low`, `medium`, and `high`. Lower values give shorter responses. OpenAI models only. +- `normalize` (Boolean) (Optional) - Controls the format of the non-streaming response. When `true`, the response is normalized to the OpenAI format regardless of the model's vendor: `message.content` is a string, tool calls appear as `message.tool_calls`, and `finish_reason` is one of `stop`, `length`, `tool_calls`, or `content_filter`. When `false`, the response keeps the vendor's native format (for Anthropic models, an array of content blocks). When unset, `puter.ai.normalize` applies if you assigned it; otherwise **models released on or after September 1, 2026 return normalized (OpenAI-format) responses by default**, and older models keep their current behavior. Streaming responses are unaffected — chunks already share one format across vendors. See [Response normalization](#response-normalization). - `compaction` (Boolean | Object) (Optional) - Opt into inline context compaction for long conversations. Pass `true` to enable it with provider defaults, or `{ trigger_tokens: number }` to set the token threshold at which earlier context is summarized. When the model compacts, you receive a `compaction` chunk while streaming (or a `compaction` field on the result when not streaming) containing an opaque `encrypted_content` summary. Resend that item in `messages` on the next turn in place of the summarized history. The compaction chunk shape is identical across providers, so the same code works whether `model` is an OpenAI or Anthropic model. See [Compaction](#compaction). #### `testMode` (Boolean) (Optional) @@ -114,6 +115,39 @@ In case of an error, the `Promise` will reject with an error message. We use different vendors for different models and try to use the best vendor available at the time of the request. Vendors currently include Alibaba Cloud, Anthropic, Azure OpenAI, DeepSeek, Google, Infron, Meta, MiniMax, Mistral, Moonshot AI, OpenAI, OpenRouter, Together AI, xAI, and Z.AI. Call [`puter.ai.listModelProviders()`](/AI/listModelProviders) for the current list, or pass `provider` in the options object to pin a request to one of them. +## Response Normalization + +Most vendors respond in the OpenAI chat format, where `message.content` is a string and tool calls appear as `message.tool_calls`. Anthropic models historically respond in Anthropic's native format instead, where `message.content` is an array of content blocks such as `[{ type: "text", text: "..." }]`. + +**Going forward, all models released on or after September 1, 2026 return responses in the OpenAI format**, no matter which vendor serves them — so the same response-handling code works across every new model. Models released before that date keep their historical behavior unless you opt in. + +You can control this per call with the `normalize` option: + +```js +// Force the OpenAI format on any model, old or new: +const response = await puter.ai.chat("Hello", { model: "claude-sonnet-5", normalize: true }); +console.log(response.message.content); // always a string +console.log(response.finish_reason); // "stop" | "length" | "tool_calls" | "content_filter" + +// Force the vendor-native format, even on a post-cutoff model: +const native = await puter.ai.chat("Hello", { model: "claude-sonnet-5", normalize: false }); +``` + +Or SDK-wide with `puter.ai.normalize`: + +```js +puter.ai.normalize = true; // every chat() call returns the OpenAI format +puter.ai.normalize = false; // every chat() call returns the vendor-native format +``` + +A `normalize` option on an individual call always overrides `puter.ai.normalize`. When neither is set, the release-date rule above decides. Normalized responses carry `normalized: true`. + +On a normalized response, extended-thinking output (from reasoning models that expose it) is joined into `message.reasoning`, and Anthropic stop reasons are mapped to OpenAI values (`end_turn` → `stop`, `max_tokens` → `length`, `tool_use` → `tool_calls`, `refusal` → `content_filter`). + +Two caveats. The release-date rule applies to the model that actually serves the request — if a request is rerouted to a fallback provider, the served model's release date decides. And normalization drops Anthropic thinking-block signatures, so agentic loops that resend Claude extended-thinking messages in tool-use continuations should request the native format (`normalize: false`). + +Streaming is unaffected by normalization: streamed [`ChatResponseChunk`](/Objects/chatresponsechunk) objects already share one format across all vendors. + ## Function Calling Function calling (also known as tool calling) allows AI models to request data or perform actions by calling functions you define. This enables the AI to access real-time information, interact with external systems, and perform tasks beyond its training data. @@ -644,9 +678,9 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned }, { role: "user", content: question }, ], - { model: "claude-sonnet-4-6" } + { model: "claude-sonnet-4-6", normalize: true } ); - return response.message.content[0].text; + return response.message.content; } (async () => { diff --git a/src/docs/src/Objects/chatresponse.md b/src/docs/src/Objects/chatresponse.md index dc4762594..d0ba3383d 100644 --- a/src/docs/src/Objects/chatresponse.md +++ b/src/docs/src/Objects/chatresponse.md @@ -13,16 +13,30 @@ An object containing the chat message data. - `role` (String) - The role of the message sender. -- `content` (String) - The content of the message. +- `content` (String | Array) - The content of the message. A string on normalized (OpenAI-format) responses — which includes all models released on or after September 1, 2026 and any call made with `normalize: true`. On older Anthropic models without `normalize: true`, this is the vendor-native array of content blocks such as `[{ type: "text", text: "..." }]`. See [Response Normalization](/AI/chat#response-normalization). - `tool_calls` (Array) - An optional array of [`ToolCall`](/Objects/toolcall) objects if the model wants to call tools. +- `reasoning` (String) - Optional extended-thinking output, when the model exposes it. + - `tool_call_id` (String) - An optional identifier linking this message to the tool call it responds to. - `cache_control` (Object) - An optional object controlling prompt caching for this message. Contains a `type` (String) property. - `images` (Array) - An array of image content objects associated with the message. Each object contains a `type` (String) and an `image_url` object with a `url` (String) property. +#### `finish_reason` (String) + +Why generation stopped. On normalized responses, known vendor stop reasons map to `stop`, `length`, `tool_calls`, or `content_filter`; a vendor value with no OpenAI analog passes through unchanged. + +#### `normalized` (Boolean) + +Present and `true` when the response was normalized to the OpenAI format (see [Response Normalization](/AI/chat#response-normalization)). + +#### `usage` (Object) + +Token accounting for the request. Values are always numbers, but the key names are provider-specific: most OpenAI-compatible providers report `prompt_tokens`, `completion_tokens`, and `cached_tokens`, while Anthropic and OpenAI Responses models report `input_tokens` and `output_tokens`. + #### `compaction` (Object) Present only on non-streaming responses where the model compacted earlier context (see [Compaction](/AI/chat#compaction)). A drop-in `messages` item of the form `{ type: 'compaction', id, encrypted_content }` — resend it on the next turn in place of the summarized history. Absent when no compaction occurred. diff --git a/src/docs/src/playground/examples/ai-claude-cache-control.html b/src/docs/src/playground/examples/ai-claude-cache-control.html index f144ac430..60288d09e 100644 --- a/src/docs/src/playground/examples/ai-claude-cache-control.html +++ b/src/docs/src/playground/examples/ai-claude-cache-control.html @@ -27,9 +27,9 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned }, { role: "user", content: question }, ], - { model: "claude-sonnet-4-6" } + { model: "claude-sonnet-4-6", normalize: true } ); - return response.message.content[0].text; + return response.message.content; } (async () => { diff --git a/src/puter-js/src/modules/ai/ai.test.js b/src/puter-js/src/modules/ai/ai.test.js index ab5da8a99..e93531fe3 100644 --- a/src/puter-js/src/modules/ai/ai.test.js +++ b/src/puter-js/src/modules/ai/ai.test.js @@ -238,6 +238,47 @@ describe('ai.chat driver payloads', () => { expect(String(result)).toBe('the answer'); expect(result.valueOf()).toBe('the answer'); }); + + // Response-format normalization: the per-call option wins in both + // directions, the module-level `ai.normalize` fills in otherwise, and + // with neither set nothing rides the wire (the server's release-date + // cutoff decides). + it('chat(prompt, {normalize: true}) forwards normalize', async () => { + await ai.chat('hello', { normalize: true }); + expect(lastBody().args.normalize).toBe(true); + }); + + it('chat(prompt, {normalize: false}) forwards normalize', async () => { + await ai.chat('hello', { normalize: false }); + expect(lastBody().args.normalize).toBe(false); + }); + + it('ai.normalize = true applies to calls that do not set it', async () => { + ai.normalize = true; + await ai.chat('hello'); + expect(lastBody().args.normalize).toBe(true); + }); + + it('ai.normalize = false applies to calls that do not set it', async () => { + ai.normalize = false; + await ai.chat('hello'); + expect(lastBody().args.normalize).toBe(false); + }); + + it('a per-call normalize overrides ai.normalize in both directions', async () => { + ai.normalize = true; + await ai.chat('hello', { normalize: false }); + expect(lastBody().args.normalize).toBe(false); + + ai.normalize = false; + await ai.chat('hello', { normalize: true }); + expect(lastBody().args.normalize).toBe(true); + }); + + it('normalize stays off the wire when neither the call nor the module sets it', async () => { + await ai.chat('hello'); + expect('normalize' in lastBody().args).toBe(false); + }); }); describe('ai.img2txt driver payloads', () => { diff --git a/src/puter-js/src/modules/ai/chat.js b/src/puter-js/src/modules/ai/chat.js index 0389d5a79..71aaed256 100644 --- a/src/puter-js/src/modules/ai/chat.js +++ b/src/puter-js/src/modules/ai/chat.js @@ -227,6 +227,17 @@ export async function chat ( } } + // Response-format normalization: the per-call option wins in both + // directions; the module-level `puter.ai.normalize` fills in when the + // call doesn't say. When neither is set, nothing is sent and the server + // applies its release-date cutoff (models released on or after + // 2026-09-01 return OpenAI-shaped responses by default). + if (userParams.normalize !== undefined) { + requestParams.normalize = userParams.normalize; + } else if (typeof this.normalize === 'boolean') { + requestParams.normalize = this.normalize; + } + // the legacy `driver` option is an alias for `provider` if (userParams.driver) { requestParams.provider = requestParams.provider || userParams.driver; diff --git a/src/puter-js/src/modules/ai/index.js b/src/puter-js/src/modules/ai/index.js index a66aaf532..8fa9c0a5c 100644 --- a/src/puter-js/src/modules/ai/index.js +++ b/src/puter-js/src/modules/ai/index.js @@ -30,6 +30,18 @@ export class AIModule extends PuterModule { /** @type {Txt2Speech} */ txt2speech; + /** + * Module-wide default for response-format normalization. `true` makes + * every `chat()` call return the OpenAI-style shape regardless of + * provider; `false` forces every call back to the provider's native + * shape, including models the server would normalize by default (those + * released on or after September 1, 2026). A `normalize` option on an + * individual `chat()` call overrides this in either direction. + * + * @type {boolean | undefined} + */ + normalize; + // The fields hold the unbound functions so they keep the full overloaded // types (`bind` erases overloads); the constructor rebinds them at // runtime so destructured calls (`const { chat } = puter.ai`) keep diff --git a/src/puter-js/src/modules/ai/types.js b/src/puter-js/src/modules/ai/types.js index 9e7161425..5d452b3b7 100644 --- a/src/puter-js/src/modules/ai/types.js +++ b/src/puter-js/src/modules/ai/types.js @@ -60,6 +60,11 @@ * @property {string} [driver] * @property {string} [provider] The provider to route the request through. * @property {Tool[]} [tools] Function/tool definitions the model can call. See Function Calling. + * @property {boolean} [normalize] Response-format control for non-streaming results. `true` returns the + * OpenAI-style shape regardless of provider (`message.content` as a string, `message.tool_calls`, a + * mapped `finish_reason`); `false` returns the provider's native shape. Left unset, `puter.ai.normalize` + * applies if it was assigned; otherwise models released on or after September 1, 2026 are normalized by + * default. Streaming responses are unaffected (chunks are already provider-uniform). * @property {unknown} [response] * @property {string} [reasoning_effort] Controls how much effort reasoning models spend thinking. Flat * form. Accepted values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh` (availability varies by @@ -95,6 +100,10 @@ * * @typedef {Object} ChatResponse * @property {ChatMessage} [message] + * @property {string} [finish_reason] Why generation stopped: `stop`, `length`, `tool_calls`, or + * `content_filter`. + * @property {boolean} [normalized] Present and `true` when the response format was normalized + * server-side (see the `normalize` option on [ChatOptions]). * @property {unknown} [choices] * @property {{ type: 'compaction', id?: string, encrypted_content: string }} [compaction] * Inline-compaction artifact, present when the upstream compacted earlier context during this diff --git a/src/puter-js/tests/api/suites/ai.suite.ts b/src/puter-js/tests/api/suites/ai.suite.ts index 694c3fd21..cc45172f5 100644 --- a/src/puter-js/tests/api/suites/ai.suite.ts +++ b/src/puter-js/tests/api/suites/ai.suite.ts @@ -144,6 +144,30 @@ export default suite('ai', { t.assert.ok(textOf(result).length > 0, 'message should contain text'); }, + 'chat with normalize true returns an OpenAI-shaped message': async (t) => { + useApiToken(t); + const result = await t.puter.ai.chat('Hello there', { + model: 'fake', + normalize: true, + }); + // The fake provider replies Anthropic-shaped (content blocks); the + // driver's normalize option must coerce that to the OpenAI shape. + t.assert.equal(typeof result.message?.content, 'string'); + t.assert.ok( + (result.message?.content as unknown as string).length > 0, + 'normalized content should contain text', + ); + t.assert.equal(result.message?.role, 'assistant'); + t.assert.equal( + (result as { finish_reason?: string }).finish_reason, + 'stop', + ); + t.assert.equal( + (result as { normalized?: boolean }).normalized, + true, + ); + }, + 'chat with stream true yields text parts': async (t) => { useApiToken(t); const stream = await t.puter.ai.chat('Stream this', { diff --git a/tools/typecheck-baseline.json b/tools/typecheck-baseline.json index 3e9ae9a21..629597072 100644 --- a/tools/typecheck-baseline.json +++ b/tools/typecheck-baseline.json @@ -10,37 +10,6 @@ "src/backend/controllers/wisp/WispController.ts | TS2345": 1, "src/backend/core/http/__typecheck__.ts | TS2578": 1, "src/backend/core/http/middleware/errorHandler.ts | TS2345": 1, - "src/backend/drivers/ai-chat/ChatCompletionDriver.ts | TS2322": 10, - "src/backend/drivers/ai-chat/ChatCompletionDriver.ts | TS2345": 1, - "src/backend/drivers/ai-chat/providers/ChatProvider.ts | TS2420": 1, - "src/backend/drivers/ai-chat/providers/FakeChatProvider.ts | TS2416": 1, - "src/backend/drivers/ai-chat/providers/FakeChatProvider.ts | TS7018": 1, - "src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts | TS2416": 1, - "src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts | TS2352": 1, - "src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS2322": 1, - "src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS2352": 1, - "src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS2353": 1, - "src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS2416": 1, - "src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts | TS7006": 1, - "src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts | TS7011": 1, - "src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts | TS2416": 1, - "src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts | TS7006": 1, - "src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts | TS7006": 3, - "src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts | TS7053": 1, - "src/backend/drivers/ai-chat/providers/ollama/OllamaProvider.ts | TS2416": 1, - "src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts | TS2352": 1, - "src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts | TS2416": 1, - "src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS2322": 1, - "src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS2352": 1, - "src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS2353": 1, - "src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS2416": 1, - "src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts | TS7006": 2, - "src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts | TS7031": 6, - "src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts | TS7053": 1, - "src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts | TS2416": 1, - "src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts | TS7053": 1, - "src/backend/drivers/ai-speech2txt/providers/openai/OpenAISpeechToTextProvider.ts | TS2352": 2, - "src/backend/drivers/ai-speech2txt/providers/xai/XAISpeechToTextProvider.ts | TS2322": 1, "src/backend/drivers/integrationTestUtil.ts | TS7011": 1, "src/backend/services/apps/AppPermissionService.ts | TS2345": 2, "src/backend/services/auth/OIDCService.ts | TS2322": 2,