mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 20:32:20 +03:00
* chore(release): open v3.8.18 development cycle * fix(catalog): stop Codex CLI model-catalog refresh from erroring (#3481) Codex's model-catalog refresh (codex_models_manager) does GET /v1/models?client_version=<v> and decodes a JSON object with a TOP-LEVEL `models` array. OmniRoute answers in the OpenAI-standard `{object,data}` shape, so codex fails with "missing field `models`" and logs "failed to refresh available models" on every startup. Detect codex clients via the `originator` / `user-agent` = `codex_*` headers they send and add an EMPTY top-level `models: []` so the decode succeeds. Non-codex OpenAI clients keep the byte-identical `{object,data}` response. The array is intentionally empty: codex replaces its built-in per-model agent prompt (`base_instructions`, ~21k chars) with whatever a populated entry carries for the selected model, so emitting our catalog would drop the agent prompt to nothing and break codex's agent behaviour (verified empirically against codex 0.137). An empty list keeps codex on its built-in model info — same inference as before, minus the error. Validated end-to-end with the real handler against codex 0.137: "failed to refresh available models" → 0 occurrences, instructions preserved (built-in Codex agent prompt, not empty). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: ignore quality reports and local prompt artifacts Add generated quality gate reports, metrics files, and local setup prompt artifacts to .gitignore to prevent committing environment-specific or temporary files. * fix(provider): detect Responses API format when body has `input` but … (#3490) Integrated into release/v3.8.18 * fix(sse): normalize numeric provider ids to strings (#3451) Integrated into release/v3.8.18 * feat(browserPool): resolve Playwright proxy from proxy_registry DB (#3492) Integrated into release/v3.8.18 * fix(theoldllm): generate X-Request-Token server-side, drop Playwright (#3491) Integrated into release/v3.8.18 * feat(plugins): add lifecycle hooks and theme-manager plugin (#3473) Integrated into release/v3.8.18 * fix(combo): parallel pre-screen + circuit-breaker fast-exit for priority combos (#3169) Integrated into release/v3.8.18 * feat(ui): unifi active and finished requests into single view #1422 (#3401) Integrated into release/v3.8.18 * docs(changelog): record #3401, #3473, #3492, #3490, #3451, #3491, #3169 under v3.8.18 * feat(docs): add doc accuracy gate + refresh AGENTS.md counts (#3510) Integrated into release/v3.8.18 * fix(sse): drop empty-choices chunks without usage instead of injecting retry text (#3513) PR #3422 ('allow OpenAI usage-only empty choices chunks') reintroduced the assistant-content injection '[OmniRoute] Upstream returned an empty response. Please retry.' for empty `choices: []` chunks that carry no valid usage. Clients (Goose/opencode) feed that text back as a turn and spin in a retry loop -- the exact regression #3400 had fixed by dropping the chunk. Restore the drop behavior for the no-usage case while preserving #3422's standards-compliant forwarding of usage-only `include_usage` final chunks. Realign the mislabeled stream-utils test (it asserted the injection) and add a dedicated regression guard. Reported-by: @mochizzan Refs: #3502, #3388, #3400, #3422 * fix(authz): fall back to URL token when Authorization isn't a usable Bearer (#3504) Integrated into release/v3.8.18 * fix(playground): authenticate via session, test key policy by id (#3503) Integrated into release/v3.8.18 * docs(changelog): record #3510, #3504, #3503 under v3.8.18 * fix: llama base url normalization (#3519) * docs(changelog): reconcile v3.8.18 — add #3519, #3513, #3435-repair, gitignore chore (full commit↔changelog coverage) * fix(opencode-plugin): bound regex quantifiers in normaliseFreeLabel (polynomial-ReDoS) CodeQL js/polynomial-redos: unbounded \s* before an anchored \s*$ allowed O(n²) backtracking on attacker-influenced display names. Bounded to {0,8}/{1,8} (ample for any real label spacing). Plugin builds + 254 tests green. * fix(types): restore clean typecheck:core for v3.8.18 release gate - getPendingRequests() typed to real shape (was widened to object) → fixes unknown 'count' in the unified-requests view (#3401) - streamChunks log payload cast to its declared type (callLogs.ts) - preScreenTargets aligned to canonical IsModelAvailable signature (#3169), Promise.resolve-normalized so .catch never hits a bare boolean All 5 gates green: lint(0 err) + typecheck:core + cycles + docs-all + unit + vitest(146). --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Andrey Borodulin <borodulin@gmail.com> Co-authored-by: Dmitrii Safronov <zimniy@cyberbrain.cc> Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com> Co-authored-by: PizzaV <103120356+pizzav-xyz@users.noreply.github.com> Co-authored-by: Markus Hartung <mail@hartmark.se> Co-authored-by: Felipe Almeman <4226997+zhiru@users.noreply.github.com>
302 lines
9.8 KiB
TypeScript
302 lines
9.8 KiB
TypeScript
/**
|
|
* Universal model naming template for the OmniRoute plugin.
|
|
*
|
|
* Naming pipeline:
|
|
* [tag] <provider-label><separator><display-name><suffix>
|
|
*
|
|
* [Free] <provider> - <name> · <budget> ← free model
|
|
* Auto: <variant> (<N>p) ← auto combo
|
|
* Combo: <name> ← DB combo
|
|
* <provider> - <name> ← regular model
|
|
*/
|
|
|
|
// ── Constants ────────────────────────────────────────────────────────────
|
|
|
|
/** Separator between provider label and model display name. */
|
|
export const PROVIDER_TAG_SEPARATOR = " - ";
|
|
|
|
/** Threshold beyond which providerDisplayName is abbreviated. */
|
|
const PROVIDER_LABEL_MAX_CHARS = 12;
|
|
|
|
/** Aliases longer than this get title-case instead of UPPER. */
|
|
const ALIAS_UPPER_MAX_CHARS = 5;
|
|
|
|
// ── Auto Combo Types ─────────────────────────────────────────────────────
|
|
|
|
export type AutoVariant =
|
|
| "coding"
|
|
| "fast"
|
|
| "cheap"
|
|
| "offline"
|
|
| "smart"
|
|
| "lkgp";
|
|
|
|
export const AUTO_VARIANTS: AutoVariant[] = [
|
|
"coding",
|
|
"fast",
|
|
"cheap",
|
|
"offline",
|
|
"smart",
|
|
"lkgp",
|
|
];
|
|
|
|
export const AUTO_VARIANT_DESCRIPTIONS: Record<
|
|
AutoVariant | "default",
|
|
string
|
|
> = {
|
|
default: "Best provider via scoring",
|
|
coding: "Quality-first for code tasks",
|
|
fast: "Latency-optimized routing",
|
|
cheap: "Cost-optimized routing",
|
|
offline: "Offline-friendly providers",
|
|
smart: "Quality-first with exploration",
|
|
lkgp: "Last-Known-Good-Provider routing",
|
|
};
|
|
|
|
// ── Free Model Types ─────────────────────────────────────────────────────
|
|
|
|
export type FreeModelFreeType =
|
|
| "recurring-daily"
|
|
| "recurring-monthly"
|
|
| "recurring-credit"
|
|
| "one-time-initial"
|
|
| "keyless"
|
|
| "discontinued";
|
|
|
|
// ── Provider Label ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Title-case a long, lowercase-looking alias.
|
|
* `antigravity` → `Antigravity`
|
|
*/
|
|
function titleCaseAlias(alias: string): string {
|
|
if (alias.length === 0) return alias;
|
|
return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Pick the short label for an upstream provider.
|
|
*
|
|
* Rules:
|
|
* 1. Trim `providerDisplayName`. If ≤12 chars → use verbatim.
|
|
* 2. Alias ≤5 chars → UPPER(alias). Alias >5 → titleCase.
|
|
* 3. Neither → undefined.
|
|
*/
|
|
export function shortProviderLabel(
|
|
enrichment:
|
|
| { providerDisplayName?: string; providerAlias?: string }
|
|
| undefined,
|
|
): string | undefined {
|
|
if (!enrichment) return undefined;
|
|
const raw =
|
|
typeof enrichment.providerDisplayName === "string"
|
|
? enrichment.providerDisplayName.trim()
|
|
: "";
|
|
if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
|
|
const alias =
|
|
typeof enrichment.providerAlias === "string"
|
|
? enrichment.providerAlias.trim()
|
|
: "";
|
|
if (alias.length > 0) {
|
|
return alias.length <= ALIAS_UPPER_MAX_CHARS
|
|
? alias.toUpperCase()
|
|
: titleCaseAlias(alias);
|
|
}
|
|
// Long displayName with no alias to fall back on: keep the long label
|
|
// rather than dropping the provider prefix entirely.
|
|
return raw.length > 0 ? raw : undefined;
|
|
}
|
|
|
|
// ── Free Label ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Normalise display name so free-tier models get a consistent `[Free] ` prefix.
|
|
*
|
|
* "GPT-4.1 (Free)" → "[Free] GPT-4.1"
|
|
* "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash"
|
|
* "Claude Opus 4.7" → "Claude Opus 4.7" (unchanged)
|
|
*/
|
|
export function normaliseFreeLabel(name: string): string {
|
|
// Bounded whitespace quantifiers ({0,8}/{1,8}) avoid the polynomial-ReDoS
|
|
// backtracking that unbounded \s* before an anchored \s*$ would allow on
|
|
// attacker-influenced display names. 8 covers any realistic label spacing.
|
|
const cleaned = name
|
|
.replace(/\s{0,8}\(free\)\s{0,8}$/i, "")
|
|
.replace(/[\s-]{1,8}free\s{0,8}$/i, "")
|
|
.trim();
|
|
const wasFree = cleaned.length < name.trim().length;
|
|
if (!wasFree) return name;
|
|
return `[Free] ${cleaned}`;
|
|
}
|
|
|
|
// ── Free Budget Formatting ────────────────────────────────────────────────
|
|
|
|
function fmtTokens(n: number): string {
|
|
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B";
|
|
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
|
|
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
|
|
return String(n);
|
|
}
|
|
|
|
/**
|
|
* Format a free model budget into a short human-readable suffix.
|
|
*
|
|
* recurring-daily → "25M tokens/day"
|
|
* recurring-monthly → "25M tokens/month"
|
|
* recurring-credit → "10M credits"
|
|
* one-time-initial → "1M credits (one-time)"
|
|
* keyless → "(keyless)"
|
|
* discontinued → "(discontinued)"
|
|
*/
|
|
export function formatFreeBudget(params: {
|
|
freeType: FreeModelFreeType;
|
|
monthlyTokens?: number;
|
|
creditTokens?: number;
|
|
}): string {
|
|
const { freeType, monthlyTokens = 0, creditTokens = 0 } = params;
|
|
|
|
switch (freeType) {
|
|
case "recurring-daily":
|
|
return `${fmtTokens(monthlyTokens)} tokens/day`;
|
|
case "recurring-monthly":
|
|
return `${fmtTokens(monthlyTokens)} tokens/month`;
|
|
case "recurring-credit":
|
|
return `${fmtTokens(creditTokens)} credits`;
|
|
case "one-time-initial":
|
|
return `${fmtTokens(creditTokens)} credits (one-time)`;
|
|
case "keyless":
|
|
return "(keyless)";
|
|
case "discontinued":
|
|
return "(discontinued)";
|
|
default:
|
|
return "";
|
|
}
|
|
}
|
|
|
|
// ── Auto Combo Naming ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Format auto combo display name.
|
|
*
|
|
* "Auto: Coding (4p)"
|
|
* "Auto: Default (6p)"
|
|
* "Auto" (no candidate count when unknown)
|
|
*/
|
|
export function formatAutoComboName(
|
|
variant: AutoVariant | undefined,
|
|
candidateCount?: number,
|
|
): string {
|
|
const label = variant
|
|
? variant.charAt(0).toUpperCase() + variant.slice(1)
|
|
: "Default";
|
|
const count =
|
|
typeof candidateCount === "number" && candidateCount > 0
|
|
? ` (${candidateCount}p)`
|
|
: "";
|
|
return `Auto: ${label}${count}`;
|
|
}
|
|
|
|
/**
|
|
* Build the model ID for an auto combo entry.
|
|
* "auto/coding", "auto/fast", "auto" (default).
|
|
*/
|
|
export function autoComboModelId(variant: AutoVariant | undefined): string {
|
|
return variant ? `auto/${variant}` : "auto";
|
|
}
|
|
|
|
// ── Universal Display Name Builder ────────────────────────────────────────
|
|
|
|
export interface ModelDisplayNameParams {
|
|
/** Raw model ID (e.g. "cc/claude-sonnet-4-6"). */
|
|
rawId: string;
|
|
/** Enrichment display name (e.g. "Claude Sonnet 4.6"). */
|
|
enrichmentName?: string;
|
|
/** Provider tag enrichment. */
|
|
providerAlias?: string;
|
|
/** Human-readable upstream provider label. */
|
|
providerDisplayName?: string;
|
|
/** Whether model is free tier. */
|
|
isFree?: boolean;
|
|
/** Free model budget info. */
|
|
freeType?: FreeModelFreeType;
|
|
/** Monthly token budget (for recurring free models). */
|
|
monthlyTokens?: number;
|
|
/** Credit token budget (for credit-based free models). */
|
|
creditTokens?: number;
|
|
/** Whether this is a combo entry (skip provider tag). */
|
|
isCombo?: boolean;
|
|
/** Whether this is an auto combo entry. */
|
|
isAutoCombo?: boolean;
|
|
/** Auto combo variant. */
|
|
autoVariant?: AutoVariant;
|
|
/** Auto combo candidate count. */
|
|
autoCandidateCount?: number;
|
|
}
|
|
|
|
/**
|
|
* Build the final display name following the universal template.
|
|
*
|
|
* Priority:
|
|
* 1. Auto combo → "Auto: <variant> (<N>p)"
|
|
* 2. DB combo → "Combo: <name>"
|
|
* 3. Free + enrichment + provider tag → "[Free] <label> - <name> · <budget>"
|
|
* 4. Free + enrichment → "[Free] <name> · <budget>"
|
|
* 5. Free + raw → "[Free] <rawId> · <budget>"
|
|
* 6. Enrichment + provider tag → "<label> - <name>"
|
|
* 7. Enrichment only → "<name>"
|
|
* 8. Raw fallback → normaliseFreeLabel(rawId)
|
|
*/
|
|
export function buildModelDisplayName(params: ModelDisplayNameParams): string {
|
|
// Auto combos
|
|
if (params.isAutoCombo) {
|
|
return formatAutoComboName(params.autoVariant, params.autoCandidateCount);
|
|
}
|
|
|
|
// Determine base name — strip any existing free suffix first
|
|
const rawBase =
|
|
params.enrichmentName && params.enrichmentName.trim().length > 0
|
|
? params.enrichmentName
|
|
: params.rawId;
|
|
const cleanedBase = rawBase
|
|
.replace(/\s*\(free\)\s*$/i, "")
|
|
.replace(/[\s-]+free\s*$/i, "")
|
|
.trim();
|
|
const wasFree = cleanedBase.length < rawBase.trim().length;
|
|
const isFree = !!params.isFree || wasFree;
|
|
|
|
let baseName = cleanedBase;
|
|
|
|
// Provider tag (skip for combos)
|
|
if (!params.isCombo) {
|
|
const label = shortProviderLabel({
|
|
providerDisplayName: params.providerDisplayName,
|
|
providerAlias: params.providerAlias,
|
|
});
|
|
if (label) {
|
|
const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
|
|
if (!baseName.startsWith(prefix)) {
|
|
baseName = `${prefix}${baseName}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Prepend [Free] if applicable (AFTER provider tag for correct ordering)
|
|
if (isFree) {
|
|
baseName = `[Free] ${baseName}`;
|
|
}
|
|
|
|
// Free budget suffix
|
|
if (isFree && params.freeType) {
|
|
const budget = formatFreeBudget({
|
|
freeType: params.freeType,
|
|
monthlyTokens: params.monthlyTokens,
|
|
creditTokens: params.creditTokens,
|
|
});
|
|
if (budget) {
|
|
baseName = `${baseName} · ${budget}`;
|
|
}
|
|
}
|
|
|
|
return baseName;
|
|
}
|