From 53c77c833365f110e3e0805f2294b7a0bbbe88a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Kujund=C5=BEi=C4=87?= <51266541+404oops@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:43:35 +0200 Subject: [PATCH 1/7] feat(ai): support Kimi K3.1 Pro models --- .../ai-chat/providers/OpenAICompatProvider.ts | 33 ++- .../moonshot/MoonshotProvider.test.ts | 267 ++++++++++++++++++ .../providers/moonshot/MoonshotProvider.ts | 133 +++++++++ .../ai-chat/providers/moonshot/models.ts | 46 +++ .../drivers/ai-chat/utils/OpenAIUtil.js | 19 +- src/docs/src/AI/chat.md | 10 +- src/docs/src/rate-limits-and-quotas.md | 2 + src/puter-js/src/modules/ai/types.js | 2 +- 8 files changed, 500 insertions(+), 12 deletions(-) diff --git a/src/backend/drivers/ai-chat/providers/OpenAICompatProvider.ts b/src/backend/drivers/ai-chat/providers/OpenAICompatProvider.ts index 1a8b0c187..14ab4f791 100644 --- a/src/backend/drivers/ai-chat/providers/OpenAICompatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/OpenAICompatProvider.ts @@ -194,7 +194,9 @@ export class OpenAICompatProvider implements IChatProvider { await OpenAIUtil.process_input_messages(messages, { preserveToolCallOrder: this.preservesToolCallOrder(model), keepExtraContent: this.keepsToolCallExtraContent(model), + keepAssistantContent: this.preservesAssistantContent(model), }), + model, ); const tools = args.tools @@ -235,9 +237,10 @@ export class OpenAICompatProvider implements IChatProvider { let pricingTime = attemptPricingTime ?? new Date(); let completion; try { - completion = await options.client.chat.completions.create( - params as never, - { signal }, + completion = await this.createCompletion( + options.client, + params, + signal, ); } catch (e) { const retryParams = @@ -249,9 +252,10 @@ export class OpenAICompatProvider implements IChatProvider { : undefined; if (!retryParams) throw e; pricingTime = attemptPricingTime ?? new Date(); - completion = await options.client.chat.completions.create( - retryParams as never, - { signal }, + completion = await this.createCompletion( + options.client, + retryParams, + signal, ); } @@ -324,8 +328,23 @@ export class OpenAICompatProvider implements IChatProvider { return false; } + protected preservesAssistantContent(_model: IChatModel): boolean { + return false; + } + + protected createCompletion( + client: ChatCompletionsClient, + params: Record, + signal?: AbortSignal, + ): Promise { + return client.chat.completions.create(params as never, { signal }); + } + /** Last pass over the messages, after `process_input_messages`. */ - protected prepareMessages(messages: PuterMessage[]): PuterMessage[] { + protected prepareMessages( + messages: PuterMessage[], + _model: IChatModel, + ): PuterMessage[] { return messages; } 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 6a23081a5..f140f14fd 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts @@ -782,3 +782,270 @@ describe('MoonshotProvider.checkModeration', () => { ); }); }); + +describe('Kimi K3.1 Pro', () => { + const models = ['kimi-k3.1-pro', 'kimi-k3.1-pro-highspeed']; + const usage = { + prompt_tokens: 100, + completion_tokens: 10, + prompt_tokens_details: { cached_tokens: 20, cache_write_tokens: 50 }, + }; + const response = () => ({ + choices: [ + { + message: { + role: 'assistant', + content: 'answer', + reasoning_content: 'reasoning', + }, + finish_reason: 'stop', + }, + ], + usage: structuredClone(usage), + }); + const mockResponse = (data: unknown, oneHour = '10') => { + createMock.mockReturnValueOnce({ + withResponse: async () => ({ + data, + response: new Response(null, { + headers: { 'Msh-Usage-Cache-Write-Tokens-1h': oneHour }, + }), + }), + }); + }; + + it.each(models)( + 'maps %s caps and reasoning without sampling parameters', + async (model) => { + const { provider } = makeProvider(); + mockResponse(response()); + await withTestActor(() => + provider.complete({ + model, + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 512, + reasoning_effort: 'high', + temperature: 0.2, + top_p: 0.1, + }), + ); + expect(createMock.mock.calls[0]![0]).toMatchObject({ + model, + max_completion_tokens: 512, + reasoning_effort: 'high', + }); + expect(createMock.mock.calls[0]![0]).not.toHaveProperty( + 'max_tokens', + ); + expect(createMock.mock.calls[0]![0]).not.toHaveProperty( + 'temperature', + ); + expect(createMock.mock.calls[0]![0]).not.toHaveProperty('top_p'); + }, + ); + + it('uses the official default output cap and preserves the default thinking mode', async () => { + const { provider } = makeProvider(); + mockResponse(response()); + await withTestActor(() => + provider.complete({ + model: models[0]!, + messages: [{ role: 'user', content: 'hi' }], + }), + ); + expect(createMock.mock.calls[0]![0].max_completion_tokens).toBe(131072); + expect(createMock.mock.calls[0]![0]).not.toHaveProperty('thinking'); + expect(createMock.mock.calls[0]![0]).not.toHaveProperty( + 'reasoning_effort', + ); + }); + + it.each(['low', 'max'] as const)( + 'forwards %s reasoning effort', + async (effort) => { + const { provider } = makeProvider(); + mockResponse(response(), '0'); + await withTestActor(() => + provider.complete({ + model: models[0]!, + messages: [{ role: 'user', content: 'hi' }], + reasoning_effort: effort, + }), + ); + expect(createMock.mock.calls[0]![0].reasoning_effort).toBe(effort); + }, + ); + + it('keeps K3 on its existing request and billing path', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(response()); + await withTestActor(() => + provider.complete({ + model: 'kimi-k3', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 512, + }), + ); + expect(createMock.mock.calls[0]![0]).toMatchObject({ + model: 'kimi-k3', + max_tokens: 512, + }); + expect(createMock.mock.calls[0]![0]).not.toHaveProperty( + 'max_completion_tokens', + ); + expect(recordSpy.mock.calls[0]![0]).toEqual({ + prompt_tokens: 80, + completion_tokens: 10, + cached_tokens: 20, + }); + }); + + it('rejects unsupported reasoning effort before inference', async () => { + const { provider } = makeProvider(); + await expect( + withTestActor(() => + provider.complete({ + model: models[0]!, + messages: [{ role: 'user', content: 'hi' }], + reasoning_effort: 'medium', + }), + ), + ).rejects.toMatchObject({ statusCode: 400 }); + expect(createMock).not.toHaveBeenCalled(); + }); + + it.each(models)( + 'meters disjoint cache reads, writes and uncached input for %s', + async (model) => { + const { provider } = makeProvider(); + mockResponse(response()); + const result = await withTestActor(() => + provider.complete({ + model, + messages: [{ role: 'user', content: 'hi' }], + }), + ); + const inputRate = model.endsWith('highspeed') ? 400 : 200; + expect(recordSpy).toHaveBeenCalledWith( + { + prompt_tokens: 30, + completion_tokens: 10, + cached_tokens: 20, + cache_write_tokens: 40, + cache_write_1h_tokens: 10, + }, + expect.anything(), + `moonshotai:${model}`, + { + prompt_tokens: 30 * inputRate, + completion_tokens: 10 * inputRate * 5, + cached_tokens: 20 * 20, + cache_write_tokens: 40 * inputRate, + cache_write_1h_tokens: 10 * inputRate * 2, + }, + ); + expect(result).toMatchObject({ + usageDetails: { + inputTokens: 30, + outputTokens: 10, + cacheReadTokens: 20, + cacheWrite5mTokens: 40, + cacheWrite1hTokens: 10, + }, + }); + }, + ); + + it('preserves assistant reasoning, text and tool-call order on replay', async () => { + const { provider } = makeProvider(); + const toolCalls = ['a', 'b'].map((id) => ({ + id, + type: 'function', + function: { name: 'lookup', arguments: '{}' }, + })); + const first = response(); + Object.assign(first.choices[0]!.message, { tool_calls: toolCalls }); + mockResponse(first); + const result = await withTestActor(() => + provider.complete({ + model: models[0]!, + messages: [{ role: 'user', content: 'hi' }], + }), + ); + mockResponse(response()); + await withTestActor(() => + provider.complete({ + model: models[0]!, + messages: [(result as { message: unknown }).message], + }), + ); + expect(createMock.mock.calls[1]![0].messages[0]).toEqual({ + role: 'assistant', + content: 'answer', + reasoning_content: 'reasoning', + tool_calls: toolCalls, + }); + mockResponse(response()); + await withTestActor(() => + provider.complete({ + model: models[0]!, + messages: [ + { + role: 'assistant', + reasoning: 'reasoning', + content: [ + { type: 'text', text: 'answer' }, + ...['a', 'b'].map((id) => ({ + type: 'tool_use', + id, + name: 'lookup', + input: {}, + })), + ], + }, + ], + }), + ); + expect(createMock.mock.calls[2]![0].messages[0]).toEqual({ + role: 'assistant', + reasoning_content: 'reasoning', + content: [{ type: 'text', text: 'answer' }], + tool_calls: toolCalls, + }); + }); + + it('meters streamed cache writes from final usage and response headers', async () => { + const { provider } = makeProvider(); + mockResponse( + asAsyncIterable([ + { choices: [{ delta: { reasoning_content: 'reasoning' } }] }, + { choices: [{ delta: { content: 'answer' } }] }, + { choices: [], usage: structuredClone(usage) }, + ]), + ); + const result = await withTestActor(() => + provider.complete({ + model: models[0]!, + messages: [{ role: 'user', content: 'hi' }], + stream: true, + }), + ); + const harness = makeCapturingChatStream(); + await ( + result as { + init_chat_stream: (p: { chatStream: unknown }) => Promise; + } + ).init_chat_stream({ chatStream: harness.chatStream }); + expect(createMock.mock.calls[0]![0].stream_options).toEqual({ + include_usage: true, + }); + expect(recordSpy).toHaveBeenCalledTimes(1); + expect(recordSpy.mock.calls[0]![0]).toEqual({ + prompt_tokens: 30, + completion_tokens: 10, + cached_tokens: 20, + cache_write_tokens: 40, + cache_write_1h_tokens: 10, + }); + }); +}); diff --git a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts index 1b9386299..7183e0e7b 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts @@ -18,14 +18,31 @@ */ import { OpenAI } from 'openai'; +import type { + ChatCompletion, + ChatCompletionChunk, +} from 'openai/resources/chat/completions.js'; +import type { Stream } from 'openai/streaming.js'; +import { HttpError } from '../../../../core/http/HttpError.js'; +import type { + IChatModel, + ICompleteArguments, + PuterMessage, +} from '../../types.js'; +import { openAICompatParams } from '../../utils/openaiParams.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { sdkClientOptions } from '../../utils/sdkClient.js'; import { type ChatProviderConfig, + type ChatCompletionsClient, + type UsageSource, OpenAICompatProvider, } from '../OpenAICompatProvider.js'; import { MOONSHOT_MODELS } from './models.js'; +const isK31 = (id: unknown) => + id === 'kimi-k3.1-pro' || id === 'kimi-k3.1-pro-highspeed'; + export class MoonshotProvider extends OpenAICompatProvider { constructor(config: ChatProviderConfig, meteringService: MeteringService) { super(meteringService, { @@ -41,4 +58,120 @@ export class MoonshotProvider extends OpenAICompatProvider { inlineImages: 'vision', }); } + + protected override preservesToolCallOrder(model: IChatModel) { + return isK31(model.id); + } + + protected override preservesAssistantContent(model: IChatModel) { + return isK31(model.id); + } + + protected override prepareMessages( + messages: PuterMessage[], + model: IChatModel, + ) { + if (!isK31(model.id)) return messages; + return messages.map((message) => { + if (message.role !== 'assistant') return message; + const { reasoning, normalized: _normalized, ...rest } = message; + return { + ...rest, + ...(rest.reasoning_content === undefined && + reasoning !== undefined + ? { reasoning_content: reasoning } + : {}), + }; + }); + } + + protected override vendorParams( + params: Record, + args: ICompleteArguments, + model: IChatModel, + ) { + if (!isK31(model.id)) return params; + if ( + args.reasoning_effort && + !['low', 'high', 'max'].includes(args.reasoning_effort) + ) { + throw new HttpError( + 400, + 'Kimi K3.1 Pro reasoning_effort must be low, high, or max', + { + legacyCode: 'bad_request', + }, + ); + } + const { max_tokens, ...rest } = params; + return { + ...rest, + ...openAICompatParams(args, 'chat', { + only: ['tool_choice', 'response_format', 'reasoning_effort'], + }), + max_completion_tokens: max_tokens ?? 131_072, + }; + } + + protected override async createCompletion( + client: ChatCompletionsClient, + params: Record, + signal?: AbortSignal, + ): Promise { + if (!isK31(params.model)) + return super.createCompletion(client, params, signal); + // TTL is locked by the cached prefix, so bill the response's TTL breakdown. + const { data, response } = await (client as OpenAI).chat.completions + .create(params as never, { signal }) + .withResponse(); + const oneHourWrites = Number( + response.headers.get('Msh-Usage-Cache-Write-Tokens-1h') ?? 0, + ); + const addWriteUsage = ( + completion: ChatCompletion | ChatCompletionChunk, + ) => { + if (completion.usage) { + Object.assign(completion.usage, { + moonshotCacheWrite1hTokens: oneHourWrites, + }); + } + return completion; + }; + if (!params.stream) return addWriteUsage(data as ChatCompletion); + return (async function* () { + for await (const chunk of data as Stream) { + yield addWriteUsage(chunk); + } + })(); + } + + protected override meteredUsage(source: UsageSource, model: IChatModel) { + if (!isK31(model.id)) return super.meteredUsage(source, model); + const usage = source.usage as typeof source.usage & { + moonshotCacheWrite1hTokens?: number; + prompt_tokens_details?: { + cached_tokens?: number; + cache_write_tokens?: number; + }; + }; + const cached = usage.prompt_tokens_details?.cached_tokens ?? 0; + const written = usage.prompt_tokens_details?.cache_write_tokens ?? 0; + const oneHour = usage.moonshotCacheWrite1hTokens ?? 0; + return { + usage: { + prompt_tokens: usage.prompt_tokens - cached - written, + completion_tokens: usage.completion_tokens, + cached_tokens: cached, + cache_write_tokens: written - oneHour, + cache_write_1h_tokens: oneHour, + }, + usageDetails: { + inputTokens: usage.prompt_tokens - cached - written, + outputTokens: usage.completion_tokens, + cacheReadTokens: cached, + cacheWrite5mTokens: written - oneHour, + cacheWrite1hTokens: oneHour, + }, + }; + } } diff --git a/src/backend/drivers/ai-chat/providers/moonshot/models.ts b/src/backend/drivers/ai-chat/providers/moonshot/models.ts index baf92746e..34591fc98 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/models.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/models.ts @@ -20,6 +20,52 @@ import type { IChatModel } from '../../types.js'; export const MOONSHOT_MODELS: IChatModel[] = [ + { + puterId: 'moonshotai:moonshotai/kimi-k3.1-pro', + id: 'kimi-k3.1-pro', + name: 'Kimi K3.1 Pro', + aliases: ['moonshotai/kimi-k3.1-pro', 'moonshot/kimi-k3.1-pro'], + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 200, + completion_tokens: 1000, + cached_tokens: 20, + cache_write_tokens: 200, + cache_write_1h_tokens: 400, + }, + context: 1_048_576, + max_tokens: 1_048_576, + tool_call: true, + }, + { + puterId: 'moonshotai:moonshotai/kimi-k3.1-pro-highspeed', + id: 'kimi-k3.1-pro-highspeed', + name: 'Kimi K3.1 Pro Highspeed', + aliases: [ + 'moonshotai/kimi-k3.1-pro-highspeed', + 'moonshot/kimi-k3.1-pro-highspeed', + ], + modalities: { input: ['text', 'image', 'video'], output: ['text'] }, + costs_currency: 'usd-cents', + input_cost_key: 'prompt_tokens', + output_cost_key: 'completion_tokens', + costs: { + tokens: 1_000_000, + prompt_tokens: 400, + completion_tokens: 2000, + cached_tokens: 20, + cache_write_tokens: 400, + cache_write_1h_tokens: 800, + }, + context: 1_048_576, + max_tokens: 1_048_576, + tool_call: true, + }, + // -- Flagship ---------------------------------------------------- { puterId: 'moonshotai:moonshotai/kimi-k3', diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js index 851612317..a85e40252 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js @@ -190,14 +190,22 @@ const hoistToolUseBlocks = ( * make changes in-place. * * @param {Message[]} messages - Array of normalized messages - * @param {{ preserveToolCallOrder?: boolean; keepExtraContent?: boolean }} [opts] + * @param {{ + * preserveToolCallOrder?: boolean; + * keepExtraContent?: boolean; + * keepAssistantContent?: boolean; + * }} [opts] * `preserveToolCallOrder` keeps a turn's tool calls in the order the model made * them. `keepExtraContent` keeps each tool call's `extra_content`. * @returns {Message[]} - Array of messages in OpenAI format */ export const process_input_messages = async ( messages, - { preserveToolCallOrder = false, keepExtraContent = false } = {}, + { + preserveToolCallOrder = false, + keepExtraContent = false, + keepAssistantContent = false, + } = {}, ) => { for (const msg of messages) { if (!msg.content) continue; @@ -220,7 +228,12 @@ export const process_input_messages = async ( extraContent: keepExtraContent, }) ) - msg.content = null; + msg.content = + keepAssistantContent && + msg.role === 'assistant' && + content.length + ? content + : null; // coerce tool results // (we assume multiple tool results were already split into separate messages) diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md index 2fb8a20ff..0fb388a6d 100755 --- a/src/docs/src/AI/chat.md +++ b/src/docs/src/AI/chat.md @@ -34,7 +34,7 @@ An object containing the following properties: - `max_tokens` (Number) - The maximum number of tokens to generate in the completion. By default, the specific model's maximum is used. - `temperature` (Number) - A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. Some OpenAI reasoning models ignore `temperature` and `top_p`. GPT-5.6 and GPT-6 Sol/Luna accept them only with `reasoning_effort: 'none'`, and GPT-6 Astra, GPT-6.1 Sol, and GPT-5.3 Codex always ignore them. `gemini-4-argon` ignores `temperature` too. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` use it. - `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, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only; Muse Spark always reasons, so `none` is ignored for it. `gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only. +- `reasoning_effort` / `reasoning.effort` (String) (Optional) - Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. Lower values give faster responses with less reasoning. OpenAI models, Moonshot's Kimi K3.1 Pro models, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only; Muse Spark always reasons, so `none` is ignored for it. `gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only. - `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). @@ -45,6 +45,14 @@ An object containing the following properties: - `thinking` (Object | null) (Optional) - Extended-thinking control: `{ type: "adaptive" | "enabled" | "disabled" | "between_tools", budgetTokens?, display? }`. Pass `null` to send no thinking configuration at all (the model's own default); leaving it unset falls back to the legacy derivation from `reasoning_effort`. `budgetTokens` only applies to `type: "enabled"`. - `streamToolInput` (Boolean) (Optional) - Streaming only. When `true`, a tool call's arguments arrive incrementally as `tool_use_start` / `tool_input_delta` chunks instead of a single `tool_use` chunk once the call completes. +### Kimi K3.1 Pro + +Select `kimi-k3.1-pro` or `kimi-k3.1-pro-highspeed` with `provider: 'moonshotai'`. Both models accept `reasoning_effort` values `low`, `high`, and `max`, with `max` as the default. Thinking is always enabled. Sampling options use the model's fixed settings. + +The context window is 1,048,576 tokens. Use `max_tokens` to set an output cap up to 1,048,576 tokens, subject to the remaining context window and available credits. Preserve the complete returned assistant message, including its `reasoning` and tool calls, when continuing a conversation. + +Usage separates uncached input, cached reads, cache writes by TTL, and output. Cache writes are billed once when a prefix is stored. The upstream cache uses a five-minute TTL by default. For current rates, see the [AI models list](https://developer.puter.com/ai/models/). + #### `testMode` (Boolean) (Optional) A boolean indicating whether you want to use the test API. Defaults to `false`. This is useful for testing your code without using up API credits. diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 1ec1b1238..50e5bbfeb 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -77,6 +77,8 @@ An input over its limit is rejected before it reaches the model, as is an image The standard chat timeout is 10 minutes for a response to start, or to finish if it isn't streamed. Stream durations depend on the provider. Native Gemini applies the 10-minute timeout to the whole response, including streaming. Groq uses 60 seconds to start a response. Mistral streams have no timeout of their own. If the caller disconnects, a stream that's still running gets 10 more minutes to finish, and is then stopped. See the [AI model directory](https://developer.puter.com/ai/models/) for model specifications. +Moonshot's `kimi-k3.1-pro` and `kimi-k3.1-pro-highspeed` have a 1,048,576-token context window. Their output cap can be set with `max_tokens` up to 1,048,576, subject to remaining context and available credits. + A `gemini-4-argon` response that isn't streamed is capped at 32,768 output tokens, and a larger `max_tokens` is lowered to that cap. Streaming uses the model-directory limit. Text-to-speech calls to ElevenLabs, xAI, Speechify, Kling and Gemini time out after 2 minutes; xAI speech-to-text and voice-changer calls after 10 minutes, upload included. A call that times out before any audio or transcript comes back fails with `504 upstream_timeout`. For Gemini, the 2 minutes cover the whole reply, audio included. diff --git a/src/puter-js/src/modules/ai/types.js b/src/puter-js/src/modules/ai/types.js index 082316538..cea7a67ee 100644 --- a/src/puter-js/src/modules/ai/types.js +++ b/src/puter-js/src/modules/ai/types.js @@ -77,7 +77,7 @@ * 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 + * form. Accepted values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` (availability varies by * model; default `medium` on newer GPT-5.x models). Reasoning models only. * @property {{ effort: string }} [reasoning] Nested form of `reasoning_effort`. The `effort` value * accepts the same values as `reasoning_effort`. Reasoning models only. From 03eab894a0a71cf173f6ae66d05f143be8e3a52c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Kujund=C5=BEi=C4=87?= <51266541+404oops@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:56:42 +0200 Subject: [PATCH 2/7] fix(ai): reserve Kimi cache writes and preserve tool arguments --- .../moonshot/MoonshotProvider.test.ts | 294 +++++++++++++++++- .../providers/moonshot/MoonshotProvider.ts | 45 ++- src/backend/drivers/ai-chat/utils/Messages.js | 16 +- .../drivers/ai-chat/utils/Messages.test.ts | 19 +- src/docs/src/AI/chat.md | 2 +- 5 files changed, 364 insertions(+), 12 deletions(-) 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 f140f14fd..725ecd778 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts @@ -46,6 +46,8 @@ import { type MockInstance, } from 'vitest'; +import { ChatCompletionDriver } from '../../ChatCompletionDriver.js'; +import { clearUnhealthyRoutes } from '../../utils/providerHealth.js'; import { SYSTEM_ACTOR } from '../../../../core/actor.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; @@ -93,10 +95,23 @@ vi.mock('../../utils/inlineImages.js', () => ({ // ── Test harness ──────────────────────────────────────────────────── let server: PuterServer; +let driver: ChatCompletionDriver; let recordSpy: MockInstance; beforeAll(async () => { server = await setupTestServer(); + driver = new ChatCompletionDriver( + { + providers: { + moonshot: { apiKey: 'test-key' }, + ollama: { enabled: false }, + }, + } as never, + server.clients, + server.stores, + server.services, + ); + await driver.onServerStart(); }); afterAll(async () => { @@ -140,6 +155,7 @@ const makeCapturingChatStream = () => { }; beforeEach(() => { + clearUnhealthyRoutes(); createMock.mockReset(); openAICtor.mockReset(); inlineHttpImageUrlsMock.mockReset(); @@ -390,7 +406,10 @@ describe('MoonshotProvider image inlining', () => { await withTestActor(() => provider.complete({ model: 'kimi-k3', // kimi-k3 declares image input modality - messages: messages as unknown as { role: string; content: unknown }[], + messages: messages as unknown as { + role: string; + content: unknown; + }[], }), ); @@ -803,13 +822,20 @@ describe('Kimi K3.1 Pro', () => { ], usage: structuredClone(usage), }); - const mockResponse = (data: unknown, oneHour = '10') => { + const mockResponse = ( + data: unknown, + oneHour: string | null = '10', + fiveMinute: string | null = null, + ) => { + const headers = new Headers(); + if (oneHour !== null) + headers.set('Msh-Usage-Cache-Write-Tokens-1h', oneHour); + if (fiveMinute !== null) + headers.set('Msh-Usage-Cache-Write-Tokens-5m', fiveMinute); createMock.mockReturnValueOnce({ withResponse: async () => ({ data, - response: new Response(null, { - headers: { 'Msh-Usage-Cache-Write-Tokens-1h': oneHour }, - }), + response: new Response(null, { headers }), }), }); }; @@ -1048,4 +1074,262 @@ describe('Kimi K3.1 Pro', () => { cache_write_1h_tokens: 10, }); }); + it.each(models)( + 'reserves the highest write rate before sending %s', + async (model) => { + const inputRate = model.endsWith('highspeed') ? 800 : 400; + const outputRate = model.endsWith('highspeed') ? 2000 : 1000; + vi.spyOn( + server.services.metering, + 'getUsageHeadroom', + ).mockResolvedValue({ balance: 2_000_000_000, held: 0 }); + const reserve = vi.spyOn( + server.services.metering, + 'reserveCredits', + ); + mockResponse( + { + choices: [ + { + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 1_000_000, + completion_tokens: 10, + prompt_tokens_details: { + cached_tokens: 100_000, + cache_write_tokens: 900_000, + }, + }, + }, + '900000', + ); + await withTestActor(() => + driver.complete({ + model, + provider: 'moonshotai', + messages: [ + { role: 'user', content: 'x'.repeat(8_000_000) }, + ], + max_tokens: 10, + }), + ); + expect(reserve).toHaveBeenCalledTimes(1); + expect(reserve.mock.calls[0]![1]).toBeGreaterThanOrEqual( + 1_000_000 * inputRate + 10 * outputRate, + ); + expect(reserve.mock.calls[0]![1]).toBeLessThan( + 1_000_001 * inputRate + 10 * outputRate, + ); + expect(recordSpy.mock.calls[0]![0]).toMatchObject({ + cached_tokens: 100_000, + cache_write_1h_tokens: 900_000, + prompt_tokens: 0, + }); + }, + ); + + it.each(['kimi-k3', 'kimi-k2.6'])( + 'keeps the existing driver reservation rate for %s', + async (model) => { + const entry = MOONSHOT_MODELS.find( + (candidate) => candidate.id === model, + )!; + vi.spyOn( + server.services.metering, + 'getUsageHeadroom', + ).mockResolvedValue({ balance: 2_000_000_000, held: 0 }); + const reserve = vi.spyOn( + server.services.metering, + 'reserveCredits', + ); + createMock.mockResolvedValueOnce(response()); + await withTestActor(() => + driver.complete({ + model, + provider: 'moonshotai', + messages: [{ role: 'user', content: 'x'.repeat(8000) }], + max_tokens: 10, + }), + ); + expect(reserve.mock.calls[0]![1]).toBe( + 1000 * Number(entry.costs.prompt_tokens) + + 10 * Number(entry.costs.completion_tokens), + ); + }, + ); + + it.each(models)( + 'rejects an underfunded inherited-cache request for %s before inference', + async (model) => { + vi.spyOn( + server.services.metering, + 'getUsageHeadroom', + ).mockResolvedValue({ + balance: model.endsWith('highspeed') + ? 600_000_000 + : 300_000_000, + held: 0, + }); + await expect( + withTestActor(() => + driver.complete({ + model, + provider: 'moonshotai', + messages: [ + { role: 'user', content: 'x'.repeat(8_000_000) }, + ], + max_tokens: 10, + }), + ), + ).rejects.toMatchObject({ statusCode: 402 }); + expect(createMock).not.toHaveBeenCalled(); + }, + ); + + it.each([null, ''])( + 'round-trips tool-only assistant content %s through the driver', + async (content) => { + const toolCalls = [ + { + id: 'call_paris', + type: 'function', + function: { + name: 'weather', + arguments: '{"city":"Paris"}', + }, + }, + ]; + mockResponse( + { + choices: [ + { + message: { + role: 'assistant', + content, + reasoning_content: 'Check weather', + tool_calls: toolCalls, + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }, + null, + ); + const first = await withTestActor(() => + driver.complete({ + model: models[0]!, + provider: 'moonshotai', + messages: [{ role: 'user', content: 'weather?' }], + }), + ); + mockResponse(response()); + await withTestActor(() => + driver.complete({ + model: models[0]!, + provider: 'moonshotai', + messages: [ + (first as { message: unknown }).message, + { + role: 'tool', + tool_call_id: 'call_paris', + content: 'sunny', + }, + ], + }), + ); + const assistant = createMock.mock.calls[1]![0].messages.find( + (message: { role: string }) => message.role === 'assistant', + ); + expect(assistant).toMatchObject({ + role: 'assistant', + content: null, + reasoning_content: 'Check weather', + tool_calls: toolCalls, + }); + expect( + JSON.parse(assistant.tool_calls[0].function.arguments), + ).toEqual({ city: 'Paris' }); + }, + ); + + it.each([{ city: 'Paris' }, 'Paris'])( + 'keeps native tool_use input %j through the driver', + async (input) => { + mockResponse(response()); + await withTestActor(() => + driver.complete({ + model: models[0]!, + provider: 'moonshotai', + messages: [ + { + role: 'assistant', + reasoning_content: 'Check weather', + content: [ + { + type: 'tool_use', + id: 'call_paris', + name: 'weather', + input, + }, + ], + }, + { + role: 'tool', + tool_call_id: 'call_paris', + content: 'sunny', + }, + ], + }), + ); + const assistant = createMock.mock.calls[0]![0].messages.find( + (message: { role: string }) => message.role === 'assistant', + ); + expect(assistant.reasoning_content).toBe('Check weather'); + expect( + JSON.parse(assistant.tool_calls[0].function.arguments), + ).toEqual(input); + }, + ); + + it.each([ + [null, null], + ['bad', null], + ['51', null], + ['-1', null], + ['10', '41'], + ] as const)( + 'rejects unverifiable TTL counters %s/%s without guessing a charge', + async (oneHour, fiveMinute) => { + const { provider } = makeProvider(); + mockResponse(response(), oneHour, fiveMinute); + await expect( + withTestActor(() => + provider.complete({ + model: models[0]!, + messages: [{ role: 'user', content: 'hi' }], + }), + ), + ).rejects.toMatchObject({ statusCode: 502 }); + expect(recordSpy).not.toHaveBeenCalled(); + }, + ); + + it('derives one-hour writes from the five-minute header and total writes', async () => { + const { provider } = makeProvider(); + mockResponse(response(), null, '40'); + await withTestActor(() => + provider.complete({ + model: models[0]!, + messages: [{ role: 'user', content: 'hi' }], + }), + ); + expect(recordSpy.mock.calls[0]![0]).toMatchObject({ + cache_write_tokens: 40, + cache_write_1h_tokens: 10, + }); + }); }); diff --git a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts index 7183e0e7b..e313c3ba9 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts @@ -59,6 +59,11 @@ export class MoonshotProvider extends OpenAICompatProvider { }); } + requestPricing(_args: ICompleteArguments, model: IChatModel) { + // Appended prefixes can inherit a one-hour cache entry's write rate. + return isK31(model.id) ? { inputKey: 'cache_write_1h_tokens' } : {}; + } + protected override preservesToolCallOrder(model: IChatModel) { return isK31(model.id); } @@ -124,8 +129,11 @@ export class MoonshotProvider extends OpenAICompatProvider { const { data, response } = await (client as OpenAI).chat.completions .create(params as never, { signal }) .withResponse(); - const oneHourWrites = Number( - response.headers.get('Msh-Usage-Cache-Write-Tokens-1h') ?? 0, + const fiveMinuteWrites = response.headers.get( + 'Msh-Usage-Cache-Write-Tokens-5m', + ); + const oneHourWrites = response.headers.get( + 'Msh-Usage-Cache-Write-Tokens-1h', ); const addWriteUsage = ( completion: ChatCompletion | ChatCompletionChunk, @@ -133,6 +141,7 @@ export class MoonshotProvider extends OpenAICompatProvider { if (completion.usage) { Object.assign(completion.usage, { moonshotCacheWrite1hTokens: oneHourWrites, + moonshotCacheWrite5mTokens: fiveMinuteWrites, }); } return completion; @@ -148,7 +157,8 @@ export class MoonshotProvider extends OpenAICompatProvider { protected override meteredUsage(source: UsageSource, model: IChatModel) { if (!isK31(model.id)) return super.meteredUsage(source, model); const usage = source.usage as typeof source.usage & { - moonshotCacheWrite1hTokens?: number; + moonshotCacheWrite1hTokens?: string | null; + moonshotCacheWrite5mTokens?: string | null; prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number; @@ -156,7 +166,34 @@ export class MoonshotProvider extends OpenAICompatProvider { }; const cached = usage.prompt_tokens_details?.cached_tokens ?? 0; const written = usage.prompt_tokens_details?.cache_write_tokens ?? 0; - const oneHour = usage.moonshotCacheWrite1hTokens ?? 0; + const fiveMinuteHeader = usage.moonshotCacheWrite5mTokens; + const oneHourHeader = usage.moonshotCacheWrite1hTokens; + const oneHour = + oneHourHeader != null + ? Number(oneHourHeader) + : fiveMinuteHeader != null + ? written - Number(fiveMinuteHeader) + : 0; + if ( + (written > 0 && + oneHourHeader == null && + fiveMinuteHeader == null) || + !Number.isSafeInteger(oneHour) || + oneHour < 0 || + oneHour > written || + (fiveMinuteHeader != null && + Number(fiveMinuteHeader) !== written - oneHour) || + fiveMinuteHeader === '' || + oneHourHeader === '' + ) { + throw new HttpError( + 502, + 'Moonshot cache-write TTL usage is unavailable or inconsistent', + { + legacyCode: 'bad_response', + }, + ); + } return { usage: { prompt_tokens: usage.prompt_tokens - cached - written, diff --git a/src/backend/drivers/ai-chat/utils/Messages.js b/src/backend/drivers/ai-chat/utils/Messages.js index c701ee497..aa3ab603d 100644 --- a/src/backend/drivers/ai-chat/utils/Messages.js +++ b/src/backend/drivers/ai-chat/utils/Messages.js @@ -85,11 +85,25 @@ export const normalize_single_message = (message, params = {}) => { { legacyCode: 'bad_request' }, ); } + let input = tool_call.function.arguments; + if (typeof input === 'string') { + try { + input = JSON.parse(input); + } catch { + throw new HttpError( + 400, + 'tool_call function arguments must be valid JSON', + { + legacyCode: 'bad_request', + }, + ); + } + } message.content.push({ type: 'tool_use', id: tool_call.id, name: tool_call.function.name, - input: tool_call.function.arguments, + input, // Gemini's thought signature rides here and must be // replayed with the call. ...(tool_call.extra_content diff --git a/src/backend/drivers/ai-chat/utils/Messages.test.ts b/src/backend/drivers/ai-chat/utils/Messages.test.ts index 7a5864fca..ac4713a94 100644 --- a/src/backend/drivers/ai-chat/utils/Messages.test.ts +++ b/src/backend/drivers/ai-chat/utils/Messages.test.ts @@ -224,6 +224,23 @@ describe('normalize_single_message tool_calls', () => { ); }); + it('rejects malformed serialized tool arguments', () => { + expect(() => + normalize_messages([ + { + role: 'assistant', + content: null, + tool_calls: [ + { + id: 'c1', + function: { name: 'lookup', arguments: '{' }, + }, + ], + }, + ] as never), + ).toThrow('tool_call function arguments must be valid JSON'); + }); + it('still converts a well-formed tool_call', () => { const result = normalize_messages([ { @@ -234,7 +251,7 @@ describe('normalize_single_message tool_calls', () => { }, ] as never); expect(result[0].content).toEqual([ - { type: 'tool_use', id: 'c1', name: 'lookup', input: '{}' }, + { type: 'tool_use', id: 'c1', name: 'lookup', input: {} }, ]); }); diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md index 0fb388a6d..adc86f7e4 100755 --- a/src/docs/src/AI/chat.md +++ b/src/docs/src/AI/chat.md @@ -51,7 +51,7 @@ Select `kimi-k3.1-pro` or `kimi-k3.1-pro-highspeed` with `provider: 'moonshotai' The context window is 1,048,576 tokens. Use `max_tokens` to set an output cap up to 1,048,576 tokens, subject to the remaining context window and available credits. Preserve the complete returned assistant message, including its `reasoning` and tool calls, when continuing a conversation. -Usage separates uncached input, cached reads, cache writes by TTL, and output. Cache writes are billed once when a prefix is stored. The upstream cache uses a five-minute TTL by default. For current rates, see the [AI models list](https://developer.puter.com/ai/models/). +Usage separates uncached input, cached reads, cache writes by TTL, and output. Cache writes are billed once when a prefix is stored. The upstream cache uses a five-minute TTL by default. Requests reserve credits at the one-hour cache-write rate because an existing prefix can retain that TTL. Actual charges use the returned usage. If writes are reported without a usable TTL breakdown, the request fails rather than estimating their charge. For current rates, see the [AI models list](https://developer.puter.com/ai/models/). #### `testMode` (Boolean) (Optional) From 97beffb5728fa1a237a4d02f4d960e56dc7125c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Kujund=C5=BEi=C4=87?= <51266541+404oops@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:05:39 +0200 Subject: [PATCH 3/7] Make completed Kimi accounting failures terminal without estimated billing --- .../drivers/ai-chat/ChatCompletionDriver.ts | 40 ++++-- .../moonshot/MoonshotProvider.test.ts | 114 +++++++++++++++++- .../providers/moonshot/MoonshotProvider.ts | 9 +- .../drivers/ai-chat/utils/meterChatUsage.ts | 18 +++ src/docs/src/AI/chat.md | 2 +- 5 files changed, 162 insertions(+), 21 deletions(-) diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index c7015567a..45e9a070d 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -36,6 +36,7 @@ import { requestAiCosts, withAiCostFactor, } from '../util/aiCostFactor.js'; +import { CompletionAccountingError } from './utils/meterChatUsage.js'; import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js'; import { isCreditExhaustion as isUpstreamCreditExhaustion, @@ -909,9 +910,13 @@ export class ChatCompletionDriver extends PuterDriver { // mark the route unhealthy and walk the fallback chain. abortSignal?.throwIfAborted(); - // A withheld completion was still a completion — charged, final, - // not a route failure worth another (billed) attempt elsewhere. - if (isModerationRefusal(e)) throw e; + // Completed inference is terminal even when moderation withholds it + // or accounting fails. Retrying would buy another completion. + if ( + e instanceof CompletionAccountingError || + isModerationRefusal(e) + ) + throw e; // Our own request validation (policy rejection, bad tool shape) // is never a route problem — no other provider would accept it // either, so there is nothing a fallback would fix. An empty @@ -978,7 +983,11 @@ export class ChatCompletionDriver extends PuterDriver { await hold.release(); hold = NO_CREDIT_HOLD; abortSignal?.throwIfAborted(); - if (isModerationRefusal(fbErr)) throw fbErr; + if ( + fbErr instanceof CompletionAccountingError || + isModerationRefusal(fbErr) + ) + throw fbErr; const attempt = recordFailure(fallback, fbErr); // A fallback's own 4xx is a translation gap between // vendors, not the caller's fault — stop climbing the @@ -1153,12 +1162,14 @@ export class ChatCompletionDriver extends PuterDriver { // Fire-and-forget — the stream writes happen async while the // response is being piped to the client. (async () => { + let accountingFailed = false; try { await init({ chatStream }); if (!passthrough.writableEnded) { throw new Error('Stream ended before completion'); } } catch (e) { + accountingFailed = e instanceof CompletionAccountingError; if (!chatStream.detached) { const attempt = toAttempt(model.id, model.provider!, e); chatStream.write( @@ -1167,10 +1178,14 @@ export class ChatCompletionDriver extends PuterDriver { message: sanitizeUpstreamMessage( e instanceof Error ? e.message : String(e), ), - ...(attempt.upstreamType - ? { code: attempt.upstreamType } - : {}), - status: streamErrorStatus(attempt), + ...(accountingFailed + ? { code: 'completion_accounting_failed' } + : attempt.upstreamType + ? { code: attempt.upstreamType } + : {}), + status: accountingFailed + ? 502 + : streamErrorStatus(attempt), })}\n`, ); } @@ -1181,9 +1196,14 @@ export class ChatCompletionDriver extends PuterDriver { abortSignal?.removeEventListener('abort', detachStream); // Providers report usage the moment they meter it (see // `AIChatStream.reportUsage`); a stream that never got - // there was never charged for. + // there was never charged for. Explicit accounting failures must + // stay uncharged; only interrupted work uses the estimate. try { - if (!blocked && !chatStream.reportedUsage) { + if ( + !blocked && + !accountingFailed && + !chatStream.reportedUsage + ) { this.#meterUnreportedStream({ actor, chatStream, 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 725ecd778..161d87196 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts @@ -33,7 +33,7 @@ * endpoint. */ -import { Writable } from 'node:stream'; +import { Readable, Writable } from 'node:stream'; import { afterAll, afterEach, @@ -47,13 +47,20 @@ import { } from 'vitest'; import { ChatCompletionDriver } from '../../ChatCompletionDriver.js'; -import { clearUnhealthyRoutes } from '../../utils/providerHealth.js'; +import { + clearUnhealthyRoutes, + isRouteUnhealthy, +} from '../../utils/providerHealth.js'; import { SYSTEM_ACTOR } from '../../../../core/actor.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; import { setupTestServer } from '../../../../testUtil.js'; -import { withTestActor } from '../../../integrationTestUtil.js'; +import { + readStreamResult, + withTestActor, +} from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; +import { FakeChatProvider } from '../FakeChatProvider.js'; import { MOONSHOT_MODELS } from './models.js'; import { MoonshotProvider } from './MoonshotProvider.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; @@ -1318,6 +1325,107 @@ describe('Kimi K3.1 Pro', () => { }, ); + it.each([ + [false, null, null, false], + [false, '10', '41', false], + [true, null, null, false], + [true, '10', '41', false], + [false, null, null, true], + [false, '10', '41', true], + ] as const)( + 'terminates accounting failures (stream=%s, TTL=%s/%s, fallback=%s) without retry or estimated charge', + async (stream, oneHour, fiveMinute, startWithFake) => { + const model = MOONSHOT_MODELS.find((m) => m.id === models[0])!; + // A real alternative route ensures a retry would be observable. + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValue([ + model, + ]); + const fallback = vi.spyOn(FakeChatProvider.prototype, 'complete'); + if (startWithFake) + fallback.mockRejectedValueOnce(new Error('Connection failed')); + const d = new ChatCompletionDriver( + { + providers: { + moonshot: { apiKey: 'test-key' }, + ollama: { enabled: false }, + }, + } as never, + server.clients, + server.stores, + server.services, + ); + await d.onServerStart(); + const actor = { + user: { + uuid: `kimi-accounting-${Math.random()}`, + username: 'kimi-accounting', + email: 'kimi@test.com', + }, + } as never; + const take = vi.spyOn(server.stores.creditHold, 'take'); + const release = vi.spyOn(server.stores.creditHold, 'release'); + mockResponse( + stream + ? asAsyncIterable([ + { choices: [{ delta: { content: 'answer' } }] }, + { choices: [], usage: structuredClone(usage) }, + ]) + : response(), + oneHour, + fiveMinute, + ); + const completion = withTestActor( + () => + d.complete({ + model: models[0]!, + provider: startWithFake ? 'fake-chat' : 'moonshot', + messages: [{ role: 'user', content: 'hi' }], + max_tokens: 10, + stream, + }), + actor, + ); + if (stream) { + const result = (await completion) as unknown as { + stream: Readable; + }; + const events = (await readStreamResult(result)) + .toString() + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'error', + code: 'completion_accounting_failed', + status: 502, + message: expect.stringContaining( + 'No charge was recorded', + ), + }), + ); + } else { + await expect(completion).rejects.toMatchObject({ + statusCode: 502, + legacyCode: 'completion_accounting_failed', + message: expect.stringContaining('No charge was recorded'), + }); + } + await vi.waitFor(() => + expect(release).toHaveBeenCalledTimes(startWithFake ? 2 : 1), + ); + expect(take).toHaveBeenCalledTimes(startWithFake ? 2 : 1); + expect(take.mock.calls[0]![1]).toBeGreaterThan(0); + expect( + await server.services.metering.getOutstandingHolds(actor), + ).toBe(0); + expect(createMock).toHaveBeenCalledTimes(1); + expect(fallback).toHaveBeenCalledTimes(startWithFake ? 1 : 0); + expect(recordSpy).not.toHaveBeenCalled(); + expect(isRouteUnhealthy('moonshot', models[0]!)).toBe(false); + }, + ); + it('derives one-hour writes from the five-minute header and total writes', async () => { const { provider } = makeProvider(); mockResponse(response(), null, '40'); diff --git a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts index e313c3ba9..ee9957420 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts @@ -29,6 +29,7 @@ import type { ICompleteArguments, PuterMessage, } from '../../types.js'; +import { CompletionAccountingError } from '../../utils/meterChatUsage.js'; import { openAICompatParams } from '../../utils/openaiParams.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { sdkClientOptions } from '../../utils/sdkClient.js'; @@ -186,13 +187,7 @@ export class MoonshotProvider extends OpenAICompatProvider { fiveMinuteHeader === '' || oneHourHeader === '' ) { - throw new HttpError( - 502, - 'Moonshot cache-write TTL usage is unavailable or inconsistent', - { - legacyCode: 'bad_response', - }, - ); + throw new CompletionAccountingError(); } return { usage: { diff --git a/src/backend/drivers/ai-chat/utils/meterChatUsage.ts b/src/backend/drivers/ai-chat/utils/meterChatUsage.ts index 38d5e253d..06962bffe 100644 --- a/src/backend/drivers/ai-chat/utils/meterChatUsage.ts +++ b/src/backend/drivers/ai-chat/utils/meterChatUsage.ts @@ -17,11 +17,29 @@ * along with this program. If not, see . */ +import { HttpError } from '../../../core/http/HttpError.js'; import type { Actor } from '../../../core/actor.js'; import type { MeteringService } from '../../../services/metering/MeteringService.js'; import type { IChatModel } from '../types.js'; import { buildCostsOverride } from './pricing.js'; +/** + * Upstream inference finished, but no reliable charge can be recorded. + * Terminal: retrying would buy another completion, and estimating would hide + * the accounting failure. Only raise this before recording any charge. + */ +export class CompletionAccountingError extends HttpError { + constructor() { + super( + 502, + 'The completion finished, but its usage could not be verified. No charge was recorded.', + { + legacyCode: 'completion_accounting_failed', + }, + ); + } +} + /** Microcents per US dollar. */ const MICROCENTS_PER_USD = 100_000_000; diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md index adc86f7e4..fed36e2be 100755 --- a/src/docs/src/AI/chat.md +++ b/src/docs/src/AI/chat.md @@ -51,7 +51,7 @@ Select `kimi-k3.1-pro` or `kimi-k3.1-pro-highspeed` with `provider: 'moonshotai' The context window is 1,048,576 tokens. Use `max_tokens` to set an output cap up to 1,048,576 tokens, subject to the remaining context window and available credits. Preserve the complete returned assistant message, including its `reasoning` and tool calls, when continuing a conversation. -Usage separates uncached input, cached reads, cache writes by TTL, and output. Cache writes are billed once when a prefix is stored. The upstream cache uses a five-minute TTL by default. Requests reserve credits at the one-hour cache-write rate because an existing prefix can retain that TTL. Actual charges use the returned usage. If writes are reported without a usable TTL breakdown, the request fails rather than estimating their charge. For current rates, see the [AI models list](https://developer.puter.com/ai/models/). +Usage separates uncached input, cached reads, cache writes by TTL, and output. Cache writes are billed once when a prefix is stored. The upstream cache uses a five-minute TTL by default. Requests reserve credits at the one-hour cache-write rate because an existing prefix can retain that TTL. Actual charges use the returned usage. If writes are reported without a usable TTL breakdown, the request ends with `completion_accounting_failed`: no charge is recorded, the credit hold is released, and Puter does not retry another provider. A stream may already have delivered content before this terminal error. For current rates, see the [AI models list](https://developer.puter.com/ai/models/). #### `testMode` (Boolean) (Optional) From ce5f28e844e4c97bdacb6e827822e77452439f32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Kujund=C5=BEi=C4=87?= <51266541+404oops@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:02:17 +0200 Subject: [PATCH 4/7] Validate Kimi completion accounting and preserve reasoning request semantics --- .../drivers/ai-chat/ChatCompletionDriver.ts | 49 ++- .../moonshot/MoonshotProvider.test.ts | 364 ++++++++++++++++-- .../providers/moonshot/MoonshotProvider.ts | 93 ++++- src/backend/drivers/ai-chat/types.ts | 4 +- src/docs/src/AI/chat.md | 2 +- 5 files changed, 441 insertions(+), 71 deletions(-) diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 45e9a070d..40247eb61 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -817,6 +817,7 @@ export class ChatCompletionDriver extends PuterDriver { // where "done" is the stream draining rather than this method // returning. let hold: CreditHold = NO_CREDIT_HOLD; + let attemptPromptTokenEstimate = promptTokenEstimate; // The current attempt's balance-bound output cap, if it had one. let fundsCap: number | undefined; // The cost factor the current attempt's charges are recorded at. @@ -825,12 +826,16 @@ export class ChatCompletionDriver extends PuterDriver { // charge, so a rate change mid-request can't split the two. let pricingTime = new Date(); if (!useFakeProvider) { - ({ hold, fundsCap, costFactor } = await this.#applyCreditGate( - actor, - model, - args, - { promptTokenEstimate, requestedMaxTokens, pricingTime }, - )); + ({ + hold, + fundsCap, + costFactor, + promptTokenEstimate: attemptPromptTokenEstimate, + } = await this.#applyCreditGate(actor, model, args, { + promptTokenEstimate, + requestedMaxTokens, + pricingTime, + })); } // First attempt @@ -962,12 +967,16 @@ export class ChatCompletionDriver extends PuterDriver { // this one starts from nothing held. pricingTime = new Date(); if (!useFakeProvider) { - ({ hold, fundsCap, costFactor } = - await this.#applyCreditGate(actor, fallback, args, { - promptTokenEstimate, - requestedMaxTokens, - pricingTime, - })); + ({ + hold, + fundsCap, + costFactor, + promptTokenEstimate: attemptPromptTokenEstimate, + } = await this.#applyCreditGate(actor, fallback, args, { + promptTokenEstimate, + requestedMaxTokens, + pricingTime, + })); } tried.add(routeId(fallback.provider!, fallback.id)); @@ -1208,7 +1217,7 @@ export class ChatCompletionDriver extends PuterDriver { actor, chatStream, model, - promptTokenEstimate, + promptTokenEstimate: attemptPromptTokenEstimate, completionId, username, intendedProvider, @@ -1452,10 +1461,15 @@ export class ChatCompletionDriver extends PuterDriver { /** The instant this attempt is priced at. */ pricingTime: Date; }, - ): Promise<{ hold: CreditHold; fundsCap?: number; costFactor: number }> { + ): Promise<{ + hold: CreditHold; + fundsCap?: number; + costFactor: number; + promptTokenEstimate: number; + }> { const metering = this.services.metering; - const { promptTokenEstimate, requestedMaxTokens, pricingTime } = - estimates; + const { requestedMaxTokens, pricingTime } = estimates; + let { promptTokenEstimate } = estimates; // A provider can name its own cost keys for this request (Claude's // fast-mode rate card), cap its output, and size an extra reservation // the per-token math can't see (a web-search/advisor tool that could @@ -1466,6 +1480,7 @@ export class ChatCompletionDriver extends PuterDriver { this.#providers[model.provider!]?.requestPricing?.(args, model, { promptTokenEstimate, }) ?? {}; + promptTokenEstimate += pricing.additionalPromptTokens ?? 0; const { inputKey: defaultInputKey, outputKey: defaultOutputKey } = costKeys(model); const inputKey = pricing.inputKey ?? defaultInputKey; @@ -1580,7 +1595,7 @@ export class ChatCompletionDriver extends PuterDriver { actor, Math.max(worstCaseCost, minimumCredits), ); - return { hold, fundsCap, costFactor }; + return { hold, fundsCap, costFactor, promptTokenEstimate }; } /** The key `model`'s usage is recorded and cost-factored under. */ 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 161d87196..6bce65ee8 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts @@ -20,17 +20,15 @@ /** * Offline unit tests for MoonshotProvider. * - * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock - * redis) and constructs MoonshotProvider directly against the live - * wired `MeteringService` so the recording side is exercised end-to- - * end. Moonshot is OpenAI-compatible so the OpenAI SDK is mocked at - * the module boundary; that's the real network egress point. Image- - * inlining behaviour is covered by `utils/inlineImages.test.ts`; here we - * stub `inlineHttpImageUrls` so http URLs in vision messages don't - * trigger network fetches and only verify the provider invokes it - * for vision-capable models. The companion integration test - * (MoonshotProvider.integration.test.ts) exercises the real Moonshot - * endpoint. + * Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and + * constructs MoonshotProvider directly against the live wired `MeteringService` + * so the recording side is exercised end-to- end. Moonshot is OpenAI-compatible + * so the OpenAI SDK is mocked at the module boundary; that's the real network + * egress point. Image- inlining behaviour is covered by + * `utils/inlineImages.test.ts`; here we stub `inlineHttpImageUrls` so http URLs + * in vision messages don't trigger network fetches and only verify the provider + * invokes it for vision-capable models. The companion integration test + * (MoonshotProvider.integration.test.ts) exercises the real Moonshot endpoint. */ import { Readable, Writable } from 'node:stream'; @@ -46,6 +44,8 @@ import { type MockInstance, } from 'vitest'; +import { chat as sdkChat } from '../../../../../puter-js/src/modules/ai/chat.js'; +import * as sdkUtils from '../../../../../puter-js/src/lib/utils.js'; import { ChatCompletionDriver } from '../../ChatCompletionDriver.js'; import { clearUnhealthyRoutes, @@ -63,6 +63,10 @@ import { AIChatStream } from '../../utils/Streaming.js'; import { FakeChatProvider } from '../FakeChatProvider.js'; import { MOONSHOT_MODELS } from './models.js'; import { MoonshotProvider } from './MoonshotProvider.js'; +import { + estimatePromptTokens, + estimateTextTokens, +} from '../../utils/usageEstimate.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; // ── OpenAI SDK mock ───────────────────────────────────────────────── @@ -1157,7 +1161,13 @@ describe('Kimi K3.1 Pro', () => { driver.complete({ model, provider: 'moonshotai', - messages: [{ role: 'user', content: 'x'.repeat(8000) }], + messages: [ + { + role: 'assistant', + content: 'x'.repeat(8000), + reasoning: 'thought '.repeat(90000), + }, + ], max_tokens: 10, }), ); @@ -1325,16 +1335,273 @@ describe('Kimi K3.1 Pro', () => { }, ); + it.each( + models.flatMap((model) => + ['reasoning', 'reasoning_content', 'both'].map((alias) => ({ + model, + alias, + })), + ), + )( + 'reserves replayed reasoning once with wire precedence: $model $alias', + async ({ model, alias }) => { + const text = 'reasoning '.repeat(90000); + const assistant = { + role: 'assistant', + content: 'answer', + ...(alias !== 'reasoning_content' + ? { reasoning: alias === 'both' ? 'short' : text } + : {}), + ...(alias !== 'reasoning' ? { reasoning_content: text } : {}), + }; + const messages = [ + { role: 'user', content: 'hi' }, + assistant, + { role: 'user', content: 'continue' }, + ]; + const tokens = + estimatePromptTokens(messages) + estimateTextTokens(text); + const inputRate = model.endsWith('highspeed') ? 800 : 400; + const outputRate = model.endsWith('highspeed') ? 2000 : 1000; + const headroom = vi + .spyOn(server.services.metering, 'getUsageHeadroom') + .mockResolvedValue({ balance: 2_000_000_000, held: 0 }); + const reserve = vi.spyOn( + server.services.metering, + 'reserveCredits', + ); + mockResponse(response()); + await withTestActor(() => + driver.complete({ + model, + messages: structuredClone(messages), + max_tokens: 10, + }), + ); + expect(reserve.mock.calls[0]![1]).toBe( + tokens * inputRate + 10 * outputRate, + ); + expect( + createMock.mock.calls[0]![0].messages[1].reasoning_content, + ).toBe(text); + headroom.mockResolvedValue({ + balance: tokens * inputRate - 1, + held: 0, + }); + await expect( + withTestActor(() => + driver.complete({ + model, + messages: structuredClone(messages), + max_tokens: 10, + }), + ), + ).rejects.toMatchObject({ statusCode: 402 }); + expect(createMock).toHaveBeenCalledTimes(1); + expect(reserve).toHaveBeenCalledTimes(1); + }, + ); + + it.each( + models.flatMap((model) => [ + ...['low', 'high', 'max'].map((effort) => ({ + model, + options: { reasoning: { effort } }, + expected: effort, + invalid: false, + })), + { + model, + options: { + reasoning: { effort: 'low' }, + reasoning_effort: 'high', + }, + expected: 'high', + invalid: false, + }, + { model, options: {}, expected: undefined, invalid: false }, + ...['medium', 'none'].map((effort) => ({ + model, + options: { reasoning: { effort } }, + expected: undefined, + invalid: true, + })), + ]), + )( + 'SDK reasoning contract $model $options', + async ({ model, options, expected, invalid }) => { + // Bridge only the SDK transport, preserving JSON serialization and the real driver. + vi.spyOn(sdkUtils, 'makeDriverMethod').mockImplementation( + () => + ((args: unknown) => + withTestActor(() => + driver.complete(JSON.parse(JSON.stringify(args))), + )) as never, + ); + mockResponse(response()); + const result = sdkChat.call( + { puter: {} }, + 'hi', + { model, ...options }, + undefined, + undefined, + ); + if (invalid) { + await expect(result).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bad_request', + }); + expect(createMock).not.toHaveBeenCalled(); + } else { + await result; + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0]![0].reasoning_effort).toBe( + expected, + ); + } + }, + ); + + it.each(models)( + 'estimates transmitted reasoning on interrupted %s streams', + async (model) => { + const reasoning = 'history '.repeat(1000); + const messages = [ + { + role: 'assistant', + content: 'answer', + reasoning: 'ignored alias', + reasoning_content: reasoning, + }, + { role: 'user', content: 'hi' }, + ]; + const expectedInput = + estimatePromptTokens(messages) + estimateTextTokens(reasoning); + mockResponse( + (async function* () { + yield { + choices: [{ delta: { content: 'partial answer' } }], + }; + throw new Error('upstream connection lost'); + })(), + null, + null, + ); + const result = await withTestActor(() => + driver.complete({ + model, + messages: structuredClone(messages), + stream: true, + }), + ); + const text = (await readStreamResult(result)).toString(); + expect(text).toContain('upstream connection lost'); + expect(text).not.toContain('completion_accounting_failed'); + await vi.waitFor(() => expect(recordSpy).toHaveBeenCalledTimes(1)); + expect( + createMock.mock.calls[0]![0].messages[0].reasoning_content, + ).toBe(reasoning); + expect(recordSpy.mock.calls[0]![0]).toHaveProperty( + 'estimated_prompt_tokens', + expectedInput, + ); + expect(recordSpy.mock.calls[0]![0]).toHaveProperty( + 'estimated_completion_tokens', + ); + }, + ); + + const invalidUsage = [ + ['absent usage', undefined], + ['null usage', null], + ['missing totals', {}], + [ + 'reads exceed total', + { + ...usage, + prompt_tokens_details: { + cached_tokens: 120, + cache_write_tokens: 0, + }, + }, + ], + [ + 'fractional writes', + { + ...usage, + prompt_tokens_details: { + cached_tokens: 20, + cache_write_tokens: 50.5, + }, + }, + ], + [ + 'negative reads', + { + ...usage, + prompt_tokens_details: { + cached_tokens: -1, + cache_write_tokens: 50, + }, + }, + ], + ['negative output', { ...usage, completion_tokens: -1 }], + ['string total', { ...usage, prompt_tokens: '100' }], + [ + 'unsafe total', + { ...usage, prompt_tokens: Number.MAX_SAFE_INTEGER + 1 }, + ], + [ + 'null writes', + { + ...usage, + prompt_tokens_details: { + cached_tokens: 20, + cache_write_tokens: null, + }, + }, + ], + ] as const; + it.each([ - [false, null, null, false], - [false, '10', '41', false], - [true, null, null, false], - [true, '10', '41', false], - [false, null, null, true], - [false, '10', '41', true], + [false, null, null, false, 'headers', usage], + [false, '10', '41', false, 'headers', usage], + [true, null, null, false, 'headers', usage], + [true, '10', '41', false, 'headers', usage], + [false, null, null, true, 'headers', usage], + [false, '10', '41', true, 'headers', usage], + ...invalidUsage.flatMap( + ([name, value]) => + [ + [false, '0', null, false, name, value], + [true, '0', null, false, name, value], + ] as const, + ), + [true, null, null, false, 'transport after finish', undefined], + [true, '10', null, false, 'transport after finish empty usage', {}], + [ + true, + '10', + null, + false, + 'transport after finish invalid usage', + { ...usage, completion_tokens: -1 }, + ], + [true, '10', null, false, 'transport after finish valid usage', usage], + [false, '0x0', null, false, 'nondecimal TTL', usage], + [true, '0x0', null, false, 'nondecimal TTL', usage], ] as const)( - 'terminates accounting failures (stream=%s, TTL=%s/%s, fallback=%s) without retry or estimated charge', - async (stream, oneHour, fiveMinute, startWithFake) => { + 'settles completed requests (stream=%s, TTL=%s/%s, fallback=%s, %s) without fallback or estimated charging', + async ( + stream, + oneHour, + fiveMinute, + startWithFake, + _name, + upstreamUsage, + ) => { + const validSettlement = + _name === 'transport after finish valid usage'; const model = MOONSHOT_MODELS.find((m) => m.id === models[0])!; // A real alternative route ensures a retry would be observable. vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValue([ @@ -1366,11 +1633,16 @@ describe('Kimi K3.1 Pro', () => { const release = vi.spyOn(server.stores.creditHold, 'release'); mockResponse( stream - ? asAsyncIterable([ - { choices: [{ delta: { content: 'answer' } }] }, - { choices: [], usage: structuredClone(usage) }, - ]) - : response(), + ? (async function* () { + yield { choices: [{ delta: { content: 'answer' } }] }; + yield { + choices: [{ delta: {}, finish_reason: 'stop' }], + usage: structuredClone(upstreamUsage), + }; + if (_name.startsWith('transport after finish')) + throw new Error('upstream connection lost'); + })() + : { ...response(), usage: structuredClone(upstreamUsage) }, oneHour, fiveMinute, ); @@ -1394,16 +1666,25 @@ describe('Kimi K3.1 Pro', () => { .trim() .split('\n') .map((line) => JSON.parse(line)); - expect(events).toContainEqual( - expect.objectContaining({ - type: 'error', - code: 'completion_accounting_failed', - status: 502, - message: expect.stringContaining( - 'No charge was recorded', - ), - }), - ); + if (validSettlement) { + expect(events.some((event) => event.type === 'error')).toBe( + false, + ); + expect(events).toContainEqual( + expect.objectContaining({ type: 'usage' }), + ); + } else { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'error', + code: 'completion_accounting_failed', + status: 502, + message: expect.stringContaining( + 'No charge was recorded', + ), + }), + ); + } } else { await expect(completion).rejects.toMatchObject({ statusCode: 502, @@ -1421,7 +1702,18 @@ describe('Kimi K3.1 Pro', () => { ).toBe(0); expect(createMock).toHaveBeenCalledTimes(1); expect(fallback).toHaveBeenCalledTimes(startWithFake ? 1 : 0); - expect(recordSpy).not.toHaveBeenCalled(); + if (validSettlement) { + expect(recordSpy).toHaveBeenCalledTimes(1); + expect(recordSpy.mock.calls[0]![0]).toEqual({ + prompt_tokens: 30, + completion_tokens: 10, + cached_tokens: 20, + cache_write_tokens: 40, + cache_write_1h_tokens: 10, + }); + } else { + expect(recordSpy).not.toHaveBeenCalled(); + } expect(isRouteUnhealthy('moonshot', models[0]!)).toBe(false); }, ); diff --git a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts index ee9957420..9e8db2306 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts @@ -30,6 +30,7 @@ import type { PuterMessage, } from '../../types.js'; import { CompletionAccountingError } from '../../utils/meterChatUsage.js'; +import { estimateTextTokens } from '../../utils/usageEstimate.js'; import { openAICompatParams } from '../../utils/openaiParams.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { sdkClientOptions } from '../../utils/sdkClient.js'; @@ -60,9 +61,26 @@ export class MoonshotProvider extends OpenAICompatProvider { }); } - requestPricing(_args: ICompleteArguments, model: IChatModel) { + requestPricing(args: ICompleteArguments, model: IChatModel) { + if (!isK31(model.id)) return {}; + const additionalPromptTokens = (args.messages ?? []).reduce( + (tokens, message) => { + if (message.role !== 'assistant') return tokens; + const reasoning = + message.reasoning_content !== undefined + ? message.reasoning_content + : message.reasoning; + return ( + tokens + + (typeof reasoning === 'string' + ? estimateTextTokens(reasoning) + : 0) + ); + }, + 0, + ); // Appended prefixes can inherit a one-hour cache entry's write rate. - return isK31(model.id) ? { inputKey: 'cache_write_1h_tokens' } : {}; + return { inputKey: 'cache_write_1h_tokens', additionalPromptTokens }; } protected override preservesToolCallOrder(model: IChatModel) { @@ -97,10 +115,8 @@ export class MoonshotProvider extends OpenAICompatProvider { model: IChatModel, ) { if (!isK31(model.id)) return params; - if ( - args.reasoning_effort && - !['low', 'high', 'max'].includes(args.reasoning_effort) - ) { + const effort = args.reasoning_effort ?? args.reasoning?.effort; + if (effort !== undefined && !['low', 'high', 'max'].includes(effort)) { throw new HttpError( 400, 'Kimi K3.1 Pro reasoning_effort must be low, high, or max', @@ -112,9 +128,17 @@ export class MoonshotProvider extends OpenAICompatProvider { const { max_tokens, ...rest } = params; return { ...rest, - ...openAICompatParams(args, 'chat', { - only: ['tool_choice', 'response_format', 'reasoning_effort'], - }), + ...openAICompatParams( + { ...args, reasoning_effort: effort }, + 'chat', + { + only: [ + 'tool_choice', + 'response_format', + 'reasoning_effort', + ], + }, + ), max_completion_tokens: max_tokens ?? 131_072, }; } @@ -149,9 +173,22 @@ export class MoonshotProvider extends OpenAICompatProvider { }; if (!params.stream) return addWriteUsage(data as ChatCompletion); return (async function* () { - for await (const chunk of data as Stream) { - yield addWriteUsage(chunk); + let finished = false; + let hasUsage = false; + try { + for await (const chunk of data as Stream) { + finished ||= chunk.choices.some( + (choice) => !!choice.finish_reason, + ); + hasUsage ||= chunk.usage != null; + yield addWriteUsage(chunk); + } + } catch (error) { + if (!finished) throw error; + // After finish, let the normal calculator validate and settle any usage. } + // A completed inference with missing accounting must not be estimated. + if (finished && !hasUsage) throw new CompletionAccountingError(); })(); } @@ -165,10 +202,36 @@ export class MoonshotProvider extends OpenAICompatProvider { cache_write_tokens?: number; }; }; - const cached = usage.prompt_tokens_details?.cached_tokens ?? 0; - const written = usage.prompt_tokens_details?.cache_write_tokens ?? 0; + const details = usage?.prompt_tokens_details; + const cached = + details?.cached_tokens === undefined ? 0 : details.cached_tokens; + const written = + details?.cache_write_tokens === undefined + ? 0 + : details.cache_write_tokens; + if ( + !usage || + (details !== undefined && + (details === null || + typeof details !== 'object' || + Array.isArray(details))) || + ![ + usage.prompt_tokens, + usage.completion_tokens, + cached, + written, + ].every((count) => Number.isSafeInteger(count) && count >= 0) || + cached > usage.prompt_tokens || + written > usage.prompt_tokens - cached + ) { + throw new CompletionAccountingError(); + } const fiveMinuteHeader = usage.moonshotCacheWrite5mTokens; const oneHourHeader = usage.moonshotCacheWrite1hTokens; + const headers = [fiveMinuteHeader, oneHourHeader]; + if (headers.some((value) => value != null && !/^\d+$/.test(value))) { + throw new CompletionAccountingError(); + } const oneHour = oneHourHeader != null ? Number(oneHourHeader) @@ -183,9 +246,7 @@ export class MoonshotProvider extends OpenAICompatProvider { oneHour < 0 || oneHour > written || (fiveMinuteHeader != null && - Number(fiveMinuteHeader) !== written - oneHour) || - fiveMinuteHeader === '' || - oneHourHeader === '' + Number(fiveMinuteHeader) !== written - oneHour) ) { throw new CompletionAccountingError(); } diff --git a/src/backend/drivers/ai-chat/types.ts b/src/backend/drivers/ai-chat/types.ts index 09c18b403..2cd5ab01a 100644 --- a/src/backend/drivers/ai-chat/types.ts +++ b/src/backend/drivers/ai-chat/types.ts @@ -238,7 +238,7 @@ export interface ICompleteArguments { 'auto' | 'default' | 'flex' | 'scale' | 'priority' | undefined; max_tokens?: number; temperature?: number; - reasoning?: { effort: 'low' | 'medium' | 'high' } | undefined; + reasoning?: { effort: ReasoningEffort } | undefined; text?: { verbosity?: 'low' | 'medium' | 'high' | undefined } | undefined; reasoning_effort?: ReasoningEffort | undefined; verbosity?: 'low' | 'medium' | 'high' | undefined; @@ -345,6 +345,8 @@ export interface IChatProvider { inputKey?: string; outputKey?: string; extraCost?: number; + /** Provider-specific prompt tokens outside message.content. */ + additionalPromptTokens?: number; maxTokens?: number; }; /** diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md index fed36e2be..54edfff73 100755 --- a/src/docs/src/AI/chat.md +++ b/src/docs/src/AI/chat.md @@ -51,7 +51,7 @@ Select `kimi-k3.1-pro` or `kimi-k3.1-pro-highspeed` with `provider: 'moonshotai' The context window is 1,048,576 tokens. Use `max_tokens` to set an output cap up to 1,048,576 tokens, subject to the remaining context window and available credits. Preserve the complete returned assistant message, including its `reasoning` and tool calls, when continuing a conversation. -Usage separates uncached input, cached reads, cache writes by TTL, and output. Cache writes are billed once when a prefix is stored. The upstream cache uses a five-minute TTL by default. Requests reserve credits at the one-hour cache-write rate because an existing prefix can retain that TTL. Actual charges use the returned usage. If writes are reported without a usable TTL breakdown, the request ends with `completion_accounting_failed`: no charge is recorded, the credit hold is released, and Puter does not retry another provider. A stream may already have delivered content before this terminal error. For current rates, see the [AI models list](https://developer.puter.com/ai/models/). +Usage separates uncached input, cached reads, cache writes by TTL, and output. Cache writes are billed once when a prefix is stored. The upstream cache uses a five-minute TTL by default. Requests reserve credits at the one-hour cache-write rate because an existing prefix can retain that TTL; this reservation includes replayed assistant reasoning. Actual charges use the returned usage. If a completed response has missing or invalid usage, or writes without a usable TTL breakdown, the request ends with `completion_accounting_failed`: no charge is recorded, the credit hold is released, and Puter does not retry another provider. A stream may already have delivered content before this terminal error. For current rates, see the [AI models list](https://developer.puter.com/ai/models/). #### `testMode` (Boolean) (Optional) From a10a9d9b935b8687dbaeb2c6755448fd8d876e1e Mon Sep 17 00:00:00 2001 From: 404oops Date: Sat, 24 Oct 2026 22:54:39 +0200 Subject: [PATCH 5/7] fix(ai): keep replayed tool-call arguments serialized once An OpenAI-shaped `tool_calls` entry normalizes with its `arguments` still a JSON string, and hoisting it back into `tool_calls` stringified that string again, so every OpenAI-compatible provider received a JSON string literal instead of the call's arguments. Hoisting now sends a string `input` as is and only serializes object inputs. Co-Authored-By: Claude Fable 5.1 --- .../drivers/ai-chat/utils/OpenAIUtil.js | 7 ++- .../drivers/ai-chat/utils/OpenAIUtil.test.ts | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js index 851612317..b351ed8c0 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js @@ -170,7 +170,12 @@ const hoistToolUseBlocks = ( type: 'function', function: { name: block.name, - arguments: JSON.stringify(block.input), + // A replayed OpenAI-shaped call arrives with `input` already + // serialized; encoding it again sends a JSON string literal. + arguments: + typeof block.input === 'string' + ? block.input + : JSON.stringify(block.input), }, ...(extraContent && block.extra_content ? { extra_content: block.extra_content } diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts index f41605389..a4f8a41f2 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts @@ -331,6 +331,29 @@ describe('process_input_messages', () => { ]); }); + it("keeps a replayed call's serialized arguments verbatim", async () => { + // An OpenAI-shaped `tool_calls` entry normalizes with its arguments + // still a JSON string. + const messages: Array> = [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'lookup', + input: '{"q":"puter"}', + }, + ], + }, + ]; + + const [out] = (await process_input_messages(messages)) as Array<{ + tool_calls: Array<{ function: { arguments: string } }>; + }>; + expect(out!.tool_calls[0]!.function.arguments).toBe('{"q":"puter"}'); + }); + describe('tool call extra_content', () => { const turn = (): Array> => [ { @@ -772,6 +795,28 @@ describe('process_input_messages_responses_api', () => { expect(out!.content).toBeUndefined(); }); + it("keeps a replayed call's serialized arguments verbatim", async () => { + const messages: Array> = [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'call_1', + name: 'lookup', + input: '{"q":"puter"}', + }, + ], + }, + ]; + + const [out] = (await process_input_messages_responses_api( + messages, + )) as Array>; + expect(out!.type).toBe('function_call'); + expect(out!.arguments).toBe('{"q":"puter"}'); + }); + it('rewrites tool_result blocks into function_call_output', async () => { const messages: Array> = [ { From 3296c1b0d6d03081c5d26dfec27fe7fc2e8dcc2a Mon Sep 17 00:00:00 2001 From: 404oops Date: Sat, 24 Oct 2026 22:55:10 +0200 Subject: [PATCH 6/7] fix(ai): bill Kimi K3.1 Pro from reported usage at the published rates Cache writes now meter under `cache_write_tokens` at the model's write rate, split out of `prompt_tokens` by the shared usage splitter, so K3 gets the same breakdown. The credit hold reserves the prompt and any replayed reasoning at the input rate, and a stream that ends without a usage chunk is charged the estimate like every other provider. The per-TTL response headers, the 502 accounting error and the one-hour write prices are gone: the cache is scoped to the account and nothing here requests a one-hour TTL, so those writes never occur and Moonshot publishes no rate for them. The catalog entries carry the release date so responses normalize by default, the provider suite no longer imports the SDK, and the docs cover the reasoning efforts, replay and cache usage keys in their existing sections. Co-Authored-By: Claude Fable 5.1 --- .../drivers/ai-chat/ChatCompletionDriver.ts | 43 +- .../ai-chat/providers/OpenAICompatProvider.ts | 26 +- .../MoonshotProvider.integration.test.ts | 35 +- .../moonshot/MoonshotProvider.test.ts | 711 ++++++------------ .../providers/moonshot/MoonshotProvider.ts | 208 +---- .../ai-chat/providers/moonshot/models.ts | 22 +- src/backend/drivers/ai-chat/utils/Messages.js | 16 +- .../drivers/ai-chat/utils/Messages.test.ts | 19 +- .../drivers/ai-chat/utils/OpenAIUtil.js | 15 +- .../drivers/ai-chat/utils/OpenAIUtil.test.ts | 29 + .../drivers/ai-chat/utils/meterChatUsage.ts | 18 - src/docs/src/AI/chat.md | 14 +- src/docs/src/rate-limits-and-quotas.md | 2 - 13 files changed, 377 insertions(+), 781 deletions(-) diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 40247eb61..1a5029f63 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -36,7 +36,6 @@ import { requestAiCosts, withAiCostFactor, } from '../util/aiCostFactor.js'; -import { CompletionAccountingError } from './utils/meterChatUsage.js'; import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js'; import { isCreditExhaustion as isUpstreamCreditExhaustion, @@ -817,6 +816,8 @@ export class ChatCompletionDriver extends PuterDriver { // where "done" is the stream draining rather than this method // returning. let hold: CreditHold = NO_CREDIT_HOLD; + // The gate's estimate for the current attempt: the message estimate + // plus whatever its provider reads back as prompt beyond the messages. let attemptPromptTokenEstimate = promptTokenEstimate; // The current attempt's balance-bound output cap, if it had one. let fundsCap: number | undefined; @@ -915,13 +916,9 @@ export class ChatCompletionDriver extends PuterDriver { // mark the route unhealthy and walk the fallback chain. abortSignal?.throwIfAborted(); - // Completed inference is terminal even when moderation withholds it - // or accounting fails. Retrying would buy another completion. - if ( - e instanceof CompletionAccountingError || - isModerationRefusal(e) - ) - throw e; + // A withheld completion was still a completion — charged, final, + // not a route failure worth another (billed) attempt elsewhere. + if (isModerationRefusal(e)) throw e; // Our own request validation (policy rejection, bad tool shape) // is never a route problem — no other provider would accept it // either, so there is nothing a fallback would fix. An empty @@ -992,11 +989,7 @@ export class ChatCompletionDriver extends PuterDriver { await hold.release(); hold = NO_CREDIT_HOLD; abortSignal?.throwIfAborted(); - if ( - fbErr instanceof CompletionAccountingError || - isModerationRefusal(fbErr) - ) - throw fbErr; + if (isModerationRefusal(fbErr)) throw fbErr; const attempt = recordFailure(fallback, fbErr); // A fallback's own 4xx is a translation gap between // vendors, not the caller's fault — stop climbing the @@ -1171,14 +1164,12 @@ export class ChatCompletionDriver extends PuterDriver { // Fire-and-forget — the stream writes happen async while the // response is being piped to the client. (async () => { - let accountingFailed = false; try { await init({ chatStream }); if (!passthrough.writableEnded) { throw new Error('Stream ended before completion'); } } catch (e) { - accountingFailed = e instanceof CompletionAccountingError; if (!chatStream.detached) { const attempt = toAttempt(model.id, model.provider!, e); chatStream.write( @@ -1187,14 +1178,10 @@ export class ChatCompletionDriver extends PuterDriver { message: sanitizeUpstreamMessage( e instanceof Error ? e.message : String(e), ), - ...(accountingFailed - ? { code: 'completion_accounting_failed' } - : attempt.upstreamType - ? { code: attempt.upstreamType } - : {}), - status: accountingFailed - ? 502 - : streamErrorStatus(attempt), + ...(attempt.upstreamType + ? { code: attempt.upstreamType } + : {}), + status: streamErrorStatus(attempt), })}\n`, ); } @@ -1205,14 +1192,9 @@ export class ChatCompletionDriver extends PuterDriver { abortSignal?.removeEventListener('abort', detachStream); // Providers report usage the moment they meter it (see // `AIChatStream.reportUsage`); a stream that never got - // there was never charged for. Explicit accounting failures must - // stay uncharged; only interrupted work uses the estimate. + // there was never charged for. try { - if ( - !blocked && - !accountingFailed && - !chatStream.reportedUsage - ) { + if (!blocked && !chatStream.reportedUsage) { this.#meterUnreportedStream({ actor, chatStream, @@ -1465,6 +1447,7 @@ export class ChatCompletionDriver extends PuterDriver { hold: CreditHold; fundsCap?: number; costFactor: number; + /** The prompt estimate this attempt was priced on. */ promptTokenEstimate: number; }> { const metering = this.services.metering; diff --git a/src/backend/drivers/ai-chat/providers/OpenAICompatProvider.ts b/src/backend/drivers/ai-chat/providers/OpenAICompatProvider.ts index 14ab4f791..ef17a2c27 100644 --- a/src/backend/drivers/ai-chat/providers/OpenAICompatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/OpenAICompatProvider.ts @@ -237,10 +237,9 @@ export class OpenAICompatProvider implements IChatProvider { let pricingTime = attemptPricingTime ?? new Date(); let completion; try { - completion = await this.createCompletion( - options.client, - params, - signal, + completion = await options.client.chat.completions.create( + params as never, + { signal }, ); } catch (e) { const retryParams = @@ -252,10 +251,9 @@ export class OpenAICompatProvider implements IChatProvider { : undefined; if (!retryParams) throw e; pricingTime = attemptPricingTime ?? new Date(); - completion = await this.createCompletion( - options.client, - retryParams, - signal, + completion = await options.client.chat.completions.create( + retryParams as never, + { signal }, ); } @@ -328,18 +326,14 @@ export class OpenAICompatProvider implements IChatProvider { return false; } + /** + * Whether a replayed assistant turn keeps its text beside hoisted tool + * calls. + */ protected preservesAssistantContent(_model: IChatModel): boolean { return false; } - protected createCompletion( - client: ChatCompletionsClient, - params: Record, - signal?: AbortSignal, - ): Promise { - return client.chat.completions.create(params as never, { signal }); - } - /** Last pass over the messages, after `process_input_messages`. */ protected prepareMessages( messages: PuterMessage[], diff --git a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.integration.test.ts b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.integration.test.ts index abab536ad..6b84e1c7d 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.integration.test.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.integration.test.ts @@ -20,8 +20,8 @@ /** * Integration test for the Moonshot provider. * - * Uses `kimi-k2.6` (the cheapest current Kimi model). Skipped - * when `PUTER_TEST_AI_MOONSHOT_API_KEY` is unset. + * Uses `kimi-k2.6` (the cheapest current Kimi model) and `kimi-k3.1-pro`. + * Skipped when `PUTER_TEST_AI_MOONSHOT_API_KEY` is unset. */ import { describe, expect, it } from 'vitest'; @@ -59,5 +59,36 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))( ?.content; expect(typeof text === 'string' && text.length > 0).toBe(true); }); + + it('returns a completion and cache usage from kimi-k3.1-pro', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => { + const provider = new MoonshotProvider( + { apiKey: optionalEnv(ENV_VAR)! }, + makeMeteringStub(), + ); + + const result = (await withTestActor(() => + provider.complete({ + model: 'kimi-k3.1-pro', + messages: [ + { role: 'user', content: 'Say hi in one word.' }, + ], + // Reasoning counts against the cap, so leave room for it. + max_tokens: 2048, + reasoning_effort: 'low', + }), + )) as { + message?: { content?: string }; + usage?: Record; + }; + + const text = result.message?.content; + expect(typeof text === 'string' && text.length > 0).toBe(true); + expect(result.usage).toMatchObject({ + prompt_tokens: expect.any(Number), + completion_tokens: expect.any(Number), + cached_tokens: expect.any(Number), + cache_write_tokens: expect.any(Number), + }); + }); }, ); 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 6bce65ee8..b454ada09 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts @@ -31,7 +31,7 @@ * (MoonshotProvider.integration.test.ts) exercises the real Moonshot endpoint. */ -import { Readable, Writable } from 'node:stream'; +import { Writable } from 'node:stream'; import { afterAll, afterEach, @@ -44,13 +44,8 @@ import { type MockInstance, } from 'vitest'; -import { chat as sdkChat } from '../../../../../puter-js/src/modules/ai/chat.js'; -import * as sdkUtils from '../../../../../puter-js/src/lib/utils.js'; import { ChatCompletionDriver } from '../../ChatCompletionDriver.js'; -import { - clearUnhealthyRoutes, - isRouteUnhealthy, -} from '../../utils/providerHealth.js'; +import { clearUnhealthyRoutes } from '../../utils/providerHealth.js'; import { SYSTEM_ACTOR } from '../../../../core/actor.js'; import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { PuterServer } from '../../../../server.js'; @@ -60,7 +55,6 @@ import { withTestActor, } from '../../../integrationTestUtil.js'; import { AIChatStream } from '../../utils/Streaming.js'; -import { FakeChatProvider } from '../FakeChatProvider.js'; import { MOONSHOT_MODELS } from './models.js'; import { MoonshotProvider } from './MoonshotProvider.js'; import { @@ -815,6 +809,11 @@ describe('MoonshotProvider.checkModeration', () => { describe('Kimi K3.1 Pro', () => { const models = ['kimi-k3.1-pro', 'kimi-k3.1-pro-highspeed']; + /** Catalog rates in µ¢ per token: $2/$10 for Pro, $4/$20 for Highspeed. */ + const rates = (model: string) => + model.endsWith('highspeed') + ? { input: 400, output: 2000 } + : { input: 200, output: 1000 }; const usage = { prompt_tokens: 100, completion_tokens: 10, @@ -833,79 +832,61 @@ describe('Kimi K3.1 Pro', () => { ], usage: structuredClone(usage), }); - const mockResponse = ( - data: unknown, - oneHour: string | null = '10', - fiveMinute: string | null = null, - ) => { - const headers = new Headers(); - if (oneHour !== null) - headers.set('Msh-Usage-Cache-Write-Tokens-1h', oneHour); - if (fiveMinute !== null) - headers.set('Msh-Usage-Cache-Write-Tokens-5m', fiveMinute); - createMock.mockReturnValueOnce({ - withResponse: async () => ({ - data, - response: new Response(null, { headers }), - }), - }); - }; + const fund = (balance: number) => + vi.spyOn( + server.services.metering, + 'getUsageHeadroom', + ).mockResolvedValue({ balance, held: 0 }); + const user = (content: string) => [{ role: 'user', content }]; it.each(models)( 'maps %s caps and reasoning without sampling parameters', async (model) => { const { provider } = makeProvider(); - mockResponse(response()); + createMock.mockResolvedValueOnce(response()); await withTestActor(() => provider.complete({ model, - messages: [{ role: 'user', content: 'hi' }], + messages: user('hi'), max_tokens: 512, reasoning_effort: 'high', temperature: 0.2, top_p: 0.1, }), ); - expect(createMock.mock.calls[0]![0]).toMatchObject({ + const sent = createMock.mock.calls[0]![0]; + expect(sent).toMatchObject({ model, max_completion_tokens: 512, reasoning_effort: 'high', }); - expect(createMock.mock.calls[0]![0]).not.toHaveProperty( - 'max_tokens', - ); - expect(createMock.mock.calls[0]![0]).not.toHaveProperty( - 'temperature', - ); - expect(createMock.mock.calls[0]![0]).not.toHaveProperty('top_p'); + expect(sent).not.toHaveProperty('max_tokens'); + expect(sent).not.toHaveProperty('temperature'); + expect(sent).not.toHaveProperty('top_p'); }, ); - it('uses the official default output cap and preserves the default thinking mode', async () => { + it('uses the upstream default output cap and leaves thinking to the model', async () => { const { provider } = makeProvider(); - mockResponse(response()); + createMock.mockResolvedValueOnce(response()); await withTestActor(() => - provider.complete({ - model: models[0]!, - messages: [{ role: 'user', content: 'hi' }], - }), - ); - expect(createMock.mock.calls[0]![0].max_completion_tokens).toBe(131072); - expect(createMock.mock.calls[0]![0]).not.toHaveProperty('thinking'); - expect(createMock.mock.calls[0]![0]).not.toHaveProperty( - 'reasoning_effort', + provider.complete({ model: models[0]!, messages: user('hi') }), ); + const sent = createMock.mock.calls[0]![0]; + expect(sent.max_completion_tokens).toBe(131072); + expect(sent).not.toHaveProperty('thinking'); + expect(sent).not.toHaveProperty('reasoning_effort'); }); it.each(['low', 'max'] as const)( 'forwards %s reasoning effort', async (effort) => { const { provider } = makeProvider(); - mockResponse(response(), '0'); + createMock.mockResolvedValueOnce(response()); await withTestActor(() => provider.complete({ model: models[0]!, - messages: [{ role: 'user', content: 'hi' }], + messages: user('hi'), reasoning_effort: effort, }), ); @@ -913,37 +894,13 @@ describe('Kimi K3.1 Pro', () => { }, ); - it('keeps K3 on its existing request and billing path', async () => { - const { provider } = makeProvider(); - createMock.mockResolvedValueOnce(response()); - await withTestActor(() => - provider.complete({ - model: 'kimi-k3', - messages: [{ role: 'user', content: 'hi' }], - max_tokens: 512, - }), - ); - expect(createMock.mock.calls[0]![0]).toMatchObject({ - model: 'kimi-k3', - max_tokens: 512, - }); - expect(createMock.mock.calls[0]![0]).not.toHaveProperty( - 'max_completion_tokens', - ); - expect(recordSpy.mock.calls[0]![0]).toEqual({ - prompt_tokens: 80, - completion_tokens: 10, - cached_tokens: 20, - }); - }); - - it('rejects unsupported reasoning effort before inference', async () => { + it('rejects an unsupported reasoning effort before inference', async () => { const { provider } = makeProvider(); await expect( withTestActor(() => provider.complete({ model: models[0]!, - messages: [{ role: 'user', content: 'hi' }], + messages: user('hi'), reasoning_effort: 'medium', }), ), @@ -951,34 +908,107 @@ describe('Kimi K3.1 Pro', () => { expect(createMock).not.toHaveBeenCalled(); }); + it.each([ + { options: { reasoning: { effort: 'low' } }, expected: 'low' }, + { options: { reasoning: { effort: 'max' } }, expected: 'max' }, + { + options: { reasoning: { effort: 'low' }, reasoning_effort: 'high' }, + expected: 'high', + }, + { options: {}, expected: undefined }, + ])( + 'sends the nested reasoning form through the driver: $options', + async ({ options, expected }) => { + createMock.mockResolvedValueOnce(response()); + await withTestActor(() => + driver.complete({ + model: models[0]!, + provider: 'moonshotai', + messages: user('hi'), + ...options, + } as never), + ); + expect(createMock).toHaveBeenCalledTimes(1); + expect(createMock.mock.calls[0]![0].reasoning_effort).toBe( + expected, + ); + }, + ); + + it.each(['medium', 'none'])( + 'rejects the nested reasoning form %s through the driver', + async (effort) => { + await expect( + withTestActor(() => + driver.complete({ + model: models[0]!, + provider: 'moonshotai', + messages: user('hi'), + reasoning: { effort }, + } as never), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bad_request', + }); + expect(createMock).not.toHaveBeenCalled(); + }, + ); + + it('keeps K3 on max_tokens and bills its cache writes at the K3 write rate', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(response()); + await withTestActor(() => + provider.complete({ + model: 'kimi-k3', + messages: user('hi'), + max_tokens: 512, + }), + ); + const sent = createMock.mock.calls[0]![0]; + expect(sent).toMatchObject({ model: 'kimi-k3', max_tokens: 512 }); + expect(sent).not.toHaveProperty('max_completion_tokens'); + expect(recordSpy).toHaveBeenCalledWith( + { + prompt_tokens: 30, + completion_tokens: 10, + cached_tokens: 20, + cache_write_tokens: 50, + }, + expect.anything(), + 'moonshotai:kimi-k3', + { + prompt_tokens: 30 * 300, + completion_tokens: 10 * 1500, + cached_tokens: 20 * 30, + cache_write_tokens: 50 * 300, + }, + ); + }); + it.each(models)( - 'meters disjoint cache reads, writes and uncached input for %s', + 'meters cache reads, writes and uncached input separately for %s', async (model) => { const { provider } = makeProvider(); - mockResponse(response()); + createMock.mockResolvedValueOnce(response()); const result = await withTestActor(() => - provider.complete({ - model, - messages: [{ role: 'user', content: 'hi' }], - }), + provider.complete({ model, messages: user('hi') }), ); - const inputRate = model.endsWith('highspeed') ? 400 : 200; + const { input, output } = rates(model); expect(recordSpy).toHaveBeenCalledWith( { prompt_tokens: 30, completion_tokens: 10, cached_tokens: 20, - cache_write_tokens: 40, - cache_write_1h_tokens: 10, + cache_write_tokens: 50, }, expect.anything(), `moonshotai:${model}`, { - prompt_tokens: 30 * inputRate, - completion_tokens: 10 * inputRate * 5, + prompt_tokens: 30 * input, + completion_tokens: 10 * output, cached_tokens: 20 * 20, - cache_write_tokens: 40 * inputRate, - cache_write_1h_tokens: 10 * inputRate * 2, + cache_write_tokens: 50 * input, }, ); expect(result).toMatchObject({ @@ -986,8 +1016,7 @@ describe('Kimi K3.1 Pro', () => { inputTokens: 30, outputTokens: 10, cacheReadTokens: 20, - cacheWrite5mTokens: 40, - cacheWrite1hTokens: 10, + cacheWrite5mTokens: 50, }, }); }, @@ -1002,27 +1031,28 @@ describe('Kimi K3.1 Pro', () => { })); const first = response(); Object.assign(first.choices[0]!.message, { tool_calls: toolCalls }); - mockResponse(first); + createMock.mockResolvedValueOnce(first); const result = await withTestActor(() => - provider.complete({ - model: models[0]!, - messages: [{ role: 'user', content: 'hi' }], - }), + provider.complete({ model: models[0]!, messages: user('hi') }), ); - mockResponse(response()); + createMock.mockResolvedValueOnce(response()); await withTestActor(() => provider.complete({ model: models[0]!, messages: [(result as { message: unknown }).message], }), ); - expect(createMock.mock.calls[1]![0].messages[0]).toEqual({ + const replayed = createMock.mock.calls[1]![0].messages[0]; + expect(replayed).toMatchObject({ role: 'assistant', content: 'answer', reasoning_content: 'reasoning', - tool_calls: toolCalls, }); - mockResponse(response()); + expect(replayed).not.toHaveProperty('reasoning'); + expect( + replayed.tool_calls.map((call: { id: string }) => call.id), + ).toEqual(['a', 'b']); + createMock.mockResolvedValueOnce(response()); await withTestActor(() => provider.complete({ model: models[0]!, @@ -1051,9 +1081,9 @@ describe('Kimi K3.1 Pro', () => { }); }); - it('meters streamed cache writes from final usage and response headers', async () => { + it('meters a stream from its final usage chunk', async () => { const { provider } = makeProvider(); - mockResponse( + createMock.mockResolvedValueOnce( asAsyncIterable([ { choices: [{ delta: { reasoning_content: 'reasoning' } }] }, { choices: [{ delta: { content: 'answer' } }] }, @@ -1063,7 +1093,7 @@ describe('Kimi K3.1 Pro', () => { const result = await withTestActor(() => provider.complete({ model: models[0]!, - messages: [{ role: 'user', content: 'hi' }], + messages: user('hi'), stream: true, }), ); @@ -1081,77 +1111,66 @@ describe('Kimi K3.1 Pro', () => { prompt_tokens: 30, completion_tokens: 10, cached_tokens: 20, - cache_write_tokens: 40, - cache_write_1h_tokens: 10, + cache_write_tokens: 50, }); }); + it.each(models)( - 'reserves the highest write rate before sending %s', + 'reserves the prompt at the input rate for %s', async (model) => { - const inputRate = model.endsWith('highspeed') ? 800 : 400; - const outputRate = model.endsWith('highspeed') ? 2000 : 1000; - vi.spyOn( - server.services.metering, - 'getUsageHeadroom', - ).mockResolvedValue({ balance: 2_000_000_000, held: 0 }); + const { input, output } = rates(model); + fund(2_000_000_000); const reserve = vi.spyOn( server.services.metering, 'reserveCredits', ); - mockResponse( - { - choices: [ - { - message: { role: 'assistant', content: 'ok' }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 1_000_000, - completion_tokens: 10, - prompt_tokens_details: { - cached_tokens: 100_000, - cache_write_tokens: 900_000, - }, + createMock.mockResolvedValueOnce({ + choices: [ + { + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 1_000_000, + completion_tokens: 10, + prompt_tokens_details: { + cached_tokens: 100_000, + cache_write_tokens: 900_000, }, }, - '900000', - ); + }); await withTestActor(() => driver.complete({ model, provider: 'moonshotai', - messages: [ - { role: 'user', content: 'x'.repeat(8_000_000) }, - ], + messages: user('x'.repeat(8_000_000)), max_tokens: 10, }), ); expect(reserve).toHaveBeenCalledTimes(1); expect(reserve.mock.calls[0]![1]).toBeGreaterThanOrEqual( - 1_000_000 * inputRate + 10 * outputRate, + 1_000_000 * input + 10 * output, ); expect(reserve.mock.calls[0]![1]).toBeLessThan( - 1_000_001 * inputRate + 10 * outputRate, + 1_000_001 * input + 10 * output, ); expect(recordSpy.mock.calls[0]![0]).toMatchObject({ - cached_tokens: 100_000, - cache_write_1h_tokens: 900_000, prompt_tokens: 0, + completion_tokens: 10, + cached_tokens: 100_000, + cache_write_tokens: 900_000, }); }, ); it.each(['kimi-k3', 'kimi-k2.6'])( - 'keeps the existing driver reservation rate for %s', + 'keeps the existing reservation for %s', async (model) => { const entry = MOONSHOT_MODELS.find( (candidate) => candidate.id === model, )!; - vi.spyOn( - server.services.metering, - 'getUsageHeadroom', - ).mockResolvedValue({ balance: 2_000_000_000, held: 0 }); + fund(2_000_000_000); const reserve = vi.spyOn( server.services.metering, 'reserveCredits', @@ -1179,25 +1198,16 @@ describe('Kimi K3.1 Pro', () => { ); it.each(models)( - 'rejects an underfunded inherited-cache request for %s before inference', + 'rejects an underfunded request for %s before inference', async (model) => { - vi.spyOn( - server.services.metering, - 'getUsageHeadroom', - ).mockResolvedValue({ - balance: model.endsWith('highspeed') - ? 600_000_000 - : 300_000_000, - held: 0, - }); + // Short of the prompt alone at the input rate. + fund(model.endsWith('highspeed') ? 300_000_000 : 150_000_000); await expect( withTestActor(() => driver.complete({ model, provider: 'moonshotai', - messages: [ - { role: 'user', content: 'x'.repeat(8_000_000) }, - ], + messages: user('x'.repeat(8_000_000)), max_tokens: 10, }), ), @@ -1219,31 +1229,28 @@ describe('Kimi K3.1 Pro', () => { }, }, ]; - mockResponse( - { - choices: [ - { - message: { - role: 'assistant', - content, - reasoning_content: 'Check weather', - tool_calls: toolCalls, - }, - finish_reason: 'tool_calls', + createMock.mockResolvedValueOnce({ + choices: [ + { + message: { + role: 'assistant', + content, + reasoning_content: 'Check weather', + tool_calls: toolCalls, }, - ], - usage: { prompt_tokens: 1, completion_tokens: 1 }, - }, - null, - ); + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); const first = await withTestActor(() => driver.complete({ model: models[0]!, provider: 'moonshotai', - messages: [{ role: 'user', content: 'weather?' }], + messages: user('weather?'), }), ); - mockResponse(response()); + createMock.mockResolvedValueOnce(response()); await withTestActor(() => driver.complete({ model: models[0]!, @@ -1265,18 +1272,20 @@ describe('Kimi K3.1 Pro', () => { role: 'assistant', content: null, reasoning_content: 'Check weather', - tool_calls: toolCalls, }); - expect( - JSON.parse(assistant.tool_calls[0].function.arguments), - ).toEqual({ city: 'Paris' }); + expect(assistant).not.toHaveProperty('normalized'); + expect(assistant.tool_calls).toHaveLength(1); + expect(assistant.tool_calls[0]).toMatchObject({ + id: 'call_paris', + function: { name: 'weather' }, + }); }, ); it.each([{ city: 'Paris' }, 'Paris'])( 'keeps native tool_use input %j through the driver', async (input) => { - mockResponse(response()); + createMock.mockResolvedValueOnce(response()); await withTestActor(() => driver.complete({ model: models[0]!, @@ -1312,29 +1321,6 @@ describe('Kimi K3.1 Pro', () => { }, ); - it.each([ - [null, null], - ['bad', null], - ['51', null], - ['-1', null], - ['10', '41'], - ] as const)( - 'rejects unverifiable TTL counters %s/%s without guessing a charge', - async (oneHour, fiveMinute) => { - const { provider } = makeProvider(); - mockResponse(response(), oneHour, fiveMinute); - await expect( - withTestActor(() => - provider.complete({ - model: models[0]!, - messages: [{ role: 'user', content: 'hi' }], - }), - ), - ).rejects.toMatchObject({ statusCode: 502 }); - expect(recordSpy).not.toHaveBeenCalled(); - }, - ); - it.each( models.flatMap((model) => ['reasoning', 'reasoning_content', 'both'].map((alias) => ({ @@ -1355,22 +1341,19 @@ describe('Kimi K3.1 Pro', () => { ...(alias !== 'reasoning' ? { reasoning_content: text } : {}), }; const messages = [ - { role: 'user', content: 'hi' }, + ...user('hi'), assistant, - { role: 'user', content: 'continue' }, + ...user('continue'), ]; const tokens = estimatePromptTokens(messages) + estimateTextTokens(text); - const inputRate = model.endsWith('highspeed') ? 800 : 400; - const outputRate = model.endsWith('highspeed') ? 2000 : 1000; - const headroom = vi - .spyOn(server.services.metering, 'getUsageHeadroom') - .mockResolvedValue({ balance: 2_000_000_000, held: 0 }); + const { input, output } = rates(model); + const headroom = fund(2_000_000_000); const reserve = vi.spyOn( server.services.metering, 'reserveCredits', ); - mockResponse(response()); + createMock.mockResolvedValueOnce(response()); await withTestActor(() => driver.complete({ model, @@ -1379,13 +1362,13 @@ describe('Kimi K3.1 Pro', () => { }), ); expect(reserve.mock.calls[0]![1]).toBe( - tokens * inputRate + 10 * outputRate, + tokens * input + 10 * output, ); expect( createMock.mock.calls[0]![0].messages[1].reasoning_content, ).toBe(text); headroom.mockResolvedValue({ - balance: tokens * inputRate - 1, + balance: tokens * input - 1, held: 0, }); await expect( @@ -1402,68 +1385,8 @@ describe('Kimi K3.1 Pro', () => { }, ); - it.each( - models.flatMap((model) => [ - ...['low', 'high', 'max'].map((effort) => ({ - model, - options: { reasoning: { effort } }, - expected: effort, - invalid: false, - })), - { - model, - options: { - reasoning: { effort: 'low' }, - reasoning_effort: 'high', - }, - expected: 'high', - invalid: false, - }, - { model, options: {}, expected: undefined, invalid: false }, - ...['medium', 'none'].map((effort) => ({ - model, - options: { reasoning: { effort } }, - expected: undefined, - invalid: true, - })), - ]), - )( - 'SDK reasoning contract $model $options', - async ({ model, options, expected, invalid }) => { - // Bridge only the SDK transport, preserving JSON serialization and the real driver. - vi.spyOn(sdkUtils, 'makeDriverMethod').mockImplementation( - () => - ((args: unknown) => - withTestActor(() => - driver.complete(JSON.parse(JSON.stringify(args))), - )) as never, - ); - mockResponse(response()); - const result = sdkChat.call( - { puter: {} }, - 'hi', - { model, ...options }, - undefined, - undefined, - ); - if (invalid) { - await expect(result).rejects.toMatchObject({ - statusCode: 400, - legacyCode: 'bad_request', - }); - expect(createMock).not.toHaveBeenCalled(); - } else { - await result; - expect(createMock).toHaveBeenCalledTimes(1); - expect(createMock.mock.calls[0]![0].reasoning_effort).toBe( - expected, - ); - } - }, - ); - it.each(models)( - 'estimates transmitted reasoning on interrupted %s streams', + 'charges the estimate, replayed reasoning included, when a %s stream is interrupted', async (model) => { const reasoning = 'history '.repeat(1000); const messages = [ @@ -1473,19 +1396,17 @@ describe('Kimi K3.1 Pro', () => { reasoning: 'ignored alias', reasoning_content: reasoning, }, - { role: 'user', content: 'hi' }, + ...user('hi'), ]; const expectedInput = estimatePromptTokens(messages) + estimateTextTokens(reasoning); - mockResponse( + createMock.mockResolvedValueOnce( (async function* () { yield { choices: [{ delta: { content: 'partial answer' } }], }; throw new Error('upstream connection lost'); })(), - null, - null, ); const result = await withTestActor(() => driver.complete({ @@ -1496,7 +1417,6 @@ describe('Kimi K3.1 Pro', () => { ); const text = (await readStreamResult(result)).toString(); expect(text).toContain('upstream connection lost'); - expect(text).not.toContain('completion_accounting_failed'); await vi.waitFor(() => expect(recordSpy).toHaveBeenCalledTimes(1)); expect( createMock.mock.calls[0]![0].messages[0].reasoning_content, @@ -1511,225 +1431,34 @@ describe('Kimi K3.1 Pro', () => { }, ); - const invalidUsage = [ - ['absent usage', undefined], - ['null usage', null], - ['missing totals', {}], - [ - 'reads exceed total', - { - ...usage, - prompt_tokens_details: { - cached_tokens: 120, - cache_write_tokens: 0, - }, - }, - ], - [ - 'fractional writes', - { - ...usage, - prompt_tokens_details: { - cached_tokens: 20, - cache_write_tokens: 50.5, - }, - }, - ], - [ - 'negative reads', - { - ...usage, - prompt_tokens_details: { - cached_tokens: -1, - cache_write_tokens: 50, - }, - }, - ], - ['negative output', { ...usage, completion_tokens: -1 }], - ['string total', { ...usage, prompt_tokens: '100' }], - [ - 'unsafe total', - { ...usage, prompt_tokens: Number.MAX_SAFE_INTEGER + 1 }, - ], - [ - 'null writes', - { - ...usage, - prompt_tokens_details: { - cached_tokens: 20, - cache_write_tokens: null, - }, - }, - ], - ] as const; - - it.each([ - [false, null, null, false, 'headers', usage], - [false, '10', '41', false, 'headers', usage], - [true, null, null, false, 'headers', usage], - [true, '10', '41', false, 'headers', usage], - [false, null, null, true, 'headers', usage], - [false, '10', '41', true, 'headers', usage], - ...invalidUsage.flatMap( - ([name, value]) => - [ - [false, '0', null, false, name, value], - [true, '0', null, false, name, value], - ] as const, - ), - [true, null, null, false, 'transport after finish', undefined], - [true, '10', null, false, 'transport after finish empty usage', {}], - [ - true, - '10', - null, - false, - 'transport after finish invalid usage', - { ...usage, completion_tokens: -1 }, - ], - [true, '10', null, false, 'transport after finish valid usage', usage], - [false, '0x0', null, false, 'nondecimal TTL', usage], - [true, '0x0', null, false, 'nondecimal TTL', usage], - ] as const)( - 'settles completed requests (stream=%s, TTL=%s/%s, fallback=%s, %s) without fallback or estimated charging', - async ( - stream, - oneHour, - fiveMinute, - startWithFake, - _name, - upstreamUsage, - ) => { - const validSettlement = - _name === 'transport after finish valid usage'; - const model = MOONSHOT_MODELS.find((m) => m.id === models[0])!; - // A real alternative route ensures a retry would be observable. - vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValue([ - model, - ]); - const fallback = vi.spyOn(FakeChatProvider.prototype, 'complete'); - if (startWithFake) - fallback.mockRejectedValueOnce(new Error('Connection failed')); - const d = new ChatCompletionDriver( - { - providers: { - moonshot: { apiKey: 'test-key' }, - ollama: { enabled: false }, - }, - } as never, - server.clients, - server.stores, - server.services, + it.each(models)( + 'charges the estimate when a %s stream completes without a usage chunk', + async (model) => { + createMock.mockResolvedValueOnce( + asAsyncIterable([ + { choices: [{ delta: { content: 'answer' } }] }, + { choices: [{ delta: {}, finish_reason: 'stop' }] }, + ]), ); - await d.onServerStart(); - const actor = { - user: { - uuid: `kimi-accounting-${Math.random()}`, - username: 'kimi-accounting', - email: 'kimi@test.com', - }, - } as never; - const take = vi.spyOn(server.stores.creditHold, 'take'); - const release = vi.spyOn(server.stores.creditHold, 'release'); - mockResponse( - stream - ? (async function* () { - yield { choices: [{ delta: { content: 'answer' } }] }; - yield { - choices: [{ delta: {}, finish_reason: 'stop' }], - usage: structuredClone(upstreamUsage), - }; - if (_name.startsWith('transport after finish')) - throw new Error('upstream connection lost'); - })() - : { ...response(), usage: structuredClone(upstreamUsage) }, - oneHour, - fiveMinute, + const result = await withTestActor(() => + driver.complete({ + model, + provider: 'moonshotai', + messages: user('hi'), + stream: true, + }), ); - const completion = withTestActor( - () => - d.complete({ - model: models[0]!, - provider: startWithFake ? 'fake-chat' : 'moonshot', - messages: [{ role: 'user', content: 'hi' }], - max_tokens: 10, - stream, - }), - actor, - ); - if (stream) { - const result = (await completion) as unknown as { - stream: Readable; - }; - const events = (await readStreamResult(result)) - .toString() - .trim() - .split('\n') - .map((line) => JSON.parse(line)); - if (validSettlement) { - expect(events.some((event) => event.type === 'error')).toBe( - false, - ); - expect(events).toContainEqual( - expect.objectContaining({ type: 'usage' }), - ); - } else { - expect(events).toContainEqual( - expect.objectContaining({ - type: 'error', - code: 'completion_accounting_failed', - status: 502, - message: expect.stringContaining( - 'No charge was recorded', - ), - }), - ); - } - } else { - await expect(completion).rejects.toMatchObject({ - statusCode: 502, - legacyCode: 'completion_accounting_failed', - message: expect.stringContaining('No charge was recorded'), - }); - } - await vi.waitFor(() => - expect(release).toHaveBeenCalledTimes(startWithFake ? 2 : 1), - ); - expect(take).toHaveBeenCalledTimes(startWithFake ? 2 : 1); - expect(take.mock.calls[0]![1]).toBeGreaterThan(0); - expect( - await server.services.metering.getOutstandingHolds(actor), - ).toBe(0); - expect(createMock).toHaveBeenCalledTimes(1); - expect(fallback).toHaveBeenCalledTimes(startWithFake ? 1 : 0); - if (validSettlement) { - expect(recordSpy).toHaveBeenCalledTimes(1); - expect(recordSpy.mock.calls[0]![0]).toEqual({ - prompt_tokens: 30, - completion_tokens: 10, - cached_tokens: 20, - cache_write_tokens: 40, - cache_write_1h_tokens: 10, - }); - } else { - expect(recordSpy).not.toHaveBeenCalled(); - } - expect(isRouteUnhealthy('moonshot', models[0]!)).toBe(false); + const events = (await readStreamResult(result)) + .toString() + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(events.some((event) => event.type === 'error')).toBe(false); + await vi.waitFor(() => expect(recordSpy).toHaveBeenCalledTimes(1)); + expect(recordSpy.mock.calls[0]![0]).toMatchObject({ + estimated_prompt_tokens: expect.any(Number), + estimated_completion_tokens: expect.any(Number), + }); }, ); - - it('derives one-hour writes from the five-minute header and total writes', async () => { - const { provider } = makeProvider(); - mockResponse(response(), null, '40'); - await withTestActor(() => - provider.complete({ - model: models[0]!, - messages: [{ role: 'user', content: 'hi' }], - }), - ); - expect(recordSpy.mock.calls[0]![0]).toMatchObject({ - cache_write_tokens: 40, - cache_write_1h_tokens: 10, - }); - }); }); diff --git a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts index 9e8db2306..eb7ca9526 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts @@ -18,32 +18,38 @@ */ import { OpenAI } from 'openai'; -import type { - ChatCompletion, - ChatCompletionChunk, -} from 'openai/resources/chat/completions.js'; -import type { Stream } from 'openai/streaming.js'; import { HttpError } from '../../../../core/http/HttpError.js'; +import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import type { IChatModel, ICompleteArguments, PuterMessage, } from '../../types.js'; -import { CompletionAccountingError } from '../../utils/meterChatUsage.js'; -import { estimateTextTokens } from '../../utils/usageEstimate.js'; import { openAICompatParams } from '../../utils/openaiParams.js'; -import type { MeteringService } from '../../../../services/metering/MeteringService.js'; import { sdkClientOptions } from '../../utils/sdkClient.js'; +import { estimateTextTokens } from '../../utils/usageEstimate.js'; import { type ChatProviderConfig, - type ChatCompletionsClient, - type UsageSource, OpenAICompatProvider, } from '../OpenAICompatProvider.js'; import { MOONSHOT_MODELS } from './models.js'; -const isK31 = (id: unknown) => - id === 'kimi-k3.1-pro' || id === 'kimi-k3.1-pro-highspeed'; +/** + * The K3-series request shape: reasoning always on, capped by + * `max_completion_tokens`. + */ +const K31_MODEL_IDS = new Set(['kimi-k3.1-pro', 'kimi-k3.1-pro-highspeed']); +const K31_REASONING_EFFORTS = ['low', 'high', 'max']; +/** Moonshot's own output cap when the request names none. */ +const K31_DEFAULT_MAX_COMPLETION_TOKENS = 131_072; + +const isK31 = (model: IChatModel) => K31_MODEL_IDS.has(model.id); + +/** A replayed assistant turn's reasoning, under the wire key or Puter's. */ +const replayedReasoning = (message: PuterMessage): string | undefined => { + const reasoning = message.reasoning_content ?? message.reasoning; + return typeof reasoning === 'string' ? reasoning : undefined; +}; export class MoonshotProvider extends OpenAICompatProvider { constructor(config: ChatProviderConfig, meteringService: MeteringService) { @@ -61,51 +67,44 @@ export class MoonshotProvider extends OpenAICompatProvider { }); } + /** + * K3.1 Pro reads replayed reasoning back as prompt, which the message + * estimate the credit gate works from doesn't count. + */ requestPricing(args: ICompleteArguments, model: IChatModel) { - if (!isK31(model.id)) return {}; + if (!isK31(model)) return {}; const additionalPromptTokens = (args.messages ?? []).reduce( (tokens, message) => { if (message.role !== 'assistant') return tokens; - const reasoning = - message.reasoning_content !== undefined - ? message.reasoning_content - : message.reasoning; - return ( - tokens + - (typeof reasoning === 'string' - ? estimateTextTokens(reasoning) - : 0) - ); + const reasoning = replayedReasoning(message); + return tokens + (reasoning ? estimateTextTokens(reasoning) : 0); }, 0, ); - // Appended prefixes can inherit a one-hour cache entry's write rate. - return { inputKey: 'cache_write_1h_tokens', additionalPromptTokens }; + return { additionalPromptTokens }; } protected override preservesToolCallOrder(model: IChatModel) { - return isK31(model.id); + return isK31(model); } protected override preservesAssistantContent(model: IChatModel) { - return isK31(model.id); + return isK31(model); } + /** K3.1 Pro wants a turn's reasoning back under `reasoning_content`, once. */ protected override prepareMessages( messages: PuterMessage[], model: IChatModel, ) { - if (!isK31(model.id)) return messages; + if (!isK31(model)) return messages; return messages.map((message) => { if (message.role !== 'assistant') return message; const { reasoning, normalized: _normalized, ...rest } = message; - return { - ...rest, - ...(rest.reasoning_content === undefined && - reasoning !== undefined - ? { reasoning_content: reasoning } - : {}), - }; + const replayed = replayedReasoning(message); + return replayed === undefined + ? rest + : { ...rest, reasoning_content: replayed }; }); } @@ -114,17 +113,16 @@ export class MoonshotProvider extends OpenAICompatProvider { args: ICompleteArguments, model: IChatModel, ) { - if (!isK31(model.id)) return params; + if (!isK31(model)) return params; const effort = args.reasoning_effort ?? args.reasoning?.effort; - if (effort !== undefined && !['low', 'high', 'max'].includes(effort)) { + if (effort !== undefined && !K31_REASONING_EFFORTS.includes(effort)) { throw new HttpError( 400, - 'Kimi K3.1 Pro reasoning_effort must be low, high, or max', - { - legacyCode: 'bad_request', - }, + `reasoning_effort must be one of ${K31_REASONING_EFFORTS.join(', ')} for ${model.id}`, + { legacyCode: 'bad_request' }, ); } + // The K3 series deprecates `max_tokens` in favour of `max_completion_tokens`. const { max_tokens, ...rest } = params; return { ...rest, @@ -139,132 +137,8 @@ export class MoonshotProvider extends OpenAICompatProvider { ], }, ), - max_completion_tokens: max_tokens ?? 131_072, - }; - } - - protected override async createCompletion( - client: ChatCompletionsClient, - params: Record, - signal?: AbortSignal, - ): Promise { - if (!isK31(params.model)) - return super.createCompletion(client, params, signal); - // TTL is locked by the cached prefix, so bill the response's TTL breakdown. - const { data, response } = await (client as OpenAI).chat.completions - .create(params as never, { signal }) - .withResponse(); - const fiveMinuteWrites = response.headers.get( - 'Msh-Usage-Cache-Write-Tokens-5m', - ); - const oneHourWrites = response.headers.get( - 'Msh-Usage-Cache-Write-Tokens-1h', - ); - const addWriteUsage = ( - completion: ChatCompletion | ChatCompletionChunk, - ) => { - if (completion.usage) { - Object.assign(completion.usage, { - moonshotCacheWrite1hTokens: oneHourWrites, - moonshotCacheWrite5mTokens: fiveMinuteWrites, - }); - } - return completion; - }; - if (!params.stream) return addWriteUsage(data as ChatCompletion); - return (async function* () { - let finished = false; - let hasUsage = false; - try { - for await (const chunk of data as Stream) { - finished ||= chunk.choices.some( - (choice) => !!choice.finish_reason, - ); - hasUsage ||= chunk.usage != null; - yield addWriteUsage(chunk); - } - } catch (error) { - if (!finished) throw error; - // After finish, let the normal calculator validate and settle any usage. - } - // A completed inference with missing accounting must not be estimated. - if (finished && !hasUsage) throw new CompletionAccountingError(); - })(); - } - - protected override meteredUsage(source: UsageSource, model: IChatModel) { - if (!isK31(model.id)) return super.meteredUsage(source, model); - const usage = source.usage as typeof source.usage & { - moonshotCacheWrite1hTokens?: string | null; - moonshotCacheWrite5mTokens?: string | null; - prompt_tokens_details?: { - cached_tokens?: number; - cache_write_tokens?: number; - }; - }; - const details = usage?.prompt_tokens_details; - const cached = - details?.cached_tokens === undefined ? 0 : details.cached_tokens; - const written = - details?.cache_write_tokens === undefined - ? 0 - : details.cache_write_tokens; - if ( - !usage || - (details !== undefined && - (details === null || - typeof details !== 'object' || - Array.isArray(details))) || - ![ - usage.prompt_tokens, - usage.completion_tokens, - cached, - written, - ].every((count) => Number.isSafeInteger(count) && count >= 0) || - cached > usage.prompt_tokens || - written > usage.prompt_tokens - cached - ) { - throw new CompletionAccountingError(); - } - const fiveMinuteHeader = usage.moonshotCacheWrite5mTokens; - const oneHourHeader = usage.moonshotCacheWrite1hTokens; - const headers = [fiveMinuteHeader, oneHourHeader]; - if (headers.some((value) => value != null && !/^\d+$/.test(value))) { - throw new CompletionAccountingError(); - } - const oneHour = - oneHourHeader != null - ? Number(oneHourHeader) - : fiveMinuteHeader != null - ? written - Number(fiveMinuteHeader) - : 0; - if ( - (written > 0 && - oneHourHeader == null && - fiveMinuteHeader == null) || - !Number.isSafeInteger(oneHour) || - oneHour < 0 || - oneHour > written || - (fiveMinuteHeader != null && - Number(fiveMinuteHeader) !== written - oneHour) - ) { - throw new CompletionAccountingError(); - } - return { - usage: { - prompt_tokens: usage.prompt_tokens - cached - written, - completion_tokens: usage.completion_tokens, - cached_tokens: cached, - cache_write_tokens: written - oneHour, - cache_write_1h_tokens: oneHour, - }, - usageDetails: { - inputTokens: usage.prompt_tokens - cached - written, - outputTokens: usage.completion_tokens, - cacheReadTokens: cached, - cacheWrite5mTokens: written - oneHour, - cacheWrite1hTokens: oneHour, - }, + max_completion_tokens: + max_tokens ?? K31_DEFAULT_MAX_COMPLETION_TOKENS, }; } } diff --git a/src/backend/drivers/ai-chat/providers/moonshot/models.ts b/src/backend/drivers/ai-chat/providers/moonshot/models.ts index 34591fc98..4ee6b9da3 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/models.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/models.ts @@ -20,6 +20,7 @@ import type { IChatModel } from '../../types.js'; export const MOONSHOT_MODELS: IChatModel[] = [ + // -- Kimi K3.1 Pro ------------------------------------------------ { puterId: 'moonshotai:moonshotai/kimi-k3.1-pro', id: 'kimi-k3.1-pro', @@ -31,15 +32,15 @@ export const MOONSHOT_MODELS: IChatModel[] = [ output_cost_key: 'completion_tokens', costs: { tokens: 1_000_000, - prompt_tokens: 200, - completion_tokens: 1000, - cached_tokens: 20, - cache_write_tokens: 200, - cache_write_1h_tokens: 400, + prompt_tokens: 200, // $2.00 per 1M + completion_tokens: 1000, // $10.00 per 1M + cached_tokens: 20, // $0.20 per 1M + cache_write_tokens: 200, // $2.00 per 1M }, context: 1_048_576, max_tokens: 1_048_576, tool_call: true, + release_date: '2026-10-24', }, { puterId: 'moonshotai:moonshotai/kimi-k3.1-pro-highspeed', @@ -55,15 +56,15 @@ export const MOONSHOT_MODELS: IChatModel[] = [ output_cost_key: 'completion_tokens', costs: { tokens: 1_000_000, - prompt_tokens: 400, - completion_tokens: 2000, - cached_tokens: 20, - cache_write_tokens: 400, - cache_write_1h_tokens: 800, + prompt_tokens: 400, // $4.00 per 1M + completion_tokens: 2000, // $20.00 per 1M + cached_tokens: 20, // $0.20 per 1M + cache_write_tokens: 400, // $4.00 per 1M }, context: 1_048_576, max_tokens: 1_048_576, tool_call: true, + release_date: '2026-10-24', }, // -- Flagship ---------------------------------------------------- @@ -81,6 +82,7 @@ export const MOONSHOT_MODELS: IChatModel[] = [ prompt_tokens: 300, // $3.00 per 1M completion_tokens: 1500, // $15.00 per 1M cached_tokens: 30, // $0.30 per 1M + cache_write_tokens: 300, // $3.00 per 1M }, context: 1_048_576, max_tokens: 1_048_576, diff --git a/src/backend/drivers/ai-chat/utils/Messages.js b/src/backend/drivers/ai-chat/utils/Messages.js index aa3ab603d..c701ee497 100644 --- a/src/backend/drivers/ai-chat/utils/Messages.js +++ b/src/backend/drivers/ai-chat/utils/Messages.js @@ -85,25 +85,11 @@ export const normalize_single_message = (message, params = {}) => { { legacyCode: 'bad_request' }, ); } - let input = tool_call.function.arguments; - if (typeof input === 'string') { - try { - input = JSON.parse(input); - } catch { - throw new HttpError( - 400, - 'tool_call function arguments must be valid JSON', - { - legacyCode: 'bad_request', - }, - ); - } - } message.content.push({ type: 'tool_use', id: tool_call.id, name: tool_call.function.name, - input, + input: tool_call.function.arguments, // Gemini's thought signature rides here and must be // replayed with the call. ...(tool_call.extra_content diff --git a/src/backend/drivers/ai-chat/utils/Messages.test.ts b/src/backend/drivers/ai-chat/utils/Messages.test.ts index ac4713a94..7a5864fca 100644 --- a/src/backend/drivers/ai-chat/utils/Messages.test.ts +++ b/src/backend/drivers/ai-chat/utils/Messages.test.ts @@ -224,23 +224,6 @@ describe('normalize_single_message tool_calls', () => { ); }); - it('rejects malformed serialized tool arguments', () => { - expect(() => - normalize_messages([ - { - role: 'assistant', - content: null, - tool_calls: [ - { - id: 'c1', - function: { name: 'lookup', arguments: '{' }, - }, - ], - }, - ] as never), - ).toThrow('tool_call function arguments must be valid JSON'); - }); - it('still converts a well-formed tool_call', () => { const result = normalize_messages([ { @@ -251,7 +234,7 @@ describe('normalize_single_message tool_calls', () => { }, ] as never); expect(result[0].content).toEqual([ - { type: 'tool_use', id: 'c1', name: 'lookup', input: {} }, + { type: 'tool_use', id: 'c1', name: 'lookup', input: '{}' }, ]); }); diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js index a85e40252..94abd5865 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.js +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.js @@ -483,11 +483,22 @@ export const extractMeteredUsage = (usage) => { * @param {import('openai/resources/completions.mjs').CompletionUsage | undefined} usage */ export const splitCachedPrompt = (usage) => { - const cachedTokens = usage?.prompt_tokens_details?.cached_tokens ?? 0; + const details = usage?.prompt_tokens_details; + const cachedTokens = details?.cached_tokens ?? 0; + // Moonshot counts the prompt tokens it wrote to its prefix cache here; + // they're billed at the model's write rate, not the plain prompt rate. + const writeTokens = + typeof details?.cache_write_tokens === 'number' + ? details.cache_write_tokens + : undefined; return { - prompt_tokens: (usage?.prompt_tokens ?? 0) - cachedTokens, + prompt_tokens: + (usage?.prompt_tokens ?? 0) - cachedTokens - (writeTokens ?? 0), completion_tokens: usage?.completion_tokens ?? 0, cached_tokens: cachedTokens, + ...(writeTokens !== undefined + ? { cache_write_tokens: writeTokens } + : {}), }; }; diff --git a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts index f41605389..62f5bc2a5 100644 --- a/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts +++ b/src/backend/drivers/ai-chat/utils/OpenAIUtil.test.ts @@ -888,6 +888,35 @@ describe('splitCachedPrompt', () => { cached_tokens: 0, }); }); + + it('takes cache writes out of prompt_tokens under their own key', () => { + expect( + splitCachedPrompt({ + prompt_tokens: 10, + completion_tokens: 5, + prompt_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 }, + }), + ).toEqual({ + prompt_tokens: 3, + completion_tokens: 5, + cached_tokens: 3, + cache_write_tokens: 4, + }); + }); + + it('leaves cache writes out when the upstream reports no count', () => { + expect( + splitCachedPrompt({ + prompt_tokens: 10, + completion_tokens: 5, + prompt_tokens_details: { cached_tokens: 3, cache_write_tokens: null }, + }), + ).toEqual({ + prompt_tokens: 7, + completion_tokens: 5, + cached_tokens: 3, + }); + }); }); // ── recorded usage costs ──────────────────────────────────────────── diff --git a/src/backend/drivers/ai-chat/utils/meterChatUsage.ts b/src/backend/drivers/ai-chat/utils/meterChatUsage.ts index 06962bffe..38d5e253d 100644 --- a/src/backend/drivers/ai-chat/utils/meterChatUsage.ts +++ b/src/backend/drivers/ai-chat/utils/meterChatUsage.ts @@ -17,29 +17,11 @@ * along with this program. If not, see . */ -import { HttpError } from '../../../core/http/HttpError.js'; import type { Actor } from '../../../core/actor.js'; import type { MeteringService } from '../../../services/metering/MeteringService.js'; import type { IChatModel } from '../types.js'; import { buildCostsOverride } from './pricing.js'; -/** - * Upstream inference finished, but no reliable charge can be recorded. - * Terminal: retrying would buy another completion, and estimating would hide - * the accounting failure. Only raise this before recording any charge. - */ -export class CompletionAccountingError extends HttpError { - constructor() { - super( - 502, - 'The completion finished, but its usage could not be verified. No charge was recorded.', - { - legacyCode: 'completion_accounting_failed', - }, - ); - } -} - /** Microcents per US dollar. */ const MICROCENTS_PER_USD = 100_000_000; diff --git a/src/docs/src/AI/chat.md b/src/docs/src/AI/chat.md index 54edfff73..417349069 100755 --- a/src/docs/src/AI/chat.md +++ b/src/docs/src/AI/chat.md @@ -34,7 +34,7 @@ An object containing the following properties: - `max_tokens` (Number) - The maximum number of tokens to generate in the completion. By default, the specific model's maximum is used. - `temperature` (Number) - A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. Some OpenAI reasoning models ignore `temperature` and `top_p`. GPT-5.6 and GPT-6 Sol/Luna accept them only with `reasoning_effort: 'none'`, and GPT-6 Astra, GPT-6.1 Sol, and GPT-5.3 Codex always ignore them. `gemini-4-argon` ignores `temperature` too. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` use it. - `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`, `xhigh`, and `max`. Lower values give faster responses with less reasoning. OpenAI models, Moonshot's Kimi K3.1 Pro models, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only; Muse Spark always reasons, so `none` is ignored for it. `gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only. +- `reasoning_effort` / `reasoning.effort` (String) (Optional) - Controls how much effort reasoning models spend thinking. Supported values: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. Lower values give faster responses with less reasoning. OpenAI models, Moonshot's Kimi K3.1 Pro models, Meta's Muse Spark models, `gemini-4-argon`, `gemini-3.1-flash-lite` and `gemini-3-flash-preview` only; Muse Spark always reasons, so `none` is ignored for it. Kimi K3.1 Pro accepts `low`, `high` and `max`, uses `max` when you leave it unset, and fails with `400 bad_request` on any other value. `gemini-4-argon` accepts `low`, `medium` and `high`, and uses `medium` when you leave it unset. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` accept `minimal`, `low`, `medium` and `high`, and use Google's default level when you leave it unset. For these three Gemini models, any other value fails with `400 bad_request`, and messages can contain text and images only. - `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). @@ -45,14 +45,6 @@ An object containing the following properties: - `thinking` (Object | null) (Optional) - Extended-thinking control: `{ type: "adaptive" | "enabled" | "disabled" | "between_tools", budgetTokens?, display? }`. Pass `null` to send no thinking configuration at all (the model's own default); leaving it unset falls back to the legacy derivation from `reasoning_effort`. `budgetTokens` only applies to `type: "enabled"`. - `streamToolInput` (Boolean) (Optional) - Streaming only. When `true`, a tool call's arguments arrive incrementally as `tool_use_start` / `tool_input_delta` chunks instead of a single `tool_use` chunk once the call completes. -### Kimi K3.1 Pro - -Select `kimi-k3.1-pro` or `kimi-k3.1-pro-highspeed` with `provider: 'moonshotai'`. Both models accept `reasoning_effort` values `low`, `high`, and `max`, with `max` as the default. Thinking is always enabled. Sampling options use the model's fixed settings. - -The context window is 1,048,576 tokens. Use `max_tokens` to set an output cap up to 1,048,576 tokens, subject to the remaining context window and available credits. Preserve the complete returned assistant message, including its `reasoning` and tool calls, when continuing a conversation. - -Usage separates uncached input, cached reads, cache writes by TTL, and output. Cache writes are billed once when a prefix is stored. The upstream cache uses a five-minute TTL by default. Requests reserve credits at the one-hour cache-write rate because an existing prefix can retain that TTL; this reservation includes replayed assistant reasoning. Actual charges use the returned usage. If a completed response has missing or invalid usage, or writes without a usable TTL breakdown, the request ends with `completion_accounting_failed`: no charge is recorded, the credit hold is released, and Puter does not retry another provider. A stream may already have delivered content before this terminal error. For current rates, see the [AI models list](https://developer.puter.com/ai/models/). - #### `testMode` (Boolean) (Optional) A boolean indicating whether you want to use the test API. Defaults to `false`. This is useful for testing your code without using up API credits. @@ -227,7 +219,7 @@ A `normalize` option on an individual call always overrides `puter.ai.normalize` 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. +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. Kimi K3.1 Pro reads its `reasoning` string back the same way, so keep `reasoning` on the assistant message when you continue a conversation with it. 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. @@ -308,6 +300,8 @@ Pass in the `cache_control` parameter inside the object in the `messages` array. You can find the implementation in our [prompt caching example](/playground/ai-claude-cache-control/). Find more details about cache control in [Anthropic documentation](https://platform.claude.com/docs/en/build-with-claude/prompt-caching). +Moonshot's Kimi K3 and Kimi K3.1 Pro models cache prompt prefixes on their own and take no `cache_control`. Their `usage` reports the tokens read from the cache as `cached_tokens` and the tokens written to it as `cache_write_tokens`. Each is billed at the model's own rate from the [AI models list](https://developer.puter.com/ai/models/). + ## Compaction For long, multi-turn conversations that you keep on the client, enable diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md index 50e5bbfeb..1ec1b1238 100644 --- a/src/docs/src/rate-limits-and-quotas.md +++ b/src/docs/src/rate-limits-and-quotas.md @@ -77,8 +77,6 @@ An input over its limit is rejected before it reaches the model, as is an image The standard chat timeout is 10 minutes for a response to start, or to finish if it isn't streamed. Stream durations depend on the provider. Native Gemini applies the 10-minute timeout to the whole response, including streaming. Groq uses 60 seconds to start a response. Mistral streams have no timeout of their own. If the caller disconnects, a stream that's still running gets 10 more minutes to finish, and is then stopped. See the [AI model directory](https://developer.puter.com/ai/models/) for model specifications. -Moonshot's `kimi-k3.1-pro` and `kimi-k3.1-pro-highspeed` have a 1,048,576-token context window. Their output cap can be set with `max_tokens` up to 1,048,576, subject to remaining context and available credits. - A `gemini-4-argon` response that isn't streamed is capped at 32,768 output tokens, and a larger `max_tokens` is lowered to that cap. Streaming uses the model-directory limit. Text-to-speech calls to ElevenLabs, xAI, Speechify, Kling and Gemini time out after 2 minutes; xAI speech-to-text and voice-changer calls after 10 minutes, upload included. A call that times out before any audio or transcript comes back fails with `504 upstream_timeout`. For Gemini, the 2 minutes cover the whole reply, audio included. From 68249028efb2b6c6c58dda85c6b9918463bfe589 Mon Sep 17 00:00:00 2001 From: 404oops Date: Sat, 24 Oct 2026 22:59:04 +0200 Subject: [PATCH 7/7] test(ai): assert replayed Kimi tool arguments reach the wire decoded once Co-Authored-By: Claude Fable 5.1 --- .../moonshot/MoonshotProvider.test.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) 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 b454ada09..ce57e9906 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.test.ts @@ -1049,9 +1049,7 @@ describe('Kimi K3.1 Pro', () => { reasoning_content: 'reasoning', }); expect(replayed).not.toHaveProperty('reasoning'); - expect( - replayed.tool_calls.map((call: { id: string }) => call.id), - ).toEqual(['a', 'b']); + expect(replayed.tool_calls).toEqual(toolCalls); createMock.mockResolvedValueOnce(response()); await withTestActor(() => provider.complete({ @@ -1274,15 +1272,14 @@ describe('Kimi K3.1 Pro', () => { reasoning_content: 'Check weather', }); expect(assistant).not.toHaveProperty('normalized'); - expect(assistant.tool_calls).toHaveLength(1); - expect(assistant.tool_calls[0]).toMatchObject({ - id: 'call_paris', - function: { name: 'weather' }, - }); + expect(assistant.tool_calls).toEqual(toolCalls); + expect( + JSON.parse(assistant.tool_calls[0].function.arguments), + ).toEqual({ city: 'Paris' }); }, ); - it.each([{ city: 'Paris' }, 'Paris'])( + it.each([{ city: 'Paris' }, '{"city":"Paris"}'])( 'keeps native tool_use input %j through the driver', async (input) => { createMock.mockResolvedValueOnce(response()); @@ -1315,9 +1312,9 @@ describe('Kimi K3.1 Pro', () => { (message: { role: string }) => message.role === 'assistant', ); expect(assistant.reasoning_content).toBe('Check weather'); - expect( - JSON.parse(assistant.tool_calls[0].function.arguments), - ).toEqual(input); + expect(assistant.tool_calls[0].function.arguments).toBe( + '{"city":"Paris"}', + ); }, );