import type { INode, INodePropertyOptions } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; export const DEFAULT_BASE_URL = 'https://api.teamorouter.com/v1'; export interface TeamorouterModel { id: string; owned_by?: string; pricing?: Record; } export function normalizeBaseUrl(baseUrl?: string): string { const value = (baseUrl ?? '').trim(); return (value === '' ? DEFAULT_BASE_URL : value).replace(/\/+$/, ''); } const IMAGE_MODEL_PATTERNS = [/(^|-)image(-|$)/i, /gpt-image/i, /nano-banana/i, /dall-e/i, /imagen/i]; export function isImageModel(modelId: string): boolean { return IMAGE_MODEL_PATTERNS.some((pattern) => pattern.test(modelId)); } function pricePerMillion(value: unknown): string | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined; // The router may report prices per token (0.00000053) or already per 1M tokens (0.29). // Below 0.005 only the per-token reading makes sense. const perMillion = value < 0.005 ? value * 1_000_000 : value; return `$${Number(perMillion.toFixed(perMillion < 1 ? 4 : 2))}`; } function modelDescription(model: TeamorouterModel): string | undefined { const parts: string[] = []; if (typeof model.owned_by === 'string' && model.owned_by !== '') parts.push(model.owned_by); const pricing = model.pricing; if (pricing !== null && typeof pricing === 'object') { const input = pricePerMillion(pricing.prompt ?? pricing.input); const output = pricePerMillion(pricing.completion ?? pricing.output); if (input || output) parts.push(`${input ?? '?'} in / ${output ?? '?'} out per 1M tokens`); } return parts.length > 0 ? parts.join(' ยท ') : undefined; } export function toModelOptions( models: TeamorouterModel[], kind: 'chat' | 'image' | 'all', ): INodePropertyOptions[] { const usable = models.filter((model) => typeof model?.id === 'string' && model.id !== ''); const matching = usable.filter((model) => isImageModel(model.id) === (kind === 'image')); // Never hand back an empty dropdown because of our own naming heuristic. const selected = kind === 'all' || matching.length === 0 ? usable : matching; return selected .map((model) => ({ name: model.id, value: model.id, description: modelDescription(model), })) .sort((a, b) => a.name.localeCompare(b.name)); } function errorStatus(error: unknown): number | undefined { if (error === null || typeof error !== 'object') return undefined; const candidate = error as { httpCode?: unknown; statusCode?: unknown; response?: { status?: unknown } }; const status = candidate.httpCode ?? candidate.statusCode ?? candidate.response?.status; return typeof status === 'number' ? status : undefined; } export function errorMessage(error: unknown): string { if (error instanceof Error) return error.message; return typeof error === 'string' ? error : 'Unknown error'; } /** Turns the router's status codes into something a workflow author can act on. */ export function hintForError(error: unknown): string | undefined { switch (errorStatus(error)) { case 401: return 'TeamoRouter rejected the API key (401). Check the key and that the Base URL ends with /v1.'; case 402: return 'TeamoRouter reports insufficient balance (402). Top up the account or pick a model with the "-free" suffix.'; case 404: return 'Endpoint or model not found (404). Verify the model ID with the List Models operation and that the Base URL ends with /v1.'; case 429: return 'TeamoRouter rate limit reached (429). Lower the request rate or retry later.'; default: return undefined; } } export function parseExtraBody(extraBody: unknown, node: INode): Record { if (extraBody === undefined || extraBody === null || extraBody === '') return {}; if (typeof extraBody === 'object' && !Array.isArray(extraBody)) return extraBody as Record; try { const parsed = JSON.parse(String(extraBody)) as unknown; if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('expected a JSON object'); } return parsed as Record; } catch (error) { throw new NodeOperationError(node, `Extra Body must be a JSON object: ${errorMessage(error)}`); } }