782 lines
26 KiB
TypeScript
782 lines
26 KiB
TypeScript
/**
|
|
* AGENT V3 — TECHNIQUES routes (extracted from routes.ts).
|
|
*
|
|
* Endpoints:
|
|
* GET /techniques/definitions
|
|
* GET /techniques/config
|
|
* GET /techniques/models
|
|
* GET /techniques/stage-assignments
|
|
* PUT /techniques/stage-assignments
|
|
* POST /techniques/test-model
|
|
* POST /techniques/test-openrouter
|
|
* POST /techniques/analyze-async, /analyze, /analyze-media (all → dispatchTechniquesAsync)
|
|
* GET /techniques/results/:sessionId
|
|
*/
|
|
import { Router, Request, Response } from 'express';
|
|
import { resolveUserId } from '../shared/auth/guards';
|
|
import crypto from 'crypto';
|
|
import { validateTextInput, MAX_TEXT_LENGTH } from '../config/analysisLimits';
|
|
import { callVision } from '../shared/media/vision';
|
|
import { transcribe, type TranscriptionResult } from '../shared/media/transcription';
|
|
import { processVideoUrl } from '../shared/media/video-processor';
|
|
import type { InputType } from '../shared/types/analysis-session';
|
|
import { ConfigKeys, AgentKeys } from '../shared/redis/keys';
|
|
import { scanKeys } from '../shared/redis/scan';
|
|
import { checkCredits, deductCredits } from '../shared/credits';
|
|
import { toTechniquesResult } from '../components/component-runner';
|
|
import { buildEmptyMediaResponse } from '../shared/helpers/empty-media-response';
|
|
import { sanitizeForLog } from '../shared/helpers/sanitize-log';
|
|
import { validateExternalUrl } from '../shared/helpers/validate-url';
|
|
import { internalError } from '../shared/helpers/error-response';
|
|
import { TierStageAssignmentsSchema } from '../shared/helpers/config-schemas';
|
|
import { log } from '../shared/logger';
|
|
import { getRedis, upload } from './_init';
|
|
import { persistStandaloneResult } from './_helpers/standalone-session';
|
|
|
|
const router = Router();
|
|
const REDIS_PREFIX = ConfigKeys.techniquesPrefix;
|
|
const FRAMEWORK_PREFIX = 'didi:framework';
|
|
|
|
// ============================================================================
|
|
// GET /api/v3/techniques/definitions - Technique definitions for frontend display
|
|
// Returns: { technique_id, technique_name, dimension, subdimension, description, severity }
|
|
// Cached in-memory for 5 minutes (data rarely changes).
|
|
// ============================================================================
|
|
let definitionsCache: { data: any; ts: number } | null = null;
|
|
const DEFINITIONS_CACHE_MS = 5 * 60 * 1000;
|
|
|
|
router.get('/techniques/definitions', async (req: Request, res: Response) => {
|
|
try {
|
|
const now = Date.now();
|
|
if (definitionsCache && (now - definitionsCache.ts) < DEFINITIONS_CACHE_MS) {
|
|
return res.json({ success: true, data: definitionsCache.data });
|
|
}
|
|
|
|
const r = getRedis();
|
|
const raw = await r.get('didi:framework:techniques');
|
|
if (!raw) {
|
|
return res.json({ success: true, data: [] });
|
|
}
|
|
|
|
const framework = JSON.parse(raw);
|
|
const definitions: any[] = [];
|
|
|
|
for (const dim of framework.dimensions || []) {
|
|
for (const sub of dim.subdimensions || []) {
|
|
for (const tech of sub.techniques || []) {
|
|
definitions.push({
|
|
technique_id: tech.technique_id,
|
|
technique_name: tech.technique_name,
|
|
dimension: dim.dimension_code,
|
|
dimension_name: dim.dimension_name,
|
|
subdimension: sub.subdimension_name,
|
|
severity: tech.severity,
|
|
description_en: typeof tech.description === 'object' ? tech.description?.en : tech.description,
|
|
description_ro: typeof tech.description === 'object' ? tech.description?.ro : tech.description,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
definitionsCache = { data: definitions, ts: now };
|
|
res.json({ success: true, data: definitions });
|
|
} catch (error) {
|
|
internalError(res, error);
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// GET /api/v3/techniques/config - Get full configuration
|
|
// ============================================================================
|
|
router.get('/techniques/config', async (req: Request, res: Response) => {
|
|
try {
|
|
const r = getRedis();
|
|
|
|
const [manifest, availableModels, stageAssignments, prompts, schemas, scoringConfig] = await Promise.all([
|
|
r.get(`${REDIS_PREFIX}:manifest`),
|
|
r.get(`${REDIS_PREFIX}:available_models`),
|
|
r.get(`${REDIS_PREFIX}:stage_assignments`),
|
|
Promise.all([
|
|
r.get(`${REDIS_PREFIX}:prompts:screening`),
|
|
r.get(`${REDIS_PREFIX}:prompts:deep_analysis`),
|
|
]),
|
|
Promise.all([
|
|
r.get(`${REDIS_PREFIX}:schemas:screening`),
|
|
r.get(`${REDIS_PREFIX}:schemas:complete`),
|
|
]),
|
|
r.get(`${REDIS_PREFIX}:scoring_config`),
|
|
]);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
manifest: manifest ? JSON.parse(manifest) : null,
|
|
available_models: availableModels ? JSON.parse(availableModels) : null,
|
|
stage_assignments: stageAssignments ? JSON.parse(stageAssignments) : null,
|
|
prompts: {
|
|
screening: prompts[0] ? JSON.parse(prompts[0]) : null,
|
|
deep_analysis: prompts[1] ? JSON.parse(prompts[1]) : null,
|
|
},
|
|
schemas: {
|
|
screening: schemas[0] ? JSON.parse(schemas[0]) : null,
|
|
complete: schemas[1] ? JSON.parse(schemas[1]) : null,
|
|
},
|
|
scoring_config: scoringConfig ? JSON.parse(scoringConfig) : null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// GET /api/v3/techniques/models - Get available models from config
|
|
// ============================================================================
|
|
router.get('/techniques/models', async (req: Request, res: Response) => {
|
|
try {
|
|
const r = getRedis();
|
|
|
|
// First try component config, then fall back to didi:framework:providers
|
|
let data = await r.get(`${REDIS_PREFIX}:available_models`);
|
|
|
|
if (data) {
|
|
// Use component config format
|
|
const { models } = JSON.parse(data);
|
|
return res.json({
|
|
success: true,
|
|
data: { models },
|
|
});
|
|
}
|
|
|
|
// Fallback to didi:framework:providers
|
|
data = await r.get(`${FRAMEWORK_PREFIX}:providers`);
|
|
|
|
if (!data) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Models not configured. Run sync-redis from didiFramework to load config.',
|
|
});
|
|
}
|
|
|
|
const { providers, models } = JSON.parse(data);
|
|
|
|
// Transform to expected format with model_key
|
|
const transformedModels = models.map((m: any) => ({
|
|
model_key: `${m.provider_code}:${m.model_code.split('/').pop()}`,
|
|
provider: m.provider_code,
|
|
provider_config: {
|
|
base_url: providers.find((p: any) => p.provider_id === m.provider_id)?.base_url || '',
|
|
auth_type: providers.find((p: any) => p.provider_id === m.provider_id)?.auth_type || 'bearer',
|
|
},
|
|
model_code: m.model_code,
|
|
model_name: m.model_name,
|
|
context_window: m.context_window,
|
|
max_output_tokens: m.max_output_tokens,
|
|
cost_input_1m: parseFloat(m.input_cost_per_1m) || 0,
|
|
cost_output_1m: parseFloat(m.output_cost_per_1m) || 0,
|
|
speed_tier: m.provider_code === 'groq' ? 'ultra_fast' : 'medium',
|
|
quality_tier: parseFloat(m.input_cost_per_1m) >= 2 ? 'premium' : 'high',
|
|
supports_vision: m.supports_vision,
|
|
}));
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
models: transformedModels,
|
|
providers: providers,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// GET /api/v3/techniques/stage-assignments - Get stage assignments
|
|
// ============================================================================
|
|
router.get('/techniques/stage-assignments', async (req: Request, res: Response) => {
|
|
try {
|
|
const r = getRedis();
|
|
const data = await r.get(`${REDIS_PREFIX}:stage_assignments`);
|
|
|
|
if (!data) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Stage assignments not configured.',
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: JSON.parse(data),
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// PUT /api/v3/techniques/stage-assignments - Update stage assignments
|
|
// ============================================================================
|
|
router.put('/techniques/stage-assignments', async (req: Request, res: Response) => {
|
|
try {
|
|
const r = getRedis();
|
|
const parsed = TierStageAssignmentsSchema.safeParse(req.body?.stage_assignments);
|
|
if (!parsed.success) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Invalid stage_assignments payload',
|
|
details: parsed.error.issues,
|
|
});
|
|
}
|
|
|
|
await r.set(`${REDIS_PREFIX}:stage_assignments`, JSON.stringify(parsed.data));
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'Stage assignments updated',
|
|
data: parsed.data,
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// POST /api/v3/techniques/test-model - Test a specific model
|
|
// ============================================================================
|
|
router.post('/techniques/test-model', async (req: Request, res: Response) => {
|
|
try {
|
|
const { model_key, test_prompt, provider_routing } = req.body;
|
|
|
|
if (!model_key) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'model_key is required',
|
|
});
|
|
}
|
|
|
|
const r = getRedis();
|
|
const modelsData = await r.get(`${REDIS_PREFIX}:available_models`);
|
|
|
|
if (!modelsData) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'Models not configured',
|
|
});
|
|
}
|
|
|
|
const { models } = JSON.parse(modelsData);
|
|
const model = models.find((m: any) => m.model_key === model_key);
|
|
|
|
if (!model) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: `Model ${model_key} not found`,
|
|
});
|
|
}
|
|
|
|
// Test the model with a simple prompt
|
|
const prompt = test_prompt || 'Respond with JSON: {"status": "ok", "model": "your_model_name"}';
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const response = await callLLM(model, prompt, { provider_routing });
|
|
const duration = Date.now() - startTime;
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
model_key,
|
|
model_name: model.model_name,
|
|
provider: model.provider,
|
|
provider_routing: provider_routing || null,
|
|
response_time_ms: duration,
|
|
response: response.substring(0, 500), // Limit response size
|
|
status: 'connected',
|
|
},
|
|
});
|
|
} catch (llmError) {
|
|
res.json({
|
|
success: false,
|
|
data: {
|
|
model_key,
|
|
model_name: model.model_name,
|
|
provider: model.provider,
|
|
provider_routing: provider_routing || null,
|
|
status: 'error',
|
|
error: (llmError as Error).message,
|
|
},
|
|
});
|
|
}
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// POST /api/v3/techniques/test-openrouter - Test OpenRouter with provider routing
|
|
// ============================================================================
|
|
router.post('/techniques/test-openrouter', async (req: Request, res: Response) => {
|
|
try {
|
|
const {
|
|
model_code = 'google/gemini-2.0-flash-001',
|
|
provider_order = ['Google AI Studio'],
|
|
allow_fallbacks = false,
|
|
test_prompt
|
|
} = req.body;
|
|
|
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
if (!apiKey) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'OPENROUTER_API_KEY not configured',
|
|
});
|
|
}
|
|
|
|
const prompt = test_prompt || 'Respond with JSON: {"status": "ok", "provider": "google-ai-studio", "model": "gemini-flash"}';
|
|
const startTime = Date.now();
|
|
|
|
const body = {
|
|
model: model_code,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
max_tokens: 500,
|
|
temperature: 0.3,
|
|
provider: {
|
|
order: provider_order,
|
|
allow_fallbacks,
|
|
},
|
|
};
|
|
|
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'HTTP-Referer': 'https://didi.ai',
|
|
'X-Title': 'DIDI Agent V3',
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
|
|
const duration = Date.now() - startTime;
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
return res.json({
|
|
success: false,
|
|
data: {
|
|
model_code,
|
|
provider_order,
|
|
status: 'error',
|
|
response_time_ms: duration,
|
|
error: `${response.status} - ${errorText}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
const data = await response.json() as {
|
|
choices?: { message?: { content?: string } }[];
|
|
model?: string;
|
|
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
};
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
model_code,
|
|
model_used: data.model,
|
|
provider_order,
|
|
allow_fallbacks,
|
|
response_time_ms: duration,
|
|
response: data.choices?.[0]?.message?.content?.substring(0, 500) || '',
|
|
usage: data.usage,
|
|
status: 'connected',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// Helper: fetch URL content (M17 first, then direct fetch fallback)
|
|
// ============================================================================
|
|
async function fetchUrlContent(url: string): Promise<string> {
|
|
validateExternalUrl(url);
|
|
const M17_WEB_API = process.env.M17_WEB_API_URL;
|
|
if (!M17_WEB_API) throw new Error('M17_WEB_API_URL env var is not set');
|
|
|
|
try {
|
|
log.info(`[URL] Fetching via M17: ${sanitizeForLog(url)}`);
|
|
const response = await fetch(`${M17_WEB_API}/v1/fetch`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ urls: [url], extract_text: true }),
|
|
signal: AbortSignal.timeout(60000),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json() as {
|
|
pages?: { url: string; title?: string; text: string }[];
|
|
total_fetched?: number;
|
|
};
|
|
|
|
if (data.pages && data.pages.length > 0 && data.pages[0].text) {
|
|
const page = data.pages[0];
|
|
const title = page.title ? `Title: ${page.title}\n\n` : '';
|
|
log.info(`[URL] M17 extracted ${page.text.length} chars`);
|
|
return title + page.text;
|
|
}
|
|
}
|
|
|
|
log.info(`[URL] M17 failed, falling back to direct fetch`);
|
|
} catch (e) {
|
|
log.warn(`[URL] M17 error: ${e}, falling back to direct fetch`);
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
signal: AbortSignal.timeout(30000),
|
|
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; DIDI-Bot/1.0)' },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch URL: ${response.status}`);
|
|
}
|
|
|
|
const html = await response.text();
|
|
return html
|
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
|
.replace(/<[^>]+>/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.substring(0, MAX_TEXT_LENGTH);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Shared async dispatch handler for all techniques endpoints
|
|
// All routes (text, media, async) use the same async pattern:
|
|
// validate → credit check → dispatch to queue → return 202
|
|
// Media preprocessing (video/audio/image) handled by worker-media-preprocess
|
|
// ============================================================================
|
|
async function dispatchTechniquesAsync(req: Request, res: Response) {
|
|
try {
|
|
const { text, media_url, url, media_type, user_id: body_uid, user_email: body_email, plan_type = 1 } = req.body;
|
|
const user_id = resolveUserId(req, body_uid);
|
|
const user_email = req.jwtEmail || body_email;
|
|
|
|
if (!text && !media_url && !url) {
|
|
return res.status(400).json({ success: false, error: 'One of text, media_url, or url is required' });
|
|
}
|
|
|
|
const validTypes = ['text', 'image', 'audio', 'video', 'url'];
|
|
const inputType = media_type || (text ? 'text' : url ? 'url' : null);
|
|
if (!inputType || !validTypes.includes(inputType)) {
|
|
return res.status(400).json({ success: false, error: `media_type is required. Valid types: ${validTypes.join(', ')}` });
|
|
}
|
|
|
|
// Credit check
|
|
if (user_id) {
|
|
const creditCheck = await checkCredits(user_id, inputType);
|
|
if (creditCheck === null) {
|
|
return res.status(402).json({
|
|
success: false, error: 'Credit service temporarily unavailable. Please try again in a few moments.',
|
|
error_code: 'CREDIT_SERVICE_UNAVAILABLE',
|
|
});
|
|
}
|
|
if (!creditCheck.hasEnoughCredits) {
|
|
return res.status(402).json({
|
|
success: false, error: 'Insufficient credits',
|
|
data: { creditsRemained: creditCheck.creditsRemained, creditCost: creditCheck.creditCost, planName: creditCheck.planName },
|
|
});
|
|
}
|
|
}
|
|
|
|
const sessionId = crypto.randomUUID();
|
|
log.info(`[${sessionId}] Techniques analysis (async), type: ${inputType}`);
|
|
|
|
// For text/url: validate and pass content; for media: workers handle extraction
|
|
let content = text || '';
|
|
if (inputType === 'url' && (url || media_url) && !text) {
|
|
try {
|
|
content = await fetchUrlContent(url || media_url);
|
|
} catch (e) {
|
|
log.warn(`[${sessionId}] Failed to fetch URL: ${(e as Error).message}`);
|
|
content = `URL: ${url || media_url}`;
|
|
}
|
|
}
|
|
|
|
// Validate text for text/url inputs (media extraction done by workers)
|
|
if (['text', 'url'].includes(inputType) && content) {
|
|
const textValidation = validateTextInput(content);
|
|
if (!textValidation.valid) {
|
|
return res.status(400).json({
|
|
success: false, error: textValidation.error,
|
|
error_code: 'INVALID_TEXT_INPUT',
|
|
details: { validation_error: textValidation.error_code, stats: textValidation.stats },
|
|
});
|
|
}
|
|
}
|
|
|
|
const { dispatch } = await import('../queue/dispatcher');
|
|
type PlanType = 1 | 2 | 3 | 4 | 5 | 6;
|
|
const planTypeNum = (plan_type >= 1 && plan_type <= 6 ? plan_type : 1) as PlanType;
|
|
|
|
const result = await dispatch(
|
|
sessionId,
|
|
{ content, url, mediaPath: media_url, userId: user_id, userEmail: user_email, inputType },
|
|
planTypeNum,
|
|
['techniques'],
|
|
);
|
|
|
|
if (!result.async) {
|
|
// Fallback to sync if RabbitMQ unavailable
|
|
const { ComponentRunner } = await import('../components/component-runner');
|
|
const r = getRedis();
|
|
const llmClient = createLLMClient();
|
|
const runner = new ComponentRunner(r, llmClient);
|
|
const syncResult = await runner.runTechniques({ text: content || undefined, media_url, media_type: inputType, sessionId });
|
|
const startTime = Date.now();
|
|
|
|
persistStandaloneResult({
|
|
sessionId, userId: user_id, userEmail: user_email,
|
|
inputType: inputType as InputType,
|
|
inputText: inputType === 'text' ? content : undefined,
|
|
mediaUrl: media_url, result: syncResult, durationMs: Date.now() - startTime,
|
|
llmUsage: runner.getLastUsageTracker(),
|
|
});
|
|
|
|
return res.json({ success: true, async: false, data: { session_id: sessionId, media_type: inputType, result: syncResult } });
|
|
}
|
|
|
|
// Deduct credits BEFORE responding so a failure can't be lost behind a 202.
|
|
// We don't refund here even if dispatch already happened — the task is in the
|
|
// queue and a BILLING_GAP log is the operational signal.
|
|
if (user_id) {
|
|
try {
|
|
const ok = await deductCredits(user_id, inputType, sessionId);
|
|
if (!ok) log.error(`[Credits] BILLING_GAP session=${sessionId} user=${user_id} type=${inputType}`);
|
|
} catch (e) {
|
|
log.error(`[Credits] BILLING_GAP session=${sessionId} user=${user_id} type=${inputType} err=`, (e as Error).message);
|
|
}
|
|
}
|
|
|
|
res.status(202).json({
|
|
success: true,
|
|
async: true,
|
|
data: {
|
|
session_id: sessionId,
|
|
status: 'processing',
|
|
media_type: inputType,
|
|
queued_components: result.queued,
|
|
plan_type: planTypeNum,
|
|
poll_url: `/api/v3/pipeline/${sessionId}/queue-status`,
|
|
result_url: `/api/v3/pipeline/${sessionId}/result`,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
log.error('[Techniques] Error:', error);
|
|
internalError(res, error);
|
|
}
|
|
}
|
|
|
|
// POST /api/v3/techniques/analyze-async
|
|
router.post('/techniques/analyze-async', dispatchTechniquesAsync);
|
|
|
|
// POST /api/v3/techniques/analyze — now async (same handler)
|
|
router.post('/techniques/analyze', dispatchTechniquesAsync);
|
|
|
|
// POST /api/v3/techniques/analyze-media — now async (same handler)
|
|
router.post('/techniques/analyze-media', dispatchTechniquesAsync);
|
|
|
|
|
|
// ============================================================================
|
|
// GET /api/v3/techniques/results/:sessionId - Get analysis results
|
|
// ============================================================================
|
|
router.get('/techniques/results/:sessionId', async (req: Request, res: Response) => {
|
|
try {
|
|
const { sessionId } = req.params;
|
|
const r = getRedis();
|
|
|
|
const keys = await scanKeys(r, AgentKeys.componentPattern(sessionId, 'techniques'));
|
|
const results: Record<string, any> = {};
|
|
|
|
for (const key of keys) {
|
|
const stage = key.split(':').pop()!;
|
|
const data = await r.get(key);
|
|
results[stage] = data ? JSON.parse(data) : null;
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
session_id: sessionId,
|
|
results,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: (error as Error).message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// LLM Client Implementation
|
|
// ============================================================================
|
|
|
|
interface LLMCallOptions {
|
|
provider_routing?: {
|
|
order?: string[];
|
|
allow_fallbacks?: boolean;
|
|
};
|
|
max_tokens?: number;
|
|
temperature?: number;
|
|
}
|
|
|
|
async function callLLM(model: any, prompt: string, options?: LLMCallOptions): Promise<string> {
|
|
const { provider, provider_config, model_code } = model;
|
|
|
|
// Get API key from environment
|
|
const apiKeyEnvName = `${provider.toUpperCase()}_API_KEY`;
|
|
const apiKey = process.env[apiKeyEnvName] || process.env.OPENROUTER_API_KEY;
|
|
|
|
if (!apiKey && provider_config.auth_type !== 'none') {
|
|
throw new Error(`API key not found for provider ${provider}. Set ${apiKeyEnvName}`);
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
if (provider_config.auth_type === 'bearer') {
|
|
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
} else if (provider_config.auth_type === 'x-api-key') {
|
|
headers['x-api-key'] = apiKey!;
|
|
} else if (provider_config.auth_type === 'api_key') {
|
|
headers['x-goog-api-key'] = apiKey!;
|
|
}
|
|
|
|
// OpenRouter specific headers
|
|
if (provider === 'openrouter') {
|
|
headers['HTTP-Referer'] = 'https://didi.ai';
|
|
headers['X-Title'] = 'DIDI Agent V3';
|
|
}
|
|
|
|
// Build request body
|
|
const body: Record<string, any> = {
|
|
model: model_code,
|
|
messages: [
|
|
{ role: 'user', content: prompt }
|
|
],
|
|
max_tokens: options?.max_tokens || 500,
|
|
temperature: options?.temperature || 0.3,
|
|
};
|
|
|
|
// OpenRouter Provider Routing - specify which provider to use
|
|
// See: https://openrouter.ai/docs/provider-routing
|
|
if (provider === 'openrouter' && options?.provider_routing) {
|
|
body.provider = {
|
|
order: options.provider_routing.order || [],
|
|
allow_fallbacks: options.provider_routing.allow_fallbacks ?? true,
|
|
};
|
|
}
|
|
|
|
const response = await fetch(`${provider_config.base_url}/chat/completions`, {
|
|
method: 'POST',
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`LLM API error: ${response.status} - ${errorText}`);
|
|
}
|
|
|
|
const data = await response.json() as {
|
|
choices?: { message?: { content?: string } }[];
|
|
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
|
|
};
|
|
|
|
// Track usage if caller provided a tracker
|
|
if ((options as any)?._usage_tracker && Array.isArray((options as any)._usage_tracker) && data.usage) {
|
|
(options as any)._usage_tracker.push({
|
|
model: model_code,
|
|
prompt_tokens: data.usage.prompt_tokens || 0,
|
|
completion_tokens: data.usage.completion_tokens || 0,
|
|
total_tokens: data.usage.total_tokens || (data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0),
|
|
});
|
|
}
|
|
|
|
return data.choices?.[0]?.message?.content || '';
|
|
}
|
|
|
|
function createLLMClient() {
|
|
return {
|
|
async call(prompt: string, systemPrompt: string, options: any): Promise<string> {
|
|
// Get model info from available_models
|
|
const r = getRedis();
|
|
const modelsData = await r.get(`${REDIS_PREFIX}:available_models`);
|
|
|
|
if (!modelsData) {
|
|
throw new Error('Models not configured');
|
|
}
|
|
|
|
const { models } = JSON.parse(modelsData);
|
|
const model = models.find((m: any) => m.model_key === options.model_key);
|
|
|
|
if (!model) {
|
|
throw new Error(`Model ${options.model_key} not found`);
|
|
}
|
|
|
|
const fullPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
|
|
|
|
// Pass provider_routing from model config or options
|
|
const providerRouting = options.provider_routing || model.provider_routing;
|
|
const llmOptions: LLMCallOptions & { _usage_tracker?: any[] } = {
|
|
temperature: options.temperature,
|
|
max_tokens: options.max_tokens,
|
|
};
|
|
|
|
if (providerRouting && providerRouting.length > 0) {
|
|
llmOptions.provider_routing = {
|
|
order: providerRouting,
|
|
allow_fallbacks: true,
|
|
};
|
|
}
|
|
|
|
// Pass through usage tracker if present
|
|
if (options._usage_tracker) {
|
|
llmOptions._usage_tracker = options._usage_tracker;
|
|
}
|
|
|
|
return callLLM(model, fullPrompt, llmOptions);
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
export default router;
|
|
|