mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-07 06:23:05 +00:00
fix(ai): meter OpenAI cache writes by lifetime
GPT-5.6 and later write prompt-cache entries with a 30m or 12h lifetime
and bill them at 1.25x and 2.5x input; every write was metered at the
30m rate. The OpenAI calculators now split `cache_write_tokens_details`
into `cache_write_tokens` (30m) and `cache_write_12h_tokens` (12h), the
GPT-5.6+ catalog carries the 12h rate, and `usageDetails` reports
`cacheWrite30mTokens` / `cacheWrite12hTokens` instead of labelling the
writes as 5m entries.
Callers pick the lifetime with `prompt_cache_options` ({ mode, ttl }) on
`puter.ai.chat`, `/openai/v1/responses` and `/openai/v1/chat/completions`.
The Chat Completions provider hands a 12h request to the Responses
provider, the only API that writes 12h entries. `prompt_cache_retention`
is forwarded on Chat Completions too, and the hyphenated `in-memory`
spelling maps to the `in_memory` value the upstreams accept.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
58456ae87c
commit
89f070f7d0
21 files changed
+749
-87
No files matched your search
@@ -201,6 +201,7 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
context_management: [{ type: 'compaction' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
prompt_cache_options: { ttl: '12h', prewarm: true },
|
||||
store: true,
|
||||
truncation: 'auto',
|
||||
service_tier: 'default',
|
||||
@@ -226,6 +227,9 @@ describe('PuterAIController.openaiResponses parameter forwarding', () => {
|
||||
context_management: [{ type: 'compaction' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
// Forwarded as sent; the provider keeps only the fields it
|
||||
// accepts.
|
||||
prompt_cache_options: { ttl: '12h', prewarm: true },
|
||||
// forced false regardless of the client's request.
|
||||
store: false,
|
||||
truncation: 'auto',
|
||||
@@ -639,6 +643,33 @@ describe('PuterAIController.openaiResponses output items', () => {
|
||||
// -- /openai/v1/chat/completions edges -------------------------------
|
||||
|
||||
describe('PuterAIController.openaiChatCompletions translation edges', () => {
|
||||
it('forwards the prompt-cache fields to the driver', async () => {
|
||||
const completeSpy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
});
|
||||
|
||||
const { res } = makeRes();
|
||||
await controller.openaiChatCompletions(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'gpt-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: 'in_memory',
|
||||
prompt_cache_options: { ttl: '30m' },
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
const args = completeSpy.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(args).toMatchObject({
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: 'in_memory',
|
||||
prompt_cache_options: { ttl: '30m' },
|
||||
});
|
||||
});
|
||||
|
||||
it('derives tool_calls from tool_use content blocks when the message has none', async () => {
|
||||
stubChatComplete({
|
||||
message: {
|
||||
|
||||
@@ -447,6 +447,7 @@ export class PuterAIController extends PuterController {
|
||||
body.reasoning_effort as ICompleteArguments['reasoning_effort'],
|
||||
}
|
||||
: {}),
|
||||
...promptCacheArgsFromBody(body),
|
||||
...openaiCompatProvider(body),
|
||||
...(body.fallback === false ? { fallback: false } : {}),
|
||||
};
|
||||
@@ -842,15 +843,7 @@ export class PuterAIController extends PuterController {
|
||||
body.compaction as ICompleteArguments['compaction'],
|
||||
}
|
||||
: {}),
|
||||
...(body.prompt_cache_key
|
||||
? { prompt_cache_key: String(body.prompt_cache_key) }
|
||||
: {}),
|
||||
...(body.prompt_cache_retention
|
||||
? {
|
||||
prompt_cache_retention:
|
||||
body.prompt_cache_retention as ICompleteArguments['prompt_cache_retention'],
|
||||
}
|
||||
: {}),
|
||||
...promptCacheArgsFromBody(body),
|
||||
// forced off regardless of what the caller sent — OpenAI
|
||||
// defaults `store` to true, which would persist state server-side
|
||||
// on OpenAI's end that this route gives no way to retrieve.
|
||||
@@ -1343,6 +1336,33 @@ const openaiCompatProvider = (
|
||||
return { provider: DEFAULTS.openaiChat };
|
||||
};
|
||||
|
||||
/**
|
||||
* The prompt-cache fields both OpenAI-shaped routes forward as sent; the
|
||||
* provider checks the values.
|
||||
*/
|
||||
const promptCacheArgsFromBody = (
|
||||
body: Record<string, unknown>,
|
||||
): Pick<
|
||||
ICompleteArguments,
|
||||
'prompt_cache_key' | 'prompt_cache_retention' | 'prompt_cache_options'
|
||||
> => ({
|
||||
...(body.prompt_cache_key
|
||||
? { prompt_cache_key: String(body.prompt_cache_key) }
|
||||
: {}),
|
||||
...(body.prompt_cache_retention
|
||||
? {
|
||||
prompt_cache_retention:
|
||||
body.prompt_cache_retention as ICompleteArguments['prompt_cache_retention'],
|
||||
}
|
||||
: {}),
|
||||
...(body.prompt_cache_options
|
||||
? {
|
||||
prompt_cache_options:
|
||||
body.prompt_cache_options as ICompleteArguments['prompt_cache_options'],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const randomId = (): string => crypto.randomUUID().replace(/-/g, '');
|
||||
const generateId = (prefix: string): string => `${prefix}_${randomId()}`;
|
||||
|
||||
|
||||
@@ -449,6 +449,22 @@ describe('AzureResponsesProvider.complete request shape', () => {
|
||||
expect(args.service_tier).toBe('default');
|
||||
});
|
||||
|
||||
it('sends the wire spelling of the in-memory cache retention', async () => {
|
||||
const provider = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce(okResponse);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-5.3-codex',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_retention: 'in-memory',
|
||||
} as never),
|
||||
);
|
||||
|
||||
const [args] = responsesCreateMock.mock.calls[0]!;
|
||||
expect(args.prompt_cache_retention).toBe('in_memory');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['previous_response_id', 'resp_1'],
|
||||
['conversation', 'conv_1'],
|
||||
|
||||
@@ -35,6 +35,7 @@ import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import {
|
||||
clampReasoningEffort,
|
||||
openAICompatParams,
|
||||
promptCacheRetentionToWire,
|
||||
rejectStatefulResponsesFields,
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
@@ -209,6 +210,9 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
const cacheRetention = promptCacheRetentionToWire(
|
||||
prompt_cache_retention,
|
||||
);
|
||||
|
||||
// Resolve any `puter_path` content parts into inline base64 data URLs
|
||||
// before the Responses API sees them.
|
||||
@@ -307,8 +311,8 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
...(instructions !== undefined ? { instructions } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(prompt_cache_retention !== undefined
|
||||
? { prompt_cache_retention }
|
||||
...(cacheRetention !== undefined
|
||||
? { prompt_cache_retention: cacheRetention }
|
||||
: {}),
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(max_tokens !== undefined
|
||||
|
||||
@@ -23,6 +23,7 @@ import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
|
||||
import type { IChatModel, ICompleteArguments } from '../../types.js';
|
||||
import { promptCacheRetentionToWire } from '../../utils/openaiParams.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
import {
|
||||
type ChatProviderConfig,
|
||||
@@ -107,12 +108,9 @@ export class MetaProvider extends OpenAICompatProvider {
|
||||
? requestedEffort
|
||||
: undefined;
|
||||
|
||||
// Puter spells the in-memory retention with a hyphen; Meta's enum
|
||||
// uses an underscore.
|
||||
const cacheRetention =
|
||||
args.prompt_cache_retention === 'in-memory'
|
||||
? 'in_memory'
|
||||
: args.prompt_cache_retention;
|
||||
const cacheRetention = promptCacheRetentionToWire(
|
||||
args.prompt_cache_retention,
|
||||
);
|
||||
|
||||
// The identifier is Puter's abuse attribution, so `custom` can't
|
||||
// override it. Cache key defaults to it; see upstreamUserIdentifier.
|
||||
|
||||
@@ -249,6 +249,44 @@ describe('OpenAiChatProvider.complete argument validation', () => {
|
||||
expect(result).toEqual({ delegated: true });
|
||||
});
|
||||
|
||||
it('delegates a 12h cache lifetime to the Responses provider, which is the only API that writes it', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const sibling = {
|
||||
complete: vi.fn().mockResolvedValue({ delegated: true }),
|
||||
};
|
||||
provider.setResponsesProvider(
|
||||
sibling as unknown as Parameters<
|
||||
typeof provider.setResponsesProvider
|
||||
>[0],
|
||||
);
|
||||
|
||||
const params = {
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_options: { ttl: '12h' as const },
|
||||
};
|
||||
const result = await withTestActor(() => provider.complete(params));
|
||||
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
expect(sibling.complete).toHaveBeenCalledWith(params);
|
||||
expect(result).toEqual({ delegated: true });
|
||||
});
|
||||
|
||||
it('throws 400 for a 12h cache lifetime without a Responses sibling provider', async () => {
|
||||
const { provider } = makeProvider();
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_options: { ttl: '12h' },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requestPricing delegates to the Responses sibling (web_search\'s own hold), and no-ops without one', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const args = {
|
||||
@@ -349,6 +387,31 @@ describe('OpenAiChatProvider.complete request shape', () => {
|
||||
expect(args.safety_identifier).toBe('puter-u42');
|
||||
});
|
||||
|
||||
it('forwards 30m prompt_cache_options and the wire spelling of prompt_cache_retention', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
prompt_cache_retention: 'in-memory',
|
||||
prompt_cache_options: {
|
||||
mode: 'explicit',
|
||||
ttl: '30m',
|
||||
prewarm: true,
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.prompt_cache_retention).toBe('in_memory');
|
||||
expect(args.prompt_cache_options).toEqual({
|
||||
mode: 'explicit',
|
||||
ttl: '30m',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'gpt-6-astra',
|
||||
'gpt-6.1-sol',
|
||||
@@ -665,7 +728,7 @@ describe('OpenAiChatProvider.complete non-stream output', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
@@ -685,6 +748,13 @@ describe('OpenAiChatProvider.complete non-stream output', () => {
|
||||
cached_tokens: 1000 * Number(luna.costs.cached_tokens),
|
||||
cache_write_tokens: 3000 * Number(luna.costs.cache_write_tokens),
|
||||
});
|
||||
// Chat Completions only writes OpenAI's default 30m entries.
|
||||
expect((result as { usageDetails: unknown }).usageDetails).toEqual({
|
||||
inputTokens: 1000,
|
||||
outputTokens: 12,
|
||||
cacheReadTokens: 1000,
|
||||
cacheWrite30mTokens: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('zeroes cached_tokens when prompt_tokens_details is missing', async () => {
|
||||
|
||||
@@ -36,6 +36,8 @@ import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import {
|
||||
clampReasoningEffort,
|
||||
openAICompatParams,
|
||||
promptCacheOptionsToWire,
|
||||
promptCacheRetentionToWire,
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
import type { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js';
|
||||
@@ -126,8 +128,11 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
temperature,
|
||||
text,
|
||||
prompt_cache_key,
|
||||
prompt_cache_retention,
|
||||
prompt_cache_options,
|
||||
} = params;
|
||||
let { messages, model } = params;
|
||||
const cacheOptions = promptCacheOptionsToWire(prompt_cache_options);
|
||||
if (
|
||||
tools?.filter((e) => isWebSearchTool(e as Record<string, unknown>))
|
||||
.length
|
||||
@@ -157,6 +162,18 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Only the Responses API writes 12h cache entries; Chat Completions
|
||||
// rejects the lifetime.
|
||||
if (cacheOptions?.ttl === '12h') {
|
||||
if (!this.#responsesProvider) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'a 12h prompt cache lifetime requires the OpenAI Responses provider, which is not configured',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new HttpError(400, '`messages` must be an array', {
|
||||
@@ -180,6 +197,9 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
const cacheRetention = promptCacheRetentionToWire(
|
||||
prompt_cache_retention,
|
||||
);
|
||||
|
||||
// Strip Anthropic-only shape a fallback-replayed message can carry
|
||||
// (thinking/server-tool blocks, cache_control, citations) before the
|
||||
@@ -216,6 +236,12 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
user: userIdentifier,
|
||||
safety_identifier: userIdentifier,
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(cacheRetention !== undefined
|
||||
? { prompt_cache_retention: cacheRetention }
|
||||
: {}),
|
||||
...(cacheOptions !== undefined
|
||||
? { prompt_cache_options: cacheOptions }
|
||||
: {}),
|
||||
messages: messages,
|
||||
model: modelUsed.id,
|
||||
...(mappedTools?.length ? { tools: mappedTools } : {}),
|
||||
@@ -254,28 +280,11 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
const cachedTokens =
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
// GPT-5.6 and later bill cache writes at 1.25x input. They're
|
||||
// reported inside `prompt_tokens`, like cached reads.
|
||||
// The SDK doesn't type `cache_write_tokens` yet.
|
||||
const cacheWriteTokens =
|
||||
(
|
||||
usage.prompt_tokens_details as
|
||||
{ cache_write_tokens?: number } | undefined
|
||||
)?.cache_write_tokens ?? 0;
|
||||
const trackedUsage = {
|
||||
prompt_tokens:
|
||||
(usage.prompt_tokens ?? 0) -
|
||||
cachedTokens -
|
||||
cacheWriteTokens,
|
||||
completion_tokens: usage.completion_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
...(cacheWriteTokens
|
||||
? { cache_write_tokens: cacheWriteTokens }
|
||||
: {}),
|
||||
};
|
||||
usage_calculator: ({ usage, setUsageCosts, setUsageDetails }) => {
|
||||
// GPT-5.6 and later report cache writes inside
|
||||
// `prompt_tokens`, like cached reads; Chat Completions only
|
||||
// writes the default 30m entries, billed at 1.25x input.
|
||||
const trackedUsage = OpenAiUtil.splitCachedPrompt(usage);
|
||||
|
||||
const metered = meterChatUsage(
|
||||
this.#meteringService,
|
||||
@@ -285,6 +294,11 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
trackedUsage,
|
||||
);
|
||||
setUsageCosts(metered.costs);
|
||||
setUsageDetails(
|
||||
OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
writeTtl: '30m',
|
||||
})!,
|
||||
);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
|
||||
@@ -246,6 +246,7 @@ describe('OpenAiResponsesChatProvider model catalog', () => {
|
||||
prompt_tokens: input,
|
||||
cached_tokens: cached,
|
||||
cache_write_tokens: input * 1.25,
|
||||
cache_write_12h_tokens: input * 2.5,
|
||||
completion_tokens: output,
|
||||
},
|
||||
context: 1_050_000,
|
||||
@@ -844,7 +845,7 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
@@ -867,6 +868,115 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
|
||||
cached_tokens: 1000 * Number(luna.costs.cached_tokens),
|
||||
cache_write_tokens: 3000 * Number(luna.costs.cache_write_tokens),
|
||||
});
|
||||
// Writes without a per-lifetime breakdown are OpenAI's default 30m
|
||||
// entries, and the neutral breakdown says so.
|
||||
expect((result as { usageDetails: unknown }).usageDetails).toEqual({
|
||||
inputTokens: 1000,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 1000,
|
||||
cacheWrite30mTokens: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it('bills 12h cache writes at 2.5x input and the other writes at 1.25x', async () => {
|
||||
const luna = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-luna')!;
|
||||
const { provider } = makeProvider();
|
||||
// One request can write both lifetimes: an explicit 12h breakpoint
|
||||
// plus the implicit 30m one OpenAI adds at the end of the prompt.
|
||||
responsesCreateMock.mockResolvedValueOnce({
|
||||
output: [{ role: 'assistant' }],
|
||||
output_text: 'ok',
|
||||
usage: {
|
||||
input_tokens: 6026,
|
||||
output_tokens: 5,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 6023,
|
||||
cache_write_tokens_details: {
|
||||
ttl_12h_tokens: 6012,
|
||||
ttl_30m_tokens: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_options: { ttl: '12h' },
|
||||
}),
|
||||
);
|
||||
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 11,
|
||||
cache_write_12h_tokens: 6012,
|
||||
});
|
||||
expect(luna.costs.cache_write_12h_tokens).toBe(
|
||||
Number(luna.costs.prompt_tokens) * 2.5,
|
||||
);
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 3 * Number(luna.costs.prompt_tokens),
|
||||
completion_tokens: 5 * Number(luna.costs.completion_tokens),
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 11 * Number(luna.costs.cache_write_tokens),
|
||||
cache_write_12h_tokens:
|
||||
6012 * Number(luna.costs.cache_write_12h_tokens),
|
||||
});
|
||||
expect((result as { usageDetails: unknown }).usageDetails).toEqual({
|
||||
inputTokens: 3,
|
||||
outputTokens: 5,
|
||||
cacheWrite30mTokens: 11,
|
||||
cacheWrite12hTokens: 6012,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards prompt_cache_options and the wire spelling of prompt_cache_retention', async () => {
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockResolvedValueOnce({
|
||||
output: [{ role: 'assistant' }],
|
||||
output_text: 'ok',
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_retention: 'in-memory',
|
||||
prompt_cache_options: {
|
||||
mode: 'explicit',
|
||||
ttl: '12h',
|
||||
comparison_response_id: 'resp_1',
|
||||
} as never,
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = responsesCreateMock.mock.calls[0]!;
|
||||
expect(args.prompt_cache_retention).toBe('in_memory');
|
||||
expect(args.prompt_cache_options).toEqual({
|
||||
mode: 'explicit',
|
||||
ttl: '12h',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a cache lifetime OpenAI does not offer before calling upstream', async () => {
|
||||
const { provider } = makeProvider();
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
prompt_cache_options: { ttl: '2h' } as never,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(responsesCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1068,6 +1178,69 @@ describe('OpenAiResponsesChatProvider.complete streaming', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('labels streamed cache writes by lifetime on the final usage chunk', async () => {
|
||||
const luna = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-luna')!;
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{ type: 'response.output_text.delta', delta: 'ok' },
|
||||
{
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
usage: {
|
||||
input_tokens: 6026,
|
||||
output_tokens: 5,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 6023,
|
||||
cache_write_tokens_details: {
|
||||
ttl_12h_tokens: 6012,
|
||||
ttl_30m_tokens: 11,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gpt-6-luna',
|
||||
messages: [{ role: 'user', content: 'say ok' }],
|
||||
stream: true,
|
||||
prompt_cache_options: { ttl: '12h' },
|
||||
}),
|
||||
);
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
|
||||
const usageEvent = harness.events().find((e) => e.type === 'usage');
|
||||
expect(usageEvent?.usage).toEqual({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 11,
|
||||
cache_write_12h_tokens: 6012,
|
||||
});
|
||||
expect(usageEvent?.usageDetails).toEqual({
|
||||
inputTokens: 3,
|
||||
outputTokens: 5,
|
||||
cacheWrite30mTokens: 11,
|
||||
cacheWrite12hTokens: 6012,
|
||||
});
|
||||
const [, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(overrides).toMatchObject({
|
||||
cache_write_tokens: 11 * Number(luna.costs.cache_write_tokens),
|
||||
cache_write_12h_tokens:
|
||||
6012 * Number(luna.costs.cache_write_12h_tokens),
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a tool_use block when response.output_item.done arrives with a function_call', async () => {
|
||||
const { provider } = makeProvider();
|
||||
responsesCreateMock.mockReturnValueOnce(
|
||||
|
||||
@@ -32,6 +32,8 @@ import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import {
|
||||
clampReasoningEffort,
|
||||
openAICompatParams,
|
||||
promptCacheOptionsToWire,
|
||||
promptCacheRetentionToWire,
|
||||
rejectStatefulResponsesFields,
|
||||
} from '../../utils/openaiParams.js';
|
||||
import { responseSamplingParams } from '../../utils/responseSampling.js';
|
||||
@@ -150,6 +152,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
prompt,
|
||||
prompt_cache_key,
|
||||
prompt_cache_retention,
|
||||
prompt_cache_options,
|
||||
store,
|
||||
top_p,
|
||||
truncation,
|
||||
@@ -195,6 +198,10 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
const cacheKey = prompt_cache_key ?? userIdentifier;
|
||||
const cacheRetention = promptCacheRetentionToWire(
|
||||
prompt_cache_retention,
|
||||
);
|
||||
const cacheOptions = promptCacheOptionsToWire(prompt_cache_options);
|
||||
|
||||
if (tools) {
|
||||
// Unravel tools to OpenAI Responses API format
|
||||
@@ -288,8 +295,11 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
...(instructions !== undefined ? { instructions } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
|
||||
...(prompt_cache_retention !== undefined
|
||||
? { prompt_cache_retention }
|
||||
...(cacheRetention !== undefined
|
||||
? { prompt_cache_retention: cacheRetention }
|
||||
: {}),
|
||||
...(cacheOptions !== undefined
|
||||
? { prompt_cache_options: cacheOptions }
|
||||
: {}),
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(max_tokens !== undefined
|
||||
@@ -310,24 +320,17 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
{ signal: Context.get('upstreamSignal') },
|
||||
);
|
||||
return OpenAiUtil.handle_completion_output_responses_api({
|
||||
usage_calculator: ({ usage, webSearchCalls, setUsageCosts }) => {
|
||||
const cachedTokens =
|
||||
(usage as any).input_tokens_details?.cached_tokens ?? 0;
|
||||
// GPT-5.6 and later bill cache writes at 1.25x input. They're
|
||||
// reported inside `input_tokens`, like cached reads.
|
||||
const cacheWriteTokens =
|
||||
(usage as any).input_tokens_details?.cache_write_tokens ??
|
||||
0;
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
webSearchCalls,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
}) => {
|
||||
// GPT-5.6 and later report cache writes inside `input_tokens`,
|
||||
// like cached reads, split by lifetime (30m and 12h entries
|
||||
// cost 1.25x and 2.5x input to write).
|
||||
const trackedUsage: Record<string, number> = {
|
||||
prompt_tokens:
|
||||
((usage as any).input_tokens ?? 0) -
|
||||
cachedTokens -
|
||||
cacheWriteTokens,
|
||||
completion_tokens: (usage as any).output_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
...(cacheWriteTokens
|
||||
? { cache_write_tokens: cacheWriteTokens }
|
||||
: {}),
|
||||
...OpenAiUtil.splitCachedPrompt(usage),
|
||||
...(webSearchCalls
|
||||
? { web_search_calls: webSearchCalls }
|
||||
: {}),
|
||||
@@ -356,6 +359,11 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
: {},
|
||||
);
|
||||
setUsageCosts?.(metered.costs);
|
||||
setUsageDetails?.(
|
||||
OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
writeTtl: '30m',
|
||||
})!,
|
||||
);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
|
||||
@@ -23,6 +23,8 @@ import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Prompts over 272K input tokens are billed at 2x input (cached reads and
|
||||
// cache writes included) and 1.5x output for the full request.
|
||||
// GPT-5.6 and later bill cache writes by the lifetime requested: 1.25x input
|
||||
// for the default 30m entries, 2.5x for 12h entries.
|
||||
const GPT_LONG_CONTEXT_PRICING = {
|
||||
threshold: 272_000,
|
||||
input_multiplier: 2,
|
||||
@@ -49,6 +51,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 100,
|
||||
cached_tokens: 5,
|
||||
cache_write_tokens: 100 * 1.25,
|
||||
cache_write_12h_tokens: 100 * 2.5,
|
||||
completion_tokens: 500,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -74,6 +77,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 10,
|
||||
cache_write_tokens: 200 * 1.25,
|
||||
cache_write_12h_tokens: 200 * 2.5,
|
||||
completion_tokens: 1000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -99,6 +103,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 200 * 1.25,
|
||||
cache_write_12h_tokens: 200 * 2.5,
|
||||
completion_tokens: 1000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -124,6 +129,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 10,
|
||||
cached_tokens: 1,
|
||||
cache_write_tokens: 10 * 1.25,
|
||||
cache_write_12h_tokens: 10 * 2.5,
|
||||
completion_tokens: 50,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -149,6 +155,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 1000,
|
||||
cached_tokens: 100,
|
||||
cache_write_tokens: 1000 * 1.25,
|
||||
cache_write_12h_tokens: 1000 * 2.5,
|
||||
completion_tokens: 5000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -174,6 +181,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 400,
|
||||
cached_tokens: 40,
|
||||
cache_write_tokens: 400 * 1.25,
|
||||
cache_write_12h_tokens: 400 * 2.5,
|
||||
completion_tokens: 2000,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -198,6 +206,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 200,
|
||||
cached_tokens: 20,
|
||||
cache_write_tokens: 200 * 1.25,
|
||||
cache_write_12h_tokens: 200 * 2.5,
|
||||
completion_tokens: 1200,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
@@ -222,6 +231,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 20,
|
||||
cached_tokens: 2,
|
||||
cache_write_tokens: 20 * 1.25,
|
||||
cache_write_12h_tokens: 20 * 2.5,
|
||||
completion_tokens: 120,
|
||||
},
|
||||
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
|
||||
|
||||
@@ -145,13 +145,19 @@ export type StopReason =
|
||||
| 'model_context_window_exceeded'
|
||||
| (string & {});
|
||||
|
||||
/** Provider-neutral usage breakdown. `inputTokens` excludes cache reads/writes. */
|
||||
/**
|
||||
* Provider-neutral usage breakdown. `inputTokens` excludes cache reads/writes.
|
||||
* Cache writes are keyed by the lifetime the entry was written with: Claude and
|
||||
* Moonshot write 5m and 1h entries, OpenAI writes 30m and 12h entries.
|
||||
*/
|
||||
export interface UsageDetails {
|
||||
inputTokens: number;
|
||||
outputTokens: number; // includes reasoning
|
||||
cacheReadTokens?: number;
|
||||
cacheWrite5mTokens?: number;
|
||||
cacheWrite30mTokens?: number;
|
||||
cacheWrite1hTokens?: number;
|
||||
cacheWrite12hTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
webSearchRequests?: number;
|
||||
webFetchRequests?: number;
|
||||
@@ -168,10 +174,22 @@ export interface UsageDetails {
|
||||
outputTokens: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheWrite5mTokens?: number;
|
||||
cacheWrite30mTokens?: number;
|
||||
cacheWrite1hTokens?: number;
|
||||
cacheWrite12hTokens?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI `prompt_cache_options`, GPT-5.6 and later. `ttl` is the minimum
|
||||
* lifetime of every cache entry the request writes; `12h` entries cost twice
|
||||
* what `30m` entries do to write and only the Responses API writes them.
|
||||
*/
|
||||
export interface PromptCacheOptions {
|
||||
mode?: 'implicit' | 'explicit';
|
||||
ttl?: '30m' | '12h';
|
||||
}
|
||||
|
||||
export interface ICompleteArguments {
|
||||
messages: PuterMessage[];
|
||||
provider?: string;
|
||||
@@ -234,7 +252,9 @@ export interface ICompleteArguments {
|
||||
metadata?: Record<string, string>;
|
||||
prompt?: unknown;
|
||||
prompt_cache_key?: string;
|
||||
prompt_cache_retention?: 'in-memory' | '24h' | undefined;
|
||||
/** `in-memory` is accepted as a spelling of OpenAI's `in_memory`. */
|
||||
prompt_cache_retention?: 'in-memory' | 'in_memory' | '24h' | undefined;
|
||||
prompt_cache_options?: PromptCacheOptions;
|
||||
store?: boolean;
|
||||
top_p?: number;
|
||||
truncation?: 'auto' | 'disabled' | undefined;
|
||||
|
||||
@@ -481,43 +481,86 @@ export const extractMeteredUsage = (usage) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Chat Completions usage as it's metered: `prompt_tokens` reports cached reads
|
||||
* inside its count, so they come out of it and are priced only once, under
|
||||
* `cached_tokens`.
|
||||
* @typedef {{
|
||||
* cached_tokens?: number | null;
|
||||
* cache_write_tokens?: number | null;
|
||||
* cache_write_tokens_details?: {
|
||||
* ttl_30m_tokens?: number | null;
|
||||
* ttl_12h_tokens?: number | null;
|
||||
* } | null;
|
||||
* }} OpenAiCacheDetails
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenAI-shaped usage as it's metered. The prompt count reports cached reads
|
||||
* and cache writes inside it, so they come out of it and are priced once each
|
||||
* under their own keys: `cached_tokens` for reads, `cache_write_tokens` for
|
||||
* writes at the model's default lifetime and `cache_write_12h_tokens` for
|
||||
* writes at OpenAI's 12h lifetime. Without the per-lifetime breakdown every
|
||||
* write is a default-lifetime write: Chat Completions writes no 12h entries,
|
||||
* and Responses reports the breakdown whenever a request asked for them. Reads
|
||||
* the Chat Completions (`prompt_tokens`) and Responses (`input_tokens`)
|
||||
* spellings alike.
|
||||
*
|
||||
* @param {import('openai/resources/completions.mjs').CompletionUsage | undefined} usage
|
||||
* @param {{
|
||||
* prompt_tokens?: number;
|
||||
* completion_tokens?: number;
|
||||
* prompt_tokens_details?: OpenAiCacheDetails | null;
|
||||
* input_tokens?: number;
|
||||
* output_tokens?: number;
|
||||
* input_tokens_details?: OpenAiCacheDetails | null;
|
||||
* }
|
||||
* | undefined} usage
|
||||
*/
|
||||
export const splitCachedPrompt = (usage) => {
|
||||
const details = usage?.prompt_tokens_details;
|
||||
const details = usage?.prompt_tokens_details ?? usage?.input_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.
|
||||
// Moonshot and GPT-5.6+ count the prompt tokens they wrote to the prefix
|
||||
// cache here; they're billed at the model's write rate, not the prompt rate.
|
||||
const writeTokens =
|
||||
typeof details?.cache_write_tokens === 'number'
|
||||
? details.cache_write_tokens
|
||||
: undefined;
|
||||
const reported12h = details?.cache_write_tokens_details?.ttl_12h_tokens;
|
||||
const write12hTokens =
|
||||
typeof reported12h === 'number'
|
||||
? Math.min(reported12h, writeTokens ?? 0)
|
||||
: undefined;
|
||||
return {
|
||||
prompt_tokens:
|
||||
(usage?.prompt_tokens ?? 0) - cachedTokens - (writeTokens ?? 0),
|
||||
completion_tokens: usage?.completion_tokens ?? 0,
|
||||
(usage?.prompt_tokens ?? usage?.input_tokens ?? 0) -
|
||||
cachedTokens -
|
||||
(writeTokens ?? 0),
|
||||
completion_tokens:
|
||||
usage?.completion_tokens ?? usage?.output_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
...(writeTokens !== undefined
|
||||
? { cache_write_tokens: writeTokens }
|
||||
? { cache_write_tokens: writeTokens - (write12hTokens ?? 0) }
|
||||
: {}),
|
||||
...(write12hTokens !== undefined
|
||||
? { cache_write_12h_tokens: write12hTokens }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The `{prompt_tokens, completion_tokens, cached_tokens, cache_write_tokens}`
|
||||
* shape every OpenAI-family `usage_calculator` already returns for billing,
|
||||
* mapped to the provider-neutral `UsageDetails` the end-of-stream/non-stream
|
||||
* result carries. `inputTokens`/`outputTokens` are the same numbers already
|
||||
* billed — this never introduces a new figure, only exposes the breakdown.
|
||||
* The `{prompt_tokens, completion_tokens, cached_tokens, cache_write_tokens,
|
||||
* cache_write_12h_tokens}` shape every OpenAI-family `usage_calculator` already
|
||||
* returns for billing, mapped to the provider-neutral `UsageDetails` the
|
||||
* end-of-stream/non-stream result carries. `inputTokens`/`outputTokens` are the
|
||||
* same numbers already billed — this never introduces a new figure, only
|
||||
* exposes the breakdown.
|
||||
*
|
||||
* @param {Record<string, number> | undefined | null} trackedUsage
|
||||
* @param {{ writeTtl?: '5m' | '30m' }} [opts] The lifetime `cache_write_tokens`
|
||||
* were written with: 5m unless the provider says otherwise (OpenAI's default
|
||||
* entries live 30m).
|
||||
* @returns {import('../types.js').UsageDetails | undefined}
|
||||
*/
|
||||
export const usageDetailsFromTrackedUsage = (trackedUsage) => {
|
||||
export const usageDetailsFromTrackedUsage = (
|
||||
trackedUsage,
|
||||
{ writeTtl = '5m' } = {},
|
||||
) => {
|
||||
if (!trackedUsage || typeof trackedUsage !== 'object') return undefined;
|
||||
// OpenRouter, Infron and Ollama track `{prompt, completion,
|
||||
// input_cache_read}` instead; any other shape is left to the driver.
|
||||
@@ -526,12 +569,19 @@ export const usageDetailsFromTrackedUsage = (trackedUsage) => {
|
||||
if (input === undefined && output === undefined) return undefined;
|
||||
const cacheRead =
|
||||
trackedUsage.cached_tokens ?? trackedUsage.input_cache_read;
|
||||
const cacheWrite = trackedUsage.cache_write_tokens;
|
||||
return {
|
||||
inputTokens: input ?? 0,
|
||||
outputTokens: output ?? 0,
|
||||
...(cacheRead ? { cacheReadTokens: cacheRead } : {}),
|
||||
...(trackedUsage.cache_write_tokens
|
||||
? { cacheWrite5mTokens: trackedUsage.cache_write_tokens }
|
||||
...(cacheWrite && writeTtl === '30m'
|
||||
? { cacheWrite30mTokens: cacheWrite }
|
||||
: {}),
|
||||
...(cacheWrite && writeTtl !== '30m'
|
||||
? { cacheWrite5mTokens: cacheWrite }
|
||||
: {}),
|
||||
...(trackedUsage.cache_write_12h_tokens
|
||||
? { cacheWrite12hTokens: trackedUsage.cache_write_12h_tokens }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
@@ -864,12 +914,16 @@ export const create_chat_stream_handler_responses_api =
|
||||
// Missing usage is left undefined rather than fed to the calculator —
|
||||
// see the sibling handler above, including why usage is reported
|
||||
// before the block flushes.
|
||||
let usageDetails;
|
||||
const usage = last_usage
|
||||
? usage_calculator({
|
||||
usage: last_usage,
|
||||
webSearchCalls,
|
||||
tool_usage: toolUsage,
|
||||
setUsageCosts: (c) => chatStream.setUsageCosts(c),
|
||||
setUsageDetails: (d) => {
|
||||
usageDetails = d;
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
chatStream.reportUsage(usage);
|
||||
@@ -877,7 +931,7 @@ export const create_chat_stream_handler_responses_api =
|
||||
reason:
|
||||
incompleteReason ?? (sawFunctionCall ? 'tool_use' : 'end_turn'),
|
||||
});
|
||||
const usageDetails = usageDetailsFromTrackedUsage(usage);
|
||||
usageDetails ??= usageDetailsFromTrackedUsage(usage);
|
||||
if (usageDetails) chatStream.setUsageDetails(usageDetails);
|
||||
|
||||
textblock.end();
|
||||
@@ -1015,6 +1069,7 @@ export const handle_completion_output = async (
|
||||
* webSearchCalls?: number;
|
||||
* tool_usage?: Record<string, unknown>;
|
||||
* setUsageCosts?: (costs: Record<string, number>) => void;
|
||||
* setUsageDetails?: (details: import('../types').UsageDetails) => void;
|
||||
* }) => unknown} params.usage_calculator
|
||||
* @param {() => Promise<void>} [params.finally_fn]
|
||||
* @returns {ReturnType<import('../types').IChatProvider['complete']>}
|
||||
@@ -1156,12 +1211,15 @@ export const handle_completion_output_responses_api = async ({
|
||||
setUsageCosts: (c) => {
|
||||
ret.usageCosts = c;
|
||||
},
|
||||
setUsageDetails: (d) => {
|
||||
ret.usageDetails = d;
|
||||
},
|
||||
})
|
||||
: {
|
||||
input_tokens: completion.usage.input_tokens,
|
||||
output_tokens: completion.usage.output_tokens,
|
||||
};
|
||||
if (usage_calculator) {
|
||||
if (usage_calculator && ret.usageDetails === undefined) {
|
||||
const usageDetails = usageDetailsFromTrackedUsage(ret.usage);
|
||||
if (usageDetails) ret.usageDetails = usageDetails;
|
||||
}
|
||||
|
||||
@@ -247,6 +247,40 @@ describe('usageDetailsFromTrackedUsage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('labels cache writes by the lifetime the provider says it wrote them with', () => {
|
||||
expect(
|
||||
usageDetailsFromTrackedUsage(
|
||||
{
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 3,
|
||||
cache_write_tokens: 11,
|
||||
cache_write_12h_tokens: 6012,
|
||||
},
|
||||
{ writeTtl: '30m' },
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 3,
|
||||
cacheWrite30mTokens: 11,
|
||||
cacheWrite12hTokens: 6012,
|
||||
});
|
||||
// Zero counts, as OpenAI reports on a cache hit, add no field.
|
||||
expect(
|
||||
usageDetailsFromTrackedUsage(
|
||||
{
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 3,
|
||||
cache_write_tokens: 0,
|
||||
cache_write_12h_tokens: 0,
|
||||
},
|
||||
{ writeTtl: '30m' },
|
||||
),
|
||||
).toEqual({ inputTokens: 10, outputTokens: 5, cacheReadTokens: 3 });
|
||||
});
|
||||
|
||||
it('returns undefined for a missing/non-object usage', () => {
|
||||
expect(usageDetailsFromTrackedUsage(undefined)).toBeUndefined();
|
||||
expect(usageDetailsFromTrackedUsage(null)).toBeUndefined();
|
||||
@@ -962,6 +996,66 @@ describe('splitCachedPrompt', () => {
|
||||
cached_tokens: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the Responses usage spelling', () => {
|
||||
expect(
|
||||
splitCachedPrompt({
|
||||
input_tokens: 6026,
|
||||
output_tokens: 5,
|
||||
input_tokens_details: { cached_tokens: 0, cache_write_tokens: 6023 },
|
||||
}),
|
||||
).toEqual({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 6023,
|
||||
});
|
||||
});
|
||||
|
||||
it('splits 12h writes out of the write count when the breakdown is reported', () => {
|
||||
// One request can write both lifetimes: an explicit 12h breakpoint
|
||||
// plus the implicit 30m one OpenAI adds at the end of the prompt.
|
||||
expect(
|
||||
splitCachedPrompt({
|
||||
input_tokens: 6026,
|
||||
output_tokens: 5,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 6023,
|
||||
cache_write_tokens_details: {
|
||||
ttl_12h_tokens: 6012,
|
||||
ttl_30m_tokens: 11,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
prompt_tokens: 3,
|
||||
completion_tokens: 5,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 11,
|
||||
cache_write_12h_tokens: 6012,
|
||||
});
|
||||
});
|
||||
|
||||
it('never reports more 12h writes than writes in total', () => {
|
||||
expect(
|
||||
splitCachedPrompt({
|
||||
input_tokens: 100,
|
||||
output_tokens: 1,
|
||||
input_tokens_details: {
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 40,
|
||||
cache_write_tokens_details: { ttl_12h_tokens: 50 },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
prompt_tokens: 60,
|
||||
completion_tokens: 1,
|
||||
cached_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
cache_write_12h_tokens: 40,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── recorded usage costs ────────────────────────────────────────────
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
outputFormatFromResponsesText,
|
||||
outputFormatToResponseFormat,
|
||||
outputFormatToResponsesText,
|
||||
promptCacheOptionsToWire,
|
||||
promptCacheRetentionToWire,
|
||||
rejectStatefulResponsesFields,
|
||||
toolChoiceFromWire,
|
||||
toolChoiceToWire,
|
||||
@@ -334,3 +336,50 @@ describe('rejectStatefulResponsesFields', () => {
|
||||
).toThrow(/previous_response_id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptCacheRetentionToWire', () => {
|
||||
it('spells the in-memory retention the way the upstreams expect', () => {
|
||||
expect(promptCacheRetentionToWire('in-memory')).toBe('in_memory');
|
||||
expect(promptCacheRetentionToWire('in_memory')).toBe('in_memory');
|
||||
expect(promptCacheRetentionToWire('24h')).toBe('24h');
|
||||
expect(promptCacheRetentionToWire(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('promptCacheOptionsToWire', () => {
|
||||
it('forwards mode and ttl and nothing else', () => {
|
||||
expect(
|
||||
promptCacheOptionsToWire({
|
||||
mode: 'explicit',
|
||||
ttl: '12h',
|
||||
prewarm: true,
|
||||
comparison_response_id: 'resp_1',
|
||||
}),
|
||||
).toEqual({ mode: 'explicit', ttl: '12h' });
|
||||
expect(promptCacheOptionsToWire({ ttl: '30m' })).toEqual({
|
||||
ttl: '30m',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends nothing for an absent or empty object', () => {
|
||||
expect(promptCacheOptionsToWire(undefined)).toBeUndefined();
|
||||
expect(promptCacheOptionsToWire(null)).toBeUndefined();
|
||||
expect(promptCacheOptionsToWire({})).toBeUndefined();
|
||||
expect(promptCacheOptionsToWire({ prewarm: true })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a lifetime or mode OpenAI does not offer', () => {
|
||||
expect(() => promptCacheOptionsToWire({ ttl: '2h' })).toThrow(
|
||||
expect.objectContaining({ statusCode: 400 }),
|
||||
);
|
||||
expect(() => promptCacheOptionsToWire({ ttl: '24h' })).toThrow(
|
||||
/30m.*12h/,
|
||||
);
|
||||
expect(() => promptCacheOptionsToWire({ mode: 'auto' })).toThrow(
|
||||
/implicit.*explicit/,
|
||||
);
|
||||
expect(() => promptCacheOptionsToWire('12h')).toThrow(
|
||||
/must be an object/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { HttpError } from '../../../core/http/HttpError.js';
|
||||
import type {
|
||||
ICompleteArguments,
|
||||
OutputFormat,
|
||||
PromptCacheOptions,
|
||||
ReasoningEffort,
|
||||
ToolChoice,
|
||||
} from '../types.js';
|
||||
@@ -305,6 +306,60 @@ export const openAICompatParams = (
|
||||
return out;
|
||||
};
|
||||
|
||||
// -- prompt caching ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The wire spelling of `prompt_cache_retention`. Puter also accepts the
|
||||
* hyphenated `in-memory`, which every OpenAI-shaped upstream rejects.
|
||||
*/
|
||||
export const promptCacheRetentionToWire = (
|
||||
retention: ICompleteArguments['prompt_cache_retention'],
|
||||
): 'in_memory' | '24h' | undefined =>
|
||||
retention === 'in-memory' ? 'in_memory' : retention;
|
||||
|
||||
const PROMPT_CACHE_MODES = new Set(['implicit', 'explicit']);
|
||||
const PROMPT_CACHE_TTLS = new Set(['30m', '12h']);
|
||||
|
||||
/**
|
||||
* `prompt_cache_options` as forwarded upstream: `mode` and `ttl` only, each
|
||||
* checked against the values OpenAI accepts, so a bad value fails here with a
|
||||
* clear message instead of as an upstream 400 the fallback loop may hide.
|
||||
* `comparison_response_id` names a stored response, which Puter never keeps,
|
||||
* and `prewarm` is dropped with it.
|
||||
*/
|
||||
export const promptCacheOptionsToWire = (
|
||||
options: unknown,
|
||||
): PromptCacheOptions | undefined => {
|
||||
if (options === undefined || options === null) return undefined;
|
||||
if (typeof options !== 'object' || Array.isArray(options)) {
|
||||
throw new HttpError(400, '`prompt_cache_options` must be an object', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const { mode, ttl } = options as Record<string, unknown>;
|
||||
if (mode !== undefined && !PROMPT_CACHE_MODES.has(mode as string)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`prompt_cache_options.mode` must be `implicit` or `explicit`',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (ttl !== undefined && !PROMPT_CACHE_TTLS.has(ttl as string)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`prompt_cache_options.ttl` must be `30m` or `12h`',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const wire: PromptCacheOptions = {
|
||||
...(mode !== undefined
|
||||
? { mode: mode as PromptCacheOptions['mode'] }
|
||||
: {}),
|
||||
...(ttl !== undefined ? { ttl: ttl as PromptCacheOptions['ttl'] } : {}),
|
||||
};
|
||||
return Object.keys(wire).length ? wire : undefined;
|
||||
};
|
||||
|
||||
// -- Responses server-side state ---------------------------------------------
|
||||
|
||||
const STATEFUL_RESPONSES_FIELDS = [
|
||||
|
||||
@@ -314,6 +314,21 @@ You can find the implementation in our [prompt caching example](/playground/ai-c
|
||||
|
||||
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/).
|
||||
|
||||
OpenAI models cache prompt prefixes on their own too. Requests from the same app and user share one cache by default, and you can pick the cache with `prompt_cache_key`. Their `usage` reports the tokens read from the cache as `cached_tokens`.
|
||||
|
||||
GPT-5.6 and later models charge for cache writes and let you choose how long an entry lives. Pass `prompt_cache_options` with a `ttl` of `"30m"` (the default) or `"12h"`. The `usage` reports the tokens written with the 30 minute lifetime as `cache_write_tokens` and the tokens written with the 12 hour lifetime as `cache_write_12h_tokens`. Writing a 30 minute entry costs 1.25 times the model's input rate, and writing a 12 hour entry costs 2.5 times the input rate. Reads cost the same for both lifetimes. The 12 hour lifetime is only available through the OpenAI Responses API, so a request with `ttl: "12h"` is sent there.
|
||||
|
||||
```js
|
||||
puter.ai.chat('<your message>', {
|
||||
model: 'gpt-6-luna',
|
||||
prompt_cache_options: { ttl: '12h' }
|
||||
});
|
||||
```
|
||||
|
||||
Older OpenAI models charge nothing for cache writes. Pass `prompt_cache_retention` with `"in_memory"` for an entry that lives a few minutes or `"24h"` for one that can live up to a day. The 24 hour option is the default, and GPT-5.5 and later models only support it.
|
||||
|
||||
The `usageDetails` object on every result carries the same counts under provider-neutral names, with one field per lifetime, such as `cacheWrite30mTokens` and `cacheWrite12hTokens`. See the [ChatResponse](/Objects/chatresponse/) object for the full list.
|
||||
|
||||
## Compaction
|
||||
|
||||
For long, multi-turn conversations that you keep on the client, enable
|
||||
|
||||
@@ -64,7 +64,7 @@ Extra detail on why generation stopped (e.g. a refusal classification). Provider
|
||||
|
||||
#### `usageDetails` (Object)
|
||||
|
||||
A provider-neutral usage breakdown, present alongside `usage` on every result. `inputTokens` excludes cache reads/writes; `outputTokens` includes reasoning tokens. Fields: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWrite5mTokens`, `cacheWrite1hTokens`, `reasoningTokens`, `webSearchRequests`, `webFetchRequests`, `speed` (`"fast"` or `"standard"`), `serviceTier`, and `iterations` (a per-pass breakdown when the upstream ran several passes, e.g. compaction or an advisor sub-call).
|
||||
A provider-neutral usage breakdown, present alongside `usage` on every result. `inputTokens` excludes cache reads/writes; `outputTokens` includes reasoning tokens. Fields: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWrite5mTokens`, `cacheWrite30mTokens`, `cacheWrite1hTokens`, `cacheWrite12hTokens`, `reasoningTokens`, `webSearchRequests`, `webFetchRequests`, `speed` (`"fast"` or `"standard"`), `serviceTier`, and `iterations` (a per-pass breakdown when the upstream ran several passes, e.g. compaction or an advisor sub-call). Each `cacheWrite*Tokens` field counts the tokens written to the cache with that lifetime: Claude and Moonshot write 5 minute and 1 hour entries, OpenAI writes 30 minute and 12 hour entries.
|
||||
|
||||
Prefer `usageDetails` over `usage` for anything beyond the raw billing totals. Claude's `usage.thinking_tokens` is informational: it's included in `output_tokens` and not billed separately — `usageDetails.reasoningTokens` carries the same count.
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ Extra detail on why generation stopped (e.g. a refusal classification). Provider
|
||||
|
||||
#### `usageDetails` (Object)
|
||||
|
||||
A provider-neutral usage breakdown, present on the final `usage` chunk. `inputTokens` excludes cache reads/writes; `outputTokens` includes reasoning tokens. Fields: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWrite5mTokens`, `cacheWrite1hTokens`, `reasoningTokens`, `webSearchRequests`, `webFetchRequests`, `speed` (`"fast"` or `"standard"`), `serviceTier`, and `iterations` (a per-pass breakdown when the upstream ran several passes, e.g. compaction or an advisor sub-call).
|
||||
A provider-neutral usage breakdown, present on the final `usage` chunk. `inputTokens` excludes cache reads/writes; `outputTokens` includes reasoning tokens. Fields: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWrite5mTokens`, `cacheWrite30mTokens`, `cacheWrite1hTokens`, `cacheWrite12hTokens`, `reasoningTokens`, `webSearchRequests`, `webFetchRequests`, `speed` (`"fast"` or `"standard"`), `serviceTier`, and `iterations` (a per-pass breakdown when the upstream ran several passes, e.g. compaction or an advisor sub-call). Each `cacheWrite*Tokens` field counts the tokens written to the cache with that lifetime: Claude and Moonshot write 5 minute and 1 hour entries, OpenAI writes 30 minute and 12 hour entries.
|
||||
|
||||
#### `message` (String)
|
||||
|
||||
|
||||
@@ -171,6 +171,20 @@ describe('ai.chat driver payloads', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('chat(prompt, options) forwards the OpenAI prompt-cache options', async () => {
|
||||
await ai.chat('hello', {
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
prompt_cache_options: { mode: 'explicit', ttl: '12h' },
|
||||
});
|
||||
expect(lastBody().args).toEqual({
|
||||
messages: [{ content: 'hello' }],
|
||||
prompt_cache_key: 'ck',
|
||||
prompt_cache_retention: '24h',
|
||||
prompt_cache_options: { mode: 'explicit', ttl: '12h' },
|
||||
});
|
||||
});
|
||||
|
||||
it('chat(prompt, options) forwards temperature: 0 and max_tokens: 0', async () => {
|
||||
await ai.chat('hello', {
|
||||
temperature: 0,
|
||||
|
||||
@@ -27,6 +27,9 @@ const PARAMS_TO_PASS = [
|
||||
'outputFormat',
|
||||
'thinking',
|
||||
'streamToolInput',
|
||||
'prompt_cache_key',
|
||||
'prompt_cache_retention',
|
||||
'prompt_cache_options',
|
||||
];
|
||||
|
||||
// Parameters renamed between the SDK's camelCase option and the driver's
|
||||
|
||||
@@ -115,6 +115,14 @@
|
||||
* @property {boolean} [streamToolInput] Streaming only: emit incremental `tool_use_start` /
|
||||
* `tool_input_delta` chunks as a tool call's arguments are generated, instead of a single `tool_use`
|
||||
* chunk once the call completes.
|
||||
* @property {string} [prompt_cache_key] OpenAI models: which prompt cache the request reads from and
|
||||
* writes to. Defaults to one cache per app and user.
|
||||
* @property {'in_memory' | '24h'} [prompt_cache_retention] OpenAI models before GPT-5.6: how long a
|
||||
* cache entry may live. `'24h'` is the default and the only value GPT-5.5 and later accept.
|
||||
* @property {{ mode?: 'implicit' | 'explicit', ttl?: '30m' | '12h' }} [prompt_cache_options] OpenAI
|
||||
* GPT-5.6 and later: `ttl` is the minimum lifetime of every cache entry the request writes. A `'12h'`
|
||||
* entry costs twice as much to write as the default `'30m'` one and is served through the Responses
|
||||
* API. `mode: 'explicit'` only writes at the `prompt_cache_breakpoint` markers in your messages.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -132,14 +140,16 @@
|
||||
* @property {number} inputTokens
|
||||
* @property {number} outputTokens
|
||||
* @property {number} [cacheReadTokens]
|
||||
* @property {number} [cacheWrite5mTokens]
|
||||
* @property {number} [cacheWrite1hTokens]
|
||||
* @property {number} [cacheWrite5mTokens] Tokens written to the cache with a 5 minute lifetime (Claude, Moonshot).
|
||||
* @property {number} [cacheWrite30mTokens] Tokens written with a 30 minute lifetime (OpenAI's default).
|
||||
* @property {number} [cacheWrite1hTokens] Tokens written with a 1 hour lifetime (Claude, Moonshot).
|
||||
* @property {number} [cacheWrite12hTokens] Tokens written with a 12 hour lifetime (OpenAI, `prompt_cache_options.ttl: '12h'`).
|
||||
* @property {number} [reasoningTokens]
|
||||
* @property {number} [webSearchRequests]
|
||||
* @property {number} [webFetchRequests]
|
||||
* @property {'fast' | 'standard'} [speed]
|
||||
* @property {string} [serviceTier]
|
||||
* @property {Array<{ type: string, model?: string, inputTokens: number, outputTokens: number, cacheReadTokens?: number, cacheWrite5mTokens?: number, cacheWrite1hTokens?: number }>} [iterations]
|
||||
* @property {Array<{ type: string, model?: string, inputTokens: number, outputTokens: number, cacheReadTokens?: number, cacheWrite5mTokens?: number, cacheWrite30mTokens?: number, cacheWrite1hTokens?: number, cacheWrite12hTokens?: number }>} [iterations]
|
||||
* Per-pass breakdown when the upstream ran several passes (compaction, an advisor sub-call).
|
||||
*/
|
||||
|
||||
|
||||
Reference in new issue
Block a user