/** * Cordis plugin entry for `cortiq-dsh-llm-router`. * * Registers one provider route (`cortiq-router`) in the DSH LLM seam: * - installs the settings section (`llm-cortiq-router`), * - resolves the router API key through the credentials service, * - resolves each candidate model id to a live provider route, * - delegates the call to that route through `ctx.llm.stream`. * * The plugin routes to models other adapters own, so at least one other LLM * provider must be registered (e.g. `@deepseek-ai/dsh-llm-deepseek` or * `@deepseek-ai/dsh-llm-pi-ai`); with none, every call fails as * `MODEL_NOT_FOUND` naming the candidates it looked for. */ import type { Context } from '@deepseek-ai/cordis'; import { LlmError, assertUsableApiKey, type GenerateOptions, type LlmModelInfo } from '@deepseek-ai/dsh-llm'; import { credentialRef, type CredentialRef } from '@deepseek-ai/dsh-credentials'; import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'; import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'; import { Config, PROVIDER, DISPLAY_NAME, SETTINGS_NS, type ConfigType, DEFAULT_CONFIG } from './config.js'; import { CortiqRouterAdapter, DEFAULT_CONTEXT_WINDOW, type ResolvedCatalogModel, type ResolvedRouterOptions, type RoutedTarget, type TextStrategy, } from './adapter.js'; import { FALLBACK_LOCALE, isLocale, type Locale } from './i18n.js'; export { Config, PROVIDER, DISPLAY_NAME, SETTINGS_NS }; export { RoutingTable, createRoutingTable, parseTaskRule, resolveTier } from './routing-table.js'; export { RouterApiClient, RouterError, RouterErrorKind } from './router-client.js'; export { CortiqRouterAdapter, extractText } from './adapter.js'; export * from './i18n.js'; export type { CortiqRoutingMeta, ResolvedRouterOptions, RoutedTarget } from './adapter.js'; export const name = 'llm-cortiq-router'; export const inject = ['llm']; export const NS = settingsNamespace(SETTINGS_NS); /** Modalities a catalog entry may declare. */ const MODEL_MODALITIES = ['text', 'image'] as const; /** Namespace the harness stores its browser-UI language under. */ const LOCALE_NS = settingsNamespace('locale'); // ── Resolve connection facts from raw config ──────────────────────────── /** Validate and detach the advertised model catalog. */ function resolveModels(models: ConfigType['models']): ResolvedCatalogModel[] { const seen = new Set(); return (models ?? DEFAULT_CONFIG.models).map((model) => { if (model.id.length === 0) throw new Error('llm-cortiq-router: catalog model ids must be non-empty'); if (seen.has(model.id)) throw new Error(`llm-cortiq-router: duplicate catalog model "${model.id}"`); seen.add(model.id); const contextWindow = model.contextWindow ?? DEFAULT_CONTEXT_WINDOW; if (!Number.isInteger(contextWindow) || contextWindow <= 0) { throw new Error(`llm-cortiq-router: catalog model "${model.id}" contextWindow must be a positive integer`); } const inputModalities = model.inputModalities ?? ['text']; if (inputModalities.length === 0 || inputModalities.some((modality) => !MODEL_MODALITIES.includes(modality))) { throw new Error(`llm-cortiq-router: catalog model "${model.id}" has invalid inputModalities`); } return { id: model.id, ...(model.name === undefined || model.name.length === 0 ? {} : { name: model.name }), contextWindow, inputModalities: [...inputModalities], }; }); } /** * The one explicit step from raw config to validated routing facts, re-judged * for the composition entry at load (fail loud) and for each settings snapshot * at its first use. * @param config - raw plugin config or resolved settings snapshot. * @returns validated routing facts plus the credential reference. */ export function resolveAdapterOptions(config: ConfigType): ResolvedRouterOptions { const timeoutMs = config.timeoutMs ?? DEFAULT_CONFIG.timeoutMs; if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { throw new Error('llm-cortiq-router: timeoutMs must be a positive number'); } const maxChars = config.maxChars ?? DEFAULT_CONFIG.maxChars; if (!Number.isInteger(maxChars) || maxChars <= 0) { throw new Error('llm-cortiq-router: maxChars must be a positive integer'); } const bands = config.complexityBands ?? DEFAULT_CONFIG.complexityBands; if (bands !== null && bands.low >= bands.medium) { throw new Error('llm-cortiq-router: complexityBands.low must be below complexityBands.medium'); } const language = config.language ?? DEFAULT_CONFIG.language; if (language !== 'auto' && !isLocale(language)) { throw new Error(`llm-cortiq-router: unknown language "${String(language)}"`); } return { language, routerUrl: config.routerUrl || DEFAULT_CONFIG.routerUrl, apiKeyEnv: credentialRef(config.apiKeyEnv || DEFAULT_CONFIG.apiKeyEnv), verifyTls: config.verifyTls !== false, taxonomyId: config.taxonomyId || DEFAULT_CONFIG.taxonomyId, timeoutMs, textStrategy: (config.textStrategy || DEFAULT_CONFIG.textStrategy) as TextStrategy, maxChars, routerProfile: config.routerProfile || DEFAULT_CONFIG.routerProfile, globalTiers: config.globalTiers ?? DEFAULT_CONFIG.globalTiers, taskRules: config.taskRules ?? DEFAULT_CONFIG.taskRules, defaultModel: config.defaultModel || DEFAULT_CONFIG.defaultModel, complexityBands: bands, echoRouting: config.echoRouting !== false, models: resolveModels(config.models), }; } // ── Plugin apply function ───────────────────────────────────────────── export function apply(ctx: Context, config: ConfigType) { let current = () => config; let lastRaw: ConfigType | undefined; let lastGood: ResolvedRouterOptions | undefined; const options = (): ResolvedRouterOptions => { const raw = current(); if (raw === lastRaw && lastGood !== undefined) return lastGood; try { const next = resolveAdapterOptions(raw); lastRaw = raw; lastGood = next; return next; } catch (error) { if (lastGood === undefined) throw error; lastRaw = raw; ctx.logger.error('llm-cortiq-router: keeping the last good configuration after an invalid settings section'); ctx.logger.error(error); return lastGood; } }; // Fail loud at load: a broken composition entry is a boot-time problem. const initial = options(); if (!initial.verifyTls) { // Node's fetch has no per-request TLS opt-out, so the setting cannot be // honored; say so rather than let it read as a silently applied bypass. ctx.logger.warn('llm-cortiq-router: verifyTls=false is not supported by the Node fetch client; the router certificate is still verified'); } const resolveApiKey = async (ref: CredentialRef): Promise => { const credentials = ctx.get('credentials'); if (credentials !== undefined) { const hit = await credentials.resolve(ref); if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-cortiq-router', ref); } else { const ambient = launchEnvironmentOf(ctx).get(ref); if (ambient !== undefined && ambient.value.length > 0) { return assertUsableApiKey(ambient.value, 'llm-cortiq-router', ref); } } throw new LlmError( `llm-cortiq-router: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials ` + `service (the web Models page writes it), or export ${ref} in the launching environment`, 'MISSING_CREDENTIAL', ); }; // ── Candidate model id → live provider route ──────────────────────── // // A configured id is either `provider:model` (explicit route) or a bare // model id looked up in the catalogs registered adapters advertise. The // catalog is advisory, so a miss is never an error here — the caller walks // to the next candidate. const catalogs = new Map(); ctx.on('llm/adapters-updated', () => catalogs.clear()); const otherProviders = () => ctx.llm.listProviders().filter((provider) => provider.id !== PROVIDER); const catalogOf = async (provider: string): Promise => { const cached = catalogs.get(provider); if (cached !== undefined) return cached; try { const models = await ctx.llm.listModels(provider); catalogs.set(provider, models); return models; } catch (error) { ctx.logger.warn(`llm-cortiq-router: provider "${provider}" failed to list its models`); ctx.logger.warn(error); catalogs.set(provider, []); return []; } }; const resolveTarget = async (modelId: string): Promise => { const separator = modelId.indexOf(':'); if (separator > 0) { const provider = modelId.slice(0, separator); const model = modelId.slice(separator + 1); // Self-routing would re-enter this adapter forever. if (model.length === 0 || provider === PROVIDER) return undefined; return otherProviders().some((entry) => entry.id === provider) ? { provider, model } : undefined; } for (const provider of otherProviders()) { const models = await catalogOf(provider.id); if (models.some((model) => model.id === modelId)) return { provider: provider.id, model: modelId }; } return undefined; }; // `auto` follows the harness UI language, which the browser client limits to // English and Chinese; any other locale has to be named in this plugin's own // settings, so it is read here rather than assumed. const resolveLocale = (): Locale => { const configured = options().language; if (configured !== 'auto') return configured; const preference = (ctx.get('settings')?.get(LOCALE_NS) as { preference?: unknown } | undefined)?.preference; return isLocale(preference) ? preference : FALLBACK_LOCALE; }; const adapter = new CortiqRouterAdapter({ options, resolveLocale, resolveApiKey, resolveTarget, delegate: (request: GenerateOptions) => ctx.llm.stream(request), logger: ctx.logger, }); ctx.llm.registerConfigurableProviders([{ provider: PROVIDER, displayName: DISPLAY_NAME, settingsNs: NS, settingsPath: [], }]); ctx.llm.registerAdapter([PROVIDER], adapter); installSettingsSection(ctx, NS, Config, config, { setSource: (source: () => ConfigType) => { current = source; }, // The route's own facts (name, retry policy) do not depend on settings, so // no re-registration is needed; re-resolving here surfaces an invalid // section at save time instead of at the next call. onChange: () => void options(), }); }