Add attention inbox and AI usage observability
This commit is contained in:
1 parent
ca59ba0f1f
commit
83b5f1f5b6
30 files changed
+702
-28
No files matched your search
+96
-22
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user