Files
2026-07-13 13:39:12 +08:00

419 lines
16 KiB
TypeScript

/**
* Pipeline Router — Smart Auto-Pipeline Orchestrator
*
* Bridges combo routing with the multi-stage pipeline engine.
* Classifies prompt intent, selects pipeline template, executes stages
* through a stageExecutor that wraps handleChatCore.
*
* @module services/autoCombo/pipelineRouter
*/
import { classifyPromptIntent, type IntentType } from "../intentClassifier.ts";
import {
executePipeline,
buildPipelineConfig,
type TaskType,
type PipelineResult,
type FitnessTier,
} from "../../../src/domain/pipeline.ts";
import { renderPrompt } from "../../../src/domain/prompts.ts";
import { getTaskFitness } from "./taskFitness.ts";
// ---------------------------------------------------------------------------
// Fitness tiers — map pipeline behavior to model fitness thresholds
// ---------------------------------------------------------------------------
export interface FitnessTierConfig {
minFitness?: number;
maxFitness?: number;
}
export const FITNESS_TIERS: Record<string, FitnessTierConfig> = {
"best-reasoning": { minFitness: 0.85 },
cheapest: { maxFitness: 0.75 },
moderate: { minFitness: 0.6, maxFitness: 0.9 },
};
// ---------------------------------------------------------------------------
// Intent → TaskType mapping
// ---------------------------------------------------------------------------
const INTENT_TO_TASK: Record<IntentType, TaskType> = {
code: "code",
math: "math",
reasoning: "reasoning",
creative: "creative",
medium: "medium",
simple: "simple",
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PipelineComboParams {
body: Record<string, unknown>;
combo: Record<string, unknown>;
handleChatCore: (body: Record<string, unknown>, modelStr?: string) => Promise<Response>;
log: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
settings: Record<string, unknown>;
signal?: AbortSignal;
}
export interface StageExecutorArgs {
messages: Array<{ role: string; content: string }>;
stream: boolean;
fitnessTier?: string;
}
export interface StageExecutorResult {
text: string;
response?: Response;
}
// ---------------------------------------------------------------------------
// Model resolver — maps fitness tiers to actual model strings
// ---------------------------------------------------------------------------
/**
* Resolve a fitness tier to a concrete model string using the available models
* from the combo's candidate pool. Falls back to sensible defaults.
*/
function resolveModelForTier(
tier: FitnessTier,
availableModels: string[],
taskType: string
): string {
// Score each available model for this task type and tier
const scored = availableModels
.map((model) => ({
model,
fitness: getTaskFitness(model, taskType),
}))
.sort((a, b) => b.fitness - a.fitness);
const tierConfig = FITNESS_TIERS[tier] as FitnessTierConfig | undefined;
if (!tierConfig) return scored[0]?.model ?? "deepseek-chat";
// Filter by fitness threshold
const filtered = scored.filter(({ fitness }) => {
if (tierConfig.minFitness !== undefined && fitness < tierConfig.minFitness) return false;
if (tierConfig.maxFitness !== undefined && fitness > tierConfig.maxFitness) return false;
return true;
});
// Return best match in tier, or fall back to best available
return filtered[0]?.model ?? scored[0]?.model ?? "deepseek-chat";
}
// ---------------------------------------------------------------------------
// Stage executor factory
// ---------------------------------------------------------------------------
/**
* Create a stageExecutor that wraps handleChatCore for pipeline stage execution.
*
* - Intermediate stages (stream:false): buffer the response, extract text
* - Final stage (stream:true): return raw Response for SSE streaming
* - Each stage gets a model override based on its fitness tier
*/
function createStageExecutor(
body: Record<string, unknown>,
handleChatCore: (body: Record<string, unknown>, modelStr?: string) => Promise<Response>,
log: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void },
availableModels: string[],
taskType: string
): (args: StageExecutorArgs & { fitnessTier?: FitnessTier }) => Promise<StageExecutorResult> {
return async ({
messages,
stream,
fitnessTier,
}: StageExecutorArgs & { fitnessTier?: FitnessTier }): Promise<StageExecutorResult> => {
// Resolve model for this stage's fitness tier
const model = fitnessTier
? resolveModelForTier(fitnessTier, availableModels, taskType)
: undefined;
// Build a modified request body with pipeline stage messages
const stageBody: Record<string, unknown> = {
...body,
messages,
stream,
};
log.info("PIPELINE", `Stage: tier=${fitnessTier}, model=${model}, stream=${stream}`);
const response = await handleChatCore(stageBody, model);
// Final stage: return raw Response for streaming
if (stream) {
return { text: "", response };
}
// Intermediate stage: buffer and extract text
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
log.warn("PIPELINE", `Stage returned ${response.status}: ${errorText.slice(0, 200)}`);
return { text: "" };
}
try {
const json = await response.json();
// OpenAI chat completions format: choices[0].message.content
const content = json?.choices?.[0]?.message?.content;
if (typeof content === "string") {
return { text: content };
}
// Fallback: try to extract any text field
return { text: JSON.stringify(json) };
} catch {
log.warn("PIPELINE", "Failed to parse stage response as JSON");
return { text: "" };
}
};
}
// ---------------------------------------------------------------------------
// Token estimation
// ---------------------------------------------------------------------------
/**
* Rough token estimate from message content (4 chars ≈ 1 token).
*/
function estimateTokens(messages: Array<{ role: string; content: unknown }>): number {
let total = 0;
for (const msg of messages) {
if (typeof msg.content === "string") {
total += Math.ceil(msg.content.length / 4);
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (
typeof part === "object" &&
part !== null &&
typeof (part as Record<string, unknown>).text === "string"
) {
total += Math.ceil(((part as Record<string, unknown>).text as string).length / 4);
}
}
}
}
return total;
}
// ---------------------------------------------------------------------------
// Main pipeline handler
// ---------------------------------------------------------------------------
/**
* Handle a combo request through the multi-stage pipeline.
*
* Flow:
* 1. Classify prompt intent → task type
* 2. Build pipeline config from task type
* 3. Execute pipeline with stageExecutor wrapping handleChatCore
* 4. Return PipelineResult (intermediate) or streaming Response (final)
*
* @returns PipelineResult for diagnostic purposes, or a streaming Response
* when the final stage streams.
*/
export async function handlePipelineCombo({
body,
combo,
handleChatCore,
log,
settings,
signal,
}: PipelineComboParams): Promise<PipelineResult | Response> {
const config = (combo as Record<string, unknown>).config as Record<string, unknown> | undefined;
const pipelineEnabled =
config?.pipeline_enabled ?? (settings as Record<string, unknown>).pipeline_enabled ?? false;
if (!pipelineEnabled) {
log.info("PIPELINE", "Pipeline disabled for this combo");
// Fall through — caller should handle with standard combo logic
throw new Error("PIPELINE_DISABLED");
}
// ── Token threshold check ────────────────────────────────────────────────
const messages = (body.messages as Array<{ role: string; content: unknown }>) || [];
const tokenEstimate = estimateTokens(messages);
const skipThreshold =
(config?.skip_pipeline_for_tokens_under as number) ??
((settings as Record<string, unknown>).skip_pipeline_for_tokens_under as number) ??
50;
if (tokenEstimate < skipThreshold) {
log.info(
"PIPELINE",
`Token estimate ${tokenEstimate} < threshold ${skipThreshold}, skipping pipeline`
);
throw new Error("PIPELINE_TOKEN_THRESHOLD");
}
// ── Intent classification ─────────────────────────────────────────────────
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
const promptText =
typeof lastUserMsg?.content === "string"
? lastUserMsg.content
: Array.isArray(lastUserMsg?.content)
? (lastUserMsg.content as Array<{ type: string; text?: string }>)
.filter((b) => b.type === "text")
.map((b) => b.text || "")
.join(" ")
: "";
const systemMsg = messages.find((m) => m.role === "system");
const systemText = typeof systemMsg?.content === "string" ? systemMsg.content : undefined;
const intent = classifyPromptIntent(promptText, systemText);
const taskType = INTENT_TO_TASK[intent] ?? "simple";
log.info("PIPELINE", `Intent: ${intent} → task: ${taskType}`);
// ── Build pipeline config ─────────────────────────────────────────────────
const pipelineConfig = buildPipelineConfig(promptText, taskType);
// ── Extract available models from combo ────────────────────────────────────
// `combo.models` is an array of model-config entries (objects with a `.model`
// field), not plain strings — older code cast it to `string[]` and passed the
// raw objects to `getTaskFitness`, which then string-matched `"[object Object]"`
// and always fell back to the default model. Normalize to model-name strings.
const rawComboModels = (combo as Record<string, unknown>).models;
const comboModels = Array.isArray(rawComboModels)
? rawComboModels
.map((entry) => {
if (typeof entry === "string") return entry;
const model = (entry as Record<string, unknown> | null)?.model;
return typeof model === "string" ? model : null;
})
.filter((model): model is string => typeof model === "string" && model.length > 0)
: [];
const availableModels = comboModels.length ? comboModels : ["deepseek-chat"];
// ── Create stage executor ─────────────────────────────────────────────────
const stageExecutor = createStageExecutor(body, handleChatCore, log, availableModels, taskType);
// ── Execute pipeline ──────────────────────────────────────────────────────
const maxReflectionLoops =
(config?.max_reflection_loops as number) ??
((settings as Record<string, unknown>).max_reflection_loops as number) ??
1;
const wrappedExecutor = async (args: StageExecutorArgs) => {
// fitnessTier is now passed by the pipeline engine via StageExecutorArgs
return stageExecutor({ ...args, fitnessTier: args.fitnessTier as FitnessTier | undefined });
};
let result = await executePipeline(pipelineConfig, wrappedExecutor);
// ── Handle reflection loops ───────────────────────────────────────────────
// While the reflect stage reports "fail", re-run the whole pipeline up to
// `maxReflectionLoops` times (previously this was a single `if`, so the
// configured loop count above 1 was silently ignored). Each re-run is a fresh
// pipeline that internally applies its own reflect→fix correction (see
// executePipeline). The first retry that passes wins; if none pass we keep the
// original result.
let reflectionCount = 0;
while (result.reflectVerdict === "fail" && reflectionCount < maxReflectionLoops) {
reflectionCount++;
log.info(
"PIPELINE",
`Reflection failed, re-running (loop ${reflectionCount}/${maxReflectionLoops})`
);
const retryConfig = buildPipelineConfig(promptText, taskType);
const retryResult = await executePipeline(retryConfig, wrappedExecutor);
// Adopt the retry only if it passed; otherwise keep scanning until the loop
// budget is exhausted, then fall through with the original result.
if (retryResult.reflectVerdict === "pass") {
result = retryResult;
break;
}
}
// ── Return result ─────────────────────────────────────────────────────────
// Check if the last stage has a streaming Response
const lastStage = result.stages[result.stages.length - 1];
// If result contains a Response (from streaming final stage), return it directly
// This happens when the pipeline decides to stream the final output
if (lastStage?.text === "" && result.text) {
// Non-streaming result — return as PipelineResult
return result;
}
log.info(
"PIPELINE",
`Complete: ${result.stages.length} stages, fallback=${result.fallback}, verdict=${result.reflectVerdict}`
);
return result;
}
/**
* Convert a buffered {@link PipelineResult} into an HTTP `Response`.
*
* `handlePipelineCombo` resolves to a `PipelineResult` (the pipeline buffers
* every stage as non-streaming and exposes only the final text), but the combo
* routing layer hands its return value to callers that expect a `Response`.
* This adapter bridges that gap, emitting an OpenAI-compatible
* `chat.completion` body — or a single-chunk SSE stream when the client
* requested `stream: true` — so the pipeline output actually reaches the client
* instead of being silently dropped.
*/
export function buildPipelineResponse(
result: PipelineResult,
body: Record<string, unknown>
): Response {
const model = typeof body.model === "string" && body.model.length > 0 ? body.model : "auto";
const id = `chatcmpl-pipeline-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const content = result.text ?? "";
// Non-streaming: standard OpenAI chat.completion JSON body.
if (body.stream !== true) {
const payload = {
id,
object: "chat.completion",
created,
model,
choices: [
{
index: 0,
message: { role: "assistant", content },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
};
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// Streaming: emit one content chunk, a terminal finish chunk, then [DONE].
const chunk = (delta: Record<string, unknown>, finishReason: string | null) =>
`data: ${JSON.stringify({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta, finish_reason: finishReason }],
})}\n\n`;
const sse = chunk({ role: "assistant", content }, null) + chunk({}, "stop") + "data: [DONE]\n\n";
return new Response(sse, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}