mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 00:20:45 +00:00
feat: meter claude ai (#1716)
* feat: meter claude ai * fix: claude meteringService dep
This commit is contained in:
@@ -413,7 +413,6 @@ const install = async ({ context, services, app, useapi, modapi }) => {
|
||||
services.registerService("worker-service", WorkerService);
|
||||
|
||||
const { MeteringAndBillingServiceWrapper } = require("./services/abuse-prevention/MeteringService/index.mjs");
|
||||
|
||||
services.registerService('meteringService', MeteringAndBillingServiceWrapper);
|
||||
|
||||
const { PermissionShortcutService } = require('./services/auth/PermissionShortcutService');
|
||||
|
||||
@@ -27,6 +27,30 @@ const { LLRead } = require("../../filesystem/ll_operations/ll_read");
|
||||
const { Context } = require("../../util/context");
|
||||
const { TeePromise } = require('@heyputer/putility').libs.promise;
|
||||
|
||||
// TODO DS: get this inside the class as a private method once the methods aren't exported directly
|
||||
/** @type {(usage: import("@anthropic-ai/sdk/resources/messages.js").Usage | import("@anthropic-ai/sdk/resources/beta/messages/messages.js").BetaUsage) => {}}) */
|
||||
const usageFormatterUtil = (usage) => {
|
||||
return {
|
||||
input_tokens: usage?.input_tokens || 0,
|
||||
ephemeral_5m_input_tokens: usage?.cache_creation?.ephemeral_5m_input_tokens || usage.cache_creation_input_tokens || 0, // this is because they're api is a bit inconsistent
|
||||
ephemeral_1h_input_tokens: usage?.cache_creation?.ephemeral_1h_input_tokens || 0,
|
||||
cache_read_input_tokens: usage?.cache_read_input_tokens || 0,
|
||||
output_tokens: usage?.output_tokens || 0,
|
||||
};
|
||||
};
|
||||
|
||||
// TODO DS: get this inside the class as a private method once the methods aren't exported directly
|
||||
const billForUsage = (actor,
|
||||
model,
|
||||
usage,
|
||||
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */ meteringAndBillingService) => {
|
||||
Object.entries(usage).forEach(([usageKind, amount]) => {
|
||||
meteringAndBillingService.incrementUsage(actor,
|
||||
`claude:${model || this.get_default_model()}:${usageKind}`,
|
||||
amount);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* ClaudeService class extends BaseService to provide integration with Anthropic's Claude AI models.
|
||||
* Implements the puter-chat-completion interface for handling AI chat interactions.
|
||||
@@ -50,6 +74,10 @@ class ClaudeService extends BaseService {
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
|
||||
/** @type {import('../../services/abuse-prevention/MeteringService/MeteringService').MeteringAndBillingService} */
|
||||
#meteringAndBillingService;
|
||||
|
||||
async _init() {
|
||||
this.anthropic = new Anthropic({
|
||||
apiKey: this.config.apiKey,
|
||||
@@ -65,6 +93,7 @@ class ClaudeService extends BaseService {
|
||||
service_name: this.service_name,
|
||||
alias: true,
|
||||
});
|
||||
this.#meteringAndBillingService = this.services.get('meteringService').meteringAndBillingService; // TODO DS: move to proper extensions
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,78 +172,77 @@ class ClaudeService extends BaseService {
|
||||
|
||||
// Perform file uploads
|
||||
const file_delete_tasks = [];
|
||||
{
|
||||
const actor = Context.get('actor');
|
||||
const { user } = actor.type;
|
||||
const actor = Context.get('actor');
|
||||
const { user } = actor.type;
|
||||
|
||||
const file_input_tasks = [];
|
||||
for ( const message of messages ) {
|
||||
// We can assume `message.content` is not undefined because
|
||||
// Messages.normalize_single_message ensures this.
|
||||
for ( const contentPart of message.content ) {
|
||||
if ( ! contentPart.puter_path ) continue;
|
||||
file_input_tasks.push({
|
||||
node: await (new FSNodeParam(contentPart.puter_path)).consolidate({
|
||||
req: { user },
|
||||
getParam: () => contentPart.puter_path,
|
||||
}),
|
||||
contentPart,
|
||||
});
|
||||
}
|
||||
const file_input_tasks = [];
|
||||
for ( const message of messages ) {
|
||||
// We can assume `message.content` is not undefined because
|
||||
// Messages.normalize_single_message ensures this.
|
||||
for ( const contentPart of message.content ) {
|
||||
if ( ! contentPart.puter_path ) continue;
|
||||
file_input_tasks.push({
|
||||
node: await (new FSNodeParam(contentPart.puter_path)).consolidate({
|
||||
req: { user },
|
||||
getParam: () => contentPart.puter_path,
|
||||
}),
|
||||
contentPart,
|
||||
});
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for ( const task of file_input_tasks ) {
|
||||
promises.push((async () => {
|
||||
const ll_read = new LLRead();
|
||||
const stream = await ll_read.run({
|
||||
actor: Context.get('actor'),
|
||||
fsNode: task.node,
|
||||
});
|
||||
|
||||
const require = this.require;
|
||||
const mime = require('mime-types');
|
||||
const mimeType = mime.contentType(await task.node.get('name'));
|
||||
|
||||
beta_mode = true;
|
||||
const fileUpload = await this.anthropic.beta.files.upload({
|
||||
file: await toFile(stream, undefined, { type: mimeType }),
|
||||
}, {
|
||||
betas: ['files-api-2025-04-14'],
|
||||
});
|
||||
|
||||
file_delete_tasks.push({ file_id: fileUpload.id });
|
||||
// We have to copy a table from the documentation here:
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/files
|
||||
const contentBlockTypeForFileBasedOnMime = (() => {
|
||||
if ( mimeType.startsWith('image/') ) {
|
||||
return 'image';
|
||||
}
|
||||
if ( mimeType.startsWith('text/') ) {
|
||||
return 'document';
|
||||
}
|
||||
if ( mimeType === 'application/pdf' || mimeType === 'application/x-pdf' ) {
|
||||
return 'document';
|
||||
}
|
||||
return 'container_upload';
|
||||
})();
|
||||
|
||||
// {
|
||||
// 'application/pdf': 'document',
|
||||
// 'text/plain': 'document',
|
||||
// 'image/': 'image'
|
||||
// }[mimeType];
|
||||
|
||||
delete task.contentPart.puter_path,
|
||||
task.contentPart.type = contentBlockTypeForFileBasedOnMime;
|
||||
task.contentPart.source = {
|
||||
type: 'file',
|
||||
file_id: fileUpload.id,
|
||||
};
|
||||
})());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
for ( const task of file_input_tasks ) {
|
||||
promises.push((async () => {
|
||||
const ll_read = new LLRead();
|
||||
const stream = await ll_read.run({
|
||||
actor: Context.get('actor'),
|
||||
fsNode: task.node,
|
||||
});
|
||||
|
||||
const require = this.require;
|
||||
const mime = require('mime-types');
|
||||
const mimeType = mime.contentType(await task.node.get('name'));
|
||||
|
||||
beta_mode = true;
|
||||
const fileUpload = await this.anthropic.beta.files.upload({
|
||||
file: await toFile(stream, undefined, { type: mimeType }),
|
||||
}, {
|
||||
betas: ['files-api-2025-04-14'],
|
||||
});
|
||||
|
||||
file_delete_tasks.push({ file_id: fileUpload.id });
|
||||
// We have to copy a table from the documentation here:
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/files
|
||||
const contentBlockTypeForFileBasedOnMime = (() => {
|
||||
if ( mimeType.startsWith('image/') ) {
|
||||
return 'image';
|
||||
}
|
||||
if ( mimeType.startsWith('text/') ) {
|
||||
return 'document';
|
||||
}
|
||||
if ( mimeType === 'application/pdf' || mimeType === 'application/x-pdf' ) {
|
||||
return 'document';
|
||||
}
|
||||
return 'container_upload';
|
||||
})();
|
||||
|
||||
// {
|
||||
// 'application/pdf': 'document',
|
||||
// 'text/plain': 'document',
|
||||
// 'image/': 'image'
|
||||
// }[mimeType];
|
||||
|
||||
delete task.contentPart.puter_path,
|
||||
task.contentPart.type = contentBlockTypeForFileBasedOnMime;
|
||||
task.contentPart.source = {
|
||||
type: 'file',
|
||||
file_id: fileUpload.id,
|
||||
};
|
||||
})());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
const cleanup_files = async () => {
|
||||
const promises = [];
|
||||
for ( const task of file_delete_tasks ) {
|
||||
@@ -245,17 +273,17 @@ class ClaudeService extends BaseService {
|
||||
|
||||
const init_chat_stream = async ({ chatStream }) => {
|
||||
const completion = await anthropic.messages.stream(sdk_params);
|
||||
const counts = { input_tokens: 0, output_tokens: 0 };
|
||||
const usageSum = {};
|
||||
|
||||
let message, contentBlock;
|
||||
for await ( const event of completion ) {
|
||||
const input_tokens =
|
||||
(event?.usage ?? event?.message?.usage)?.input_tokens;
|
||||
const output_tokens =
|
||||
(event?.usage ?? event?.message?.usage)?.output_tokens;
|
||||
|
||||
if ( input_tokens ) counts.input_tokens += input_tokens;
|
||||
if ( output_tokens ) counts.output_tokens += output_tokens;
|
||||
const usageObject = (event?.usage ?? event?.message?.usage ?? {});
|
||||
const meteredData = usageFormatterUtil (usageObject);
|
||||
Object.keys(meteredData).forEach((key) => {
|
||||
if ( ! usageSum[key] ) usageSum[key] = 0;
|
||||
usageSum[key] += meteredData[key];
|
||||
});
|
||||
|
||||
if ( event.type === 'message_start' ) {
|
||||
message = chatStream.message();
|
||||
@@ -300,7 +328,13 @@ class ClaudeService extends BaseService {
|
||||
}
|
||||
}
|
||||
chatStream.end();
|
||||
usage_promise.resolve(counts);
|
||||
|
||||
billForUsage(actor, model || this.get_default_model(), usageSum, this.#meteringAndBillingService);
|
||||
// TODO DS: Legacy cost metering, remove when new is ready
|
||||
usage_promise.resolve({
|
||||
input_tokens: usageSum.input_tokens,
|
||||
output_tokens: usageSum.input_tokens,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -314,6 +348,9 @@ class ClaudeService extends BaseService {
|
||||
const msg = await anthropic.messages.create(sdk_params);
|
||||
await cleanup_files();
|
||||
|
||||
billForUsage(actor, model || this.get_default_model(), usageFormatterUtil(msg.usage), this.#meteringAndBillingService);
|
||||
|
||||
// TODO DS: cleanup old usage tracking
|
||||
return {
|
||||
message: msg,
|
||||
usage: msg.usage,
|
||||
|
||||
+46
-16
@@ -19,34 +19,64 @@
|
||||
|
||||
export const CLAUDE_COST_MAP = {
|
||||
// Claude Sonnet 4.5
|
||||
"claude:claude-sonnet-4-5-20250929:input": 300,
|
||||
"claude:claude-sonnet-4-5-20250929:output": 1500,
|
||||
"claude:claude-sonnet-4-5-20250929:input_tokens": 300,
|
||||
"claude:claude-sonnet-4-5-20250929:ephemeral_5m_input_tokens": 300 * 1.25,
|
||||
"claude:claude-sonnet-4-5-20250929:ephemeral_1h_input_tokens": 300 * 2,
|
||||
"claude:claude-sonnet-4-5-20250929:cache_read_input_tokens": 300 * 0.1,
|
||||
"claude:claude-sonnet-4-5-20250929:output_tokens": 1500,
|
||||
|
||||
// Claude Opus 4.1
|
||||
"claude:claude-opus-4-1-20250805:input": 1500,
|
||||
"claude:claude-opus-4-1-20250805:output": 7500,
|
||||
"claude:claude-opus-4-1-20250805:input_tokens": 1500,
|
||||
"claude:claude-opus-4-1-20250805:ephemeral_5m_input_tokens": 1500 * 1.25,
|
||||
"claude:claude-opus-4-1-20250805:ephemeral_1h_input_tokens": 1500 * 2,
|
||||
"claude:claude-opus-4-1-20250805:cache_read_input_tokens": 1500 * 0.1,
|
||||
"claude:claude-opus-4-1-20250805:output_tokens": 7500,
|
||||
|
||||
|
||||
// Claude Opus 4
|
||||
"claude:claude-opus-4-20250514:input": 1500,
|
||||
"claude:claude-opus-4-20250514:output": 7500,
|
||||
"claude:claude-opus-4-20250514:input_tokens": 1500,
|
||||
"claude:claude-opus-4-20250514:ephemeral_5m_input_tokens": 1500 * 1.25,
|
||||
"claude:claude-opus-4-20250514:ephemeral_1h_input_tokens": 1500 * 2,
|
||||
"claude:claude-opus-4-20250514:cache_read_input_tokens": 1500 * 0.1,
|
||||
"claude:claude-opus-4-20250514:output_tokens": 7500,
|
||||
|
||||
|
||||
// Claude Sonnet 4
|
||||
"claude:claude-sonnet-4-20250514:input": 300,
|
||||
"claude:claude-sonnet-4-20250514:output": 1500,
|
||||
"claude:claude-sonnet-4-20250514:input_tokens": 300,
|
||||
"claude:claude-sonnet-4-20250514:ephemeral_5m_input_tokens": 300 * 1.25,
|
||||
"claude:claude-sonnet-4-20250514:ephemeral_1h_input_tokens": 300 * 2,
|
||||
"claude:claude-sonnet-4-20250514:cache_read_input_tokens": 300 * 0.1,
|
||||
"claude:claude-sonnet-4-20250514:output_tokens": 1500,
|
||||
|
||||
|
||||
// Claude 3.7 Sonnet
|
||||
"claude:claude-3-7-sonnet-20250219:input": 300,
|
||||
"claude:claude-3-7-sonnet-20250219:output": 1500,
|
||||
"claude:claude-3-7-sonnet-20250219:input_tokens": 300,
|
||||
"claude:claude-3-7-sonnet-20250219:ephemeral_5m_input_tokens": 300 * 1.25,
|
||||
"claude:claude-3-7-sonnet-20250219:ephemeral_1h_input_tokens": 300 * 2,
|
||||
"claude:claude-3-7-sonnet-20250219:cache_read_input_tokens": 300 * 0.1,
|
||||
"claude:claude-3-7-sonnet-20250219:output_tokens": 1500,
|
||||
|
||||
|
||||
// Claude 3.5 Sonnet (Oct 2024)
|
||||
"claude:claude-3-5-sonnet-20241022:input": 300,
|
||||
"claude:claude-3-5-sonnet-20241022:output": 1500,
|
||||
"claude:claude-3-5-sonnet-20241022:input_tokens": 300,
|
||||
"claude:claude-3-5-sonnet-20241022:ephemeral_5m_input_tokens": 300 * 1.25,
|
||||
"claude:claude-3-5-sonnet-20241022:ephemeral_1h_input_tokens": 300 * 2,
|
||||
"claude:claude-3-5-sonnet-20241022:cache_read_input_tokens": 300 * 0.1,
|
||||
"claude:claude-3-5-sonnet-20241022:output_tokens": 1500,
|
||||
|
||||
|
||||
// Claude 3.5 Sonnet (June 2024)
|
||||
"claude:claude-3-5-sonnet-20240620:input": 300,
|
||||
"claude:claude-3-5-sonnet-20240620:output": 1500,
|
||||
"claude:claude-3-5-sonnet-20240620:input_tokens": 300,
|
||||
"claude:claude-3-5-sonnet-20240620:ephemeral_5m_input_tokens": 300 * 1.25,
|
||||
"claude:claude-3-5-sonnet-20240620:ephemeral_1h_input_tokens": 300 * 2,
|
||||
"claude:claude-3-5-sonnet-20240620:cache_read_input_tokens": 300 * 0.1,
|
||||
"claude:claude-3-5-sonnet-20240620:output_tokens": 1500,
|
||||
|
||||
|
||||
// Claude 3 Haiku
|
||||
"claude:claude-3-haiku-20240307:input": 25,
|
||||
"claude:claude-3-haiku-20240307:output": 125,
|
||||
"claude:claude-3-haiku-20240307:input_tokens": 25,
|
||||
"claude:claude-3-haiku-20240307:ephemeral_5m_input_tokens": 25 * 1.25,
|
||||
"claude:claude-3-haiku-20240307:ephemeral_1h_input_tokens": 25 * 2,
|
||||
"claude:claude-3-haiku-20240307:cache_read_input_tokens": 25 * 0.1,
|
||||
"claude:claude-3-haiku-20240307:output_tokens": 125
|
||||
};
|
||||
@@ -12,16 +12,4 @@ export class MeteringAndBillingServiceWrapper extends BaseService {
|
||||
alarmService: this.services.get('alarm'),
|
||||
});
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['meteringService']: Object.getOwnPropertyNames(MeteringAndBillingService.prototype)
|
||||
.filter(n => n !== 'constructor')
|
||||
.reduce((acc, fn) => ({
|
||||
...acc,
|
||||
[fn]: async function(...a) {
|
||||
return await this.meteringAndBillingService[fn](...a);
|
||||
},
|
||||
}), {}),
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user