/** * Cortiq Router LlmAdapter. * * The adapter owns one provider route (`cortiq-router`). Every call it serves * is classified by the allaigate router (task label + complexity), turned into * an ordered candidate chain by the routing table, and dispatched to the first * candidate a registered provider actually serves: route → select → delegate. * * Delegation goes back through the `llm` seam (`delegate`, wired in index.ts to * `ctx.llm.stream`) rather than into another adapter instance directly. The * target's call-config resolution, credentials, replay state, and retry policy * are its own business, and re-implementing any of them here would diverge from * what the same model does when picked directly. For the same reason this * route's own retry policy is a single attempt (`maxRetries: 0`): a retry of a * routed call is the delegated call's job, and retrying at both levels would * multiply attempts. * * A candidate chain is only as good as its ability to skip a candidate that * cannot answer. Whether a provider *can* answer is not knowable from the * catalog — an unconfigured key looks exactly like a configured one until the * call is made — so a candidate whose provider refuses the call before emitting * anything is stepped over and the next candidate takes it. Once a chunk has * reached the caller the route is committed: a mid-stream failure is that * provider's answer, not an invitation to start over somewhere else. */ import { LlmAdapter, LlmError, resolveRetryPolicy, type ContentBlock, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type LlmResolvedModelInfo, type Message, type ModelModality, type ResolvedRetryPolicy, type StreamChunk, } from '@deepseek-ai/dsh-llm'; import type { CredentialRef } from '@deepseek-ai/dsh-credentials'; import { DISPLAY_NAME, ENTRY_PROFILES } from './config.js'; import { entryLabel, translate, type Locale } from './i18n.js'; import { RouteDecision, RouterApiClient, RouterError } from './router-client.js'; import { ComplexityBands, RoutingTable, createRoutingTable } from './routing-table.js'; /** Context window advertised for a catalog model that configures none. */ export const DEFAULT_CONTEXT_WINDOW = 128_000; /** Which part of the conversation is handed to the classifier. */ export type TextStrategy = 'last_user' | 'last_user_plus_system' | 'concat_all'; /** One catalog entry, with every optional field already defaulted. */ export interface ResolvedCatalogModel { id: string; /** Configured override; absent means this plugin's own translated label. */ name?: string; description?: string; contextWindow: number; inputModalities: readonly ModelModality[]; } /** Validated connection and routing facts, re-resolved on every settings change. */ export interface ResolvedRouterOptions { /** Configured copy language, or `auto` to follow the harness UI language. */ language: 'auto' | Locale; routerUrl: string; /** Credential reference (env var name), never the key itself. */ apiKeyEnv: CredentialRef; verifyTls: boolean; taxonomyId: string; timeoutMs: number; textStrategy: TextStrategy; maxChars: number; routerProfile: string; globalTiers: Record; taskRules: Record; defaultModel: string; complexityBands: ComplexityBands | null; echoRouting: boolean; models: ResolvedCatalogModel[]; } /** One live provider route serving a candidate model id. */ export interface RoutedTarget { /** Registered provider route that serves the model. */ provider: string; /** Model id as that provider names it. */ model: string; } /** The subset of the cordis logger this adapter uses. */ export interface RouterLogger { info(...args: unknown[]): void; warn(...args: unknown[]): void; error(...args: unknown[]): void; } export interface CortiqRouterAdapterOptions { /** Current resolved options (connection + routing config). */ options: () => ResolvedRouterOptions; /** Resolve the router API key from its credential reference. Throws when unset. */ resolveApiKey: (ref: CredentialRef) => Promise; /** * Find the live provider route serving one candidate model id, or `undefined` * when no registered adapter advertises it. */ resolveTarget: (modelId: string, signal?: AbortSignal) => Promise; /** Dispatch the routed call through the llm seam. */ delegate: (options: GenerateOptions) => AsyncIterable; /** The locale this plugin's user-facing copy is rendered in, read per use. */ resolveLocale: () => Locale; logger?: RouterLogger; } /** Routing facts for the last call this adapter served, for diagnostics. */ export interface CortiqRoutingMeta { taskLabel: string; complexityScore: number; complexityTier: string; selectedModel: string; selectedProvider: string; routeSource: string; confidence: number; routerRequestId?: string; } // ── Text extraction strategies ────────────────────────────────────────── /** Model-facing text of one content block; non-text blocks contribute nothing. */ function blockText(block: ContentBlock): string { return block.type === 'text' ? block.text : ''; } /** Model-facing text of one message, blocks joined in order. */ function messageText(message: Message): string { return message.content.map(blockText).filter((text) => text.length > 0).join('\n'); } /** * Extract the text the classifier sees. The strategies mirror * cortiq-gateway's: the last user turn alone routes best, the system prompt * helps when it carries the task, and the whole transcript is the blunt option. * @param options - the request being routed. * @param strategy - configured extraction strategy. * @param maxChars - hard bound on what leaves the process. * @returns the text to classify, truncated to `maxChars`. */ export function extractText( options: Pick, strategy: TextStrategy, maxChars: number, ): string { const messages = options.messages; const lastUser = (): string => { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === 'user') return messageText(messages[i]); } return ''; }; let raw = ''; switch (strategy) { case 'last_user': raw = lastUser(); break; case 'last_user_plus_system': { const system = options.system ?? ''; const user = lastUser(); raw = system.length > 0 ? `${system}\n---\n${user}` : user; break; } case 'concat_all': raw = messages.map((message) => `${message.role}: ${messageText(message)}`).join('\n'); break; } return raw.slice(0, maxChars); } // ── CortiqRouterAdapter ───────────────────────────────────────────────── /** * Failure codes that mean "this provider cannot serve this call at all": * a missing or rejected credential, no adapter behind the route, or a model * the target does not have. Each is a property of the route rather than of the * request, so the same request may well succeed on the next candidate. */ const FAILOVER_CODES: readonly string[] = ['MISSING_CREDENTIAL', 'AUTH', 'NO_ADAPTER', 'MODEL_NOT_FOUND']; /** * The failure code when `chunk` is a terminal refusal worth stepping over. * The seam turns an adapter throw into one terminal `finish` chunk, so a route * that cannot serve the call says so as its first and only chunk. * @param chunk - the first chunk a delegated call produced. * @returns the code to report, or `undefined` when this is a real answer. */ function refusalCode(chunk: StreamChunk): string | undefined { if (chunk.type !== 'finish' || chunk.reason.kind !== 'error') return undefined; const code = chunk.reason.failure.code; return FAILOVER_CODES.includes(code) ? code : undefined; } /** This route never retries itself; the delegated call owns retry. See the module note. */ const NO_RETRY: ResolvedRetryPolicy = resolveRetryPolicy( { mode: 'normal', maxRetries: 0 }, 'llm-cortiq-router: retryPolicy', ); export class CortiqRouterAdapter extends LlmAdapter { private routerClient: RouterApiClient | null = null; private clientSignature = ''; private routingTable: RoutingTable | null = null; private routingSignature = ''; private lastRouteMeta: CortiqRoutingMeta | null = null; private missingKeyReported = false; constructor(private readonly opts: CortiqRouterAdapterOptions) { super(); } // ── LlmAdapter contract ────────────────────────────────────────────── providerInfo(provider: string): LlmProviderInfo { return { id: provider, name: DISPLAY_NAME }; } providerRetryPolicy(_provider: string): ResolvedRetryPolicy { return NO_RETRY; } listModels(provider: string): Promise { const locale = this.opts.resolveLocale(); return Promise.resolve( this.opts.options().models.map((model) => ({ provider, id: model.id, name: this.labelOf(model, locale), ...(model.description === undefined ? {} : { description: model.description }), inputModalities: [...model.inputModalities], })), ); } resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise { const configured = this.opts.options().models.find((entry) => entry.id === model); return Promise.resolve({ provider, id: model, name: configured === undefined ? model : this.labelOf(configured, this.opts.resolveLocale()), ...(configured?.description === undefined ? {} : { description: configured.description }), inputModalities: [...(configured?.inputModalities ?? ['text'])], context: { contextWindow: configured?.contextWindow ?? DEFAULT_CONTEXT_WINDOW }, }); } /** * The name one catalog entry shows in the picker: the configured override * when there is one, this plugin's translated label for an entry it ships, * and the bare id for anything else. */ private labelOf(model: ResolvedCatalogModel, locale: Locale): string { if (model.name !== undefined && model.name.length > 0) return model.name; return entryLabel(locale, model.id) ?? model.id; } /** * Route one call: classify, build the candidate chain, dispatch to the first * candidate a registered provider serves. * @param options - the request, carrying this route as `provider`. * @returns the delegated provider's chunk stream, unmodified. */ async *stream(options: GenerateOptions): AsyncIterable { const o = this.opts.options(); // A profile-pinned entry point (auto-cost-saver, …) overrides the // configured policy for this conversation; plain entries use the default. const profile = ENTRY_PROFILES[options.model] ?? o.routerProfile; const decision = await this.classify(options, o, profile); const tier = decision === null ? 'medium' : this.ensureRoutingTable(o).tierOf(decision.complexityScore, decision.complexityTier); const chain = this.candidates(decision, tier, o); // The effort was validated against this route's model, which advertises // none; the target resolves its own default through the seam. const { reasoningEffort: _routeEffort, ...request } = options; const refused: string[] = []; for (const modelId of chain) { const target = await this.opts.resolveTarget(modelId, options.signal); if (target === undefined) continue; const iterator = this.opts .delegate({ ...request, provider: target.provider, model: target.model })[Symbol.asyncIterator](); const first = await iterator.next(); if (first.done !== true) { const refusal = refusalCode(first.value); if (refusal !== undefined) { refused.push(`${target.provider}/${target.model} (${refusal})`); this.opts.logger?.warn( `llm-cortiq-router: ${target.provider}/${target.model} refused the call (${refusal}); ` + 'trying the next candidate', ); await iterator.return?.(undefined); continue; } } this.lastRouteMeta = { taskLabel: decision?.taskLabel ?? '__unrouted__', complexityScore: decision?.complexityScore ?? 0, complexityTier: tier, selectedModel: target.model, selectedProvider: target.provider, routeSource: decision?.source ?? 'fallback', confidence: decision?.confidence ?? 0, ...(decision?.requestId === undefined ? {} : { routerRequestId: decision.requestId }), }; if (o.echoRouting) { const meta = this.lastRouteMeta; this.opts.logger?.info( `llm-cortiq-router: task=${meta.taskLabel} tier=${meta.complexityTier} ` + `score=${meta.complexityScore.toFixed(2)} source=${meta.routeSource} ` + `→ ${meta.selectedProvider}/${meta.selectedModel}`, ); } // From here the route is committed: the caller sees this provider's // stream, failures included. let completed = false; try { if (first.done === true) { completed = true; return; } yield first.value; while (true) { const next = await iterator.next(); if (next.done === true) { completed = true; return; } yield next.value; } } finally { if (!completed) await iterator.return?.(undefined); } } const locale = this.opts.resolveLocale(); throw new LlmError( 'llm-cortiq-router: ' + (refused.length === 0 ? translate(locale, 'error.noProvider', { chain: chain.join(', ') }) : translate(locale, 'error.refused', { chain: chain.join(', '), refused: refused.join(', ') })), 'MODEL_NOT_FOUND', ); } // ── Routing ────────────────────────────────────────────────────────── /** * Classify the request. Every router-side failure degrades to `null` — an * unreachable or unpaid classifier must not take the conversation down with * it, it only costs the call its task-specific routing. */ private async classify( options: GenerateOptions, o: ResolvedRouterOptions, profile: string, ): Promise { const client = await this.ensureClient(o); if (client === null) return null; const text = extractText(options, o.textStrategy, o.maxChars); if (text.length === 0) return null; try { return await client.route(text, profile, options.signal); } catch (error) { if (options.signal?.aborted === true) throw error; const detail = error instanceof RouterError ? `${error.kind}: ${error.message}` : String(error); this.opts.logger?.warn( `llm-cortiq-router: classification failed (${detail}); falling back to "${o.defaultModel}"`, ); return null; } } /** * The ordered candidate chain for one decision. * * Unclassified calls lead with the configured fallback model, as that setting * promises, but do not stop there: the medium tier backs it up, so a * degraded call still has somewhere to go when the fallback's provider turns * out to be unusable. */ private candidates( decision: RouteDecision | null, tier: string, o: ResolvedRouterOptions, ): string[] { if (decision !== null) { return this.ensureRoutingTable(o).candidatesFor(decision.taskLabel, tier).models; } const chain: string[] = []; for (const id of [o.defaultModel, ...(o.globalTiers['medium'] ?? [])]) { if (id.length > 0 && !chain.includes(id)) chain.push(id); } return chain; } /** Rebuild the router client whenever the connection facts or the key change. */ private async ensureClient(o: ResolvedRouterOptions): Promise { let apiKey: string; try { apiKey = await this.opts.resolveApiKey(o.apiKeyEnv); } catch (error) { if (!this.missingKeyReported) { this.missingKeyReported = true; this.opts.logger?.warn( `llm-cortiq-router: ${error instanceof Error ? error.message : String(error)}; ` + `every call falls back to "${o.defaultModel}" until the key is stored`, ); } return null; } this.missingKeyReported = false; const signature = JSON.stringify([o.routerUrl, o.taxonomyId, o.timeoutMs, o.verifyTls, apiKey]); if (this.routerClient === null || this.clientSignature !== signature) { this.routerClient = new RouterApiClient({ url: o.routerUrl, apiKey, verifyTls: o.verifyTls, timeoutMs: o.timeoutMs, taxonomyId: o.taxonomyId, }); this.clientSignature = signature; } return this.routerClient; } /** Rebuild the routing table whenever the routing config changes. */ private ensureRoutingTable(o: ResolvedRouterOptions): RoutingTable { const signature = JSON.stringify([o.globalTiers, o.taskRules, o.defaultModel, o.complexityBands]); if (this.routingTable === null || this.routingSignature !== signature) { this.routingTable = createRoutingTable({ globalTiers: o.globalTiers, taskRules: o.taskRules, defaultModel: o.defaultModel, complexityBands: o.complexityBands, }); this.routingSignature = signature; } return this.routingTable; } /** Routing facts for the last call this adapter served. */ getLastRoutingMeta(): CortiqRoutingMeta | null { return this.lastRouteMeta; } }