Add attention inbox and AI usage observability

This commit is contained in:
jjbrunton committed 2026-08-09 08:52:32 +01:00
1 parent ca59ba0f1f
commit 83b5f1f5b6
30 files changed
+702 -28

No files matched your search

+96 -22
View File
@@ -1,6 +1,7 @@
import OpenAI from 'openai';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
import type { AiUsageRecordInput, AiUsageOutcome } from '../database/ai-usage-repository.js';
import {
formatConversationAsUntrustedData,
isSafeConversationReply,
@@ -29,6 +30,8 @@ interface ProtectedTextPrompt {
user: string;
}
export type AiUsageRecorder = (usage: AiUsageRecordInput) => void;
export class OpenRouterClient {
private client: OpenAI;
@@ -36,6 +39,7 @@ export class OpenRouterClient {
private config: Config,
private logger: Logger,
client?: OpenAI,
private readonly usageRecorder?: AiUsageRecorder,
) {
this.client = client ?? new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
@@ -48,6 +52,7 @@ export class OpenRouterClient {
}
async detectGenderOnly(imageBase64: string, mimeType: string): Promise<DetectedGender> {
const startedAt = Date.now();
try {
this.logger.debug('Quick gender detection...');
@@ -74,12 +79,14 @@ export class OpenRouterClient {
});
const result = response.choices[0]?.message?.content?.toLowerCase().trim() || 'unknown';
this.recordUsage('detectGender', this.config.openrouter.imageAnalysisModel, startedAt, response, result ? 'success' : 'empty');
if (result.includes('female')) return 'female';
if (result.includes('male') && !result.includes('female')) return 'male';
if (result.includes('other')) return 'other';
return 'unknown';
} catch (error) {
this.recordError('detectGender', this.config.openrouter.imageAnalysisModel, startedAt, error);
this.logger.error('Gender detection failed:', error);
return 'unknown';
}
@@ -134,33 +141,40 @@ export class OpenRouterClient {
}
private async tryAnalyzeWithModel(model: string, imageBase64: string, mimeType: string): Promise<ImageAnalysisResult | null> {
const startedAt = Date.now();
const genderPrompt = `First, identify the gender of the person in this image. Respond with EXACTLY one of these on the first line: GENDER:male, GENDER:female, GENDER:other, or GENDER:unknown
Then provide your analysis:
${this.config.openrouter.imageAnalysisPrompt}`;
const response = await this.client.chat.completions.create({
model,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: genderPrompt,
},
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${imageBase64}`,
let response;
try {
response = await this.client.chat.completions.create({
model,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: genderPrompt,
},
},
],
},
],
max_tokens: 600,
});
{
type: 'image_url',
image_url: {
url: `data:${mimeType};base64,${imageBase64}`,
},
},
],
},
],
max_tokens: 600,
});
} catch (error) {
this.recordError('analyzeImage', model, startedAt, error);
throw error;
}
const fullResponse = response.choices[0]?.message?.content || '';
@@ -190,9 +204,12 @@ ${this.config.openrouter.imageAnalysisPrompt}`;
// Detect AI refusal - return null to signal fallback should be tried
if (this.isRefusal(description)) {
this.recordUsage('analyzeImage', model, startedAt, response, 'refusal');
return null;
}
this.recordUsage('analyzeImage', model, startedAt, response, description ? 'success' : 'empty');
this.logger.debug(`Image analysis complete (${model}) - Gender: ${detectedGender}, Rating: ${attractivenessScore ?? 'n/a'}`);
this.logger.debug(`Description: ${description}`);
@@ -480,7 +497,10 @@ Just write the message, nothing else.`;
if (isFallback) {
this.logger.info(`[AI] ${label} trying fallback model: ${model}`);
}
const response = await this.client.chat.completions.create({
const startedAt = Date.now();
let response;
try {
response = await this.client.chat.completions.create({
model,
messages: typeof prompt === 'string'
? [{ role: 'user', content: prompt }]
@@ -490,7 +510,11 @@ Just write the message, nothing else.`;
],
max_tokens: opts.max_tokens,
temperature: opts.temperature,
});
});
} catch (error) {
this.recordError(label, model, startedAt, error);
throw error;
}
const raw = response.choices[0]?.message?.content || '';
const choice = response.choices[0] as unknown as Record<string, unknown> | undefined;
@@ -508,14 +532,18 @@ Just write the message, nothing else.`;
const trimmed = raw.trim();
if (!trimmed) {
this.recordUsage(label, model, startedAt, response, 'empty');
this.logger.warn(`[AI] ${label}: ${model} returned empty content${i < models.length - 1 ? ', trying fallback...' : ''}`);
continue;
}
if (this.isRefusal(trimmed)) {
this.recordUsage(label, model, startedAt, response, 'refusal');
this.logger.warn(`[AI] ${label}: ${model} returned a refusal${i < models.length - 1 ? ', trying fallback...' : ''}`);
continue;
}
this.recordUsage(label, model, startedAt, response, 'success');
if (isFallback) {
this.logger.info(`[AI] ${label}: fallback model ${model} succeeded`);
}
@@ -532,6 +560,48 @@ Just write the message, nothing else.`;
return '';
}
private recordUsage(
operation: string,
requestedModel: string,
startedAt: number,
response: unknown,
outcome: AiUsageOutcome,
): void {
if (!this.usageRecorder) return;
const record = response as Record<string, unknown>;
const usage = (record.usage ?? {}) as Record<string, unknown>;
try {
this.usageRecorder({
operation,
requestedModel,
actualModel: typeof record.model === 'string' ? record.model : requestedModel,
outcome,
promptTokens: numericUsage(usage.prompt_tokens),
completionTokens: numericUsage(usage.completion_tokens),
totalTokens: numericUsage(usage.total_tokens),
costUsd: typeof usage.cost === 'number' && Number.isFinite(usage.cost) ? usage.cost : null,
latencyMs: Date.now() - startedAt,
});
} catch (error) {
this.logger.warn('Failed to persist AI usage accounting:', error);
}
}
private recordError(operation: string, requestedModel: string, startedAt: number, error: unknown): void {
if (!this.usageRecorder) return;
try {
this.usageRecorder({
operation,
requestedModel,
outcome: 'error',
latencyMs: Date.now() - startedAt,
error: error instanceof Error ? error.message : String(error),
});
} catch (recordingError) {
this.logger.warn('Failed to persist AI usage error:', recordingError);
}
}
async analyzeAndGenerateMessage(
imageBase64: string,
mimeType: string,
@@ -547,3 +617,7 @@ Just write the message, nothing else.`;
return { analysis, message };
}
}
function numericUsage(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
}