170 lines
5.7 KiB
TypeScript
170 lines
5.7 KiB
TypeScript
/**
|
|
* Shared OpenRouter API helper for structured JSON responses.
|
|
*/
|
|
|
|
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
|
|
|
export interface JsonSchemaDefinition {
|
|
name: string;
|
|
schema: {
|
|
type: 'object';
|
|
properties: Record<string, unknown>;
|
|
required: string[];
|
|
additionalProperties: boolean;
|
|
};
|
|
}
|
|
|
|
export interface OpenRouterRequestOptions<T> {
|
|
/** The model to use (e.g., 'google/gemini-3-flash-preview') */
|
|
model: string;
|
|
/** The prompt messages to send */
|
|
messages: Array<{ role: 'user' | 'system' | 'assistant'; content: string }>;
|
|
/** JSON schema for structured output */
|
|
jsonSchema: JsonSchemaDefinition;
|
|
/** Number of retries on parsing failures (default: 0) */
|
|
maxRetries?: number;
|
|
/** Delay between retries in ms (default: 500) */
|
|
retryDelayMs?: number;
|
|
/** Job ID for logging purposes */
|
|
jobId?: string;
|
|
}
|
|
|
|
export interface OpenRouterResult<T> {
|
|
success: true;
|
|
data: T;
|
|
}
|
|
|
|
export interface OpenRouterError {
|
|
success: false;
|
|
error: string;
|
|
}
|
|
|
|
export type OpenRouterResponse<T> = OpenRouterResult<T> | OpenRouterError;
|
|
|
|
/**
|
|
* Call OpenRouter API with structured JSON output.
|
|
*
|
|
* @returns Parsed JSON response matching the schema, or an error object
|
|
*/
|
|
export async function callOpenRouter<T>(
|
|
options: OpenRouterRequestOptions<T>
|
|
): Promise<OpenRouterResponse<T>> {
|
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
|
|
if (!apiKey) {
|
|
return { success: false, error: 'OPENROUTER_API_KEY not configured' };
|
|
}
|
|
|
|
const { model, messages, jsonSchema, maxRetries = 0, retryDelayMs = 500, jobId } = options;
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
if (attempt > 0) {
|
|
console.log(`🔄 [${jobId ?? 'unknown'}] Retry attempt ${attempt}/${maxRetries}...`);
|
|
await sleep(retryDelayMs * attempt);
|
|
}
|
|
|
|
const response = await fetch(OPENROUTER_API_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
'HTTP-Referer': 'JobOps',
|
|
'X-Title': 'JobOpsOrchestrator',
|
|
},
|
|
body: JSON.stringify({
|
|
model,
|
|
messages,
|
|
stream: false,
|
|
response_format: {
|
|
type: 'json_schema',
|
|
json_schema: {
|
|
name: jsonSchema.name,
|
|
strict: true,
|
|
schema: jsonSchema.schema,
|
|
},
|
|
},
|
|
plugins: [{ id: 'response-healing' }],
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Throw error with status to allow specific retries
|
|
const errorBody = await response.text().catch(() => 'No error body');
|
|
const err = new Error(`OpenRouter API error: ${response.status}`);
|
|
(err as any).status = response.status;
|
|
(err as any).body = errorBody;
|
|
throw err;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const content = data.choices?.[0]?.message?.content;
|
|
|
|
if (!content) {
|
|
throw new Error('No content in response');
|
|
}
|
|
|
|
// Parse JSON - structured outputs should always return valid JSON
|
|
const parsed = parseJsonContent<T>(content, jobId);
|
|
|
|
return { success: true, data: parsed };
|
|
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const status = (error as any).status;
|
|
|
|
// Retry on:
|
|
// 1. Parsing errors (AI returned malformed JSON)
|
|
// 2. Rate limits (429)
|
|
// 3. Server errors (5xx)
|
|
// 4. Timeouts/Network issues
|
|
const shouldRetry =
|
|
message.includes('parse') ||
|
|
status === 429 ||
|
|
(status >= 500 && status <= 599) ||
|
|
message.toLowerCase().includes('timeout') ||
|
|
message.toLowerCase().includes('fetch failed');
|
|
|
|
if (attempt < maxRetries && shouldRetry) {
|
|
console.warn(`⚠️ [${jobId ?? 'unknown'}] Attempt ${attempt + 1} failed (${status ?? 'no-status'}): ${message}. Retrying...`);
|
|
continue;
|
|
}
|
|
|
|
return { success: false, error: message };
|
|
}
|
|
}
|
|
|
|
return { success: false, error: 'All retry attempts failed' };
|
|
}
|
|
|
|
/**
|
|
* Parse JSON content from OpenRouter response.
|
|
* Handles common AI quirks like markdown code fences.
|
|
*/
|
|
export function parseJsonContent<T>(content: string, jobId?: string): T {
|
|
let candidate = content.trim();
|
|
|
|
// Remove markdown code fences if present
|
|
candidate = candidate.replace(/```(?:json|JSON)?\s*/g, '').replace(/```/g, '').trim();
|
|
|
|
// Try to extract JSON object if there's surrounding text
|
|
// Use non-greedy match and find the outermost braces
|
|
const firstBrace = candidate.indexOf('{');
|
|
const lastBrace = candidate.lastIndexOf('}');
|
|
|
|
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
|
|
candidate = candidate.substring(firstBrace, lastBrace + 1);
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(candidate) as T;
|
|
} catch (error) {
|
|
console.error(`❌ [${jobId ?? 'unknown'}] Failed to parse JSON:`, candidate.substring(0, 200));
|
|
throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : 'unknown'}`);
|
|
}
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|