/** * Schemastery config schema for the cortiq-router plugin. * * This schema is the settings form shown in the DSH web UI (Settings → Models → * Cortiq Router), so every field description is the UI's explanation of it. * * The form reads top-down as the story of one request: connect to the router, * decide what it classifies, then say which model serves each answer. */ import z from '@deepseek-ai/schemastery'; import { LOCALES, type Locale } from './i18n.js'; /** The provider route id used in DSH's LLM seam. */ export const PROVIDER = 'cortiq-router'; /** Display name shown in model pickers and settings tabs. */ export const DISPLAY_NAME = 'Cortiq Router'; /** Settings namespace — the plugin's config lives under `llm-cortiq-router` in settings.yaml. */ export const SETTINGS_NS = 'llm-cortiq-router'; /** Where to get a key, repeated in the one field that needs it. */ export const SIGNUP_URL = 'https://api.allaigate.com/'; /** The hosted classifier endpoint. */ export const DEFAULT_ROUTER_URL = 'https://router.allaigate.com'; /** * Entry points offered in the model picker. Each one routes; the three * profile-pinned ids override the configured policy profile for that call, so * "cheap for this chat, careful for that one" needs no settings trip. */ export const ENTRY_PROFILES: Record = { auto: undefined, 'auto-cost-saver': 'cost-saver', 'auto-balanced': 'balanced', 'auto-quality-first': 'quality-first', }; // ── Public config types ──────────────────────────────────────────────── // // Written out rather than inferred from the schema: the emitted declarations // then name only this package's own types, and the settings section, the // resolver, and DEFAULT_CONFIG all agree on one shape. /** Per-tier model lists for one task label. */ export interface TaskRuleTiers { low: string[]; medium: string[]; high: string[]; any: string[]; } /** One task rule: a single model, a chain, or per-tier chains. */ export type TaskRuleConfig = string | string[] | TaskRuleTiers; /** One entry offered in the model picker. */ export interface CatalogModelConfig { id: string; /** Overrides this plugin's own localized label; absent keeps the shipped one. */ name?: string; contextWindow: number; inputModalities: ('text' | 'image')[]; } /** Every setting this plugin owns, fully populated. */ export interface CortiqRouterSettings { language: 'auto' | Locale; apiKeyEnv: string; routerUrl: string; timeoutMs: number; taxonomyId: string; verifyTls: boolean; routerProfile: 'cost-saver' | 'balanced' | 'quality-first'; textStrategy: 'last_user' | 'last_user_plus_system' | 'concat_all'; maxChars: number; complexityBands: { low: number; medium: number }; globalTiers: { low: string[]; medium: string[]; high: string[] }; taskRules: Record; defaultModel: string; echoRouting: boolean; models: CatalogModelConfig[]; } /** What a composition entry or a settings snapshot may actually carry. */ export type ConfigType = Partial; // ── Per-model entry in the catalog ───────────────────────────────────── const CatalogModel = z.object({ id: z.string().required().description('Id shown in the model picker (e.g. auto)'), name: z.string().description('Label shown in the picker; leave empty to use this plugin\'s own translated label'), contextWindow: z.number().step(1).min(1).default(128_000) .description('Context window advertised to the agent, in tokens. Keep it at or below the smallest routed model.'), inputModalities: z.array(z.union(['text', 'image'])).min(1).default(['text']) .description('Modalities every routed model must accept'), }); // ── Task rule schema ─────────────────────────────────────────────────── /** * A task rule: either a simple array of models, or a per-tier map. * * In the UI this renders as a card per task with three tier model pickers * (low / medium / high) + an "any" fallback. * * Storage form: * translation: ["model-a", "model-b"] ← any-tier * code: * low: ["fast-model"] * high: ["powerful-model"] * any: ["fallback-model"] */ const TaskRuleValue = z.union([ z.array(z.string()), z.string(), z.object({ low: z.array(z.string()).default([]).description('Model(s) for simple requests'), medium: z.array(z.string()).default([]).description('Model(s) for medium complexity'), high: z.array(z.string()).default([]).description('Model(s) for hard requests'), any: z.array(z.string()).default([]).description('Fallback model(s) at any tier'), }), ]); // ── Main config schema ───────────────────────────────────────────────── const ConfigSchema = z.object({ // ── Language ──────────────────────────────────────────── language: z.union(['auto', ...LOCALES]) .default('auto') .description('Language of this plugin\'s own copy — the model picker entries and the message a refused call shows. "auto" follows the harness UI language, which offers English and Chinese; name one of the other locales here to use it.'), // ── Connection ────────────────────────────────────────── apiKeyEnv: z.string() .role('credential-ref') .default('CORTIQ_ROUTER_KEY') .description(`Credential holding your allaigate API key (cortiq_…). Get one at ${SIGNUP_URL}. Without a key nothing breaks — every call goes straight to the fallback model below.`), routerUrl: z.string() .default(DEFAULT_ROUTER_URL) .description('Classifier endpoint. Point this at your own deployment to keep prompts in-house.'), timeoutMs: z.number().min(1000).max(60_000).default(15_000) .description('How long to wait for a routing decision, in ms. Hard prompts escalate to an oracle (~10s), so keep this ≥ 12000; on timeout the call still runs, on the fallback model.'), taxonomyId: z.string() .default('data-assistant') .description('Task taxonomy the router classifies against. Change only if your account has a custom one.'), verifyTls: z.boolean() .default(true) .description('Verify the classifier\'s TLS certificate. Node\'s fetch cannot skip verification, so turning this off only logs a warning; a self-hosted endpoint with a private CA is trusted by pointing NODE_EXTRA_CA_CERTS at that CA, and one with a self-signed certificate by giving it a real one (Let\'s Encrypt is free).'), // ── What gets classified ──────────────────────────────── routerProfile: z.union(['cost-saver', 'balanced', 'quality-first']) .default('balanced') .description('Default policy profile. Picking the auto-cost-saver / auto-balanced / auto-quality-first model overrides it for that conversation.'), textStrategy: z.union(['last_user', 'last_user_plus_system', 'concat_all']) .default('last_user') .description('Which part of the conversation is sent to the classifier. "last_user" routes best and leaks least; add the system prompt when it carries the task.'), maxChars: z.number().min(100).max(32_000).default(4_000) .description('Hard cap on the characters sent to the router'), complexityBands: z.object({ low: z.number().min(0.01).max(0.99).default(0.35) .description('Score ≤ this → "low" complexity'), medium: z.number().min(0.02).max(0.99).default(0.65) .description('Score ≤ this → "medium"; above it → "high"'), }) .default({ low: 0.35, medium: 0.65 }) .description('Where your low / medium / high tiers sit on the router\'s 0…1 complexity score. Raise them to send more work to cheap models.'), // ── Which model answers ───────────────────────────────── globalTiers: z.object({ low: z.array(z.string()).default(['deepseek-v4-flash', 'deepseek-v3-chat']) .description('Simple requests — fast and cheap'), medium: z.array(z.string()).default(['deepseek-v4-pro', 'deepseek-v4-flash']) .description('Medium complexity'), high: z.array(z.string()).default(['deepseek-v4-pro']) .description('Hard requests — the capable, expensive models'), }).description('Model chain per complexity tier. Each list is tried in order, so put the preferred model first; ids come from your other provider plugins ("deepseek-v4-pro"), or name a route explicitly as "provider:model".'), taskRules: z.dict(TaskRuleValue) .default({ code: { low: ['deepseek-v3-chat'], medium: [], high: ['deepseek-v4-pro'], any: ['deepseek-v4-flash'], }, math: { low: ['deepseek-v3-chat'], medium: [], high: ['deepseek-v4-pro'], any: [], }, translation: ['deepseek-v3-chat'], 'creative-writing': { low: ['deepseek-v4-flash'], medium: [], high: ['deepseek-v4-pro'], any: [], }, summarization: ['deepseek-v4-flash'], extraction: ['deepseek-v3-chat'], qa: ['deepseek-v4-flash', 'deepseek-v3-chat'], chitchat: ['deepseek-v3-chat'], }) .description('Per-task overrides, applied before the tier chains above: which model serves each task label at each complexity.'), defaultModel: z.string() .default('deepseek-v4-flash') .description('Last resort: the model used when the router is unreachable, unpaid, or nothing else in the chain is registered.'), // ── Diagnostics ──────────────────────────────────────── echoRouting: z.boolean() .default(true) .description('Log every routing decision (task, tier, score, chosen provider/model)'), // ── Model picker entries ──────────────────────────────── // An empty name means "use this plugin's own translated label". models: z.array(CatalogModel).default([ { id: 'auto', name: '', contextWindow: 128_000, inputModalities: ['text'] }, { id: 'auto-cost-saver', name: '', contextWindow: 128_000, inputModalities: ['text'] }, { id: 'auto-balanced', name: '', contextWindow: 128_000, inputModalities: ['text'] }, { id: 'auto-quality-first', name: '', contextWindow: 128_000, inputModalities: ['text'] }, ]).description('Entries this provider offers in the model picker. Every one of them routes — the three profile-pinned ones just override the policy profile for that conversation.'), }); /** The settings form, as the web UI renders it. */ export const Config = ConfigSchema as unknown as Schemastery; /** * Default config as a plain object (for programmatic use without Schemastery). * Matches the defaults in the schema above. */ export const DEFAULT_CONFIG: CortiqRouterSettings = { language: 'auto', apiKeyEnv: 'CORTIQ_ROUTER_KEY', routerUrl: DEFAULT_ROUTER_URL, timeoutMs: 15_000, taxonomyId: 'data-assistant', verifyTls: true, routerProfile: 'balanced', textStrategy: 'last_user', maxChars: 4_000, complexityBands: { low: 0.35, medium: 0.65 }, globalTiers: { low: ['deepseek-v4-flash', 'deepseek-v3-chat'], medium: ['deepseek-v4-pro', 'deepseek-v4-flash'], high: ['deepseek-v4-pro'], }, taskRules: { code: { low: ['deepseek-v3-chat'], medium: [], high: ['deepseek-v4-pro'], any: ['deepseek-v4-flash'] }, math: { low: ['deepseek-v3-chat'], medium: [], high: ['deepseek-v4-pro'], any: [] }, translation: ['deepseek-v3-chat'], 'creative-writing': { low: ['deepseek-v4-flash'], medium: [], high: ['deepseek-v4-pro'], any: [] }, summarization: ['deepseek-v4-flash'], extraction: ['deepseek-v3-chat'], qa: ['deepseek-v4-flash', 'deepseek-v3-chat'], chitchat: ['deepseek-v3-chat'], }, defaultModel: 'deepseek-v4-flash', echoRouting: true, models: [ { id: 'auto', contextWindow: 128_000, inputModalities: ['text'] }, { id: 'auto-cost-saver', contextWindow: 128_000, inputModalities: ['text'] }, { id: 'auto-balanced', contextWindow: 128_000, inputModalities: ['text'] }, { id: 'auto-quality-first', contextWindow: 128_000, inputModalities: ['text'] }, ], };