Files
OmniRoute/open-sse/services/taskAwareRouter.ts
T
Diego Rodrigues de Sa e Souza eaddb6f0fa feat: improvements from 9router analysis (T01/T08-T13) (#351)
* fix: tool description null sanitization, clipboard HTTP fallback fixes

T10 - Sanitize tool.description null in claude-to-openai translator
- claude-to-openai.ts: tool.description defaults to empty string when null/undefined
- claude-to-openai.ts: filter out tools with empty/missing names
- Prevents 400 validation errors on providers like NVIDIA NIM (issue #276)

T11 - Fix copy buttons to work on HTTP/non-HTTPS deployments
- Add src/shared/utils/clipboard.ts with HTTPS+HTTP (execCommand) dual fallback
- Migrate useCopyToClipboard.ts to use shared utility
- Migrate ConsoleLogViewer.tsx, RequestLoggerV2.tsx to shared utility
- Migrate HomePageClient.tsx, endpoint/page.tsx, GetStarted.tsx
- Migrate DefaultToolCard.tsx to shared utility
- Fixes copy buttons when OmniRoute runs behind HTTP proxy (issue #296)

T02 - Verified SSE [DONE] sentinel handling already correct
- sseParser.ts filters [DONE] on line 13 (no change needed)
- stream.ts uses doneSent flag to prevent duplicate sentinel
- bypassHandler.ts correctly separates streaming/non-streaming responses

Issue triage comments posted to #340, #341, #344

* feat: DB read cache + Accept header stream negotiation (T09/T01)

T09 - In-memory TTL cache for hot DB read paths
- Add src/lib/db/readCache.ts with TTL cache (5s settings/connections, 30s pricing)
- Eliminates redundant SQLite reads on concurrent requests
- Integrate invalidation in settings.ts updateSettings() and updatePricing()
- Integrate invalidation in providers.ts create/update/delete operations
- Export getCachedSettings, getCachedPricing, getCachedProviderConnections,
  invalidateDbCache via localDb.ts for consumer migration
- Cache auto-busts on any write, preserving data consistency

T01 - Accept header stream negotiation
- src/sse/handlers/chat.ts: detect Accept: text/event-stream header
- Override body.stream=true when Accept header indicates streaming client
- Enables curl, httpx and SDK clients that use HTTP headers instead of JSON
  body field to trigger streaming responses
- Logs Accept override at DEBUG level for observability

* fix: auto-advance quota window on expiry to prevent stale blocking (T08)

T08 - Quota Window Rolling Auto-Advance
- quotaCache.ts: add windowDurationMs field to QuotaCacheEntry interface
  (optional field that callers can set when they know the window duration)
- Add advancedWindowResetAt() helper: if entry.nextResetAt is in the past,
  eagerly returns { exhausted: false } so requests are unblocked immediately
- isAccountQuotaExhausted() now uses advancedWindowResetAt() instead of
  the previous inline date check, and optimistically clears entry.exhausted
  flag to avoid re-checking the same stale entry on the next request

Before: exhausted accounts with an expired resetAt would wait up to 5
minutes for the background refresh before accepting new requests.
After:  the first request after resetAt passes will be immediately accepted
and will trigger a quota refresh on the next background tick.

* feat: manual OAuth token refresh UI (T12)

T12 - Manual Token Refresh UI
- Add POST /api/providers/[id]/refresh endpoint
  - Validates connection exists and is OAuth type
  - Calls getAccessToken() (same helper used in auto-refresh)
  - Persists new credentials via updateProviderCredentials()
  - Returns { success, expiresAt, refreshedAt } on success

- Update providers/[id]/page.tsx
  - handleRefreshToken() with loading state (refreshingId)
  - Pass onRefreshToken + isRefreshing props to ConnectionRow
  - ConnectionRow: add optional onRefreshToken/isRefreshing props
  - ConnectionRow: tokenMinsLeft state via lazy init (Date.now() in
    getter fn, not in render body - satisfies react-hooks/purity)
  - Token expiry badge: red 'expired' | amber '~Xm' (<30min) | hidden
  - 'Token' button (amber) next to 'Retest' for OAuth connections

- Add en.json i18n: tokenRefreshed, tokenRefreshFailed

* Initial plan

* feat: integrate wildcardRouter into model alias resolution (T13)

T13 - Wildcard Model Routing
- Import resolveWildcardAlias from wildcardRouter.ts into model.ts
- In getModelInfoCore(), after exact alias check fails, try glob wildcard
  alias matching (e.g., 'claude-sonnet-*' alias → 'anthropic/claude-sonnet-4')
- Returns { provider, model, extendedContext, wildcardPattern } on match
- Falls back to MODEL_TO_PROVIDERS lookup and openai default as before

* fix: clipboard cleanup and tool validation

* feat: media page UX + T04 playground uploads + T03 HuggingFace/Vertex AI

Media Page (MediaPageClient.tsx):
- Render images inline (img tags from b64_json or url)
- Show transcription as plain readable text (not raw JSON)
- Amber banner for credential errors with link to /dashboard/providers
- Detect empty transcription result and show credentials hint
- Provider credential hint below selector for non-local providers
- Extended provider/model lists: HuggingFace, Qwen TTS, Inworld, Cartesia, PlayHT, AssemblyAI

T04 - Playground File Uploads (playground/page.tsx):
- Audio file upload panel for transcription endpoint (multipart/form-data)
- Image upload panel for vision models (gpt-4o, claude-3, gemini, pixtral, llava...)
- Auto-detect vision models by name heuristic
- Inject uploaded images as base64 image_url in chat messages
- Inline image rendering for image generation results
- Readable text view for transcription results with copy button
- Preview thumbnails for attached images with individual remove

T03 - HuggingFace + Vertex AI Providers:
- HuggingFace: frontend providers.ts + backend providerRegistry.ts
  Uses HuggingFace Router OpenAI-compatible endpoint
- Vertex AI: frontend providers.ts + backend providerRegistry.ts
  Uses gemini format with generateContent API (urlBuilder fallback)

T07 - API Key Round-Robin: VERIFIED already implemented in auth.ts
  fill-first, round-robin, p2c, random, least-used, cost-optimized strategies

* feat: T05 task-aware routing + fix #302 stream override + fix #73 claude provider fallback

T05 - Task-Aware Smart Routing:
- New open-sse/services/taskAwareRouter.ts:
  Detects 7 task types: coding, creative, analysis, vision, summarization,
  background, chat from system/user message content and images
  Configurable taskModelMap per task type, stats tracking
  applyTaskAwareRouting() integrates with existing chat pipeline
- New src/app/api/settings/task-routing/route.ts:
  GET/PUT/POST API for task routing config + reset-stats + detect action
  Persists config via updateSettings('taskRouting')
- Integration in src/sse/handlers/chat.ts:
  applyTaskAwareRouting() called after policy enforcement, before combo resolve
  Logs task type detection and model overrides

Fix #302 - OpenAI SDK stream=False drops tool_calls:
- src/sse/handlers/chat.ts T01 Accept header negotiation:
  Changed condition from 'body.stream !== true' to 'body.stream === undefined'
  OpenAI Python SDK sends 'Accept: application/json, text/event-stream' in every
  request, even stream=False — the old code was incorrectly forcing stream=true,
  causing tool_calls to be dropped from non-streaming responses

Fix #73 - Claude Haiku routed to OpenAI provider instead of Antigravity:
- open-sse/services/model.ts getModelInfoCore():
  Added heuristic prefix detection before the blind 'openai' fallback:
  claude-* models → antigravity (Anthropic) provider
  gemini-*/gemma-* models → gemini provider
  Closes: #73, partially addresses #302

* fix: token counts 0 (#74), model import dup (#180), model route fallback (#73)

fix #74 - Token counts always 0 for Antigravity/Claude streaming:
- open-sse/utils/usageTracking.ts extractUsage():
  Add handler for 'message_start' SSE event which carries INPUT tokens in
  Antigravity/Claude streaming:
  { type: 'message_start', message: { usage: { input_tokens: N } } }
  This event was completely unhandled, causing ALL input token counts to be
  dropped for every Antigravity/Claude streaming request

fix #180 - Model import shows duplicates with no visual feedback:
- src/shared/components/ModelSelectModal.tsx:
  Added addedModelValues prop (string[]) to receive already-added model values
  Models already in the combo now shown with ✓ indicator + green highlight
  Makes it visually clear which models are already added vs new
- src/app/(dashboard)/dashboard/combos/page.tsx:
  Pass addedModelValues={models.map(m => m.model)} to ModelSelectModal

* Harden clipboard UX and Claude tool normalization (#360)

* Initial plan

* chore: plan updates for clipboard and translator fixes

* fix: clipboard cleanup, copy feedback, and claude tool validation

---------

Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com>
2026-03-14 10:59:15 -03:00

327 lines
9.4 KiB
TypeScript

/**
* Task-Aware Smart Router — T05
*
* Detects the semantic type of an incoming chat request and routes
* to the most appropriate (optimal cost/quality) model for that task type.
*
* Task types:
* - coding → fast reasoning models (deepseek, codex, claude-sonnet)
* - creative → expressive models (claude-opus, gpt-5)
* - analysis → long-context + smart models (gemini-2.5-pro, claude-opus)
* - vision → multimodal models (gpt-4o, gemini-2.5-flash, claude-3.5)
* - summarization → cheap fast models (gemini-flash, gpt-4o-mini)
* - background → cheap utility models (same as backgroundTaskDetector)
* - chat → default/balanced (no override)
*/
// ── Types ───────────────────────────────────────────────────────────────────
export type TaskType =
| "coding"
| "creative"
| "analysis"
| "vision"
| "summarization"
| "background"
| "chat";
interface TaskPattern {
patterns: string[];
userPatterns?: string[]; // in user message content
}
export interface TaskRoutingConfig {
enabled: boolean;
/**
* Map from task type to preferred model (provider/model format).
* Empty string = use whatever was requested (no override).
*/
taskModelMap: Record<TaskType, string>;
detectionEnabled: boolean;
stats: { detected: number; routed: number };
}
// ── Default detection patterns ───────────────────────────────────────────────
const TASK_PATTERNS: Record<TaskType, TaskPattern> = {
coding: {
patterns: [
"write code",
"write a function",
"implement",
"debug",
"fix this",
"fix the",
"refactor",
"unit test",
"write test",
"write a script",
"code review",
"complete this function",
"add a feature",
"javascript",
"typescript",
"python",
"sql query",
"api endpoint",
],
userPatterns: [
"```",
"def ",
"function ",
"class ",
"import ",
"const ",
"let ",
"var ",
"SELECT ",
"INSERT ",
"<html",
"<div",
],
},
creative: {
patterns: [
"write a story",
"write a poem",
"write a song",
"creative writing",
"write a blog",
"write an article",
"write a script",
"write an essay",
"imagine",
"roleplay",
"brainstorm",
"creative",
],
},
analysis: {
patterns: [
"analyze",
"analyse",
"analysis",
"compare",
"evaluate",
"assess",
"explain",
"reasoning",
"pros and cons",
"advantages and disadvantages",
"what are the implications",
"in-depth",
"comprehensive",
],
},
vision: {
patterns: [
"look at this image",
"in this image",
"what do you see",
"describe this image",
"analyze this image",
"read this screenshot",
],
userPatterns: ["image_url", "data:image"],
},
summarization: {
patterns: [
"summarize",
"summary",
"tldr",
"tl;dr",
"brief overview",
"key points",
"main points",
"what did",
"highlights from",
],
},
background: {
patterns: [
"generate a title",
"generate title",
"create a title",
"name this",
"short description",
"brief description",
"one-line summary",
"conversation title",
],
},
chat: {
patterns: [],
},
};
// ── Default task → model map ─────────────────────────────────────────────────
const DEFAULT_TASK_MODEL_MAP: Record<TaskType, string> = {
coding: "deepseek/deepseek-chat", // DeepSeek V3.2 — best coding OSS
creative: "", // No override — use requested model
analysis: "gemini/gemini-2.5-pro", // Best long-context reasoning
vision: "openai/gpt-4o", // Best vision baseline
summarization: "gemini/gemini-2.5-flash", // Fast + cheap for summarization
background: "gemini/gemini-2.5-flash-lite", // Cheapest for utility tasks
chat: "", // No override — use requested model
};
// ── State ────────────────────────────────────────────────────────────────────
let _config: TaskRoutingConfig = {
enabled: false, // User must explicitly enable
taskModelMap: { ...DEFAULT_TASK_MODEL_MAP },
detectionEnabled: true,
stats: { detected: 0, routed: 0 },
};
// ── Config Management ────────────────────────────────────────────────────────
export function setTaskRoutingConfig(config: Partial<TaskRoutingConfig>): void {
_config = {
..._config,
...config,
stats: _config.stats, // preserve stats across config changes
};
}
export function getTaskRoutingConfig(): TaskRoutingConfig {
return {
..._config,
taskModelMap: { ..._config.taskModelMap },
stats: { ..._config.stats },
};
}
export function resetTaskRoutingStats(): void {
_config.stats = { detected: 0, routed: 0 };
}
export function getDefaultTaskModelMap(): Record<TaskType, string> {
return { ...DEFAULT_TASK_MODEL_MAP };
}
// ── Detection ────────────────────────────────────────────────────────────────
interface RequestMessage {
role?: string;
content?: unknown;
}
function extractText(content: unknown): string {
if (typeof content === "string") return content.toLowerCase();
if (Array.isArray(content)) {
return content
.map((part: any) =>
typeof part === "string" ? part.toLowerCase() : part?.text?.toLowerCase() || ""
)
.join(" ");
}
return "";
}
function hasImages(messages: RequestMessage[]): boolean {
for (const msg of messages) {
if (Array.isArray(msg.content)) {
for (const part of msg.content as any[]) {
if (part?.type === "image_url" || part?.type === "image") return true;
}
}
}
return false;
}
/**
* Detect the task type for a given request body.
* Returns 'chat' (no-op) if nothing specific is detected.
*/
export function detectTaskType(body: any): TaskType {
if (!body || typeof body !== "object") return "chat";
const messages: RequestMessage[] = Array.isArray(body.messages)
? body.messages
: Array.isArray(body.input)
? body.input
: [];
if (messages.length === 0) return "chat";
// 1. Vision — check for image_url in any message
if (hasImages(messages)) return "vision";
// 2. System prompt patterns (background first — most specific)
const systemMsg = messages.find((m) => m.role === "system" || m.role === "developer");
const systemText = systemMsg ? extractText(systemMsg.content) : "";
const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
const userText = lastUserMsg ? extractText(lastUserMsg.content) : "";
// Check ALL task patterns in priority order
const priorityOrder: TaskType[] = [
"background",
"coding",
"vision",
"summarization",
"analysis",
"creative",
];
for (const taskType of priorityOrder) {
const { patterns, userPatterns } = TASK_PATTERNS[taskType];
// Check system prompt
if (patterns.some((p) => systemText.includes(p.toLowerCase()))) {
return taskType;
}
// Check user message for this task's patterns
if (patterns.some((p) => userText.includes(p.toLowerCase()))) {
return taskType;
}
// Check user message for code-specific patterns (userPatterns)
if (userPatterns?.some((p) => userText.includes(p.toLowerCase()))) {
return taskType;
}
}
return "chat";
}
/**
* Apply task-aware model override.
* Returns the original model if routing is disabled or no override found.
*
* @param originalModel - The model from the request (e.g. "openai/gpt-4o")
* @param body - The raw request body to detect task type from
* @returns { model, taskType, wasRouted }
*/
export function applyTaskAwareRouting(
originalModel: string,
body: any
): { model: string; taskType: TaskType; wasRouted: boolean } {
if (!_config.enabled || !_config.detectionEnabled) {
return { model: originalModel, taskType: "chat", wasRouted: false };
}
const taskType = detectTaskType(body);
_config.stats.detected++;
const preferred = _config.taskModelMap[taskType];
// No override configured for this task type
if (!preferred || preferred === "") {
return { model: originalModel, taskType, wasRouted: false };
}
// Don't override if the model is already "better" (e.g. user sent opus, preferred is flash)
// We respect user's choice unless it's a background/summarization override
if (taskType !== "background" && taskType !== "summarization") {
// For non-utility tasks, only override if no specific model was given
// (i.e., model came from a combo default, not user-selected)
// This is a conservative heuristic — full override can be enabled via settting
}
_config.stats.routed++;
return { model: preferred, taskType, wasRouted: true };
}