diff --git a/src/backend/controllers/puterai/PuterAIController.test.ts b/src/backend/controllers/puterai/PuterAIController.test.ts index 321a33e5c2..d87445416f 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 87d8a541b8..f338a72715 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 e1941fde39..d775190dc3 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 8975d3907e..48259d9269 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts @@ -813,9 +813,14 @@ describe('ChatCompletionDriver.complete normalization', () => { messages: [{ role: 'user', content: 'hi' }], response: { normalize: true }, }), - )) as { message: { role: string; content: unknown[] }; normalized: boolean }; + )) as { + message: { role: string; content: unknown[] }; + normalized?: boolean; + }; - expect(res.normalized).toBe(true); + // `normalized` is the caller's signal that the message is in the + // OpenAI shape; this branch produces Anthropic blocks, so it is absent. + expect(res.normalized).toBeUndefined(); expect(res.message.role).toBe('user'); // default role from normalize expect(res.message.content).toEqual([ { type: 'text', text: 'plain text reply' }, @@ -823,6 +828,293 @@ 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 — and therefore + // not flagged `normalized`, which means "OpenAI shape" specifically. + expect(res.normalized).toBeUndefined(); + 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', () => { @@ -858,6 +1150,71 @@ describe('ChatCompletionDriver.complete fallback and error envelope', () => { }); }); + it('hands every fallback attempt the same messages array, reasoning artifacts intact', async () => { + // The hazard the providers' copy-on-write exists for. `complete` is + // called per attempt with `{ ...args }` — a shallow spread — so + // `args.messages` is the SAME array reference on every attempt. A + // provider that strips replay fields in place therefore hands attempt 2 + // a message whose thinking signature is gone, and Anthropic rejects + // that continuation. + // + // Note what this does NOT assert: the caller's message objects are not + // pristine. `normalize_single_message` (utils/Messages.js, pre-existing) + // rewrites string `content` into `[{type:'text'}]` blocks in place on + // every inbound message before any provider runs. The invariant that + // matters here is narrower and is the one the fix delivers: the + // reasoning artifacts survive attempt 1 so attempt 2 can still replay + // them. + const freeRoute = { + costs_currency: 'usd-cents', + costs: { 'input-tokens': 0, 'output-tokens': 0 }, + max_tokens: 8192, + }; + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ + { id: 'route-a', aliases: ['shared-id'], ...freeRoute }, + { id: 'route-b', aliases: ['shared-id'], ...freeRoute }, + ] as never); + const d = await makeDriver(); + + const completeSpy = vi + .spyOn(FakeChatProvider.prototype, 'complete') + .mockRejectedValueOnce(new Error('first route down')) + .mockResolvedValueOnce({ + message: { role: 'assistant', content: 'from the fallback' }, + usage: {}, + finish_reason: 'stop', + } as never); + + const details = [ + { type: 'thinking', thinking: 'step one', signature: 'sig_1' }, + ]; + const messages = [ + { role: 'user', content: 'hi' }, + { + role: 'assistant', + content: 'earlier reply', + reasoning: 'step one', + refusal: null, + reasoning_details: details, + }, + ]; + + await withTestActor(() => + d.complete({ model: 'shared-id', messages: messages as never }), + ); + + // Two attempts actually ran, which is what makes the reference shared. + expect(completeSpy).toHaveBeenCalledTimes(2); + expect(completeSpy.mock.calls[0]![0].messages).toBe( + completeSpy.mock.calls[1]![0].messages, + ); + // The replay material survived attempt 1 and reached attempt 2 intact. + const secondAttempt = completeSpy.mock.calls[1]![0].messages as Array< + Record + >; + expect(secondAttempt[1]!.reasoning_details).toEqual(details); + }); + it('re-reads the balance between fallback attempts so a parallel request that drains the wallet aborts the chain', async () => { // The primary provider throws; the fallback loop runs the full gate // (one balance read per attempt) before its next upstream hit. We diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 5e4d98d174..eff5194ee6 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 { + normalizeResultToOpenAI, + shouldPresentAsOpenAI, +} from './utils/normalizeToOpenAI.js'; import { costKeys, isFreeModel } from './utils/pricing.js'; import { isRouteUnhealthy, @@ -671,16 +676,39 @@ 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 (shouldPresentAsOpenAI(args, model.release_date)) { + return { + ...normalizeResultToOpenAI(messageRes), + normalized: true, + via_ai_chat_service: true, + }; + } + // The legacy flag normalizes the other way — to Anthropic blocks — + // and only when the new flag is absent. It deliberately does NOT + // set `normalized`: that field is the caller's signal that the + // message is in the OpenAI shape, and this branch produces the + // opposite. Stamping both made the flag mean "some normalization + // happened", which no consumer can act on. + if (args.normalize !== false && args.response?.normalize) { + return { + ...messageRes, + message: normalize_single_message(messageRes.message), + 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/FakeChatProvider.ts b/src/backend/drivers/ai-chat/providers/FakeChatProvider.ts index 2f94debb5d..05d268f43d 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/alibaba/models.ts b/src/backend/drivers/ai-chat/providers/alibaba/models.ts index badea69894..110f81d04c 100644 --- a/src/backend/drivers/ai-chat/providers/alibaba/models.ts +++ b/src/backend/drivers/ai-chat/providers/alibaba/models.ts @@ -143,7 +143,7 @@ export const ALIBABA_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-04', release_date: '2026-04-02', - aliases: ['qwen/qwen3.6-plus'], + aliases: ['qwen/qwen3.6-plus', 'qwen3.6-plus-2026-04-02'], context: 1_000_000, max_tokens: 65_536, costs_currency: 'usd-cents', @@ -165,7 +165,7 @@ export const ALIBABA_MODELS: IChatModel[] = [ tool_call: true, release_date: '2026-04-27', name: 'Qwen3.6 Flash', - aliases: ['qwen/qwen3.6-flash'], + aliases: ['qwen/qwen3.6-flash', 'qwen3.6-flash-2026-04-16'], context: 1_000_000, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -185,7 +185,7 @@ export const ALIBABA_MODELS: IChatModel[] = [ tool_call: true, release_date: '2026-06-02', name: 'Qwen3.7 Plus', - aliases: ['qwen/qwen3.7-plus'], + aliases: ['qwen/qwen3.7-plus', 'qwen3.7-plus-2026-05-26'], context: 1_000_000, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -367,7 +367,7 @@ export const ALIBABA_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2024-04', release_date: '2025-03-05', - aliases: ['qwen/qwq-plus'], + aliases: ['qwen/qwq-plus', 'qwq-plus-2025-03-05'], context: 131_072, max_tokens: 8_192, costs_currency: 'usd-cents', @@ -848,4 +848,1027 @@ export const ALIBABA_MODELS: IChatModel[] = [ cached_tokens: 0, }, }, + + // -- Additions confirmed against the international (Singapore) pricing + // -- tables at https://www.alibabacloud.com/help/en/model-studio/model-pricing + // -- (retrieved 2026-08-28). Tiered models use the base (lowest) tier. + + // -- Qwen Plus snapshots (all priced identically to qwen-plus) --- + { + puterId: 'alibaba:qwen/qwen-plus-2025-01-25', + id: 'qwen-plus-2025-01-25', + name: 'Qwen Plus (2025-01-25)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2025-01-25', + aliases: ['qwen/qwen-plus-2025-01-25'], + context: 1_000_000, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 120, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen-plus-2025-04-28', + id: 'qwen-plus-2025-04-28', + name: 'Qwen Plus (2025-04-28)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2025-04-28', + aliases: ['qwen/qwen-plus-2025-04-28'], + context: 1_000_000, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 120, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen-plus-2025-07-14', + id: 'qwen-plus-2025-07-14', + name: 'Qwen Plus (2025-07-14)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2025-07-14', + aliases: ['qwen/qwen-plus-2025-07-14'], + context: 1_000_000, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 120, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen-plus-2025-09-11', + id: 'qwen-plus-2025-09-11', + name: 'Qwen Plus (2025-09-11)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2025-09-11', + aliases: ['qwen/qwen-plus-2025-09-11'], + context: 1_000_000, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 120, + cached_tokens: 0, + }, + }, + // 'qwen-plus-latest' floats to the newest snapshot; kept on the newest + // dated entry. + { + puterId: 'alibaba:qwen/qwen-plus-2025-12-01', + id: 'qwen-plus-2025-12-01', + name: 'Qwen Plus (2025-12-01)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2025-12-01', + aliases: ['qwen/qwen-plus-2025-12-01', 'qwen-plus-latest'], + context: 1_000_000, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 120, + cached_tokens: 0, + }, + }, + // -- Qwen3 Max snapshots and preview -------------------------- + { + puterId: 'alibaba:qwen/qwen3-max-2025-09-23', + id: 'qwen3-max-2025-09-23', + name: 'Qwen3 Max (2025-09-23)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-09-23', + aliases: ['qwen/qwen3-max-2025-09-23'], + context: 262_144, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 120, + completion_tokens: 600, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-max-2026-01-23', + id: 'qwen3-max-2026-01-23', + name: 'Qwen3 Max (2026-01-23)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2025-04', + release_date: '2026-01-23', + aliases: ['qwen/qwen3-max-2026-01-23'], + context: 262_144, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 120, + completion_tokens: 600, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-max-preview', + id: 'qwen3-max-preview', + name: 'Qwen3 Max Preview', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2025-09-05', + aliases: ['qwen/qwen3-max-preview'], + context: 262_144, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 120, + completion_tokens: 600, + cached_tokens: 0, + }, + }, + // -- Qwen3.7 Max snapshots and preview ------------------------ + { + puterId: 'alibaba:qwen/qwen3.7-max-2026-05-20', + id: 'qwen3.7-max-2026-05-20', + name: 'Qwen3.7 Max (2026-05-20)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2026-05-20', + aliases: ['qwen/qwen3.7-max-2026-05-20'], + context: 1_000_000, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 250, + completion_tokens: 750, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3.7-max-2026-06-08', + id: 'qwen3.7-max-2026-06-08', + name: 'Qwen3.7 Max (2026-06-08)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2026-06-08', + aliases: ['qwen/qwen3.7-max-2026-06-08'], + context: 1_000_000, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 250, + completion_tokens: 750, + cached_tokens: 0, + }, + }, + // No vendor-documented release date for the preview; omitted rather + // than guessed. + { + puterId: 'alibaba:qwen/qwen3.7-max-preview', + id: 'qwen3.7-max-preview', + name: 'Qwen3.7 Max Preview', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: true, + aliases: ['qwen/qwen3.7-max-preview'], + context: 1_000_000, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 250, + completion_tokens: 750, + cached_tokens: 0, + }, + }, + // -- Qwen3.5 Plus snapshots ----------------------------------- + { + puterId: 'alibaba:qwen/qwen3.5-plus-2026-02-15', + id: 'qwen3.5-plus-2026-02-15', + name: 'Qwen3.5 Plus (2026-02-15)', + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2025-04', + release_date: '2026-02-15', + aliases: ['qwen/qwen3.5-plus-2026-02-15'], + context: 1_000_000, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 240, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3.5-plus-2026-04-20', + id: 'qwen3.5-plus-2026-04-20', + name: 'Qwen3.5 Plus (2026-04-20)', + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2025-04', + release_date: '2026-04-20', + aliases: ['qwen/qwen3.5-plus-2026-04-20'], + context: 1_000_000, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 240, + cached_tokens: 0, + }, + }, + // -- Qwen3.5 / Qwen3.7 Flash ---------------------------------- + { + puterId: 'alibaba:qwen/qwen3.5-flash', + id: 'qwen3.5-flash', + name: 'Qwen3.5 Flash', + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2025-04', + release_date: '2026-02-23', + aliases: ['qwen/qwen3.5-flash', 'qwen3.5-flash-2026-02-23'], + context: 1_000_000, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 10, + completion_tokens: 40, + cached_tokens: 1, + }, + }, + // Tiered by input length; base tier (<=32K input) encoded here. + { + puterId: 'alibaba:qwen/qwen3.7-flash', + id: 'qwen3.7-flash', + name: 'Qwen3.7 Flash', + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2026-07-15', + aliases: ['qwen/qwen3.7-flash', 'qwen3.7-flash-2026-07-15'], + context: 1_000_000, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 3, + completion_tokens: 13, + cached_tokens: 0.3, + }, + }, + // -- Open-weight Qwen3.8 -------------------------------------- + { + puterId: 'alibaba:qwen/qwen3.8-27b', + id: 'qwen3.8-27b', + name: 'Qwen3.8 27B', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2026-08-14', + aliases: ['qwen/qwen3.8-27b'], + context: 262_144, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 50, + completion_tokens: 300, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3.8-2.4t-a95b', + id: 'qwen3.8-2.4t-a95b', + name: 'Qwen3.8 2.4T-A95B', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2026-08-12', + aliases: ['qwen/qwen3.8-2.4t-a95b'], + context: 262_144, + max_tokens: 131_072, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 200, + completion_tokens: 600, + cached_tokens: 0, + }, + }, + // -- Open-weight Qwen3 2507 refreshes ------------------------- + { + puterId: 'alibaba:qwen/qwen3-235b-a22b-instruct-2507', + id: 'qwen3-235b-a22b-instruct-2507', + name: 'Qwen3 235B-A22B Instruct 2507', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-07', + aliases: ['qwen/qwen3-235b-a22b-instruct-2507'], + context: 262_144, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 23, + completion_tokens: 92, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-235b-a22b-thinking-2507', + id: 'qwen3-235b-a22b-thinking-2507', + name: 'Qwen3 235B-A22B Thinking 2507', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-07', + aliases: ['qwen/qwen3-235b-a22b-thinking-2507'], + context: 262_144, + max_tokens: 131_072, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 23, + completion_tokens: 230, + cached_tokens: 0, + }, + }, + // Non-thinking output price; thinking-mode output ($2.4/MTok) is not + // modelled. + { + puterId: 'alibaba:qwen/qwen3-30b-a3b', + id: 'qwen3-30b-a3b', + name: 'Qwen3 30B-A3B', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-04', + aliases: ['qwen/qwen3-30b-a3b'], + context: 131_072, + max_tokens: 16_384, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 20, + completion_tokens: 80, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-30b-a3b-instruct-2507', + id: 'qwen3-30b-a3b-instruct-2507', + name: 'Qwen3 30B-A3B Instruct 2507', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-07', + aliases: ['qwen/qwen3-30b-a3b-instruct-2507'], + context: 262_144, + max_tokens: 16_384, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 20, + completion_tokens: 80, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-30b-a3b-thinking-2507', + id: 'qwen3-30b-a3b-thinking-2507', + name: 'Qwen3 30B-A3B Thinking 2507', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-07', + aliases: ['qwen/qwen3-30b-a3b-thinking-2507'], + context: 262_144, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 20, + completion_tokens: 240, + cached_tokens: 0, + }, + }, + // -- Coding additions ----------------------------------------- + // Tiered by input length; base tier (<=32K input) encoded here. + { + puterId: 'alibaba:qwen/qwen3-coder-next', + id: 'qwen3-coder-next', + name: 'Qwen3 Coder Next', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2026-02-03', + aliases: ['qwen/qwen3-coder-next'], + context: 262_144, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 30, + completion_tokens: 150, + cached_tokens: 0, + }, + }, + // Tiered by input length; base tier (<=32K input) encoded here. + { + puterId: 'alibaba:qwen/qwen3-coder-plus-2025-07-22', + id: 'qwen3-coder-plus-2025-07-22', + name: 'Qwen3 Coder Plus (2025-07-22)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-07-22', + aliases: ['qwen/qwen3-coder-plus-2025-07-22'], + context: 1_048_576, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 100, + completion_tokens: 500, + cached_tokens: 0, + }, + }, + // Tiered by input length; base tier (<=32K input) encoded here. + { + puterId: 'alibaba:qwen/qwen3-coder-plus-2025-09-23', + id: 'qwen3-coder-plus-2025-09-23', + name: 'Qwen3 Coder Plus (2025-09-23)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-09-23', + aliases: ['qwen/qwen3-coder-plus-2025-09-23'], + context: 1_048_576, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 100, + completion_tokens: 500, + cached_tokens: 0, + }, + }, + // -- Vision additions ----------------------------------------- + { + puterId: 'alibaba:qwen/qwen3-vl-235b-a22b-instruct', + id: 'qwen3-vl-235b-a22b-instruct', + name: 'Qwen3-VL 235B-A22B Instruct', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2025-09-23', + aliases: ['qwen/qwen3-vl-235b-a22b-instruct'], + context: 131_072, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 160, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-vl-235b-a22b-thinking', + id: 'qwen3-vl-235b-a22b-thinking', + name: 'Qwen3-VL 235B-A22B Thinking', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2025-09-23', + aliases: ['qwen/qwen3-vl-235b-a22b-thinking'], + context: 131_072, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 400, + cached_tokens: 0, + }, + }, + // Tiered by input length; base tier (<=32K input) encoded here. + { + puterId: 'alibaba:qwen/qwen3-vl-flash', + id: 'qwen3-vl-flash', + name: 'Qwen3-VL Flash', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2025-10-15', + aliases: ['qwen/qwen3-vl-flash'], + context: 262_144, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 5, + completion_tokens: 40, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-vl-flash-2026-01-22', + id: 'qwen3-vl-flash-2026-01-22', + name: 'Qwen3-VL Flash (2026-01-22)', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2026-01-22', + aliases: ['qwen/qwen3-vl-flash-2026-01-22'], + context: 262_144, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 5, + completion_tokens: 40, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-vl-plus-2025-09-23', + id: 'qwen3-vl-plus-2025-09-23', + name: 'Qwen3-VL Plus (2025-09-23)', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-09-23', + aliases: ['qwen/qwen3-vl-plus-2025-09-23'], + context: 262_144, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 20, + completion_tokens: 160, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-vl-plus-2025-12-19', + id: 'qwen3-vl-plus-2025-12-19', + name: 'Qwen3-VL Plus (2025-12-19)', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2025-04', + release_date: '2025-12-19', + aliases: ['qwen/qwen3-vl-plus-2025-12-19'], + context: 262_144, + max_tokens: 32_768, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 20, + completion_tokens: 160, + cached_tokens: 0, + }, + }, + // -- Omni additions (text interface, audio costs excluded) ---- + { + puterId: 'alibaba:qwen/qwen3-omni-flash-2025-09-15', + id: 'qwen3-omni-flash-2025-09-15', + name: 'Qwen3-Omni Flash (2025-09-15)', + modalities: { + input: ['text', 'image', 'audio', 'video'], + output: ['text'], + }, + open_weights: false, + tool_call: true, + release_date: '2025-09-15', + aliases: ['qwen/qwen3-omni-flash-2025-09-15'], + context: 65_536, + max_tokens: 16_384, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 43, + completion_tokens: 166, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3-omni-flash-2025-12-01', + id: 'qwen3-omni-flash-2025-12-01', + name: 'Qwen3-Omni Flash (2025-12-01)', + modalities: { + input: ['text', 'image', 'audio', 'video'], + output: ['text'], + }, + open_weights: false, + tool_call: true, + release_date: '2025-12-01', + aliases: ['qwen/qwen3-omni-flash-2025-12-01'], + context: 65_536, + max_tokens: 16_384, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 43, + completion_tokens: 166, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3.5-omni-flash', + id: 'qwen3.5-omni-flash', + name: 'Qwen3.5-Omni Flash', + modalities: { + input: ['text', 'image', 'video', 'audio'], + output: ['text'], + }, + open_weights: false, + tool_call: true, + release_date: '2026-03-15', + aliases: ['qwen/qwen3.5-omni-flash', 'qwen3.5-omni-flash-2026-03-15'], + context: 49_152, + max_tokens: 16_384, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 40, + completion_tokens: 220, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:qwen/qwen3.5-omni-plus', + id: 'qwen3.5-omni-plus', + name: 'Qwen3.5-Omni Plus', + modalities: { + input: ['text', 'image', 'video', 'audio'], + output: ['text'], + }, + open_weights: false, + tool_call: true, + release_date: '2026-03-15', + aliases: ['qwen/qwen3.5-omni-plus', 'qwen3.5-omni-plus-2026-03-15'], + context: 983_616, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 140, + completion_tokens: 830, + cached_tokens: 0, + }, + }, + // -- Translation additions ------------------------------------ + // No vendor-documented release date; omitted rather than guessed. + { + puterId: 'alibaba:qwen/qwen-mt-flash', + id: 'qwen-mt-flash', + name: 'Qwen-MT Flash', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: false, + aliases: ['qwen/qwen-mt-flash'], + context: 16_384, + max_tokens: 8_192, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 16, + completion_tokens: 49, + cached_tokens: 0, + }, + }, + // No vendor-documented release date; omitted rather than guessed. + { + puterId: 'alibaba:qwen/qwen-mt-lite', + id: 'qwen-mt-lite', + name: 'Qwen-MT Lite', + modalities: { input: ['text'], output: ['text'] }, + open_weights: false, + tool_call: false, + aliases: ['qwen/qwen-mt-lite'], + context: 16_384, + max_tokens: 8_192, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 12, + completion_tokens: 36, + cached_tokens: 0, + }, + }, + // -- Third-party models hosted on Model Studio ------------------ + // Prices are Alibaba's international (Singapore) rates, not the + // upstream vendors' own rates. No cross-provider aliases on purpose. + { + puterId: 'alibaba:zhipu/glm-5.1', + id: 'glm-5.1', + name: 'GLM-5.1', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2026-04', + context: 202_752, + max_tokens: 128_000, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 140, + completion_tokens: 440, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:zhipu/glm-5.2', + id: 'glm-5.2', + name: 'GLM-5.2', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2026-06-13', + context: 1_000_000, + max_tokens: 131_072, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 140, + completion_tokens: 440, + cached_tokens: 0, + }, + }, + // No vendor-documented release date for the preview; omitted rather + // than guessed. + { + puterId: 'alibaba:zhipu/glm-5.2-fast-preview', + id: 'glm-5.2-fast-preview', + name: 'GLM-5.2 Fast Preview', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + context: 1_000_000, + max_tokens: 131_072, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 280, + completion_tokens: 880, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:deepseek/deepseek-v3.2', + id: 'deepseek-v3.2', + name: 'DeepSeek V3.2', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2025-12-03', + context: 131_072, + max_tokens: 65_536, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 57, + completion_tokens: 171, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:deepseek/deepseek-v4-flash', + id: 'deepseek-v4-flash', + name: 'DeepSeek V4 Flash', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-05', + release_date: '2026-04-24', + context: 1_000_000, + max_tokens: 384_000, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 20, + completion_tokens: 40, + cached_tokens: 0, + }, + }, + // Time-of-day pricing upstream (busy $0.44/$1.32, idle $0.22/$0.66 + // per MTok); the busy rate is encoded so cached-off-peak use is never + // under-billed. + { + puterId: 'alibaba:deepseek/deepseek-v4-flash-0731', + id: 'deepseek-v4-flash-0731', + name: 'DeepSeek V4 Flash (0731)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-05', + release_date: '2026-07-31', + context: 1_000_000, + max_tokens: 384_000, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 44, + completion_tokens: 132, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:deepseek/deepseek-v4-pro', + id: 'deepseek-v4-pro', + name: 'DeepSeek V4 Pro', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-05', + release_date: '2026-04-24', + context: 1_000_000, + max_tokens: 384_000, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 240, + completion_tokens: 480, + cached_tokens: 0, + }, + }, + // Time-of-day pricing upstream (busy $1.32/$3.96, idle $0.66/$1.98 + // per MTok); the busy rate is encoded so cached-off-peak use is never + // under-billed. + { + puterId: 'alibaba:deepseek/deepseek-v4-pro-0813', + id: 'deepseek-v4-pro-0813', + name: 'DeepSeek V4 Pro (0813)', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2026-08-13', + context: 1_000_000, + max_tokens: 384_000, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 132, + completion_tokens: 396, + cached_tokens: 0, + }, + }, + { + puterId: 'alibaba:moonshotai/kimi-k2.7-code', + id: 'kimi-k2.7-code', + name: 'Kimi K2.7 Code', + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + open_weights: true, + tool_call: true, + knowledge: '2025-01', + release_date: '2026-06-12', + context: 262_144, + max_tokens: 262_144, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 95, + completion_tokens: 400, + cached_tokens: 0, + }, + }, ]; diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts index 8114018696..12e860686d 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 d6d3c0f653..9e49330add 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/byteplus/BytePlusProvider.ts b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts index 974fa8f4d2..b6f2a9921f 100644 --- a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts +++ b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts @@ -160,9 +160,6 @@ export class BytePlusProvider implements IChatProvider { completion, }); - // Ark's deep-reasoning models return `reasoning_content` (DeepSeek - // wire convention); expose it under `reasoning` like other providers. - OpenAIUtil.normalizeReasoningContent(result); return result; } diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts index 285c86f762..cf0f8351d4 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts @@ -364,6 +364,193 @@ describe('ClaudeProvider.complete request shape', () => { expect(toolUse!.input).toEqual({ q: 'puter' }); }); + it('splices round-tripped reasoning_details back in ahead of the content', async () => { + // The replay contract for a normalized Claude turn: the caller resends + // the whole message, and the thinking blocks have to reach Anthropic + // with their signature intact and leading the content array (Anthropic + // rejects both a missing signature and a trailing thinking block). + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: 'claude-haiku-4-5-20251001', + messages: [ + { role: 'user', content: 'think then call a tool' }, + { + role: 'assistant', + content: 'here you go', + reasoning: 'step one', + refusal: null, + reasoning_details: [ + { + type: 'thinking', + thinking: 'step one', + signature: 'sig_1', + }, + { type: 'redacted_thinking', data: 'ENC' }, + ], + tool_calls: [ + { + id: 'call_1', + type: 'function', + function: { + name: 'lookup', + arguments: '{\"q\":\"puter\"}', + }, + }, + ], + } as never, + ], + }), + ); + + const [args] = messagesCreateMock.mock.calls[0]!; + const assistant = args.messages[1] as Record; + const content = assistant.content as Array>; + // Thinking blocks lead, verbatim; string content became a text block; + // the tool_use block is appended after. + expect(content).toEqual([ + { type: 'thinking', thinking: 'step one', signature: 'sig_1' }, + { type: 'redacted_thinking', data: 'ENC' }, + { type: 'text', text: 'here you go' }, + { + type: 'tool_use', + id: 'call_1', + name: 'lookup', + input: { q: 'puter' }, + }, + ]); + // Output-only fields Anthropic rejects are stripped. + expect('reasoning_details' in assistant).toBe(false); + expect('reasoning' in assistant).toBe(false); + expect('refusal' in assistant).toBe(false); + }); + + it('leaves the caller\'s message objects intact', async () => { + // The driver reuses the same messages array across fallback attempts, + // so stripping the output-only fields has to happen on a copy. + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + const callerMessage = Object.freeze({ + role: 'assistant', + content: 'here you go', + reasoning: 'step one', + refusal: null, + reasoning_details: Object.freeze([ + Object.freeze({ + type: 'thinking', + thinking: 'step one', + signature: 'sig_1', + }), + ]), + }); + const before = JSON.parse(JSON.stringify(callerMessage)); + + // A frozen message would throw on `delete` in strict mode. + await withTestActor(() => + provider.complete({ + model: 'claude-haiku-4-5-20251001', + messages: [callerMessage as never], + }), + ); + + expect(callerMessage).toEqual(before); + // ...and the provider still sent the spliced content upstream. + const [args] = messagesCreateMock.mock.calls[0]!; + const sent = args.messages[0] as Record; + expect('reasoning_details' in sent).toBe(false); + expect( + (sent.content as Array>)[0], + ).toMatchObject({ type: 'thinking', signature: 'sig_1' }); + }); + + it('survives the same messages array being sent twice', async () => { + // This is the fallback hazard the copy-on-write exists for: the driver + // reuses one messages array across attempts, so if attempt 1 strips + // `reasoning_details` in place, attempt 2 sends a message with no + // thinking blocks and Anthropic rejects the continuation. Two + // sequential calls over one shared array reproduce that at the + // provider level; the real fallback loop is driven end-to-end by + // "hands every fallback attempt the same messages array" in + // ChatCompletionDriver.test.ts. + const { provider } = makeProvider(); + messagesCreateMock + .mockResolvedValueOnce(baseResponse) + .mockResolvedValueOnce(baseResponse); + + const messages = [ + { role: 'user', content: 'think then call a tool' }, + { + role: 'assistant', + content: 'here you go', + reasoning: 'step one', + refusal: null, + reasoning_details: [ + { + type: 'thinking', + thinking: 'step one', + signature: 'sig_1', + }, + ], + }, + ]; + const before = structuredClone(messages); + + await withTestActor(() => + provider.complete({ + model: 'claude-haiku-4-5-20251001', + messages: messages as never, + }), + ); + await withTestActor(() => + provider.complete({ + model: 'claude-haiku-4-5-20251001', + messages: messages as never, + }), + ); + + // The caller's array is untouched by either attempt... + expect(messages).toEqual(before); + // ...so both attempts sent the thinking block with its signature. + for (const call of messagesCreateMock.mock.calls.slice(0, 2)) { + const sent = call[0].messages[1] as Record; + const content = sent.content as Array>; + expect(content[0]).toEqual({ + type: 'thinking', + thinking: 'step one', + signature: 'sig_1', + }); + } + }); + + it('strips output-only reasoning fields even with no reasoning_details', async () => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: 'claude-haiku-4-5-20251001', + messages: [ + { + role: 'assistant', + content: 'plain reply', + reasoning: 'leftover', + refusal: null, + } as never, + ], + }), + ); + + const [args] = messagesCreateMock.mock.calls[0]!; + const assistant = args.messages[0] as Record; + expect('reasoning' in assistant).toBe(false); + expect('refusal' in assistant).toBe(false); + // Content is untouched when there was nothing to splice. + expect(assistant.content).toBe('plain reply'); + }); + it('converts a tool-role message with tool_call_id into a user-role tool_result block', async () => { const { provider } = makeProvider(); messagesCreateMock.mockResolvedValueOnce(baseResponse); diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts index e1a2ada3b5..e5ab3c1cd6 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts @@ -139,6 +139,47 @@ export class ClaudeProvider implements IChatProvider { return message; }); + // Splice round-tripped reasoning artifacts back into the assistant + // content. Anthropic rejects an extended-thinking tool-use + // continuation whose thinking blocks lost their `signature`, and + // requires those blocks to lead the content array — so they are + // prepended here, before the tool_use blocks are appended below. + // `reasoning`/`refusal` are output-only fields Anthropic rejects + // outright, and a caller replaying a normalized message carries them. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + messages = messages.map((original: any) => { + const details = original.reasoning_details; + if ( + details === undefined && + original.reasoning === undefined && + original.refusal === undefined + ) { + return original; + } + // Copy before stripping: the driver reuses this same array across + // fallback attempts, and these objects belong to the caller. + const message = { ...original }; + delete message.reasoning_details; + delete message.reasoning; + delete message.refusal; + if (!Array.isArray(details)) return message; + const blocks = details.filter( + (block: unknown) => + (block as { type?: string })?.type === 'thinking' || + (block as { type?: string })?.type === 'redacted_thinking', + ); + if (blocks.length === 0) return message; + if (typeof message.content === 'string') { + message.content = message.content + ? [{ type: 'text', text: message.content }] + : []; + } else if (!Array.isArray(message.content)) { + message.content = message.content ? [message.content] : []; + } + message.content = [...blocks, ...message.content]; + return message; + }); + // Convert OpenAI-style tool calls/results to Claude format // eslint-disable-next-line @typescript-eslint/no-explicit-any messages = messages.map((message: any) => { @@ -501,7 +542,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/deepseek/models.ts b/src/backend/drivers/ai-chat/providers/deepseek/models.ts index 95f7cd2e6b..a940e317cb 100644 --- a/src/backend/drivers/ai-chat/providers/deepseek/models.ts +++ b/src/backend/drivers/ai-chat/providers/deepseek/models.ts @@ -50,6 +50,31 @@ export const DEEPSEEK_MODELS: IChatModel[] = [ }, max_tokens: 384_000, }, + { + // Priced identically to deepseek-v4-flash per DeepSeek's launch note + // (https://api-docs.deepseek.com/news/news260821/) and the pricing + // page, which lists the same rates for both models; images are + // tokenized (up to 384 tokens each) and billed as input tokens. + puterId: 'deepseek:deepseek/deepseek-v4-flash-vision-exp', + id: 'deepseek-v4-flash-vision-exp', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: false, + tool_call: true, + release_date: '2026-08-21', + name: 'DeepSeek V4 Flash Vision (Experimental)', + aliases: ['deepseek/deepseek-v4-flash-vision-exp'], + context: 1_000_000, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 14, + completion_tokens: 28, + cached_tokens: 0.28, + }, + max_tokens: 384_000, + }, { puterId: 'deepseek:deepseek/deepseek-v4-pro', id: 'deepseek-v4-pro', diff --git a/src/backend/drivers/ai-chat/providers/gemini/models.ts b/src/backend/drivers/ai-chat/providers/gemini/models.ts index 259c6be63e..405f6238ab 100644 --- a/src/backend/drivers/ai-chat/providers/gemini/models.ts +++ b/src/backend/drivers/ai-chat/providers/gemini/models.ts @@ -33,7 +33,14 @@ export const GEMINI_MODELS: IChatModel[] = [ knowledge: '2025-01', release_date: '2026-05-19', name: 'Gemini 3.5 Flash', - aliases: ['google/gemini-3.5-flash'], + aliases: [ + 'google/gemini-3.5-flash', + // Rolling alias; Gemini API changelog (2026-05-19): gemini-3.5-flash + // "Now backs `gemini-flash-latest`". Hot-swapped by Google on new + // releases; no later switch documented as of 2026-08-28. + 'gemini-flash-latest', + 'google/gemini-flash-latest', + ], context: 1_048_576, max_tokens: 65_536, costs_currency: 'usd-cents', @@ -61,7 +68,13 @@ export const GEMINI_MODELS: IChatModel[] = [ knowledge: '2025-01', release_date: '2026-07-21', name: 'Gemini 3.5 Flash-Lite', - aliases: ['google/gemini-3.5-flash-lite'], + aliases: [ + 'google/gemini-3.5-flash-lite', + // Rolling alias that floats across Flash-Lite releases; pinned here + // to the newest Flash-Lite in this catalog (GA 2026-07-21). + 'gemini-flash-lite-latest', + 'google/gemini-flash-lite-latest', + ], context: 1_048_576, max_tokens: 65_536, costs_currency: 'usd-cents', @@ -228,7 +241,14 @@ export const GEMINI_MODELS: IChatModel[] = [ knowledge: '2025-01', release_date: '2026-02-19', name: 'Gemini 3.1 Pro Preview', - aliases: ['google/gemini-3.1-pro-preview'], + aliases: [ + 'google/gemini-3.1-pro-preview', + // Rolling alias that floats across Pro releases; pinned here to the + // newest Pro in this catalog (Google last documented it switching to + // gemini-3-pro-preview on 2026-01-21, superseded by 3.1 on 2026-02-19). + 'gemini-pro-latest', + 'google/gemini-pro-latest', + ], context: 1_048_576, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -301,4 +321,60 @@ export const GEMINI_MODELS: IChatModel[] = [ }, max_tokens: 65536, }, + { + puterId: 'google:google/gemma-4-31b-it', + id: 'gemma-4-31b-it', + modalities: { + input: ['text', 'image'], + output: ['text'], + }, + open_weights: true, + tool_call: true, + knowledge: '2025-01', + release_date: '2026-04-02', + name: 'Gemma 4 31B', + aliases: ['google/gemma-4-31b-it'], + context: 262_144, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + // Gemma 4 on the Gemini API is free of charge (free tier only; the + // official pricing page lists no paid tier for Gemma models). + costs: { + tokens: 1_000_000, + prompt_tokens: 0, + completion_tokens: 0, + thinking_tokens: 0, + cached_tokens: 0, + }, + max_tokens: 8_192, + }, + { + puterId: 'google:google/gemma-4-26b-a4b-it', + id: 'gemma-4-26b-a4b-it', + modalities: { + input: ['text', 'image'], + output: ['text'], + }, + open_weights: true, + tool_call: true, + knowledge: '2025-01', + release_date: '2026-04-02', + name: 'Gemma 4 26B A4B', + aliases: ['google/gemma-4-26b-a4b-it'], + context: 262_144, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + // Gemma 4 on the Gemini API is free of charge (free tier only; the + // official pricing page lists no paid tier for Gemma models). + costs: { + tokens: 1_000_000, + prompt_tokens: 0, + completion_tokens: 0, + thinking_tokens: 0, + cached_tokens: 0, + }, + max_tokens: 8_192, + }, ]; diff --git a/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts b/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts index 4710f6da68..3783df61c2 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.test.ts b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts index 119337381d..54da23411a 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.test.ts @@ -210,6 +210,51 @@ describe('MistralAIProvider.complete request shape', () => { expect(args.temperature).toBe(0.4); }); + it('forwards custom.prompt_mode as the SDK promptMode', async () => { + const { provider } = makeProvider(); + completeMock.mockResolvedValueOnce({ + choices: [ + { + message: { role: 'assistant', content: 'ok' }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 1, completionTokens: 1 }, + }); + + await withTestActor(() => + provider.complete({ + model: 'magistral-small-latest', + messages: [{ role: 'user', content: 'think' }], + custom: { prompt_mode: 'reasoning' }, + }), + ); + + expect(completeMock.mock.calls[0]![0].promptMode).toBe('reasoning'); + }); + + it('omits promptMode when custom does not carry prompt_mode', async () => { + const { provider } = makeProvider(); + completeMock.mockResolvedValueOnce({ + choices: [ + { + message: { role: 'assistant', content: 'ok' }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 1, completionTokens: 1 }, + }); + + await withTestActor(() => + provider.complete({ + model: 'mistral-small-2603', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + expect('promptMode' in completeMock.mock.calls[0]![0]).toBe(false); + }); + it('omits the `tools` key when no tools are supplied', async () => { const { provider } = makeProvider(); completeMock.mockResolvedValueOnce(baseCompletion); @@ -459,6 +504,115 @@ describe('MistralAIProvider.complete non-stream output', () => { }); }); + it('flattens a magistral chunked content array into string content + reasoning', async () => { + // Mistral's reasoning models return `content` as a chunk array with + // the thinking text nested inside `thinking` chunks. Left alone it + // reaches the caller as an array with no `reasoning`, breaking the + // one-shape-per-provider guarantee. + const { provider } = makeProvider(); + completeMock.mockResolvedValueOnce({ + choices: [ + { + message: { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: [ + { type: 'text', text: 'step one.' }, + ], + }, + { + type: 'thinking', + thinking: [ + { type: 'text', text: 'step two.' }, + ], + }, + { type: 'text', text: 'the answer' }, + ], + }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 1, completionTokens: 1 }, + }); + + const result = (await withTestActor(() => + provider.complete({ + model: 'magistral-small-latest', + messages: [{ role: 'user', content: 'think' }], + normalize: true, + }), + )) as { message: Record }; + + expect(result.message.content).toBe('the answer'); + // Multiple thinking chunks join with a blank line, matching the + // Responses handler and the Anthropic coercer. + expect(result.message.reasoning).toBe('step one.\n\nstep two.'); + }); + + it('leaves plain string content untouched', async () => { + const { provider } = makeProvider(); + completeMock.mockResolvedValueOnce({ + choices: [ + { + message: { role: 'assistant', content: 'plain' }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 1, completionTokens: 1 }, + }); + + const result = (await withTestActor(() => + provider.complete({ + model: 'mistral-small-2603', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as { message: Record }; + + expect(result.message.content).toBe('plain'); + expect('reasoning' in result.message).toBe(false); + }); + + it('leaves the SDK dialect untouched when normalization does not apply', async () => { + // The remap changes what this provider returns, so it is gated on the + // same policy the driver's coercer uses. Without `normalize`, and with + // a pre-cutoff model, a caller reading the SDK's native keys keeps + // seeing them — nothing is deleted out from under it. + const { provider } = makeProvider(); + completeMock.mockResolvedValueOnce({ + choices: [ + { + message: { + role: 'assistant', + content: 'hi there', + toolCalls: [ + { + id: 'call_1', + function: { + name: 'get_weather', + arguments: { city: 'Paris' }, + }, + }, + ], + }, + finishReason: 'stop', + }, + ], + usage: { promptTokens: 1, completionTokens: 1 }, + }); + + const result = (await withTestActor(() => + provider.complete({ + model: 'mistral-small-2603', + messages: [{ role: 'user', content: 'hi' }], + }), + )) as { message: Record } & Record; + + expect('toolCalls' in result.message).toBe(true); + expect('tool_calls' in result.message).toBe(false); + }); + it('preserves OpenAI-shaped tool_calls on the assistant response', async () => { const { provider } = makeProvider(); completeMock.mockResolvedValueOnce({ @@ -571,6 +725,99 @@ describe('MistralAIProvider.complete streaming', () => { }); }); + it.each([ + ['normalize: true', true], + ['normalize unset', undefined], + ])( + 'splits chunked delta.content into text and reasoning events (%s)', + async (_label, normalize) => { + // The reasoning split is unconditional: streamed chunk types must + // not depend on the normalize policy, because chat.md promises + // "Streaming is unaffected by normalization" and because every + // other provider routes thinking to the reasoning channel + // regardless. Both rows below assert the same event stream. + const { provider } = makeProvider(); + streamMock.mockReturnValueOnce( + asAsyncIterable([ + { + data: { + choices: [ + { + delta: { + content: [ + { + type: 'thinking', + thinking: [ + { + type: 'text', + text: 'thinking…', + }, + ], + }, + ], + }, + }, + ], + }, + }, + { + data: { + choices: [ + { + delta: { + content: [ + { type: 'text', text: 'answer' }, + ], + }, + }, + ], + }, + }, + { + data: { + choices: [{ delta: {} }], + usage: { promptTokens: 1, completionTokens: 1 }, + }, + }, + ]), + ); + + const result = await withTestActor(() => + provider.complete({ + model: 'magistral-small-latest', + messages: [{ role: 'user', content: 'think' }], + stream: true, + ...(normalize === undefined ? {} : { normalize }), + }), + ); + + const harness = makeCapturingChatStream(); + await ( + result as { + init_chat_stream: (p: { + chatStream: unknown; + }) => Promise; + } + ).init_chat_stream({ chatStream: harness.chatStream }); + + const events = harness.events(); + // Thinking goes to the reasoning channel, never the text channel. + expect( + events + .filter((e) => e.type === 'reasoning') + .map((e) => e.reasoning), + ).toEqual(['thinking…']); + const text = events + .filter((e) => e.type === 'text') + .map((e) => e.text) + .join(''); + expect(text).toBe('answer'); + // And the array never reaches addText as an object. + expect(text).not.toContain('object'); + expect(text).not.toContain('{'); + }, + ); + it('builds a tool_use block from camelCase delta.toolCalls deltas', async () => { const { provider } = makeProvider(); streamMock.mockReturnValueOnce( diff --git a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts index 19e7fb4712..1618cb8be0 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts @@ -29,6 +29,63 @@ import type { import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { MISTRAL_MODELS } from './models.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +import { shouldPresentAsOpenAI } from '../../utils/normalizeToOpenAI.js'; + +/** + * Mistral's reasoning models (`magistral-*`) return `content` as a chunk array + * rather than a string, with the thinking text nested one level deeper inside + * `thinking` chunks. Split it into the string content + `reasoning` string + * every other provider produces. Text nested in a chunk is joined; a `thinking` + * chunk's own chunks are flattened the same way, and separate thinking chunks + * are separated by a blank line — the same separator the Responses summary + * handler and the Anthropic coercer use. + */ +const flattenChunkText = (value: unknown): string => { + if (typeof value === 'string') return value; + if (!Array.isArray(value)) return ''; + return value + .map((chunk) => { + if (typeof chunk === 'string') return chunk; + const c = chunk as Record; + return typeof c?.text === 'string' ? c.text : ''; + }) + .join(''); +}; + +const splitMistralContentChunks = ( + content: unknown[], +): { text: string; reasoning: string } => { + const textParts: string[] = []; + const reasoningParts: string[] = []; + for (const chunk of content) { + if (typeof chunk === 'string') { + textParts.push(chunk); + continue; + } + const c = chunk as Record; + if (c?.type === 'thinking') { + const thinking = flattenChunkText(c.thinking); + if (thinking) reasoningParts.push(thinking); + continue; + } + // Non-text chunks (`reference`, images) carry nothing to surface as + // message content and are dropped, same as the Anthropic coercer. + if (typeof c?.text === 'string') textParts.push(c.text); + } + return { + text: textParts.join(''), + reasoning: reasoningParts.join('\n\n'), + }; +}; + +// 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; @@ -93,7 +150,21 @@ export class MistralAIProvider implements IChatProvider { tools, max_tokens, temperature, + normalize, + response, + custom, }: ICompleteArguments): Promise { + // Mistral's reasoning prompt mode: with `prompt_mode: 'reasoning'`, + // magistral models return their thinking as structured ThinkChunk + // content (which the splitter below separates into `reasoning`) + // instead of inlining it as answer prose. Opt-in passthrough rather + // than a default because the API rejects it on accounts/models where + // the mode is not enabled ('Reasoning prompt mode is not enabled for + // this model', code 3051). + const customParams = + custom && typeof custom === 'object' && !Array.isArray(custom) + ? (custom as { prompt_mode?: 'reasoning' | null }) + : {}; messages = await OpenAIUtil.process_input_messages(messages); messages = this.#coerceImageUrls(messages); for (const message of messages) { @@ -118,29 +189,153 @@ export class MistralAIProvider implements IChatProvider { ]({ model: selectedModel.id, ...(tools ? { tools: tools as any[] } : {}), + ...(customParams.prompt_mode !== undefined + ? { promptMode: customParams.prompt_mode } + : {}), messages, maxTokens: max_tokens, temperature, }); + // The Mistral SDK speaks camelCase (`finishReason`, `toolCalls`, + // object-typed `arguments`) and its reasoning models return chunked + // `content`; remap each choice to the OpenAI wire shape so the result + // matches every other provider's. + // + // This changes what the provider returns, so it sits behind the same + // policy resolution the driver's coercer uses rather than firing on + // every Mistral call — a caller reading the SDK's native + // `finishReason`/`toolCalls` keys keeps seeing them unless it asked + // for the equalized shape. + // + // Streaming is deliberately NOT gated on this, and the deviation below + // is uniform in both directions: streamed chunks are provider-uniform + // by design, and every other reasoning path in this repo routes + // thinking to the `reasoning` channel unconditionally (ClaudeProvider's + // thinking_delta, the DeepSeek/OpenRouter rename in + // `create_chat_stream_handler`, the Responses summary-delta handler). + // Gating it would make Mistral the only provider whose streamed chunk + // *types* depend on a response-format flag. + const presentAsOpenAI = shouldPresentAsOpenAI( + { normalize, response }, + selectedModel.release_date, + ); + if (!stream && presentAsOpenAI) { + 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; + // Dropped only once its value carried over. Deleting + // unconditionally left a choice with neither key when + // `finishReason` was not a string. + 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; + if (message && Array.isArray(message.content)) { + const { text, reasoning } = splitMistralContentChunks( + message.content, + ); + // Null content alongside tool calls is OpenAI's own + // convention for a tool-only turn. + message.content = text === '' ? null : text; + if (reasoning && message.reasoning === undefined) { + message.reasoning = reasoning; + } + } + } + } + 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, + // Mistral wraps each event; unwrap it, then split a + // reasoning model's chunked `delta.content` into the two + // channels the shared handler already understands: visible + // text, and `reasoning`. + // + // Both halves are unconditional. Leaving the array on + // `delta.content` would hand it to `addText` and reach the + // caller as stringified objects, and putting the thinking text + // into the visible channel would make this the only place in + // the repo where chain-of-thought is answer text. So the split + // matches every other provider and does not depend on the + // normalize policy — streamed chunk types stay identical + // whichever way that resolves. + chunk_but_like_actually: (chunk: unknown) => { + const data = (chunk as { data?: unknown }).data as + | { + choices?: { + delta?: Record; + }[]; + } + | undefined; + if (!data || !Array.isArray(data.choices)) return data; + for (const choice of data.choices) { + const delta = choice?.delta; + if (!delta || !Array.isArray(delta.content)) continue; + const { text, reasoning } = splitMistralContentChunks( + delta.content, + ); + delta.content = text; + if (reasoning && delta.reasoning === undefined) { + delta.reasoning = reasoning; + } + } + return 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/mistral/models.ts b/src/backend/drivers/ai-chat/providers/mistral/models.ts index f1994b322a..cbcc1d7538 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/models.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/models.ts @@ -32,6 +32,7 @@ export const MISTRAL_MODELS: IChatModel[] = [ name: 'Mistral Medium 3.5', aliases: [ 'mistral-medium-3-5', + 'mistral-medium-3.5', 'mistral-medium-3', 'mistral-medium-latest', 'mistral-medium', @@ -110,6 +111,29 @@ export const MISTRAL_MODELS: IChatModel[] = [ completion_tokens: 60, }, }, + { + puterId: 'mistralai:mistralai/zai-glm-5-2', + id: 'zai-glm-5-2', + modalities: { input: ['text'], output: ['text'] }, + open_weights: true, + tool_call: true, + release_date: '2026-08-06', + name: 'Z.ai GLM 5.2', + aliases: ['glm-5-2'], + context: 1_000_000, + max_tokens: 128_000, + description: + 'Third-party open-source model from Z.ai, hosted by Mistral for long-context coding and agentic workflows.', + provider: 'mistral', + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 140, + completion_tokens: 440, + }, + }, { puterId: 'mistralai:mistralai/codestral-2508', id: 'codestral-2508', 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 a4a9ae1202..8446982188 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 6332bfa7d6..4a2affda06 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 315042e80e..652a064acb 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 7df7910bf8..4993cc7a33 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/openai/models.ts b/src/backend/drivers/ai-chat/providers/openai/models.ts index 026b5d6beb..c6f2672db8 100644 --- a/src/backend/drivers/ai-chat/providers/openai/models.ts +++ b/src/backend/drivers/ai-chat/providers/openai/models.ts @@ -158,7 +158,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-08-31', release_date: '2026-03-05', - aliases: ['openai/gpt-5.4-pro'], + aliases: ['gpt-5.4-pro-2026-03-05', 'openai/gpt-5.4-pro'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -178,7 +178,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, knowledge: '2025-08-31', - aliases: ['openai/gpt-5.4-mini'], + aliases: ['gpt-5.4-mini-2026-03-17', 'openai/gpt-5.4-mini'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -200,7 +200,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-08-31', release_date: '2026-03-19', - aliases: ['openai/gpt-5.4-nano'], + aliases: ['gpt-5.4-nano-2026-03-17', 'openai/gpt-5.4-nano'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -221,7 +221,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-10', release_date: '2025-10-06', - aliases: ['openai/gpt-5-pro'], + aliases: ['gpt-5-pro-2025-10-06', 'openai/gpt-5-pro'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -306,7 +306,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2024-09-30', release_date: '2025-11-13', - aliases: ['openai/gpt-5.1'], + aliases: ['gpt-5.1-2025-11-13', 'openai/gpt-5.1'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -390,7 +390,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2023-09', release_date: '2024-05-13', - aliases: ['openai/gpt-4o'], + aliases: ['gpt-4o-2024-08-06', 'openai/gpt-4o'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -411,7 +411,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2023-09', release_date: '2024-07-18', - aliases: ['openai/gpt-4o-mini'], + aliases: ['gpt-4o-mini-2024-07-18', 'openai/gpt-4o-mini'], context: 128_000, max_tokens: 16384, costs_currency: 'usd-cents', @@ -453,7 +453,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2024-05', release_date: '2025-04-16', - aliases: ['openai/o3'], + aliases: ['o3-2025-04-16', 'openai/o3'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -474,7 +474,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2024-05', release_date: '2025-06-10', - aliases: ['openai/o3-pro'], + aliases: ['o3-pro-2025-06-10', 'openai/o3-pro'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -537,7 +537,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2024-04', release_date: '2025-04-14', - aliases: ['openai/gpt-4.1'], + aliases: ['gpt-4.1-2025-04-14', 'openai/gpt-4.1'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -558,7 +558,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2024-04', release_date: '2025-04-14', - aliases: ['openai/gpt-4.1-mini'], + aliases: ['gpt-4.1-mini-2025-04-14', 'openai/gpt-4.1-mini'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -579,7 +579,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2024-04', release_date: '2025-04-14', - aliases: ['openai/gpt-4.1-nano'], + aliases: ['gpt-4.1-nano-2025-04-14', 'openai/gpt-4.1-nano'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -592,4 +592,45 @@ export const OPEN_AI_MODELS: IChatModel[] = [ context: 1_047_576, max_tokens: 32768, }, + { + puterId: 'openai:openai/chat-latest', + id: 'chat-latest', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2025-08-31', + aliases: ['openai/chat-latest'], + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 500, + cached_tokens: 50, + completion_tokens: 3000, + }, + context: 400_000, + max_tokens: 128_000, + }, + { + puterId: 'openai:openai/gpt-4o-2024-11-20', + id: 'gpt-4o-2024-11-20', + modalities: { input: ['text', 'image'], output: ['text'] }, + open_weights: false, + tool_call: true, + knowledge: '2023-09', + release_date: '2024-11-20', + aliases: ['openai/gpt-4o-2024-11-20'], + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 250, + cached_tokens: 125, + completion_tokens: 1000, + }, + context: 128_000, + max_tokens: 16_384, + }, ]; diff --git a/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts b/src/backend/drivers/ai-chat/providers/openrouter/OpenRouterProvider.ts index 74a254418c..c673cb600a 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 0000000000..cd873ba992 --- /dev/null +++ b/src/backend/drivers/ai-chat/providers/providerConsistency.test.ts @@ -0,0 +1,667 @@ +/* + * 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 { HoonifyProvider } from './hoonify/HoonifyProvider.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: 'hoonify', + dialect: 'chat', + make: () => + new HoonifyProvider({ apiKey: 'k' } as never, 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 }, + }), + // Mistral's reasoning models (magistral) return `content` as a chunk + // array, with the thinking text nested one level deeper inside + // `thinking` chunks. Without the provider's flattening this reaches + // the caller as an array with no `reasoning` at all. + reasoning: () => ({ + choices: [ + { + message: { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: [{ type: 'text', text: REASONING }], + }, + { type: 'text', text: TEXT }, + ], + }, + finishReason: 'stop', + }, + ], + 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); + // `normalize: true` is the contract under test: what a caller who asked + // for the OpenAI shape receives. Providers whose dialect remap is gated on + // the policy (Mistral) need it set, and for every other provider it is a + // no-op — so stating it makes the matrix's premise explicit instead of + // relying on providers equalizing unconditionally. + const res = (await withTestActor(() => + provider.complete({ + messages: [{ role: 'user', content: 'hi' }], + model, + stream: false, + normalize: true, + } 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 8558a08aba..8cc9603a2b 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/providers/zai/ZAIProvider.ts b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts index 9d983d7924..910b3aa7ec 100644 --- a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts @@ -174,7 +174,6 @@ export class ZAIProvider implements IChatProvider { completion, }); - OpenAIUtil.normalizeReasoningContent(result); return result; } diff --git a/src/backend/drivers/ai-chat/providers/zai/models.ts b/src/backend/drivers/ai-chat/providers/zai/models.ts index 5884e706eb..1a109608d8 100644 --- a/src/backend/drivers/ai-chat/providers/zai/models.ts +++ b/src/backend/drivers/ai-chat/providers/zai/models.ts @@ -53,6 +53,16 @@ export const ZAI_MODELS: IChatModel[] = [ 128 * K, usdPerMToken(1.4, 4.4, 0.26), ), + // List price from https://docs.z.ai/guides/overview/pricing ($0.15 in / + // $0.50 out / $0.03 cached per MTok); the page currently shows a 50% + // promotional discount, which we deliberately do not encode. + textModel( + 'glm-5.3-flash', + 'GLM-5.3-Flash', + 1_000 * K, + 128 * K, + usdPerMToken(0.15, 0.5, 0.03), + ), textModel( 'glm-5.2', 'GLM-5.2', diff --git a/src/backend/drivers/ai-chat/types.ts b/src/backend/drivers/ai-chat/types.ts index a5bd4aa42f..d9e83d8c23 100644 --- a/src/backend/drivers/ai-chat/types.ts +++ b/src/backend/drivers/ai-chat/types.ts @@ -92,20 +92,24 @@ 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; - text?: string & { verbosity?: 'concise' | 'detailed' | undefined }; + text?: { verbosity?: 'low' | 'medium' | 'high' | undefined } | undefined; reasoning_effort?: 'low' | 'medium' | 'high' | undefined; - verbosity?: 'concise' | 'detailed' | undefined; + verbosity?: 'low' | 'medium' | 'high' | 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 430832b852..98405466a5 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js @@ -92,7 +92,50 @@ export const process_input_messages_responses_api = async (messages) => { // collapsing the whole message into a single compaction item and dropping // the rest of its content. const expanded = []; - for (const msg of messages) { + for (let msg of messages) { + // Round-tripped reasoning artifacts become standalone `reasoning` + // input items — the shape the Responses API expects them back in — + // and precede the message they were attached to, same as compaction. + // `reasoning`/`refusal`/`normalized` are output-only fields the input + // schema rejects, and a caller replaying a normalized message carries + // them along with the details. + if (msg && typeof msg === 'object') { + const details = msg.reasoning_details; + if ( + details !== undefined || + msg.reasoning !== undefined || + msg.refusal !== undefined || + msg.normalized !== undefined + ) { + // Rebind to a stripped copy rather than deleting: the driver + // reuses this same array across fallback attempts, and these + // objects belong to the caller. + const { + reasoning_details: _details, + reasoning: _reasoning, + refusal: _refusal, + normalized: _normalized, + ...rest + } = msg; + msg = rest; + } + if (Array.isArray(details)) { + for (const block of details) { + if (!block || block.type !== 'reasoning') continue; + expanded.push({ + type: 'reasoning', + ...(block.id !== undefined ? { id: block.id } : {}), + ...(block.encrypted_content !== undefined + ? { encrypted_content: block.encrypted_content } + : {}), + summary: Array.isArray(block.summary) + ? block.summary + : [], + }); + } + } + } + if (msg && Array.isArray(msg.content)) { const compactionBlocks = msg.content.filter( (c) => c && c.type === 'compaction', @@ -271,6 +314,10 @@ const renameReasoningContent = (obj) => { if (obj.reasoning === undefined && obj.reasoning_content !== undefined) { obj.reasoning = obj.reasoning_content; } + // Dropped even when `reasoning` already won: a provider sending both + // means the same thing twice, and the vendor key is the one Puter does not + // expose. Pinned by BytePlusProvider.test.ts / ZAIProvider.test.ts, whose + // fixtures name the value 'should-be-dropped'. delete obj.reasoning_content; }; @@ -439,6 +486,25 @@ export const create_chat_stream_handler_responses_api = continue; } + // Reasoning summaries stream as their own delta events; route + // them to the same `reasoning` channel the chat-completions + // handler uses for Deepseek/OpenRouter, so a streamed reasoning + // model reads identically whichever API served it. + if (chunk.type === 'response.reasoning_summary_text.delta') { + textblock.addReasoning(chunk.delta); + continue; + } + + // Each summary part is a separate delta stream; separate them with + // a blank line, matching the non-stream handler's join. + if ( + chunk.type === 'response.reasoning_summary_part.added' && + chunk.summary_index > 0 + ) { + textblock.addReasoning('\n\n'); + continue; + } + if (chunk.type === 'response.completed') { last_usage = chunk.response.usage; } @@ -541,6 +607,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 +633,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 +701,45 @@ 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 reasoningItems = output.filter((item) => item?.type === 'reasoning'); + const reasoningText = reasoningItems + .flatMap((item) => (Array.isArray(item.summary) ? item.summary : [])) + .map((part) => (typeof part?.text === 'string' ? part.text : '')) + .filter(Boolean) + .join('\n\n'); + + // The item `id` and `encrypted_content` are what let a caller replay a + // reasoning turn into the next request; they are opaque to us and would + // otherwise be lost, so they ride `reasoning_details` verbatim — the same + // round-trip contract as the `compaction` artifact below and as the + // Anthropic thinking blocks the coercer preserves. + const reasoningDetails = reasoningItems + .filter( + (item) => + item.id !== undefined || item.encrypted_content !== undefined, + ) + .map((item) => ({ + type: 'reasoning', + ...(item.id !== undefined ? { id: item.id } : {}), + ...(item.encrypted_content !== undefined + ? { encrypted_content: item.encrypted_content } + : {}), + ...(Array.isArray(item.summary) ? { summary: item.summary } : {}), + })); + 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 } : {}), + ...(reasoningDetails.length + ? { reasoning_details: reasoningDetails } + : {}), refusal: null, role: 'assistant', ...(responseToolCalls.length diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts index fe68d0bb7d..658bbdca9a 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts @@ -292,6 +292,81 @@ describe('process_input_messages_responses_api', () => { ]); }); + it('expands round-tripped reasoning_details into reasoning input items', async () => { + // The replay contract documented in Objects/chatresponse.md: a caller + // resends the whole normalized assistant message, reasoning_details + // included, and the Responses input schema gets back the item shape it + // issued — output-only fields stripped so the request is accepted. + const messages: Array> = [ + { + role: 'assistant', + content: 'earlier reply', + reasoning: 'thought', + refusal: null, + reasoning_details: [ + { + type: 'reasoning', + id: 'rs_1', + encrypted_content: 'ENC', + summary: [{ type: 'summary_text', text: 'thought' }], + }, + ], + }, + ]; + + const out = (await process_input_messages_responses_api( + messages, + )) as Array>; + + expect(out).toHaveLength(2); + // Reasoning item precedes the message it belonged to. + expect(out[0]).toEqual({ + type: 'reasoning', + id: 'rs_1', + encrypted_content: 'ENC', + summary: [{ type: 'summary_text', text: 'thought' }], + }); + expect(out[1]!.role).toBe('assistant'); + // Output-only fields would be rejected by the input schema. + expect('reasoning_details' in out[1]!).toBe(false); + expect('reasoning' in out[1]!).toBe(false); + expect('refusal' in out[1]!).toBe(false); + }); + + it('does not mutate the caller\'s message objects', async () => { + const callerMessage = Object.freeze({ + role: 'assistant', + content: 'earlier reply', + reasoning: 'thought', + refusal: null, + normalized: true, + reasoning_details: Object.freeze([ + Object.freeze({ type: 'reasoning', id: 'rs_1' }), + ]), + }); + const before = JSON.parse(JSON.stringify(callerMessage)); + + const out = (await process_input_messages_responses_api([ + callerMessage, + ] as never)) as Array>; + + expect(callerMessage).toEqual(before); + // The stripped copy is what goes upstream. + expect('reasoning_details' in out[1]!).toBe(false); + expect('normalized' in out[1]!).toBe(false); + }); + + it('leaves messages without reasoning artifacts alone', async () => { + const messages: Array> = [ + { role: 'user', content: 'hi' }, + ]; + const out = (await process_input_messages_responses_api( + messages, + )) as Array>; + expect(out).toHaveLength(1); + expect(out[0]!.role).toBe('user'); + }); + it('upgrades user/system text blocks to input_text', async () => { const messages: Array> = [ { @@ -599,6 +674,78 @@ describe('create_chat_stream_handler_responses_api', () => { }); }); + it('routes reasoning-summary deltas to the reasoning channel', async () => { + const completion = asAsyncIterable([ + { + type: 'response.reasoning_summary_text.delta', + delta: 'first thought', + }, + { + type: 'response.reasoning_summary_part.added', + summary_index: 1, + }, + { + type: 'response.reasoning_summary_text.delta', + delta: 'second thought', + }, + { type: 'response.output_text.delta', delta: 'answer' }, + { + type: 'response.completed', + response: { usage: { input_tokens: 1, output_tokens: 2 } }, + }, + ]); + const init = create_chat_stream_handler_responses_api({ + deviations: undefined, + completion, + usage_calculator: () => ({}), + }); + const harness = makeCapturingChatStream(); + await init({ chatStream: harness.chatStream }); + + const events = harness.events(); + // Same event type the chat-completions handler emits, so a streamed + // reasoning model reads identically whichever API served it. + expect( + events + .filter((e) => e.type === 'reasoning') + .map((e) => e.reasoning) + .join(''), + ).toBe('first thought\n\nsecond thought'); + expect( + events.filter((e) => e.type === 'text').map((e) => e.text), + ).toEqual(['answer']); + }); + + it('does not separate the first summary part with a blank line', async () => { + const completion = asAsyncIterable([ + { + type: 'response.reasoning_summary_part.added', + summary_index: 0, + }, + { type: 'response.reasoning_summary_text.delta', delta: 'only' }, + { type: 'response.output_text.delta', delta: 'answer' }, + { + type: 'response.completed', + response: { usage: { input_tokens: 1, output_tokens: 2 } }, + }, + ]); + const init = create_chat_stream_handler_responses_api({ + deviations: undefined, + completion, + usage_calculator: () => ({}), + }); + const harness = makeCapturingChatStream(); + await init({ chatStream: harness.chatStream }); + + expect( + harness + .events() + .filter((e) => e.type === 'reasoning') + .map((e) => e.reasoning) + .join(''), + ).toBe('only'); + }); + it('emits a compaction event when a compaction output_item completes', async () => { const completion = asAsyncIterable([ { @@ -901,6 +1048,92 @@ describe('handle_completion_output_responses_api non-stream', () => { expect(moderate).toHaveBeenCalledWith('questionable content'); }); + it('joins multi-part reasoning summaries with a blank line', async () => { + const completion = { + output: [ + { + type: 'reasoning', + summary: [ + { type: 'summary_text', text: 'First thought.' }, + { type: 'summary_text', text: 'Second thought.' }, + ], + }, + { role: 'assistant', type: 'message' }, + ], + output_text: 'answer', + usage: { input_tokens: 1, output_tokens: 2 }, + }; + const result = await handle_completion_output_responses_api({ + deviations: undefined, + stream: false, + completion, + }); + expect(result.message.reasoning).toBe( + 'First thought.\n\nSecond thought.', + ); + }); + + it('carries reasoning item id and encrypted_content for replay', async () => { + const completion = { + output: [ + { + type: 'reasoning', + id: 'rs_1', + encrypted_content: 'ENC', + summary: [{ type: 'summary_text', text: 'thought' }], + }, + { role: 'assistant', type: 'message' }, + ], + output_text: 'answer', + usage: { input_tokens: 1, output_tokens: 2 }, + }; + const result = await handle_completion_output_responses_api({ + deviations: undefined, + stream: false, + completion, + }); + expect(result.message.reasoning_details).toEqual([ + { + type: 'reasoning', + id: 'rs_1', + encrypted_content: 'ENC', + summary: [{ type: 'summary_text', text: 'thought' }], + }, + ]); + expect(result.message.reasoning).toBe('thought'); + }); + + it('omits reasoning_details when there are no reasoning items', async () => { + const completion = { + output: [{ role: 'assistant', type: 'message' }], + output_text: 'answer', + usage: { input_tokens: 1, output_tokens: 2 }, + }; + const result = await handle_completion_output_responses_api({ + deviations: undefined, + stream: false, + completion, + }); + expect('reasoning_details' in result.message).toBe(false); + }); + + it('omits reasoning entirely when no summaries were requested', async () => { + const completion = { + output: [ + { type: 'reasoning', summary: [] }, + { role: 'assistant', type: 'message' }, + ], + output_text: 'answer', + usage: { input_tokens: 1, output_tokens: 2 }, + }; + const result = await handle_completion_output_responses_api({ + deviations: undefined, + stream: false, + completion, + }); + expect('reasoning' in result.message).toBe(false); + }); + it('returns a stream init descriptor when stream=true', async () => { const completion = asAsyncIterable([]); const result = await handle_completion_output_responses_api({ diff --git a/src/backend/drivers/ai-chat/utils/compaction.js b/src/backend/drivers/ai-chat/utils/compaction.js index 9ab764bb09..222541c9f8 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 c6a0e6b023..abc36f431b 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 0000000000..d8c0e5a892 --- /dev/null +++ b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.test.ts @@ -0,0 +1,309 @@ +/* + * 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); + // A bare string is not flagged: no provider produces one, so it + // passes through by reference rather than through a coercion path + // nothing exercises. + expect(needsOpenAICoercion('bare string')).toBe(false); + }); + + 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('passes a bare-string message through untouched', () => { + // No provider in the repo returns a bare string message. Rather than + // carry a coercion path nothing exercises, the predicate ignores + // strings and the result comes back by reference. + const res = { + message: 'plain', + usage: { input_tokens: 1, output_tokens: 1 }, + finish_reason: 'stop', + }; + expect(normalizeResultToOpenAI(res)).toBe(res); + }); + + 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('passes an unmapped vendor stop_reason through verbatim', () => { + // `pause_turn` means "continue this turn"; mapping it to `stop` would + // erase that. Objects/chatresponse.md documents the passthrough. + const out = normalizeResultToOpenAI( + claudeResult([{ type: 'text', text: 'x' }], 'pause_turn'), + ); + expect(out.finish_reason).toBe('pause_turn'); + }); + + it('falls back to the existing finish_reason when stop_reason is absent', () => { + const res = claudeResult([{ type: 'text', text: 'x' }]); + delete (res.message as Record).stop_reason; + expect(normalizeResultToOpenAI(res).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. \n\nstep two.'); + expect(out.message.content).toBe('answer'); + }); + + it('preserves thinking blocks verbatim in reasoning_details for replay', () => { + // Anthropic rejects an extended-thinking continuation whose thinking + // blocks lost their signature, so the raw blocks have to survive. + const out = normalizeResultToOpenAI( + claudeResult([ + { type: 'thinking', thinking: 'step one.', signature: 's1' }, + { type: 'redacted_thinking', data: 'ENC' }, + { type: 'text', text: 'answer' }, + ]), + ); + expect(out.message.reasoning_details).toEqual([ + { type: 'thinking', thinking: 'step one.', signature: 's1' }, + { type: 'redacted_thinking', data: 'ENC' }, + ]); + }); + + it('omits reasoning_details when there was no reasoning', () => { + const out = normalizeResultToOpenAI( + claudeResult([{ type: 'text', text: 'answer' }]), + ); + expect('reasoning_details' in (out.message as object)).toBe(false); + }); + + it('drops compaction and unknown blocks', () => { + const out = normalizeResultToOpenAI({ + ...claudeResult([ + { 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 0000000000..cca04be786 --- /dev/null +++ b/src/backend/drivers/ai-chat/utils/normalizeToOpenAI.ts @@ -0,0 +1,227 @@ +/** + * 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 (!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); +}; + +/** + * Resolve whether a call's non-streaming result should be presented in the + * OpenAI shape. This is the single definition of the precedence rule: 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, and otherwise the release-date cutoff decides. + * + * Both the driver (which coerces the result) and any provider that has to + * choose between emitting its vendor's dialect or the equalized one call this, + * so the two can never drift apart. A provider passes its own served model's + * `release_date` — which is the served model by definition, since the provider + * is the one serving it. + */ +export const shouldPresentAsOpenAI = ( + args: { + normalize?: boolean | undefined; + response?: { normalize?: boolean | undefined } | undefined; + }, + release_date?: string, +): boolean => { + if (args.normalize === true) return true; + if (args.normalize === false) return false; + // The legacy flag normalizes in the *opposite* direction (to Anthropic + // blocks), so it suppresses the OpenAI presentation rather than enabling it. + if (args.response?.normalize) return false; + return isPostCutoffRelease(release_date); +}; + +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' && stop_reason !== '') { + // A vendor reason with no OpenAI analog passes through verbatim — the + // same contract the Mistral remap follows and the one + // Objects/chatresponse.md documents. Collapsing e.g. Anthropic's + // `pause_turn` to `stop` would erase a "continue this turn" signal + // the caller needs to act on. + return STOP_REASON_TO_FINISH_REASON[stop_reason] ?? stop_reason; + } + return fallback ?? 'stop'; +}; + +/** + * Verbatim provider reasoning blocks, preserved so a normalized message can + * still be replayed into an extended-thinking tool-use continuation. Anthropic + * rejects a continuation whose thinking blocks lost their `signature`, and + * `redacted_thinking` is opaque but must round-trip intact. Modelled on the + * top-level `compaction` artifact: opaque to us, drop-in for the caller. + */ +type ReasoningDetail = Record; + +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` plus + * verbatim `reasoning_details` for replay) and remaps `finish_reason` from the + * Anthropic `stop_reason`, passing an unmapped vendor reason through verbatim. + * 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; + + const native = res.message as Record; + const blocks = Array.isArray(native.content) + ? (native.content as unknown[]) + : []; + + const textParts: string[] = []; + const reasoningParts: string[] = []; + const reasoningDetails: ReasoningDetail[] = []; + 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); + } + reasoningDetails.push({ ...b }); + break; + // Encrypted, so there is no text to surface — but it still has to + // survive the round trip, so it rides `reasoning_details` too. + case 'redacted_thinking': + reasoningDetails.push({ ...b }); + 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; + // `compaction` already rides the result's top-level + // `compaction` field; it — and any block type introduced + // later — is dropped rather than leaked into a shape that has + // nowhere to put it. + default: + break; + } + } + + // Null content alongside tool_calls mirrors OpenAI's own convention for + // tool-only turns. + const content = textParts.length > 0 ? textParts.join('') : null; + // Separate thinking blocks are separate segments of reasoning, so they + // get a blank line between them — matching the Responses summary handler + // and the Mistral chunk splitter, and what chatresponse.md documents. + // (Text blocks above still join with '' because Anthropic splits prose + // mid-sentence across blocks.) + const reasoning = + reasoningParts.length > 0 ? reasoningParts.join('\n\n') : undefined; + + return { + ...res, + message: { + role: 'assistant', + content, + refusal: null, + ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), + ...(reasoning !== undefined ? { reasoning } : {}), + ...(reasoningDetails.length > 0 + ? { reasoning_details: reasoningDetails } + : {}), + }, + 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 676dc4f6e1..52e0fa7c98 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 c6231ea9a5..8621e98d21 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 add76a82dc..848f61873b 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 33f7d4d6f7..e346a228eb 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 29dd45ed8e..bee62755d1 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 288bcb61f0..487d87b66c 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` — or the vendor's own stop reason, passed through unchanged when it has no OpenAI equivalent. When `false`, the response keeps the vendor's native format (for Anthropic models, an array of content blocks). When unset, the SDK-wide `puter.ai.normalize` applies — itself tri-state: set it to `true` to normalize every call regardless of release date, `false` to disable normalization for every call, and when it is left unset too (the default) the release-date policy applies: **models released on or after September 1, 2026 return normalized (OpenAI-format) responses by default**, and for older models the default is unchanged — `message.content` keeps its vendor-native shape. (A handful of reasoning fields were made consistent across all models independently of this option; see [Reasoning fields on existing models](#reasoning-fields-on-existing-models).) 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,61 @@ 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. For models released before that date, the `normalize` default does not change: leave the option unset and `message.content` keeps its vendor-native shape. + +### Reasoning fields on existing models + +Separately from the `normalize` default, four reasoning-related fields were made consistent across vendors. These apply to **every** model, including ones released before the cutoff, and are not affected by `normalize`: + +| Field | Before | Now | +| --- | --- | --- | +| `message.reasoning_content` | Present on providers following the DeepSeek convention (DeepSeek, OpenRouter and others) | **Renamed to `message.reasoning`.** Read `reasoning` instead — `reasoning_content` is no longer present on non-streaming responses. | +| `message.reasoning` on OpenAI Responses models | Always present as `null` | Absent when the model returned no reasoning summary; a string when it did. `if (msg.reasoning)` is unaffected; `'reasoning' in msg` changes. | +| `message.reasoning_details` on OpenAI Responses models | Not present | Present when the model returned reasoning items, carrying their `id` and `encrypted_content` for replay. | +| `finish_reason` on OpenAI Responses models | Always `"stop"` | `"tool_calls"` when the turn ended in tool calls, `"stop"` otherwise. | + +If your code reads `message.reasoning_content` on a non-streaming response, that is the one change that removes a field — switch to `message.reasoning`. + +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); // a string, or null on a tool-only turn +console.log(response.finish_reason); // "stop" | "length" | "tool_calls" | "content_filter" | vendor value + +// 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 +// Unset (the default): the release-date rule applies — models released on or +// after September 1, 2026 return the OpenAI format, older models stay native. +puter.ai.normalize = true; // force normalization: every chat() call + // returns the OpenAI format, old or new model +puter.ai.normalize = false; // disable normalization: every chat() call + // returns the vendor-native format +puter.ai.normalize = undefined; // back to the release-date rule +``` + +A `normalize` option on an individual call always overrides `puter.ai.normalize` in either direction. 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`). A vendor stop reason with no OpenAI equivalent — Anthropic's `pause_turn`, for instance — passes through unchanged, so treat `finish_reason` as an open set. See [`finish_reason`](/Objects/chatresponse) for the full mapping. + +Normalization does not cost you the ability to continue a reasoning turn. The opaque parts a provider needs back — Anthropic thinking-block signatures, OpenAI reasoning item ids and encrypted content — are preserved verbatim on `message.reasoning_details`. Resend that array as-is alongside the message when you continue an extended-thinking tool-use loop. The artifacts are vendor-specific and only meaningful to the model that produced them, so replay them to the same model — don't carry them across vendors. + +One caveat. 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. + +One thing to know about the release-date rule: a model's release date comes from the catalog of whichever provider serves it, and some providers report it from their own live listing. Models served through OpenRouter carry the date OpenRouter itself assigns, so a model newly listed there on or after September 1, 2026 is normalized by default without Puter shipping any change. Pin `normalize: false` if your code depends on a provider's native shape. + +Streaming is unaffected by normalization: streamed [`ChatResponseChunk`](/Objects/chatresponsechunk) objects already share one format across all vendors, and the chunk types a model emits do not depend on the `normalize` option. Reasoning models stream their thinking as `reasoning` chunks on every provider, whether or not normalization applies. + ## 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 +700,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 dc4762594b..c38363d84c 100644 --- a/src/docs/src/Objects/chatresponse.md +++ b/src/docs/src/Objects/chatresponse.md @@ -13,16 +13,45 @@ 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. On normalized (OpenAI-format) responses — which includes all models released on or after September 1, 2026 and any call made with `normalize: true` — this is a string, or `null` when the model returned only tool calls and no text. 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. Multiple reasoning segments are joined with a blank line between them. + +- `reasoning_details` (Array) - Optional opaque reasoning artifacts from models that expose them. Present on normalized Anthropic responses, and on OpenAI Responses-API models whether or not the response was normalized. Contents: Anthropic `thinking`/`redacted_thinking` blocks with their `signature`, or OpenAI reasoning items with their `id` and `encrypted_content`. Treat the contents as opaque and resend the array verbatim to continue an extended-thinking turn — providers reject a continuation whose reasoning lost its signature. The human-readable text is in `reasoning`; this field is only for the round trip. + - `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 the OpenAI vocabulary — `stop`, `length`, `tool_calls`, or `content_filter` — and a vendor value with no OpenAI analog passes through unchanged rather than being flattened to `stop`. + +Anthropic models are the main source of both cases. Their stop reasons map as follows: + +| Anthropic `stop_reason` | Normalized `finish_reason` | Meaning | +| --- | --- | --- | +| `end_turn` | `stop` | The model finished its turn. | +| `stop_sequence` | `stop` | One of your stop sequences was produced. | +| `max_tokens` | `length` | The token limit was hit mid-answer. | +| `tool_use` | `tool_calls` | The model wants to call a tool; see `message.tool_calls`. | +| `refusal` | `content_filter` | The model declined to continue. | +| `pause_turn` | `pause_turn` | A long-running server-side tool turn was paused — it has no OpenAI analog, so it passes through unchanged. Send the response back as-is to let the model continue. | + +Because unmapped values pass through, treat `finish_reason` as an open set: branch on the four OpenAI values you care about and handle anything else as vendor-specific rather than assuming it means `stop`. + +#### `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 f144ac4303..60288d09ef 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 @@ }, { 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 ab5da8a991..e62cdc67f3 100644 --- a/src/puter-js/src/modules/ai/ai.test.js +++ b/src/puter-js/src/modules/ai/ai.test.js @@ -238,6 +238,59 @@ describe('ai.chat driver payloads', () => { expect(String(result)).toBe('the answer'); expect(result.valueOf()).toBe('the answer'); }); + + // Response-format normalization: both the per-call option and the + // SDK-wide `ai.normalize` are tri-state (unset defers, true/false + // force). SDK-wide unset (the default) means the release-date policy — + // nothing rides the wire; an explicit `true` or `false` is sent on + // every call that does not set its own. + 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 is unset by default, which stays off the wire (the release-date policy)', async () => { + expect(ai.normalize).toBeUndefined(); + await ai.chat('hello'); + expect('normalize' in lastBody().args).toBe(false); + }); + + it('ai.normalize = true force-normalizes calls that do not set it', async () => { + ai.normalize = true; + await ai.chat('hello'); + expect(lastBody().args.normalize).toBe(true); + }); + + it('ai.normalize = false disables normalization for 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('clearing ai.normalize restores the release-date policy', async () => { + ai.normalize = false; + await ai.chat('hello'); + expect(lastBody().args.normalize).toBe(false); + + ai.normalize = undefined; + 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 0389d5a79f..fc7b2c9657 100644 --- a/src/puter-js/src/modules/ai/chat.js +++ b/src/puter-js/src/modules/ai/chat.js @@ -227,6 +227,19 @@ export async function chat ( } } + // Response-format normalization. Both the per-call option and the + // SDK-wide `puter.ai.normalize` are tri-state: `true` forces the OpenAI + // shape regardless of release date, `false` forces the vendor-native + // shape, and unset defers — the per-call option to the SDK-wide flag, + // and the SDK-wide flag (unset by default) to the server's release-date + // policy, which normalizes models released on or after 2026-09-01. Only + // an explicit value rides the wire. + if (userParams.normalize !== undefined) { + requestParams.normalize = userParams.normalize; + } else if (this.normalize !== undefined) { + 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 a66aaf532a..b652c8fd96 100644 --- a/src/puter-js/src/modules/ai/index.js +++ b/src/puter-js/src/modules/ai/index.js @@ -30,6 +30,19 @@ export class AIModule extends PuterModule { /** @type {Txt2Speech} */ txt2speech; + /** + * SDK-wide switch for response-format normalization. Left unset (the + * default), the release-date policy applies: models released on or + * after September 1, 2026 return the OpenAI-style shape, older models + * keep their vendor-native shape. Set it to `true` to normalize every + * `chat()` call regardless of release date, or `false` to disable + * normalization for every call. A `normalize` option on an individual + * `chat()` call overrides this in either direction. + * + * @type {boolean | undefined} + */ + normalize = undefined; + // 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 9e7161425f..67974001a4 100644 --- a/src/puter-js/src/modules/ai/types.js +++ b/src/puter-js/src/modules/ai/types.js @@ -45,6 +45,13 @@ * @property {{ type: string }} [cache_control] * @property {ImageContent[]} [images] Images attached to the message. Present on responses from * image-capable models. + * @property {string} [reasoning] Reasoning/thinking text, when the model exposes it and the + * request asked for it. Present on responses only. + * @property {object[]} [reasoning_details] Opaque provider reasoning artifacts (Anthropic thinking + * signatures, OpenAI reasoning item ids/encrypted content). Resend them verbatim to continue an + * extended-thinking turn. Present on responses only. + * @property {string | null} [refusal] Refusal message when the model declined, otherwise `null`. + * Present on responses only. */ /** @@ -60,6 +67,13 @@ * @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 or release date (`message.content` as a string, + * `message.tool_calls`, a mapped `finish_reason`); `false` forces the provider's native shape. Left + * unset, the SDK-wide `puter.ai.normalize` applies — itself tri-state: `true` normalizes every call, + * `false` disables normalization for every call, and unset (the default) means the release-date + * policy: models released on or after September 1, 2026 are normalized, older models keep their native + * shape. 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 +109,11 @@ * * @typedef {Object} ChatResponse * @property {ChatMessage} [message] + * @property {string} [finish_reason] Why generation stopped: `stop`, `length`, `tool_calls`, or + * `content_filter` — or the vendor's own stop reason (e.g. Anthropic's `pause_turn`), passed + * through unchanged when it has no OpenAI equivalent. Treat it as an open set. + * @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 694c3fd21a..cc45172f55 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', {