mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62bc8d6d6f | ||
|
|
4700c2dc65 |
No files matched your search
@@ -463,6 +463,53 @@ describe('ChatCompletionDriver.complete events and cost emission', () => {
|
||||
expect(data.service_used).toBe('fake-chat');
|
||||
});
|
||||
|
||||
it('charges cached prompt tokens only at their cache rate', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'cached-pricing',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
promptTokensIncludeCached: true,
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 200,
|
||||
cached_tokens: 10,
|
||||
},
|
||||
max_tokens: 8192,
|
||||
},
|
||||
]);
|
||||
const driver = await makeDriver();
|
||||
const costEvents = captureEvent('ai.prompt.cost-calculated');
|
||||
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
},
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 1,
|
||||
cached_tokens: 40,
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
} as never);
|
||||
|
||||
await withTestActor(() =>
|
||||
driver.complete({
|
||||
model: 'cached-pricing',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const data = costEvents[0]![1] as {
|
||||
input_ucents: number;
|
||||
output_ucents: number;
|
||||
};
|
||||
expect(data.input_ucents).toBe(60 * 100 + 40 * 10);
|
||||
expect(data.output_ucents).toBe(200);
|
||||
});
|
||||
|
||||
it('injects `usd_cents` on the usage object derived from the cost map', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -41,6 +41,7 @@ import { AzureResponsesProvider } from './providers/azure/AzureResponsesProvider
|
||||
import { BytePlusProvider } from './providers/byteplus/BytePlusProvider.js';
|
||||
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
|
||||
import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
|
||||
import { deepSeekPricing } from './providers/deepseek/pricing.js';
|
||||
import { FakeChatProvider } from './providers/FakeChatProvider.js';
|
||||
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
|
||||
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
|
||||
@@ -824,6 +825,9 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
model,
|
||||
trackedInputTokens(usage, model),
|
||||
);
|
||||
const cachedTokens = Number.isFinite(usage.cached_tokens)
|
||||
? Math.max(0, usage.cached_tokens)
|
||||
: 0;
|
||||
|
||||
let inputMicroCents = 0;
|
||||
let outputMicroCents = 0;
|
||||
@@ -837,6 +841,11 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
if (key === 'usd_cents') continue;
|
||||
if (key === 'tokens') continue;
|
||||
|
||||
const amount =
|
||||
model.promptTokensIncludeCached && key === inputKey
|
||||
? Math.max(0, rawAmount - cachedTokens)
|
||||
: rawAmount;
|
||||
|
||||
// thinking_tokens → output rate fallback
|
||||
let rate = costs[key];
|
||||
if (typeof rate !== 'number' || !Number.isFinite(rate)) {
|
||||
@@ -859,9 +868,9 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
|
||||
sawAnyRate = true;
|
||||
if (isOutputKey(key)) {
|
||||
outputMicroCents += rawAmount * rate * multipliers.output;
|
||||
outputMicroCents += amount * rate * multipliers.output;
|
||||
} else {
|
||||
inputMicroCents += rawAmount * rate * multipliers.input;
|
||||
inputMicroCents += amount * rate * multipliers.input;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1276,6 +1285,15 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
|
||||
const deepseekKey = readKey(providers['deepseek']);
|
||||
if (deepseekKey) {
|
||||
deepSeekPricing.onRefreshError = () => {
|
||||
this.clients.alarm.create(
|
||||
'deepseek_pricing_refresh_failed',
|
||||
'DeepSeek pricing refresh failed; using last verified rates',
|
||||
{},
|
||||
'warning',
|
||||
{ dedup: true },
|
||||
);
|
||||
};
|
||||
this.#providers['deepseek'] = new DeepSeekProvider(
|
||||
{ apiKey: deepseekKey },
|
||||
metering,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
/**
|
||||
* Integration test for the DeepSeek provider.
|
||||
*
|
||||
* Uses `deepseek-chat` (the cheap V3 chat model, provider default).
|
||||
* Uses `deepseek-chat` (the Flash legacy alias).
|
||||
* Skipped when `PUTER_TEST_AI_DEEPSEEK_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
|
||||
@@ -17,17 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for DeepSeekProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs DeepSeekProvider directly against the live
|
||||
* wired `MeteringService` so the recording side is exercised end-to-
|
||||
* end. DeepSeek is OpenAI-compatible so the OpenAI SDK is mocked at
|
||||
* the module boundary; that's the real network egress point. The
|
||||
* companion integration test (DeepSeekProvider.integration.test.ts)
|
||||
* exercises the real DeepSeek endpoint.
|
||||
*/
|
||||
/** Offline provider tests with an in-memory server and mocked OpenAI network boundary. */
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import {
|
||||
@@ -145,9 +135,9 @@ describe('DeepSeekProvider construction', () => {
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('DeepSeekProvider model catalog', () => {
|
||||
it('returns deepseek-v4-pro as the default', () => {
|
||||
it('returns deepseek-flash as the default', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('deepseek-v4-pro');
|
||||
expect(provider.getDefaultModel()).toBe('deepseek-flash');
|
||||
});
|
||||
|
||||
it('exposes the static DEEPSEEK_MODELS list verbatim from models()', () => {
|
||||
@@ -165,6 +155,9 @@ describe('DeepSeekProvider model catalog', () => {
|
||||
}
|
||||
}
|
||||
expect(ids).toContain('deepseek-v4-pro');
|
||||
expect(ids).toContain('deepseek-flash');
|
||||
expect(ids).toContain('deepseek-v4-flash');
|
||||
expect(ids).toContain('deepseek-v4-flash-vision-exp');
|
||||
expect(ids).toContain('deepseek-chat');
|
||||
expect(ids).toContain('deepseek/deepseek-v4-pro');
|
||||
});
|
||||
@@ -474,26 +467,34 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-pro');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-flash');
|
||||
const flash = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ prompt_tokens: 1, completion_tokens: 1, cached_tokens: 0 },
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-pro',
|
||||
expect.any(Object),
|
||||
'deepseek:deepseek-flash',
|
||||
{
|
||||
prompt_tokens: Number(flash.costs.prompt_tokens),
|
||||
completion_tokens: Number(flash.costs.completion_tokens),
|
||||
cached_tokens: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The legacy DeepSeek chat/reasoner ids (and their `deepseek/…` and
|
||||
// `deepseek:deepseek/…` variants) are aliased onto deepseek-v4-pro
|
||||
// so callers using the old names get transparently upgraded — and
|
||||
// metered against the v4-pro canonical prefix.
|
||||
it.each([
|
||||
'deepseek-v4-flash',
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek:deepseek/deepseek-v4-flash',
|
||||
'deepseek-v4-flash-vision-exp',
|
||||
'deepseek/deepseek-v4-flash-vision-exp',
|
||||
'deepseek:deepseek/deepseek-v4-flash-vision-exp',
|
||||
'deepseek-chat',
|
||||
'deepseek/deepseek-chat',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-reasoner',
|
||||
])('maps legacy alias %s onto deepseek-v4-pro', async (alias) => {
|
||||
])('maps Flash and legacy alias %s onto deepseek-flash', async (alias) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
@@ -504,11 +505,11 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-pro');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-pro',
|
||||
'deepseek:deepseek-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -550,24 +551,91 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
cached_tokens: 10,
|
||||
});
|
||||
|
||||
// deepseek-v4-pro costs: prompt=174, completion=348, cached=1.45
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-pro')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-pro');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 100 * Number(chat.costs.prompt_tokens),
|
||||
prompt_tokens: 90 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(chat.costs.completion_tokens),
|
||||
cached_tokens: 10 * Number(chat.costs.cached_tokens ?? 0),
|
||||
});
|
||||
});
|
||||
|
||||
it('charges a fully cached prompt only at the cache rate', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 0,
|
||||
prompt_tokens_details: { cached_tokens: 100 },
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const flash = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
{ prompt_tokens: 0, completion_tokens: 0, cached_tokens: 100 },
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-flash',
|
||||
{
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
cached_tokens: 100 * Number(flash.costs.cached_tokens),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('uses DeepSeek cache hit and miss counts when provided', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 1,
|
||||
prompt_cache_hit_tokens: 40,
|
||||
prompt_cache_miss_tokens: 60,
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-v4-pro',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
{ prompt_tokens: 60, completion_tokens: 1, cached_tokens: 40 },
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-pro',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
@@ -693,7 +761,7 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-pro');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 4 * Number(chat.costs.prompt_tokens),
|
||||
prompt_tokens: 3 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(chat.costs.completion_tokens),
|
||||
cached_tokens: 1 * Number(chat.costs.cached_tokens ?? 0),
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { DEEPSEEK_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { deepSeekPricing } from './pricing.js';
|
||||
|
||||
export class DeepSeekProvider implements IChatProvider {
|
||||
#openai: OpenAI;
|
||||
@@ -38,10 +39,11 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
});
|
||||
this.#meteringService = meteringService;
|
||||
deepSeekPricing.start();
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'deepseek-v4-pro';
|
||||
return 'deepseek-flash';
|
||||
}
|
||||
|
||||
models() {
|
||||
@@ -60,6 +62,7 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
max_tokens,
|
||||
temperature,
|
||||
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
|
||||
await deepSeekPricing.refreshIfStale();
|
||||
const actor = Context.get('actor');
|
||||
const availableModels = this.models();
|
||||
const modelUsed =
|
||||
@@ -122,14 +125,29 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const deepSeekUsage = usage as typeof usage & {
|
||||
prompt_cache_hit_tokens?: number;
|
||||
prompt_cache_miss_tokens?: number;
|
||||
};
|
||||
const cachedTokens =
|
||||
deepSeekUsage.prompt_cache_hit_tokens ??
|
||||
usage.prompt_tokens_details?.cached_tokens ??
|
||||
0;
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const meteredUsage = {
|
||||
prompt_tokens:
|
||||
deepSeekUsage.prompt_cache_miss_tokens ??
|
||||
Math.max(0, (usage.prompt_tokens ?? 0) - cachedTokens),
|
||||
completion_tokens: usage.completion_tokens ?? 0,
|
||||
cached_tokens: cachedTokens,
|
||||
};
|
||||
const costsOverrideFromModel = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
Object.entries(meteredUsage).map(([k, v]) => {
|
||||
return [k, v * modelUsed.costs[k]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
meteredUsage,
|
||||
actor!,
|
||||
`deepseek:${modelUsed.id}`,
|
||||
costsOverrideFromModel,
|
||||
|
||||
@@ -18,9 +18,52 @@
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { deepSeekPricing, type DeepSeekModelId } from './pricing.js';
|
||||
|
||||
const dynamicCosts = (modelId: DeepSeekModelId) => ({
|
||||
tokens: 1_000_000,
|
||||
get prompt_tokens() {
|
||||
return deepSeekPricing.costs(modelId).prompt;
|
||||
},
|
||||
get completion_tokens() {
|
||||
return deepSeekPricing.costs(modelId).completion;
|
||||
},
|
||||
get cached_tokens() {
|
||||
return deepSeekPricing.costs(modelId).cached;
|
||||
},
|
||||
});
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const DEEPSEEK_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'deepseek:deepseek/deepseek-flash',
|
||||
id: 'deepseek-flash',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
name: 'DeepSeek Flash',
|
||||
aliases: [
|
||||
'deepseek/deepseek-flash',
|
||||
'deepseek-v4-flash',
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek:deepseek/deepseek-v4-flash',
|
||||
'deepseek-v4-flash-vision-exp',
|
||||
'deepseek/deepseek-v4-flash-vision-exp',
|
||||
'deepseek:deepseek/deepseek-v4-flash-vision-exp',
|
||||
'deepseek-chat',
|
||||
'deepseek/deepseek-chat',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
'deepseek-reasoner',
|
||||
'deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-reasoner',
|
||||
],
|
||||
context: 1_000_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
promptTokensIncludeCached: true,
|
||||
costs: dynamicCosts('deepseek-flash'),
|
||||
max_tokens: 384_000,
|
||||
},
|
||||
{
|
||||
puterId: 'deepseek:deepseek/deepseek-v4-pro',
|
||||
id: 'deepseek-v4-pro',
|
||||
@@ -30,24 +73,13 @@ export const DEEPSEEK_MODELS: IChatModel[] = [
|
||||
knowledge: '2026-04',
|
||||
release_date: '2026-04-24',
|
||||
name: 'DeepSeek Chat',
|
||||
aliases: [
|
||||
'deepseek/deepseek-v4-pro',
|
||||
'deepseek-chat',
|
||||
'deepseek/deepseek-chat',
|
||||
'deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
],
|
||||
aliases: ['deepseek/deepseek-v4-pro'],
|
||||
context: 1_000_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 174,
|
||||
completion_tokens: 348,
|
||||
cached_tokens: 1.45,
|
||||
},
|
||||
promptTokensIncludeCached: true,
|
||||
costs: dynamicCosts('deepseek-v4-pro'),
|
||||
max_tokens: 384_000,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DeepSeekPricing } from './pricing.js';
|
||||
|
||||
describe.skipIf(!process.env.PUTER_TEST_AI_DEEPSEEK_PRICING)(
|
||||
'DeepSeek pricing page (integration)',
|
||||
() => {
|
||||
it('fetches and parses current official rates', async () => {
|
||||
const pricing = new DeepSeekPricing();
|
||||
await pricing.refresh();
|
||||
expect(pricing.costs('deepseek-flash').prompt).toBeGreaterThan(0);
|
||||
expect(pricing.costs('deepseek-v4-pro').cached).toBeGreaterThan(0);
|
||||
}, 15_000);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DEEPSEEK_MODELS } from './models.js';
|
||||
import {
|
||||
DeepSeekPricing,
|
||||
isDeepSeekPeak,
|
||||
parseDeepSeekPrices,
|
||||
} from './pricing.js';
|
||||
|
||||
const pricePage = (flashInput = '$0.15') => `
|
||||
<table><tr><td>MODEL</td><td>deepseek-flash<sup>(1)</sup></td><td>deepseek-v4-pro</td></tr>
|
||||
<tr><td>PRICING</td><td>1M INPUT TOKENS<br>(CACHE HIT)</td><td>OFF-PEAK</td><td>$0.003</td><td>$0.022</td></tr>
|
||||
<tr><td>PEAK</td><td>$0.006</td><td>$0.044</td></tr>
|
||||
<tr><td>1M INPUT TOKENS<br>(CACHE MISS)</td><td>OFF-PEAK</td><td>${flashInput}</td><td>$0.66</td></tr>
|
||||
<tr><td>PEAK</td><td>$0.30</td><td>$1.32</td></tr>
|
||||
<tr><td>1M OUTPUT TOKENS</td><td>OFF-PEAK</td><td>$0.60</td><td>$1.98</td></tr>
|
||||
<tr><td>PEAK</td><td>$1.20</td><td>$3.96</td></tr></table>
|
||||
<p>Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays.</p>`;
|
||||
|
||||
describe('DeepSeek pricing', () => {
|
||||
it('parses both models, periods, and cache rates from the official table shape', () => {
|
||||
const prices = parseDeepSeekPrices(pricePage());
|
||||
expect(prices['deepseek-flash'].offPeak).toEqual({
|
||||
prompt: 0.15,
|
||||
completion: 0.6,
|
||||
cached: 0.003,
|
||||
});
|
||||
expect(prices['deepseek-v4-pro'].peak).toEqual({
|
||||
prompt: 1.32,
|
||||
completion: 3.96,
|
||||
cached: 0.044,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects incomplete tables and changed peak rules', () => {
|
||||
expect(() =>
|
||||
parseDeepSeekPrices(
|
||||
pricePage().replace('excluding Chinese public holidays', ''),
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseDeepSeekPrices(pricePage().replace('<td>$3.96</td>', '')),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['2026-09-28T00:59:00Z', false],
|
||||
['2026-09-28T01:00:00Z', true],
|
||||
['2026-09-28T04:00:00Z', false],
|
||||
['2026-09-28T06:00:00Z', true],
|
||||
['2026-09-28T10:00:00Z', false],
|
||||
['2026-09-27T07:00:00Z', false],
|
||||
['2026-10-01T07:00:00Z', false],
|
||||
['2026-05-04T07:00:00Z', false],
|
||||
['2027-09-28T07:00:00Z', false],
|
||||
])('classifies %s peak=%s', (iso, expected) => {
|
||||
expect(isDeepSeekPeak(new Date(iso))).toBe(expected);
|
||||
});
|
||||
|
||||
it('updates catalog rates at UTC peak boundaries even after model routing copies an entry', () => {
|
||||
const flash = {
|
||||
...DEEPSEEK_MODELS.find((model) => model.id === 'deepseek-flash')!,
|
||||
};
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date('2026-09-28T05:59:59Z'));
|
||||
expect(flash.costs.prompt_tokens).toBe(15);
|
||||
vi.setSystemTime(new Date('2026-09-28T06:00:00Z'));
|
||||
expect(flash.costs.prompt_tokens).toBe(30);
|
||||
expect(flash.costs.cached_tokens).toBe(0.6);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('refreshes rates and keeps the last good schedule after a bad response', async () => {
|
||||
const fetchPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(pricePage('$0.18')))
|
||||
.mockResolvedValueOnce(new Response('<html>bad prices</html>'));
|
||||
const pricing = new DeepSeekPricing(fetchPage);
|
||||
const offPeak = new Date('2026-09-28T05:00:00Z');
|
||||
|
||||
await pricing.refresh();
|
||||
expect(pricing.costs('deepseek-flash', offPeak).prompt).toBe(18);
|
||||
await expect(pricing.refresh()).rejects.toThrow();
|
||||
expect(pricing.costs('deepseek-flash', offPeak).prompt).toBe(18);
|
||||
expect(fetchPage).toHaveBeenCalledWith(
|
||||
'https://api-docs.deepseek.com/quick_start/pricing/',
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a failed automatic refresh without replacing rates', async () => {
|
||||
const reportError = vi.fn();
|
||||
const pricing = new DeepSeekPricing(
|
||||
vi.fn().mockRejectedValue(new Error('offline')),
|
||||
reportError,
|
||||
);
|
||||
await expect(pricing.refreshIfStale()).resolves.toBeUndefined();
|
||||
expect(reportError).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
pricing.costs('deepseek-flash', new Date('2026-09-28T05:00:00Z'))
|
||||
.prompt,
|
||||
).toBe(15);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type DeepSeekModelId = 'deepseek-flash' | 'deepseek-v4-pro';
|
||||
type Period = 'offPeak' | 'peak';
|
||||
type TokenRates = { prompt: number; completion: number; cached: number };
|
||||
type PriceSchedule = Record<DeepSeekModelId, Record<Period, TokenRates>>;
|
||||
|
||||
const PRICING_URL = 'https://api-docs.deepseek.com/quick_start/pricing/';
|
||||
const REFRESH_MS = 60 * 60 * 1000;
|
||||
|
||||
// Last verified official rates, in USD per million tokens. Used until a valid
|
||||
// refresh succeeds; the page remains the source of subsequent changes.
|
||||
const FALLBACK_RATES: PriceSchedule = {
|
||||
'deepseek-flash': {
|
||||
offPeak: { prompt: 0.15, completion: 0.6, cached: 0.003 },
|
||||
peak: { prompt: 0.3, completion: 1.2, cached: 0.006 },
|
||||
},
|
||||
'deepseek-v4-pro': {
|
||||
offPeak: { prompt: 0.66, completion: 1.98, cached: 0.022 },
|
||||
peak: { prompt: 1.32, completion: 3.96, cached: 0.044 },
|
||||
},
|
||||
};
|
||||
|
||||
// State Council's 2026 public holiday arrangement:
|
||||
// https://en.bjhd.gov.cn/workinginhaidian/supportingservices/publicholidays/202512/t20251211_4797062.shtml
|
||||
const HOLIDAYS_2026 = [
|
||||
['2026-01-01', '2026-01-03'],
|
||||
['2026-02-15', '2026-02-23'],
|
||||
['2026-04-04', '2026-04-06'],
|
||||
['2026-05-01', '2026-05-05'],
|
||||
['2026-06-19', '2026-06-21'],
|
||||
['2026-09-25', '2026-09-27'],
|
||||
['2026-10-01', '2026-10-07'],
|
||||
];
|
||||
|
||||
const isChinesePublicHoliday = (date: Date): boolean => {
|
||||
const day = date.toISOString().slice(0, 10);
|
||||
return HOLIDAYS_2026.some(([start, end]) => day >= start && day <= end);
|
||||
};
|
||||
|
||||
export const isDeepSeekPeak = (date: Date): boolean => {
|
||||
// An unpublished year's holiday calendar must not cause peak overbilling.
|
||||
if (date.getUTCFullYear() !== 2026) return false;
|
||||
const weekday = date.getUTCDay();
|
||||
const hour = date.getUTCHours();
|
||||
return (
|
||||
weekday >= 1 &&
|
||||
weekday <= 5 &&
|
||||
!isChinesePublicHoliday(date) &&
|
||||
((hour >= 1 && hour < 4) || (hour >= 6 && hour < 10))
|
||||
);
|
||||
};
|
||||
|
||||
const plainText = (html: string): string =>
|
||||
html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const readDollars = (cell: string): number => {
|
||||
if (!/^\$\d+(?:\.\d+)?$/.test(cell))
|
||||
throw new Error('Invalid DeepSeek price');
|
||||
const value = Number(cell.slice(1));
|
||||
if (!Number.isFinite(value) || value <= 0 || value > 100) {
|
||||
throw new Error('DeepSeek price outside expected range');
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const parseDeepSeekPrices = (html: string): PriceSchedule => {
|
||||
if (
|
||||
!plainText(html).includes(
|
||||
'Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays.',
|
||||
)
|
||||
) {
|
||||
throw new Error('DeepSeek peak schedule changed');
|
||||
}
|
||||
const table = [...html.matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)]
|
||||
.map(([match]) => match)
|
||||
.find(
|
||||
(value) =>
|
||||
value.includes('deepseek-flash') && value.includes('PRICING'),
|
||||
);
|
||||
if (!table) throw new Error('DeepSeek pricing table missing');
|
||||
|
||||
const rows = [...table.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map(
|
||||
([, row]) =>
|
||||
[...row.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map(([, cell]) =>
|
||||
plainText(cell),
|
||||
),
|
||||
);
|
||||
if (
|
||||
rows[0]?.at(-2) !== 'deepseek-flash (1)' ||
|
||||
rows[0]?.at(-1) !== 'deepseek-v4-pro'
|
||||
) {
|
||||
throw new Error('DeepSeek model columns changed');
|
||||
}
|
||||
|
||||
const rates = structuredClone(FALLBACK_RATES);
|
||||
const seen = new Set<string>();
|
||||
let category: keyof TokenRates | undefined;
|
||||
let pricingStarted = false;
|
||||
for (const cells of rows) {
|
||||
if (cells.some((cell) => cell.startsWith('PRICING')))
|
||||
pricingStarted = true;
|
||||
if (!pricingStarted) continue;
|
||||
const label = cells.join(' ');
|
||||
if (label.includes('CACHE HIT')) category = 'cached';
|
||||
else if (label.includes('CACHE MISS')) category = 'prompt';
|
||||
else if (label.includes('OUTPUT TOKENS')) category = 'completion';
|
||||
const periodCell = cells.at(-3);
|
||||
if (periodCell !== 'PEAK' && periodCell !== 'OFF-PEAK') continue;
|
||||
if (!category) throw new Error('DeepSeek price category missing');
|
||||
const period = periodCell === 'PEAK' ? 'peak' : 'offPeak';
|
||||
const key = `${period}:${category}`;
|
||||
if (seen.has(key)) throw new Error('Duplicate DeepSeek price');
|
||||
rates['deepseek-flash'][period][category] = readDollars(cells.at(-2)!);
|
||||
rates['deepseek-v4-pro'][period][category] = readDollars(cells.at(-1)!);
|
||||
seen.add(key);
|
||||
}
|
||||
if (seen.size !== 6) throw new Error('Incomplete DeepSeek prices');
|
||||
for (const model of Object.values(rates)) {
|
||||
for (const category of ['prompt', 'completion', 'cached'] as const) {
|
||||
if (model.peak[category] < model.offPeak[category]) {
|
||||
throw new Error('DeepSeek peak price below off-peak price');
|
||||
}
|
||||
}
|
||||
if (model.offPeak.cached > model.offPeak.prompt) {
|
||||
throw new Error('DeepSeek cache price above input price');
|
||||
}
|
||||
}
|
||||
return rates;
|
||||
};
|
||||
|
||||
export class DeepSeekPricing {
|
||||
#rates: PriceSchedule = structuredClone(FALLBACK_RATES);
|
||||
#refreshPromise?: Promise<void>;
|
||||
#lastAttempt = 0;
|
||||
#timer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor(
|
||||
private readonly fetchPage: typeof fetch = fetch,
|
||||
public onRefreshError: (error: unknown) => void = (error) =>
|
||||
console.warn('[deepseek] pricing refresh failed', error),
|
||||
) {}
|
||||
|
||||
costs(modelId: DeepSeekModelId, date = new Date()): TokenRates {
|
||||
const period = isDeepSeekPeak(date) ? 'peak' : 'offPeak';
|
||||
const rates = this.#rates[modelId][period];
|
||||
return {
|
||||
prompt: rates.prompt * 100,
|
||||
completion: rates.completion * 100,
|
||||
cached: rates.cached * 100,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (this.#refreshPromise) return this.#refreshPromise;
|
||||
this.#lastAttempt = Date.now();
|
||||
this.#refreshPromise = (async () => {
|
||||
const response = await this.fetchPage(PRICING_URL, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error(`DeepSeek pricing HTTP ${response.status}`);
|
||||
this.#rates = parseDeepSeekPrices(await response.text());
|
||||
})();
|
||||
try {
|
||||
await this.#refreshPromise;
|
||||
} finally {
|
||||
this.#refreshPromise = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async refreshIfStale(): Promise<void> {
|
||||
if (
|
||||
!this.#refreshPromise &&
|
||||
Date.now() - this.#lastAttempt < REFRESH_MS
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await this.refresh();
|
||||
} catch (error) {
|
||||
try {
|
||||
this.onRefreshError(error);
|
||||
} catch {
|
||||
console.warn('[deepseek] pricing refresh failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.#timer) return;
|
||||
void this.refreshIfStale();
|
||||
this.#timer = setInterval(() => void this.refreshIfStale(), REFRESH_MS);
|
||||
this.#timer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
export const deepSeekPricing = new DeepSeekPricing();
|
||||
@@ -43,6 +43,8 @@ export interface IChatModel<T extends ModelCost = ModelCost> extends Record<
|
||||
input_cost_key?: keyof T;
|
||||
output_cost_key?: keyof T;
|
||||
costs: T;
|
||||
/** The prompt count includes cached tokens, which have their own rate. */
|
||||
promptTokensIncludeCached?: boolean;
|
||||
/**
|
||||
* A request whose input exceeds `threshold` tokens is billed at raised
|
||||
* rates for the whole request, not only the tokens past the threshold:
|
||||
|
||||
@@ -115,6 +115,10 @@ In case of an error, the `Promise` will reject with an error message.
|
||||
|
||||
We use different vendors for different models and try to use the best vendor available at the time of the request. Vendors currently include Alibaba Cloud, Anthropic, Azure OpenAI, DeepSeek, Google, Infron, Meta, MiniMax, Mistral, Moonshot AI, OpenAI, OpenRouter, Together AI, xAI, and Z.AI. Call [`puter.ai.listModelProviders()`](/AI/listModelProviders) for the current list, or pass `provider` in the options object to pin a request to one of them.
|
||||
|
||||
For DeepSeek's direct provider, use `deepseek-flash` for the Flash price tier or `deepseek-v4-pro` to select Pro explicitly. Existing `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `deepseek-chat`, and `deepseek-reasoner` selections continue to use the Flash tier.
|
||||
|
||||
DeepSeek prices follow its published [pricing page](https://api-docs.deepseek.com/quick_start/pricing/) and can change. Peak prices apply Monday through Friday from 01:00 to 04:00 and 06:00 to 10:00 UTC, except on Chinese public holidays; all other times use off-peak prices. Puter refreshes the published rates hourly and retains the last verified rates if the page cannot be read. If the public holiday calendar for a year has not been published and configured, Puter uses off-peak prices for that year.
|
||||
|
||||
## Response Normalization
|
||||
|
||||
Most vendors respond in the OpenAI chat format, where `message.content` is a string and tool calls appear as `message.tool_calls`. Anthropic models historically respond in Anthropic's native format instead, where `message.content` is an array of content blocks such as `[{ type: "text", text: "..." }]`.
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<script>
|
||||
(async () => {
|
||||
// DeepSeek v3.2
|
||||
const chat_resp = await puter.ai.chat('Tell me something I might not know about chemistry.', {model: 'deepseek-v3.2', stream: true });
|
||||
puter.print('<h1>DeepSeek v3.2:</h1>');
|
||||
// DeepSeek Flash
|
||||
const chat_resp = await puter.ai.chat('Tell me something I might not know about chemistry.', {model: 'deepseek-flash', stream: true });
|
||||
puter.print('<h1>DeepSeek Flash:</h1>');
|
||||
for await ( const part of chat_resp )
|
||||
if (part?.reasoning) puter.print(part?.reasoning);
|
||||
else puter.print(part?.text);
|
||||
|
||||
// DeepSeek Reasoner
|
||||
const reasoner_resp = await puter.ai.chat('Tell me something I might not know about chemistry.', {model: 'deepseek-reasoner', stream: true });
|
||||
puter.print('<h1>DeepSeek Reasoner:</h1>');
|
||||
// DeepSeek Pro
|
||||
const reasoner_resp = await puter.ai.chat('Tell me something I might not know about chemistry.', {model: 'deepseek-v4-pro', stream: true });
|
||||
puter.print('<h1>DeepSeek Pro:</h1>');
|
||||
for await ( const part of reasoner_resp )
|
||||
if (part?.reasoning) puter.print(part?.reasoning);
|
||||
else puter.print(part?.text);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
Reference in new issue
Block a user