fix(mana-media): use prom-client directly instead of shared metrics package

mana-media uses NestJS 11 while shared-nestjs-metrics targets NestJS 10,
causing DynamicModule type incompatibility. Use prom-client directly with
a simple MetricsController to expose /metrics endpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 11:06:09 +01:00
parent 677a499c93
commit 7910737dd9
12 changed files with 246 additions and 240 deletions

View file

@ -8,7 +8,6 @@ import {
CreditOperationType,
CREDIT_COSTS,
} from '@manacore/nestjs-integration';
import OpenAI from 'openai';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { models } from '../db/schema/models.schema';
@ -20,31 +19,13 @@ import { OllamaService } from './ollama.service';
@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
// OpenRouter config (cloud provider)
private readonly openRouterClient: OpenAI | null = null;
constructor(
private configService: ConfigService,
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private readonly ollamaService: OllamaService,
private readonly creditClient: CreditClientService
) {
// OpenRouter setup (cloud provider)
const openRouterApiKey = this.configService.get<string>('OPENROUTER_API_KEY');
if (openRouterApiKey) {
this.openRouterClient = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: openRouterApiKey,
defaultHeaders: {
'HTTP-Referer': this.configService.get<string>('APP_URL') || 'http://localhost:3002',
'X-Title': 'Mana Chat',
},
});
this.logger.log('OpenRouter client initialized');
} else {
this.logger.warn('OPENROUTER_API_KEY not set - only local Ollama models will work');
}
}
) {}
async getAvailableModels(): Promise<Model[]> {
try {
@ -209,57 +190,28 @@ export class ChatService {
model: Model,
dto: ChatCompletionDto
): AsyncResult<ChatCompletionResponseDto> {
if (!this.openRouterClient) {
return err(ServiceError.externalError('OpenRouter', 'OpenRouter client not configured'));
}
const params = model.parameters as {
model?: string;
temperature?: number;
max_tokens?: number;
} | null;
// Route through mana-llm with openrouter/ prefix
const modelName = params?.model || 'meta-llama/llama-3.1-8b-instruct';
const prefixedModel = modelName.includes('/') ? `openrouter/${modelName}` : modelName;
const temperature = dto.temperature ?? params?.temperature ?? 0.7;
const maxTokens = dto.maxTokens ?? params?.max_tokens ?? 4096;
this.logger.log(`Sending request to OpenRouter model: ${modelName}`);
this.logger.log(`Sending request to mana-llm (OpenRouter): ${prefixedModel}`);
try {
const response = await this.openRouterClient.chat.completions.create({
model: modelName,
messages: dto.messages.map((msg) => ({
role: msg.role as 'system' | 'user' | 'assistant',
content: msg.content,
})),
temperature,
max_tokens: maxTokens,
});
const messageContent = response.choices?.[0]?.message?.content;
if (!messageContent) {
this.logger.warn('No message content in OpenRouter response');
return err(ServiceError.generationFailed('OpenRouter', 'No response generated'));
}
return ok({
content: messageContent,
usage: {
prompt_tokens: response.usage?.prompt_tokens || 0,
completion_tokens: response.usage?.completion_tokens || 0,
total_tokens: response.usage?.total_tokens || 0,
},
});
} catch (error) {
this.logger.error('Error calling OpenRouter API', error);
return err(
ServiceError.generationFailed(
'OpenRouter',
error instanceof Error ? error.message : 'Unknown error',
error instanceof Error ? error : undefined
)
);
}
return this.ollamaService.createChatCompletion(
prefixedModel,
dto.messages.map((msg) => ({
role: msg.role as 'system' | 'user' | 'assistant',
content: msg.content,
})),
temperature,
maxTokens
);
}
}

View file

@ -1,9 +1,7 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TokenService } from '../token/token.service';
type AIProvider = 'azure' | 'google';
interface GenerateOptions {
prompt: string;
model?: string;
@ -18,21 +16,20 @@ function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function getProvider(model: string): AIProvider {
if (model.startsWith('gpt')) return 'azure';
return 'google';
}
@Injectable()
export class AiService {
private readonly logger = new Logger(AiService.name);
private readonly manaLlmUrl: string;
constructor(
private configService: ConfigService,
private tokenService: TokenService
) {}
) {
this.manaLlmUrl = this.configService.get<string>('MANA_LLM_URL') || 'http://localhost:3025';
}
async generate(userId: string, options: GenerateOptions) {
const model = options.model || 'gpt-4.1';
const provider = getProvider(model);
const model = options.model || 'ollama/gemma3:4b';
// Build full prompt with referenced documents
let fullPrompt = options.prompt;
@ -53,13 +50,8 @@ export class AiService {
throw new BadRequestException('Nicht genügend Tokens. Bitte kaufe weitere Tokens.');
}
// Generate text
let completionText: string;
if (provider === 'azure') {
completionText = await this.generateWithAzure(fullPrompt, options);
} else {
completionText = await this.generateWithGoogle(fullPrompt, { ...options, model });
}
// Generate text via mana-llm
const completionText = await this.generateWithManaLlm(fullPrompt, options, model);
// Calculate actual cost and log
const actualPromptTokens = estimateTokens(fullPrompt);
@ -93,7 +85,7 @@ export class AiService {
referencedDocuments?: { title: string; content: string }[];
}
) {
const model = options.model || 'gpt-4.1';
const model = options.model || 'ollama/gemma3:4b';
let totalInputTokens = estimateTokens(options.prompt);
@ -119,66 +111,33 @@ export class AiService {
};
}
private async generateWithAzure(prompt: string, options: GenerateOptions): Promise<string> {
const apiKey = this.configService.get<string>('AZURE_OPENAI_API_KEY', '');
const endpoint = this.configService.get<string>(
'AZURE_OPENAI_ENDPOINT',
'https://memoroseopenai.openai.azure.com/'
);
const deployment = 'gpt-4.1';
const apiVersion = '2025-01-01-preview';
const response = await fetch(
`${endpoint}openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': apiKey,
},
body: JSON.stringify({
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: prompt },
],
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 2000,
}),
}
);
private async generateWithManaLlm(
prompt: string,
options: GenerateOptions,
model: string
): Promise<string> {
const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: prompt },
],
temperature: options.temperature || 0.7,
max_tokens: options.maxTokens || 2000,
}),
signal: AbortSignal.timeout(120000),
});
if (!response.ok) {
throw new BadRequestException(`Azure OpenAI error: ${response.statusText}`);
const errorText = await response.text();
this.logger.error(`mana-llm error: ${response.status} - ${errorText}`);
throw new BadRequestException(`LLM generation failed: ${response.status}`);
}
const data = await response.json();
return data.choices?.[0]?.message?.content || '';
}
private async generateWithGoogle(prompt: string, options: GenerateOptions): Promise<string> {
const apiKey = this.configService.get<string>('GOOGLE_API_KEY', '');
const model = options.model || 'gemini-pro';
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
generationConfig: {
temperature: options.temperature || 0.7,
maxOutputTokens: options.maxTokens || 2000,
},
}),
}
);
if (!response.ok) {
throw new BadRequestException(`Google AI error: ${response.statusText}`);
}
const data = await response.json();
return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
}
}

View file

@ -1,6 +1,5 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai';
import type { AIAnalysisResult } from '../types/nutrition.types';
const ANALYSIS_PROMPT = `Du bist ein Ernährungsexperte. Analysiere das Bild dieser Mahlzeit und liefere eine detaillierte Nährwertanalyse.
@ -77,36 +76,53 @@ Antworte NUR mit einem validen JSON-Objekt im folgenden Format:
@Injectable()
export class GeminiService implements OnModuleInit {
private model: GenerativeModel | null = null;
private readonly logger = new Logger(GeminiService.name);
private manaLlmUrl: string | null = null;
private readonly visionModel = 'ollama/llava:7b';
private readonly textModel = 'ollama/gemma3:4b';
constructor(private configService: ConfigService) {}
onModuleInit() {
const apiKey = this.configService.get<string>('GEMINI_API_KEY');
if (apiKey) {
const genAI = new GoogleGenerativeAI(apiKey);
// Use Gemini 2.5 Flash - fast and cost-effective
this.model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
}
this.manaLlmUrl = this.configService.get<string>('MANA_LLM_URL') || 'http://localhost:3025';
this.logger.log(`NutriPhi AI using mana-llm at ${this.manaLlmUrl}`);
}
async analyzeImage(imageBase64: string, mimeType = 'image/jpeg'): Promise<AIAnalysisResult> {
if (!this.model) {
throw new Error('Gemini API not configured');
if (!this.manaLlmUrl) {
throw new Error('mana-llm not configured');
}
const result = await this.model.generateContent([
ANALYSIS_PROMPT,
{
inlineData: {
mimeType,
data: imageBase64,
},
},
]);
const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.visionModel,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: ANALYSIS_PROMPT },
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
},
],
},
],
temperature: 0.3,
}),
signal: AbortSignal.timeout(120000),
});
const response = result.response;
const text = response.text();
if (!response.ok) {
const errorText = await response.text();
this.logger.error(`mana-llm vision error: ${response.status} - ${errorText}`);
throw new Error('Failed to analyze image');
}
const data = await response.json();
const text = data.choices?.[0]?.message?.content || '';
// Extract JSON from response
const jsonMatch = text.match(/\{[\s\S]*\}/);
@ -118,15 +134,29 @@ export class GeminiService implements OnModuleInit {
}
async analyzeText(description: string): Promise<AIAnalysisResult> {
if (!this.model) {
throw new Error('Gemini API not configured');
if (!this.manaLlmUrl) {
throw new Error('mana-llm not configured');
}
const prompt = TEXT_ANALYSIS_PROMPT.replace('{INPUT}', description);
const result = await this.model.generateContent(prompt);
const response = result.response;
const text = response.text();
const response = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.textModel,
messages: [{ role: 'user', content: prompt }],
temperature: 0.3,
}),
signal: AbortSignal.timeout(60000),
});
if (!response.ok) {
throw new Error(`mana-llm error: ${response.status}`);
}
const data = await response.json();
const text = data.choices?.[0]?.message?.content || '';
// Extract JSON from response
const jsonMatch = text.match(/\{[\s\S]*\}/);

