mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-12 18:19:28 -05:00
test: claudeService (#2074)
This commit is contained in:
+1
-1
@@ -59,7 +59,7 @@ export const rules = {
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
files: ['**/*.d.ts', '**/*.d.mts', '**/*.d.cts'],
|
||||
parserOptions: {
|
||||
project: null,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"./**/*.d.ts"
|
||||
"./**/*.d.ts",
|
||||
"./**/*.d.mts",
|
||||
"./**/*.d.cts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.test.ts",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user