Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/services/service-ai/src/__tests__/ai-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ describe('MemoryLLMAdapter', () => {
expect(result.usage).toBeDefined();
});

it('estimates non-zero, monotonic token usage so metering is testable money-free', async () => {
const short = await adapter.chat([{ role: 'user', content: 'hi' }]);
const long = await adapter.chat([
{ role: 'system', content: 'You are a helpful assistant with a long preamble. '.repeat(20) },
{ role: 'user', content: 'Tell me everything about my data. '.repeat(20) },
]);
// Non-zero (the whole point — a flat 0 made quota/guardrail features untestable).
expect(short.usage!.totalTokens).toBeGreaterThan(0);
// total = prompt + completion, and grows with input size (monotonic).
expect(short.usage!.totalTokens).toBe(
short.usage!.promptTokens + short.usage!.completionTokens,
);
expect(long.usage!.promptTokens).toBeGreaterThan(short.usage!.promptTokens);
});

it('should handle no user message in chat()', async () => {
const messages: ModelMessage[] = [{ role: 'system', content: 'System only' }];
const result = await adapter.chat(messages);
Expand Down
44 changes: 33 additions & 11 deletions packages/services/service-ai/src/adapters/memory-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,27 @@ import type { LLMAdapter } from '@objectstack/spec/contracts';
* Always echoes back the last user message prefixed with "[memory] ".
* Useful for unit tests, CI pipelines, and local dev without an LLM key.
*/
/**
* Rough token estimate for the in-memory adapter. The memory adapter stands in
* for a real LLM in dev/CI/local-E2E; returning a flat `0` usage made every
* token-metering feature (quota guardrails, usage dashboards, cost caps)
* impossible to exercise without a paid provider key. This is intentionally
* crude — ~4 chars/token, the standard ballpark — NOT provider-accurate. It
* exists so usage-driven behaviour can be tested money-free, not to bill anyone.
*/
function estimateTokens(text: string): number {
return text ? Math.max(1, Math.ceil(text.length / 4)) : 0;
}

function estimateUsage(messages: ModelMessage[], output = ''): AIResult['usage'] {
const promptText = messages
.map(m => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
.join('\n');
const promptTokens = estimateTokens(promptText);
const completionTokens = estimateTokens(output);
return { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
}

export class MemoryLLMAdapter implements LLMAdapter {
readonly name = 'memory';

Expand Down Expand Up @@ -66,7 +87,7 @@ export class MemoryLLMAdapter implements LLMAdapter {
return {
content: '',
model: options?.model ?? 'memory',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
usage: estimateUsage(messages),
toolCalls: [
{
type: 'tool-call',
Expand All @@ -89,7 +110,7 @@ export class MemoryLLMAdapter implements LLMAdapter {
return {
content: '',
model: options?.model ?? 'memory',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
usage: estimateUsage(messages),
toolCalls: [
{
type: 'tool-call',
Expand Down Expand Up @@ -126,13 +147,13 @@ export class MemoryLLMAdapter implements LLMAdapter {
return {
content: `[memory] action ${payload.action ?? ''} failed: ${payload.error}`,
model: options?.model ?? 'memory',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
usage: estimateUsage(messages),
};
}
return {
content: `[memory] ${payload.message ?? 'Action executed.'} (${payload.action ?? 'action'})`,
model: options?.model ?? 'memory',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
usage: estimateUsage(messages),
};
}

Expand Down Expand Up @@ -163,15 +184,15 @@ export class MemoryLLMAdapter implements LLMAdapter {
return {
content: `[memory] query_data failed: ${payload.error}`,
model: options?.model ?? 'memory',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
usage: estimateUsage(messages),
};
}
const records = payload.records ?? [];
const count = payload.count ?? records.length;
return {
content: `[memory] Found ${count} record${count === 1 ? '' : 's'} for "${userText}".`,
model: options?.model ?? 'memory',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
usage: estimateUsage(messages),
};
}

Expand All @@ -182,15 +203,16 @@ export class MemoryLLMAdapter implements LLMAdapter {
return {
content,
model: options?.model ?? 'memory',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
usage: estimateUsage(messages),
};
}

async complete(prompt: string, options?: AIRequestOptions): Promise<AIResult> {
const content = `[memory] ${prompt}`;
return {
content: `[memory] ${prompt}`,
content,
model: options?.model ?? 'memory',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
usage: estimateUsage([{ role: 'user', content: prompt }], content),
};
}

Expand All @@ -208,7 +230,7 @@ export class MemoryLLMAdapter implements LLMAdapter {
yield {
type: 'finish',
finishReason: 'stop' as const,
totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
totalUsage: estimateUsage(messages, result.content),
rawFinishReason: 'stop',
} as unknown as TextStreamPart<ToolSet>;
}
Expand Down Expand Up @@ -310,7 +332,7 @@ export class MemoryLLMAdapter implements LLMAdapter {
return {
object: result.data,
model: options?.model ?? 'memory',
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
usage: estimateUsage(messages),
};
}
}
Expand Down
Loading