mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-03 20:43:22 +00:00
Merge main and preserve Terra and Kimi reasoning documentation
This commit is contained in:
commit
223d2eb4cd
11 files changed
+1037
-43
No files matched your search
@@ -816,6 +816,9 @@ 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;
|
||||
// The cost factor the current attempt's charges are recorded at.
|
||||
@@ -824,12 +827,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
|
||||
@@ -957,12 +964,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));
|
||||
@@ -1188,7 +1199,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
actor,
|
||||
chatStream,
|
||||
model,
|
||||
promptTokenEstimate,
|
||||
promptTokenEstimate: attemptPromptTokenEstimate,
|
||||
completionId,
|
||||
username,
|
||||
intendedProvider,
|
||||
@@ -1432,10 +1443,16 @@ 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;
|
||||
/** The prompt estimate this attempt was priced on. */
|
||||
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
|
||||
@@ -1446,6 +1463,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;
|
||||
@@ -1560,7 +1578,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. */
|
||||
|
||||
@@ -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
|
||||
@@ -324,8 +326,19 @@ 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;
|
||||
}
|
||||
|
||||
/** Last pass over the messages, after `process_input_messages`. */
|
||||
protected prepareMessages(messages: PuterMessage[]): PuterMessage[] {
|
||||
protected prepareMessages(
|
||||
messages: PuterMessage[],
|
||||
_model: IChatModel,
|
||||
): PuterMessage[] {
|
||||
return messages;
|
||||
}
|
||||
|
||||
|
||||
+33
-2
@@ -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<string, number>;
|
||||
};
|
||||
|
||||
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),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 { Writable } from 'node:stream';
|
||||
@@ -46,14 +44,23 @@ 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';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import {
|
||||
readStreamResult,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.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 ─────────────────────────────────────────────────
|
||||
@@ -93,10 +100,23 @@ vi.mock('../../utils/inlineImages.js', () => ({
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: ChatCompletionDriver;
|
||||
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
|
||||
|
||||
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 +160,7 @@ const makeCapturingChatStream = () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
clearUnhealthyRoutes();
|
||||
createMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
inlineHttpImageUrlsMock.mockReset();
|
||||
@@ -390,7 +411,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;
|
||||
}[],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -782,3 +806,656 @@ 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,
|
||||
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 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();
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
messages: user('hi'),
|
||||
max_tokens: 512,
|
||||
reasoning_effort: 'high',
|
||||
temperature: 0.2,
|
||||
top_p: 0.1,
|
||||
}),
|
||||
);
|
||||
const sent = createMock.mock.calls[0]![0];
|
||||
expect(sent).toMatchObject({
|
||||
model,
|
||||
max_completion_tokens: 512,
|
||||
reasoning_effort: 'high',
|
||||
});
|
||||
expect(sent).not.toHaveProperty('max_tokens');
|
||||
expect(sent).not.toHaveProperty('temperature');
|
||||
expect(sent).not.toHaveProperty('top_p');
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the upstream default output cap and leaves thinking to the model', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
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();
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: models[0]!,
|
||||
messages: user('hi'),
|
||||
reasoning_effort: effort,
|
||||
}),
|
||||
);
|
||||
expect(createMock.mock.calls[0]![0].reasoning_effort).toBe(effort);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an unsupported reasoning effort before inference', async () => {
|
||||
const { provider } = makeProvider();
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: models[0]!,
|
||||
messages: user('hi'),
|
||||
reasoning_effort: 'medium',
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
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 cache reads, writes and uncached input separately for %s',
|
||||
async (model) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({ model, messages: user('hi') }),
|
||||
);
|
||||
const { input, output } = rates(model);
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 10,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 50,
|
||||
},
|
||||
expect.anything(),
|
||||
`moonshotai:${model}`,
|
||||
{
|
||||
prompt_tokens: 30 * input,
|
||||
completion_tokens: 10 * output,
|
||||
cached_tokens: 20 * 20,
|
||||
cache_write_tokens: 50 * input,
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
usageDetails: {
|
||||
inputTokens: 30,
|
||||
outputTokens: 10,
|
||||
cacheReadTokens: 20,
|
||||
cacheWrite5mTokens: 50,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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 });
|
||||
createMock.mockResolvedValueOnce(first);
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({ model: models[0]!, messages: user('hi') }),
|
||||
);
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: models[0]!,
|
||||
messages: [(result as { message: unknown }).message],
|
||||
}),
|
||||
);
|
||||
const replayed = createMock.mock.calls[1]![0].messages[0];
|
||||
expect(replayed).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
reasoning_content: 'reasoning',
|
||||
});
|
||||
expect(replayed).not.toHaveProperty('reasoning');
|
||||
expect(replayed.tool_calls).toEqual(toolCalls);
|
||||
createMock.mockResolvedValueOnce(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 a stream from its final usage chunk', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(
|
||||
asAsyncIterable([
|
||||
{ choices: [{ delta: { reasoning_content: 'reasoning' } }] },
|
||||
{ choices: [{ delta: { content: 'answer' } }] },
|
||||
{ choices: [], usage: structuredClone(usage) },
|
||||
]),
|
||||
);
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: models[0]!,
|
||||
messages: user('hi'),
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).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: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(models)(
|
||||
'reserves the prompt at the input rate for %s',
|
||||
async (model) => {
|
||||
const { input, output } = rates(model);
|
||||
fund(2_000_000_000);
|
||||
const reserve = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCredits',
|
||||
);
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
provider: 'moonshotai',
|
||||
messages: user('x'.repeat(8_000_000)),
|
||||
max_tokens: 10,
|
||||
}),
|
||||
);
|
||||
expect(reserve).toHaveBeenCalledTimes(1);
|
||||
expect(reserve.mock.calls[0]![1]).toBeGreaterThanOrEqual(
|
||||
1_000_000 * input + 10 * output,
|
||||
);
|
||||
expect(reserve.mock.calls[0]![1]).toBeLessThan(
|
||||
1_000_001 * input + 10 * output,
|
||||
);
|
||||
expect(recordSpy.mock.calls[0]![0]).toMatchObject({
|
||||
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 reservation for %s',
|
||||
async (model) => {
|
||||
const entry = MOONSHOT_MODELS.find(
|
||||
(candidate) => candidate.id === model,
|
||||
)!;
|
||||
fund(2_000_000_000);
|
||||
const reserve = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCredits',
|
||||
);
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
provider: 'moonshotai',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'x'.repeat(8000),
|
||||
reasoning: 'thought '.repeat(90000),
|
||||
},
|
||||
],
|
||||
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 request for %s before inference',
|
||||
async (model) => {
|
||||
// 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: user('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"}',
|
||||
},
|
||||
},
|
||||
];
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
reasoning_content: 'Check weather',
|
||||
tool_calls: toolCalls,
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
});
|
||||
const first = await withTestActor(() =>
|
||||
driver.complete({
|
||||
model: models[0]!,
|
||||
provider: 'moonshotai',
|
||||
messages: user('weather?'),
|
||||
}),
|
||||
);
|
||||
createMock.mockResolvedValueOnce(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',
|
||||
});
|
||||
expect(assistant).not.toHaveProperty('normalized');
|
||||
expect(assistant.tool_calls).toEqual(toolCalls);
|
||||
expect(
|
||||
JSON.parse(assistant.tool_calls[0].function.arguments),
|
||||
).toEqual({ city: 'Paris' });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ city: 'Paris' }, '{"city":"Paris"}'])(
|
||||
'keeps native tool_use input %j through the driver',
|
||||
async (input) => {
|
||||
createMock.mockResolvedValueOnce(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(assistant.tool_calls[0].function.arguments).toBe(
|
||||
'{"city":"Paris"}',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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 = [
|
||||
...user('hi'),
|
||||
assistant,
|
||||
...user('continue'),
|
||||
];
|
||||
const tokens =
|
||||
estimatePromptTokens(messages) + estimateTextTokens(text);
|
||||
const { input, output } = rates(model);
|
||||
const headroom = fund(2_000_000_000);
|
||||
const reserve = vi.spyOn(
|
||||
server.services.metering,
|
||||
'reserveCredits',
|
||||
);
|
||||
createMock.mockResolvedValueOnce(response());
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
messages: structuredClone(messages),
|
||||
max_tokens: 10,
|
||||
}),
|
||||
);
|
||||
expect(reserve.mock.calls[0]![1]).toBe(
|
||||
tokens * input + 10 * output,
|
||||
);
|
||||
expect(
|
||||
createMock.mock.calls[0]![0].messages[1].reasoning_content,
|
||||
).toBe(text);
|
||||
headroom.mockResolvedValue({
|
||||
balance: tokens * input - 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)(
|
||||
'charges the estimate, replayed reasoning included, when a %s stream is interrupted',
|
||||
async (model) => {
|
||||
const reasoning = 'history '.repeat(1000);
|
||||
const messages = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'answer',
|
||||
reasoning: 'ignored alias',
|
||||
reasoning_content: reasoning,
|
||||
},
|
||||
...user('hi'),
|
||||
];
|
||||
const expectedInput =
|
||||
estimatePromptTokens(messages) + estimateTextTokens(reasoning);
|
||||
createMock.mockResolvedValueOnce(
|
||||
(async function* () {
|
||||
yield {
|
||||
choices: [{ delta: { content: 'partial answer' } }],
|
||||
};
|
||||
throw new Error('upstream connection lost');
|
||||
})(),
|
||||
);
|
||||
const result = await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
messages: structuredClone(messages),
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
const text = (await readStreamResult(result)).toString();
|
||||
expect(text).toContain('upstream connection lost');
|
||||
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',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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' }] },
|
||||
]),
|
||||
);
|
||||
const result = await withTestActor(() =>
|
||||
driver.complete({
|
||||
model,
|
||||
provider: 'moonshotai',
|
||||
messages: user('hi'),
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
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),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -18,14 +18,39 @@
|
||||
*/
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
ICompleteArguments,
|
||||
PuterMessage,
|
||||
} from '../../types.js';
|
||||
import { openAICompatParams } from '../../utils/openaiParams.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import { estimateTextTokens } from '../../utils/usageEstimate.js';
|
||||
import {
|
||||
type ChatProviderConfig,
|
||||
OpenAICompatProvider,
|
||||
} from '../OpenAICompatProvider.js';
|
||||
import { MOONSHOT_MODELS } from './models.js';
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
super(meteringService, {
|
||||
@@ -41,4 +66,79 @@ export class MoonshotProvider extends OpenAICompatProvider {
|
||||
inlineImages: 'vision',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)) return {};
|
||||
const additionalPromptTokens = (args.messages ?? []).reduce(
|
||||
(tokens, message) => {
|
||||
if (message.role !== 'assistant') return tokens;
|
||||
const reasoning = replayedReasoning(message);
|
||||
return tokens + (reasoning ? estimateTextTokens(reasoning) : 0);
|
||||
},
|
||||
0,
|
||||
);
|
||||
return { additionalPromptTokens };
|
||||
}
|
||||
|
||||
protected override preservesToolCallOrder(model: IChatModel) {
|
||||
return isK31(model);
|
||||
}
|
||||
|
||||
protected override preservesAssistantContent(model: IChatModel) {
|
||||
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)) return messages;
|
||||
return messages.map((message) => {
|
||||
if (message.role !== 'assistant') return message;
|
||||
const { reasoning, normalized: _normalized, ...rest } = message;
|
||||
const replayed = replayedReasoning(message);
|
||||
return replayed === undefined
|
||||
? rest
|
||||
: { ...rest, reasoning_content: replayed };
|
||||
});
|
||||
}
|
||||
|
||||
protected override vendorParams(
|
||||
params: Record<string, unknown>,
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
) {
|
||||
if (!isK31(model)) return params;
|
||||
const effort = args.reasoning_effort ?? args.reasoning?.effort;
|
||||
if (effort !== undefined && !K31_REASONING_EFFORTS.includes(effort)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`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,
|
||||
...openAICompatParams(
|
||||
{ ...args, reasoning_effort: effort },
|
||||
'chat',
|
||||
{
|
||||
only: [
|
||||
'tool_choice',
|
||||
'response_format',
|
||||
'reasoning_effort',
|
||||
],
|
||||
},
|
||||
),
|
||||
max_completion_tokens:
|
||||
max_tokens ?? K31_DEFAULT_MAX_COMPLETION_TOKENS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,53 @@
|
||||
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',
|
||||
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, // $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',
|
||||
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, // $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 ----------------------------------------------------
|
||||
{
|
||||
puterId: 'moonshotai:moonshotai/kimi-k3',
|
||||
@@ -35,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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
@@ -190,14 +195,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 +233,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)
|
||||
@@ -470,11 +488,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 }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Record<string, unknown>> = [
|
||||
{
|
||||
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<Record<string, unknown>> => [
|
||||
{
|
||||
@@ -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<Record<string, unknown>> = [
|
||||
{
|
||||
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<Record<string, unknown>>;
|
||||
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<Record<string, unknown>> = [
|
||||
{
|
||||
@@ -888,6 +933,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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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, GPT-6 Sol/Luna, and GPT-6.1 Terra 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`. GPT-6.1 Terra accepts `none`, `low`, `medium` (default), `high`, `xhigh`, and `max`. 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`. GPT-6.1 Terra accepts `none`, `low`, `medium` (default), `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).
|
||||
@@ -219,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.
|
||||
|
||||
@@ -300,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user