104 lines
4.2 KiB
TypeScript
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)}`);
|
|
}
|
|
}
|