n8n-nodes-teamorouter/nodes/shared/teamorouter.ts

104 lines
4.2 KiB
TypeScript

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<string, unknown>;
}
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<string, unknown> {
if (extraBody === undefined || extraBody === null || extraBody === '') return {};
if (typeof extraBody === 'object' && !Array.isArray(extraBody)) return extraBody as Record<string, unknown>;
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<string, unknown>;
} catch (error) {
throw new NodeOperationError(node, `Extra Body must be a JSON object: ${errorMessage(error)}`);
}
}