test: claudeService (#2074)

This commit is contained in:
Daniel Salazar
2025-12-01 13:39:55 -08:00
committed by GitHub
parent c77272012d
commit 405e02cf9c
11 changed files with 350 additions and 273 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ export const rules = {
export default defineConfig([
{
files: ['**/*.d.ts'],
files: ['**/*.d.ts', '**/*.d.mts', '**/*.d.cts'],
parserOptions: {
project: null,
},
+3 -1
View File
@@ -14,7 +14,9 @@
},
"include": [
"./**/*.ts",
"./**/*.d.ts"
"./**/*.d.ts",
"./**/*.d.mts",
"./**/*.d.cts"
],
"exclude": [
"**/*.test.ts",
+2 -2
View File
@@ -8,9 +8,9 @@
"build:worker": "cd src/services/worker && npm run build"
},
"dependencies": {
"@aws-sdk/client-cloudwatch": "^3.940.0",
"@aws-sdk/client-polly": "^3.622.0",
"@aws-sdk/client-textract": "^3.621.0",
"@aws-sdk/client-cloudwatch": "^3.940.0",
"@google/generative-ai": "^0.21.0",
"@heyputer/kv.js": "^0.1.9",
"@heyputer/multest": "^0.0.2",
@@ -101,7 +101,7 @@
"nyc": "^15.1.0",
"sinon": "^15.2.0",
"typescript": "^5.9.3",
"vitest": "4.0.14"
"vitest": "^4.0.14"
},
"author": "Puter Technologies Inc.",
"license": "AGPL-3.0-only"
+236 -250
View File
@@ -35,18 +35,27 @@ const mime = require('mime-types');
* @extends BaseService
*/
class ClaudeService extends BaseService {
// Traits definitions
static IMPLEMENTS = {
['puter-chat-completion']: {
async models () {
return this.models();
},
async list () {
return this.list();
},
async complete (...args) {
return this.complete(...args);
},
},
};
/**
* @type {import('@anthropic-ai/sdk').Anthropic}
*/
anthropic;
/**
* Initializes the Claude service by creating an Anthropic client instance
* and registering this service as a provider with the AI chat service.
* @private
* @returns {Promise<void>}
*/
/** @type {import('../../services/MeteringService/MeteringService').MeteringService} */
#meteringService;
@@ -76,274 +85,252 @@ class ClaudeService extends BaseService {
return 'claude-3-5-sonnet-latest';
}
static IMPLEMENTS = {
['puter-chat-completion']: {
/**
* Returns a list of available models and their details.
* See AIChatService for more information.
*
* @returns Promise<Array<Object>> Array of model details
*/
async models () {
return this.models_();
async list () {
const models = this.models();
const model_names = [];
for ( const model of models ) {
model_names.push(model.id);
if ( model.aliases ) {
model_names.push(...model.aliases);
}
}
return model_names;
}
/**
*
* @param {object} arg
* @param {Array} arg.messages
* @param {boolean} [arg.stream]
* @param {string} arg.model
* @param {Array} [arg.tools]
* @param {number} [arg.max_tokens]
* @param {number} [arg.temperature]
* @returns
*/
async complete ({ messages, stream, model, tools, max_tokens, temperature }) {
tools = FunctionCalling.make_claude_tools(tools);
let system_prompts;
// unsure why system_prompts is an array but it always seems to only have exactly one element,
// and the real array of system_prompts seems to be the [0].content -- NS
[system_prompts, messages] = Messages.extract_and_remove_system_messages(messages);
// Apply the cache control tag to all content blocks
if (
system_prompts.length > 0 &&
system_prompts[0].cache_control &&
system_prompts[0]?.content
) {
system_prompts[0].content = system_prompts[0].content.map(prompt => {
prompt.cache_control = system_prompts[0].cache_control;
return prompt;
});
}
messages = messages.map(message => {
if ( message.cache_control ) {
message.content[0].cache_control = message.cache_control;
}
delete message.cache_control;
return message;
});
const sdk_params = {
model: model ?? this.get_default_model(),
max_tokens: Math.floor(max_tokens) ||
((
model === 'claude-3-5-sonnet-20241022'
|| model === 'claude-3-5-sonnet-20240620'
) ? 8192 : this.models().filter(e => (e.name === model || e.aliases?.includes(model)))[0]?.max_tokens || 4096), //required
temperature: temperature || 0, // required
...( (system_prompts && system_prompts[0]?.content) ? {
system: system_prompts[0]?.content,
} : {}),
tool_choice: {
type: 'auto',
disable_parallel_tool_use: true,
},
messages,
...(tools ? { tools } : {}),
};
console.log(sdk_params.max_tokens);
/**
* Returns a list of available model names including their aliases
* @returns {Promise<string[]>} Array of model identifiers and their aliases
* @description Retrieves all available model IDs and their aliases,
* flattening them into a single array of strings that can be used for model selection
*/
async list () {
const models = this.models_();
const model_names = [];
for ( const model of models ) {
model_names.push(model.id);
if ( model.aliases ) {
model_names.push(...model.aliases);
}
}
return model_names;
},
let beta_mode = false;
/**
* Completes a chat interaction with the Claude AI model
* @param {Object} options - The completion options
* @param {Array} options.messages - Array of chat messages to process
* @param {boolean} options.stream - Whether to stream the response
* @param {string} [options.model] - The Claude model to use, defaults to service default
* @returns {Object} Returns either a TypedValue with streaming response or a completion object
* @this {ClaudeService}
*/
async complete ({ messages, stream, model, tools, max_tokens, temperature }) {
tools = FunctionCalling.make_claude_tools(tools);
// console.log("here are the messages: ", messages)
// Perform file uploads
const file_delete_tasks = [];
const actor = Context.get('actor');
const { user } = actor.type;
let system_prompts;
// unsure why system_prompts is an array but it always seems to only have exactly one element,
// and the real array of system_prompts seems to be the [0].content -- NS
[system_prompts, messages] = Messages.extract_and_remove_system_messages(messages);
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,
});
}
}
// Apply the cache control tag to all content blocks
if (
system_prompts.length > 0 &&
system_prompts[0].cache_control &&
system_prompts[0]?.content
) {
system_prompts[0].content = system_prompts[0].content.map(prompt => {
prompt.cache_control = system_prompts[0].cache_control;
return prompt;
});
}
messages = messages.map(message => {
if ( message.cache_control ) {
message.content[0].cache_control = message.cache_control;
}
delete message.cache_control;
return message;
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 sdk_params = {
model: model ?? this.get_default_model(),
max_tokens: Math.floor(max_tokens) ||
((
model === 'claude-3-5-sonnet-20241022'
|| model === 'claude-3-5-sonnet-20240620'
) ? 8192 : this.models_().filter(e => (e.name === model || e.aliases?.includes(model)))[0]?.max_tokens || 4096), //required
temperature: temperature || 0, // required
...( (system_prompts && system_prompts[0]?.content) ? {
system: system_prompts[0]?.content,
} : {}),
tool_choice: {
type: 'auto',
disable_parallel_tool_use: true,
},
messages,
...(tools ? { tools } : {}),
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';
})();
delete task.contentPart.puter_path,
task.contentPart.type = contentBlockTypeForFileBasedOnMime;
task.contentPart.source = {
type: 'file',
file_id: fileUpload.id,
};
console.log(sdk_params.max_tokens);
})());
}
await Promise.all(promises);
// console.log('\x1B[26;1m ===== SDK PARAMETERS', require('util').inspect(sdk_params, undefined, Infinity));
let beta_mode = false;
// Perform file uploads
const file_delete_tasks = [];
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 cleanup_files = async () => {
const promises = [];
for ( const task of file_delete_tasks ) {
promises.push((async () => {
try {
await this.anthropic.beta.files.delete(task.file_id,
{ betas: ['files-api-2025-04-14'] });
} catch (e) {
this.errors.report('claude:file-delete-task', {
source: e,
trace: true,
alarm: true,
extra: { file_id: task.file_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,
});
if ( beta_mode ) {
Object.assign(sdk_params, { betas: ['files-api-2025-04-14'] });
}
const anthropic = beta_mode ? this.anthropic.beta : this.anthropic;
const mimeType = mime.contentType(await task.node.get('name'));
if ( stream ) {
const init_chat_stream = async ({ chatStream }) => {
const completion = await anthropic.messages.stream(sdk_params);
const usageSum = {};
beta_mode = true;
const fileUpload = await this.anthropic.beta.files.upload({
file: await toFile(stream, undefined, { type: mimeType }),
}, {
betas: ['files-api-2025-04-14'],
});
let message, contentBlock;
for await ( const event of completion ) {
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';
})();
const usageObject = (event?.usage ?? event?.message?.usage ?? {});
const meteredData = this.usageFormatterUtil(usageObject);
Object.keys(meteredData).forEach((key) => {
if ( ! usageSum[key] ) usageSum[key] = 0;
usageSum[key] += meteredData[key];
});
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 ) {
promises.push((async () => {
try {
await this.anthropic.beta.files.delete(task.file_id,
{ betas: ['files-api-2025-04-14'] });
} catch (e) {
this.errors.report('claude:file-delete-task', {
source: e,
trace: true,
alarm: true,
extra: { file_id: task.file_id },
});
}
})());
if ( event.type === 'message_start' ) {
message = chatStream.message();
continue;
}
if ( event.type === 'message_stop' ) {
message.end();
message = null;
continue;
}
await Promise.all(promises);
};
if ( beta_mode ) {
Object.assign(sdk_params, { betas: ['files-api-2025-04-14'] });
}
const anthropic = (c => beta_mode ? c.beta : c)(this.anthropic);
if ( stream ) {
const init_chat_stream = async ({ chatStream }) => {
const completion = await anthropic.messages.stream(sdk_params);
const usageSum = {};
let message, contentBlock;
for await ( const event of completion ) {
const usageObject = (event?.usage ?? event?.message?.usage ?? {});
const meteredData = this.usageFormatterUtil(usageObject);
Object.keys(meteredData).forEach((key) => {
if ( ! usageSum[key] ) usageSum[key] = 0;
usageSum[key] += meteredData[key];
if ( event.type === 'content_block_start' ) {
if ( event.content_block.type === 'tool_use' ) {
contentBlock = message.contentBlock({
type: event.content_block.type,
id: event.content_block.id,
name: event.content_block.name,
});
if ( event.type === 'message_start' ) {
message = chatStream.message();
continue;
}
if ( event.type === 'message_stop' ) {
message.end();
message = null;
continue;
}
if ( event.type === 'content_block_start' ) {
if ( event.content_block.type === 'tool_use' ) {
contentBlock = message.contentBlock({
type: event.content_block.type,
id: event.content_block.id,
name: event.content_block.name,
});
continue;
}
contentBlock = message.contentBlock({
type: event.content_block.type,
});
continue;
}
if ( event.type === 'content_block_stop' ) {
contentBlock.end();
contentBlock = null;
continue;
}
if ( event.type === 'content_block_delta' ) {
if ( event.delta.type === 'input_json_delta' ) {
contentBlock.addPartialJSON(event.delta.partial_json);
continue;
}
if ( event.delta.type === 'text_delta' ) {
contentBlock.addText(event.delta.text);
continue;
}
}
continue;
}
chatStream.end();
contentBlock = message.contentBlock({
type: event.content_block.type,
});
continue;
}
this.#meteringService.utilRecordUsageObject(usageSum, actor, `claude:${this.models_().find(m => [m.id, ...(m.aliases || [])].includes(model || this.get_default_model())).id}`);
};
if ( event.type === 'content_block_stop' ) {
contentBlock.end();
contentBlock = null;
continue;
}
return {
init_chat_stream,
stream: true,
finally_fn: cleanup_files,
};
if ( event.type === 'content_block_delta' ) {
if ( event.delta.type === 'input_json_delta' ) {
contentBlock.addPartialJSON(event.delta.partial_json);
continue;
}
if ( event.delta.type === 'text_delta' ) {
contentBlock.addText(event.delta.text);
continue;
}
}
}
chatStream.end();
const msg = await anthropic.messages.create(sdk_params);
await cleanup_files();
this.#meteringService.utilRecordUsageObject(usageSum, actor, `claude:${this.models().find(m => [m.id, ...(m.aliases || [])].includes(model || this.get_default_model())).id}`);
};
const usage = this.usageFormatterUtil(msg.usage);
this.#meteringService.utilRecordUsageObject(usage, actor, `claude:${this.models_().find(m => [m.id, ...(m.aliases || [])].includes(model || this.get_default_model())).id}`);
return {
init_chat_stream,
stream: true,
finally_fn: cleanup_files,
};
}
// TODO DS: cleanup old usage tracking
return {
message: msg,
usage: msg.usage,
finish_reason: 'stop',
};
},
},
};
const msg = await anthropic.messages.create(sdk_params);
await cleanup_files();
const usage = this.usageFormatterUtil(msg.usage);
this.#meteringService.utilRecordUsageObject(usage, actor, `claude:${this.models().find(m => [m.id, ...(m.aliases || [])].includes(model || this.get_default_model())).id}`);
// TODO DS: cleanup old usage tracking
return {
message: msg,
usage: msg.usage,
finish_reason: 'stop',
};
}
// 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) => {}}) */
usageFormatterUtil (usage) {
return {
@@ -367,7 +354,7 @@ class ClaudeService extends BaseService {
* - max_output: Maximum output tokens
* - training_cutoff: Training data cutoff date
*/
models_ () {
models () {
return [
{
id: 'claude-opus-4-5-20251101',
@@ -489,7 +476,6 @@ class ClaudeService extends BaseService {
},
{
id: 'claude-3-haiku-20240307',
// aliases: ['claude-3-haiku-latest'],
context: 200000,
cost: {
currency: 'usd-cents',
@@ -0,0 +1,57 @@
import { describe, expect, it, test } from 'vitest';
import { createTestKernel } from '../../../tools/test.mjs';
import { COST_MAPS } from '../../services/MeteringService/costMaps';
import { SUService } from '../../services/SUService';
import { AIChatService } from './AIChatService';
import { ClaudeService } from './ClaudeService';
describe('ClaudeService ', async () => {
const testKernel = await createTestKernel({
serviceMap: {
'claude': ClaudeService,
'ai-chat': AIChatService,
},
initLevelString: 'init',
testCore: true,
serviceConfigOverrideMap: {
'database': {
path: ':memory:',
},
'claude': {
apiKey: process.env.PUTER_CLAUDE_API_KEY,
},
},
});
const target = testKernel.services!.get('claude') as ClaudeService;
const su = testKernel.services!.get('su') as SUService;
it('should have all models mapped in cost maps', async () => {
const models = await target.models();
for ( const model of models ) {
const entry = Object.entries(COST_MAPS).find(([key, _value]) => key.startsWith('claude') && key.includes(model.id));
expect(entry, `Model ${model.id} is missing in cost maps`).toBeDefined();
}
});
test.skipIf(!process.env.PUTER_CLAUDE_API_KEY)('should return flat response from claude if token provided', async () => {
const response = await su.sudo(async () => await target.complete({
messages: [
{ role: 'user', content: 'Only reply: "hi"' },
],
model: 'claude-haiku-4-5-20251001',
max_tokens: 15,
}));
expect(response.message.id).toBeDefined();
expect(response.message.content.length).toBeGreaterThan(0);
expect(response.message.content[0].text).include('hi');
expect(response.message.model).toEqual('claude-haiku-4-5-20251001');
expect(response.message.usage).toBeDefined();
expect(response.message.usage.output_tokens).toBeLessThan(15);
expect(response.finish_reason).toBe('stop');
});
});
@@ -11,6 +11,7 @@ const { DBKVServiceWrapper } = require('../../services/repositories/DBKVStore/in
const { SUService } = require('../../services/SUService');
const { TraceService } = require('../../services/TraceService');
const { AlarmService } = require('../core/AlarmService');
const APIErrorService = require('../web/APIErrorService');
class TestCoreModule {
async install (context) {
@@ -28,6 +29,7 @@ class TestCoreModule {
services.registerService('permission', PermissionService);
services.registerService('group', GroupService);
services.registerService('anomaly', AnomalyService);
services.registerService('api-error', APIErrorService);
}
}
+2
View File
@@ -26,6 +26,8 @@ export class BaseService {
log: Logger;
errors: any;
as(interfaceName: string): Record<string, unknown>;
run_as_early_as_possible (): Promise<void>;
construct (): Promise<void>;
init (): Promise<void>;
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { createTestKernel } from '../../../tools/test.mjs';
import * as config from '../../config';
import { Actor } from '../auth/Actor';
import type { EventService } from '../EventService.js';
import { DBKVServiceWrapper } from '../repositories/DBKVStore/index.mjs';
@@ -10,14 +9,6 @@ import { MeteringService } from './MeteringService';
import { MeteringServiceWrapper } from './MeteringServiceWrapper.mjs';
describe('MeteringService', async () => {
config.load_config({
'services': {
'database': {
path: ':memory:',
},
},
});
const testKernel = await createTestKernel({
serviceMap: {
meteringService: MeteringServiceWrapper,
@@ -25,6 +16,11 @@ describe('MeteringService', async () => {
},
initLevelString: 'init',
testCore: true,
serviceConfigOverrideMap: {
'database': {
path: ':memory:',
},
},
});
const testSubject = testKernel.services!.get('meteringService') as MeteringServiceWrapper;
@@ -7,8 +7,6 @@ import { DBKVServiceWrapper } from './index.mjs';
describe('DBKVStore', async () => {
config.load_config({
kv_max_key_size: 1000,
kv_max_value_size: 1000,
'services': {
'database': {
path: ':memory:',
@@ -20,6 +18,11 @@ describe('DBKVStore', async () => {
serviceMap: {},
initLevelString: 'init',
testCore: true,
serviceConfigOverrideMap: {
'database': {
path: ':memory:',
},
},
});
const kvServiceWrapper = testKernel.services!.get('puter-kvstore') as DBKVServiceWrapper;
+27
View File
@@ -280,6 +280,8 @@ export const createTestKernel = async ({
initLevelString = 'construct',
extraSteps = true,
testCore = false,
serviceConfigOverrideMap = {},
globalConfigOverrideMap = {},
}) => {
const initLevelMap = { CONSTRUCT: 1, INIT: 2 };
@@ -298,6 +300,31 @@ export const createTestKernel = async ({
testKernel.boot();
await testKernel.services.ready;
const service_names = Object.keys(testKernel.services.instances_);
for ( const name of service_names ) {
const serviceConfigOverride = serviceConfigOverrideMap[name] ;
const globalConfigOverride = globalConfigOverrideMap[name] ;
if ( serviceConfigOverride ) {
const ins = testKernel.services.instances_[name];
// Apply service config overrides
ins.config = {
...ins.config,
...serviceConfigOverride,
};
}
if ( globalConfigOverride ) {
const ins = testKernel.services.instances_[name];
// Apply global config overrides
ins.global_config = {
...ins.global_config,
...globalConfigOverride,
};
}
}
for ( const name of service_names ) {
const ins = testKernel.services.instances_[name];
// Fix context
+10 -8
View File
@@ -5,21 +5,23 @@ import { defineConfig } from 'vitest/config';
export default defineConfig(({ mode }) => ({
test: {
globals: true,
environment: 'jsdom',
setupFiles: [],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'json-summary', 'html', 'lcov'],
include: ['src/backend/**/*.js', 'src/backend/**/*.mjs', 'src/backend/**/*.ts', 'src/backend/**/*.ts'],
include: ['src/**/*.{js,mjs,ts}'],
exclude: [
'**/types/**',
'**/constants/**',
'**/*.d.ts',
'**/dist/**',
'**/*.min.*',
'src/**/types/**',
'src/**/constants/**',
'src/**/*.d.ts',
'src/**/*.d.mts',
'src/**/*.d.cts',
'src/**/dist/**',
'src/**/*.min.*',
],
},
env: loadEnv(mode, '', 'PUTER_'),
include: ['src/backend/**/*.test.ts', 'src/backend/**/*.test.js']
include: ['src/**/*.{test,spec}.{ts,js}'],
root: __dirname, // Ensures paths are relative to backend/
},
}));