View file

@ -1,6 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GoogleGenerativeAI } from '@google/generative-ai';
import type { AnalysisResult } from '@planta/shared';
const PLANT_ANALYSIS_PROMPT = `Du bist ein erfahrener Botaniker und Pflanzenexperte. Analysiere dieses Pflanzenfoto und erstelle einen detaillierten Steckbrief.
@ -44,36 +43,48 @@ Falls du die Pflanze nicht identifizieren kannst, setze confidence auf 0 und sci
@Injectable()
export class VisionService {
private readonly logger = new Logger(VisionService.name);
private genAI: GoogleGenerativeAI | null = null;
private readonly manaLlmUrl: string;
private readonly visionModel = 'ollama/llava:7b';
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>('GOOGLE_GEMINI_API_KEY');
if (apiKey) {
this.genAI = new GoogleGenerativeAI(apiKey);
this.logger.log('Gemini Vision AI initialized');
} else {
this.logger.warn('GOOGLE_GEMINI_API_KEY not configured - Vision analysis disabled');
}
this.manaLlmUrl = this.configService.get<string>('MANA_LLM_URL') || 'http://localhost:3025';
this.logger.log(`Planta Vision using mana-llm at ${this.manaLlmUrl}`);
}
async analyzePlantImage(imageBuffer: Buffer, mimeType: string): Promise<AnalysisResult | null> {
if (!this.genAI) {
this.logger.error('Gemini AI not configured');
return null;
}
try {
const model = this.genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
const base64 = imageBuffer.toString('base64');
const imagePart = {
inlineData: {
data: imageBuffer.toString('base64'),
mimeType: mimeType,
},
};
const result = await fetch(`${this.manaLlmUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.visionModel,
messages: [
{
role: 'user',
content: [
{ type: 'text', text: PLANT_ANALYSIS_PROMPT },
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${base64}` },
},
],
},
],
temperature: 0.3,
}),
signal: AbortSignal.timeout(120000),
});
const result = await model.generateContent([PLANT_ANALYSIS_PROMPT, imagePart]);
const response = result.response.text().trim();
if (!result.ok) {
const errorText = await result.text();
this.logger.error(`mana-llm vision error: ${result.status} - ${errorText}`);
return null;
}
const data = await result.json();
const response = (data.choices?.[0]?.message?.content || '').trim();
this.logger.debug(`Gemini raw response: ${response}`);