Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2173,9 +2173,16 @@ APP_LOG_TO_FILE=true
# Also configurable from Dashboard > Settings > Feature Flags.
# OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK=false

# Rate limit maximum wait time before failing a request (ms). Default: 15000 (15s)
# Rate limit queue-wait budget: how long a request may wait for a rate-limit
# slot (gates + limiter queue) before being dropped (ms). Default: 120000 (2 min)
# Used by: open-sse/services/rateLimitManager.ts
# RATE_LIMIT_MAX_WAIT_MS=15000
# RATE_LIMIT_MAX_WAIT_MS=120000

# Limiter-managed execution backstop (Bottleneck `expiration`): bounds a job's
# post-dispatch execution, never queue wait. Must stay ABOVE upstream
# fetch-start timeouts on non-incremental gateways. Default: 600000 (10 min)
# Used by: open-sse/services/rateLimitManager.ts
# RATE_LIMIT_EXECUTION_MAX_WAIT_MS=600000

# Limiter-managed execution backstop (Bottleneck `expiration`): bounds a job's
# post-dispatch execution, never queue wait. Must stay ABOVE upstream
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(combo):** `ALL_TARGETS_SKIPPED` 503 responses now carry per-target pre-dispatch skip reasons (`skippedTargets`) and the earliest persisted-cooldown reset (`nextRetryAt`) instead of an opaque 503, and API-key allowlist rejections during combo target screening are logged at info with the key name ([#12294](https://github.com/diegosouzapw/OmniRoute/issues/12294))
5 changes: 5 additions & 0 deletions changelog.d/fixes/combo-dispatch-shape-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- **fix(combo):** tiny-budget reasoning probes (max_tokens < 256) whose upstream 200 burned the whole budget on thinking are answered with a truncated 200 (`finish_reason: "length"`) in both combo dispatchers instead of a 502 + model lockout (#10281 combo-path parity)
- **fix(translator):** opencode console gateways (opencode-go / opencode-zen) now get the reasoning replay sentinel on cache misses for thinking-mode tool turns, and the Responses converter emits it as a `reasoning_text` item — upstream 400 "The reasoning_text in the thinking mode must be passed back to the API" no longer takes out deepseek/glm multi-turn traffic; DeepSeek-direct absent-field behavior unchanged (#9573/#9610 contract preserved)
- **fix(translator):** GLM-family upstreams get a synthetic `(continue)` user turn on the same-format (openai→openai) lane when history has no user turn, mirroring the Claude-lane `_ensureUserTurn` behavior — upstream 400 `[1214] The messages parameter is illegal` fixed
- **fix(embeddings):** per-item string inputs are clamped to 20k chars before upstream dispatch so oversized documents no longer fail with "Invalid 'input': maximum context length is 8192 tokens"
- **fix(config):** egress-IP lockout provider family is overridable via `OMNIROUTE_EGRESS_IP_LOCK_PROVIDERS` (`none` disables the shared-IP cascade parking for paid per-workspace plans)
34 changes: 3 additions & 31 deletions config/quality/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -633,11 +633,6 @@
"count": 1
}
},
"open-sse/services/tlsClientBase.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"open-sse/services/tokenLimitCounter.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
Expand Down Expand Up @@ -2753,7 +2748,7 @@
},
"tests/unit/build/check-licenses.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 5
"count": 4
}
},
"tests/unit/bypass-handler.test.ts": {
Expand Down Expand Up @@ -2862,7 +2857,7 @@
},
"tests/unit/chatcore-translation-paths.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 31
"count": 30
}
},
"tests/unit/check-docs-symbols.test.ts": {
Expand Down Expand Up @@ -3348,7 +3343,7 @@
},
"tests/unit/combo-routing-engine.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 268
"count": 267
}
},
"tests/unit/combo-same-provider-cascade.test.ts": {
Expand Down Expand Up @@ -5367,32 +5362,9 @@
"count": 1
}
},
"tests/unit/ui/use-improve-prompt.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-presets.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-stream-metrics.test.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-structured-output.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-tools-builder.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"tests/unit/ui/use-traffic-stream.test.tsx": {
Expand Down
15 changes: 8 additions & 7 deletions config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
"_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.",
"open-sse/executors/antigravity.ts": 1665,
"open-sse/executors/base.ts": 1751,
"open-sse/executors/base.ts": 1762,
"open-sse/executors/chatgpt-web.ts": 5056,
"open-sse/executors/codex.ts": 1505,
"open-sse/executors/cursor.ts": 1759,
Expand All @@ -425,16 +425,16 @@
"open-sse/mcp-server/server.ts": 1572,
"open-sse/services/accountFallback.ts": 2467,
"open-sse/services/adobeFireflyBrowserLogin.ts": 1401,
"open-sse/services/combo.ts": 4080,
"open-sse/services/combo.ts": 4219,
"open-sse/translator/response/openai-responses.ts": 1466,
"open-sse/utils/cursorAgentProtobuf.ts": 1547,
"open-sse/utils/proxyFetch.ts": 1271,
"open-sse/utils/stream.ts": 3072,
"open-sse/utils/proxyFetch.ts": 1435,
"open-sse/utils/stream.ts": 3080,
"open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/browser-worker.ts": 4398,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1335,
"src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344,
"src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5018,
"src/app/(dashboard)/dashboard/combos/page.tsx": 5025,
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319,
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631,
Expand All @@ -457,7 +457,7 @@
"src/shared/components/RequestLoggerV2.tsx": 1718,
"src/shared/constants/providers/apikey/gateways.ts": 1462,
"src/shared/services/cliRuntime.ts": 1296,
"src/sse/handlers/chat.ts": 2454,
"src/sse/handlers/chat.ts": 2467,
"src/sse/services/auth.ts": 3450,
"tests/unit/account-fallback-service.test.ts": 2453,
"tests/unit/provider-validation-specialty.test.ts": 4656
Expand Down Expand Up @@ -643,5 +643,6 @@
"_rebaseline_2026_09_03_error_boundary_campaign": "Campanha de error-boundary (#12431 #12438 #12444 #12454 #12455 #12456 #12457 #12458 #12459 #12465 #12466 #12467 #12469 #12435), medido no tip com os 14 mergeados. open-sse/executors/codex.ts 1499->1505: os primeiros 4 (1499->1503) sao DRIFT ANTERIOR a esta campanha, ja presente no tip antes dela; os 2 ultimos (1503->1505) sao do #12444, que fecha o boundary de falha da resposta do Codex. Absorver o drift junto foi inevitavel porque o cap e um numero so, mas fica registrado aqui que 4 das 6 linhas nao sao desta leva. open-sse/vendor/codex-chatgpt-web/bridge.ts 1322->1335 (+13): tambem do #12444, no mesmo caminho de falha. NAO cobre open-sse/utils/stream.ts, que segue violando por drift anterior e independente.",
"_rebaseline_2026_09_03_12352_apikey_acl": "PR #12352 (fix/api-key-create-acl-12275) crescimento proprio: src/lib/db/apiKeys.ts 1610->1625 (+15). A criacao de API key descartava a ACL enviada no payload; preservar essa ACL exige carregar e persistir o conjunto no mesmo chokepoint de INSERT do modulo de dominio, sem extracao possivel sem partir a funcao de criacao ao meio. Coberto pelos testes do proprio PR (54/54 focados na leva).",
"_rebaseline_2026_09_03_houminxi_combo_stacked": "Leva HouMinXi (#12624 #12626 #12632 #12637): open-sse/services/combo.ts 4075->4080 (+5), medido no tip com os quatro mergeados. Cada PR registrou o proprio crescimento contra o tip de onde forkou (o #12637 ja subira o cap para 4075); as 5 linhas restantes so aparecem quando eles empilham, porque mais de um toca o mesmo chokepoint de scoring reset-aware em combo.ts. Fiacao em ponto existente, sem extracao possivel sem partir a funcao de selecao de alvos. Coberto por combo-strategies e reset-aware-request-scope-12600 (119/119 focados na leva).",
"_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva)."
"_rebaseline_2026_09_04_12641_continuation_effective_input": "PR #12641 crescimento proprio: src/sse/handlers/chat.ts 2450->2454 (+4). A continuacao por previous_response_id encadeava a partir de clientRawRequest.body.input, que e capturado ANTES da reconstrucao do proprio chat.ts; quando o turno anterior ja era uma continuacao, esse campo guarda so o delta do cliente, e o erro se acumulava a cada salto ate a reconstrucao virar itens de tool sem prefixo. Persistir o input EFETIVO exige as linhas no ponto onde a reconstrucao termina, dentro do fluxo de despacho. Coberto por tests/unit/responses-continuation-store.test.ts (22/22 focados na leva).",
"_rebaseline_2026_09_05_bounded_abort_and_tls_ladder": "Resilience work 2026-09-04/05."
}
2 changes: 2 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ const nextConfig = {
webpackMemoryOptimizations: true,
// Run webpack in a separate Node worker, lowering main-process memory.
webpackBuildWorker: true,
// Cap page-data/export workers on memory-constrained machines (default: ncpu-1).
cpus: process.env.NEXT_BUILD_CPUS ? Number(process.env.NEXT_BUILD_CPUS) : undefined,
// Next.js proxy (middleware) has a default 10MB body clone limit. File
// uploads (OpenAI-compatible /v1/files) routinely exceed this. Match the
// 512 MB server-side cap; tune via env if needed.
Expand Down
38 changes: 37 additions & 1 deletion open-sse/config/providerErrorRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,43 @@ export function honorsRuleLockScope(provider: string | null | undefined): boolea
* HONORS_RULE_LOCK_SCOPE_PROVIDERS (#10334): a provider must opt in, and any
* widening is an explicit owner decision.
*/
const EGRESS_BUCKETED_LOCK_PROVIDERS = new Set(["opencode", "opencode-go", "opencode-cli"]);
// Default free-tier set (#9611). PAID plans are account-bucketed, not
// IP-bucketed, so operators running paid subscriptions can disable or trim
// the egress-IP lockout via OMNIROUTE_EGRESS_IP_LOCK_PROVIDERS:
// unset → default set below (free-tier behavior)
// none/off/false/0 → empty set (egress-IP lockout fully disabled)
// "a,b,c" → exact replacement set
// Static default as a plain lookup table; the runtime set is env-derived
// (dynamic membership), so a Set is the right structure there.
const EGRESS_BUCKETED_LOCK_PROVIDERS_DEFAULT: Record<string, true> = {
opencode: true,
"opencode-go": true,
"opencode-cli": true,
};

/**
* Pure parser for OMNIROUTE_EGRESS_IP_LOCK_PROVIDERS (exported for tests).
* Returns the effective provider set: default free-tier family when unset,
* empty for none/off/false/0, otherwise the comma-separated replacement set.
*/
export function egressIpLockProvidersFromEnv(
raw: string | undefined,
fallback: Record<string, true> = EGRESS_BUCKETED_LOCK_PROVIDERS_DEFAULT
): Set<string> {
const trimmed = raw?.trim();
if (trimmed === undefined || trimmed === "") return new Set(Object.keys(fallback));
if (/^(none|off|false|0)$/i.test(trimmed)) return new Set<string>();
return new Set(
trimmed
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
);
}

const EGRESS_BUCKETED_LOCK_PROVIDERS = egressIpLockProvidersFromEnv(
process.env.OMNIROUTE_EGRESS_IP_LOCK_PROVIDERS
);

export function isEgressBucketedLockScope(provider: string | null | undefined): boolean {
return !!provider && EGRESS_BUCKETED_LOCK_PROVIDERS.has(provider.toLowerCase());
Expand Down
8 changes: 8 additions & 0 deletions open-sse/config/providerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export function generateLegacyProviders(): Record<string, LegacyProvider> {
if (typeof entry.timeoutMs === "number") {
p.timeoutMs = entry.timeoutMs;
}
// #11526 follow-up: the headers-wait cap override must reach the executor's
// LegacyProvider config — dropping it here silently fell back to the 110s
// global cap even for providers whose registry entry overrides it (observed:
// opencode-go deepseek thinking generations 504ing at exactly 110s despite
// fetchStartTimeoutCapMs on the registry entry).
if (typeof entry.fetchStartTimeoutCapMs === "number") {
p.fetchStartTimeoutCapMs = entry.fetchStartTimeoutCapMs;
}

// Headers
const mergedHeaders = {
Expand Down
7 changes: 7 additions & 0 deletions open-sse/config/providers/registry/command-code/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export const command_codeProvider: RegistryEntry = {
// The discovery response is a partial routing catalog; static registry
// entries omitted from it can still be accepted by the gateway.
liveCatalogAuthoritative: false,
// Reasoning models (e.g. z-ai/glm-5.3-flash) exhaust small client budgets on
// thinking before emitting content; default to a reasoning-safe budget.
requestDefaults: { maxTokens: 16_384 },
// Console Go / Command Code gateways buffer entire generations — no upstream
// bytes flow until the model finishes thinking. Streaming needs a headers-wait
// ceiling well above the 110s global cap for long reasoning generations.
fetchStartTimeoutCapMs: 600_000,
authType: "apikey",
authHeader: "Authorization",
authPrefix: "Bearer ",
Expand Down
7 changes: 7 additions & 0 deletions open-sse/config/providers/registry/opencode/go/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export const opencode_goProvider: RegistryEntry = {
authHeader: "Authorization",
authPrefix: "Bearer",
defaultContextLength: 200000,
// glm-5.3-flash and other always-thinking models need a generous output
// budget or reasoning consumes every token before content is emitted.
requestDefaults: { maxTokens: 16_384 },
// Console Go / Command Code gateways buffer entire generations — no upstream
// bytes flow until the model finishes thinking. Streaming needs a headers-wait
// ceiling well above the 110s global cap for long reasoning generations.
fetchStartTimeoutCapMs: 600_000,
models: [
// Port from decolua/9router 8efacc11: align with official Go endpoints —
// glm-5.2 is now advertised and Kimi chat traffic must route through
Expand Down
6 changes: 6 additions & 0 deletions open-sse/config/providers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ export interface RegistryEntry {
chatPath?: string;
clientVersion?: string;
timeoutMs?: number;
/** Headers-wait ceiling override for streaming requests (#11526). Gateways
* that buffer entire generations (Console Go / Command Code) need this well
* above the 110s global cap — generateLegacyProviders() copies it into the
* executor's LegacyProvider config. */
fetchStartTimeoutCapMs?: number;
passthroughModels?: boolean;
/**
* Whether a non-empty synchronized live model list is exhaustive enough
Expand Down Expand Up @@ -264,6 +269,7 @@ export interface LegacyProvider {
chatPath?: string;
clientVersion?: string;
timeoutMs?: number;
fetchStartTimeoutCapMs?: number;
}

export const buildModels = (ids: readonly string[]): RegistryModel[] =>
Expand Down
14 changes: 12 additions & 2 deletions open-sse/executors/base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { HTTP_STATUS, FETCH_TIMEOUT_MS } from "../config/constants.ts";
import { applyProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
import { getRegistryEntry } from "../config/providerRegistry.ts";
import { resolveFetchStartTimeout } from "../utils/fetchStartTimeoutPolicy.ts";
import {
Expand Down Expand Up @@ -162,6 +163,7 @@ export type ProviderConfig = {
headers?: Record<string, string>;
requestDefaults?: ProviderRequestDefaults;
timeoutMs?: number;
fetchStartTimeoutCapMs?: number;
format?: string;
};

Expand Down Expand Up @@ -584,10 +586,14 @@ export class BaseExecutor {

stripInternalBodyFields(cloned);

return cloned;
// Registry requestDefaults (e.g. maxTokens for reasoning-heavy models like
// GLM-5.3-flash) fill fields the client left unset. No-op for providers
// without requestDefaults and idempotent for DefaultExecutor, which
// re-applies the same helper downstream.
return applyProviderRequestDefaults(cloned, this.config?.requestDefaults ?? null);
}

return body;
return applyProviderRequestDefaults(body, this.config?.requestDefaults ?? null);
}

shouldRetry(status: number, urlIndex: number) {
Expand Down Expand Up @@ -913,6 +919,10 @@ export class BaseExecutor {
const fetchStartTimeoutPolicy = resolveFetchStartTimeout({
baseTimeoutMs: this.getTimeoutMs(),
stream,
// Providers with non-incremental upstreams (whole generation buffered
// behind the gateway, e.g. opencode-go's Console Go GLM tier) can take
// minutes before first bytes; the registry overrides the 110s cap.
capMs: this.config?.fetchStartTimeoutCapMs,
});
const fetchStartTimeoutMs = fetchStartTimeoutPolicy.timeoutMs;
if (fetchStartTimeoutPolicy.capped) {
Expand Down
17 changes: 17 additions & 0 deletions open-sse/handlers/embeddings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,23 @@ function validateRequestedModalities(runtime: EmbeddingRuntime): EmbeddingFailur
: null;
}

/**
* Conservative per-item character budget for embedding string inputs. 20k
* chars stays under the smallest common embedding context (8192 tokens,
* text-embedding-3-small) even for code/CJK-heavy text. Array items are
* clamped individually; non-string items (structured/native modalities) are
* passed through untouched.
*/
const MAX_EMBEDDING_INPUT_CHARS = 20_000;

export function clampEmbeddingStringInput(input: unknown): unknown {
const clampString = (s: string): string =>
s.length > MAX_EMBEDDING_INPUT_CHARS ? s.slice(0, MAX_EMBEDDING_INPUT_CHARS) : s;
if (typeof input === "string") return clampString(input);
if (Array.isArray(input)) return input.map((item) => (typeof item === "string" ? clampString(item) : item));
return input;
}

function buildUpstreamBody(runtime: EmbeddingRuntime): Record<string, unknown> {
const upstreamBody: Record<string, unknown> = {
model: runtime.model,
Expand Down
Loading
Loading