Compare commits

..

5 Commits

222 changed files with 5431 additions and 14319 deletions

View File

@@ -1232,13 +1232,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
# OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000
# ── Provider probe (credential validation / model discovery) ──
# Timeout in ms for provider validationRead and modelsProbe presets.
# Default: 8000 (was 5000). Raise it if slow endpoints (Cerebras, Cloudflare AI, Groq)
# cause flapping between active/error in the dashboard.
# Used by: src/shared/network/safeOutboundFetch.ts — centralized timeout resolution.
# OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS=8000
# ── Proxy/relay fetch (connection pooling, #9158) ──
# Used by: open-sse/utils/proxyFetch.ts.
# A hung relay must fail BEFORE the client/agent timeout (typically 30s) so the
@@ -1331,28 +1324,6 @@ CURSOR_USER_AGENT="Cursor/3.4"
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD=2
# OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS=15000
# ── Provider-level circuit breaker thresholds and cooldowns ──
# Used by: open-sse/config/constants.ts (PROVIDER_PROFILES → accountFallback).
# These control the provider-level fuse (entire provider cooldown after repeated
# failures) — distinct from the per-key breaker above. Defaults match the
# historical PROVIDER_PROFILES values. Raise to tolerate transient upstream
# sheds without blacklisting the provider; lower to fail over faster.
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD=10
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS=900000
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS=300000
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD=5
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER=8
# OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT=2
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD=15
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS=1800000
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS=600000
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD=7
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER=4
# OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT=3
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD=2
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS=300000
# OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS=60000
# ── Context-cache pin health gate ──
# Used by: open-sse/services/combo.ts. When a context-cache pin points at a
# provider that is durably unhealthy, the pin is dropped to allow failover.
@@ -1478,11 +1449,6 @@ APP_LOG_TO_FILE=true
# CHAT_LOG_ARRAY_TAIL_ITEMS=128 # Number of array items retained from tail (default: 128)
# CHAT_LOG_MAX_DEPTH=6 # Max nesting depth before truncation (default: 6)
# CHAT_LOG_MAX_OBJECT_KEYS=80 # Max object keys retained (default: 80, 0 = no limit)
# CHAT_LOG_MAX_BODY_KB=1024 # Whole request/response body size before it's replaced by a bare
# {_truncated, messageCount, ...} summary instead of the full clone
# (default: 1024 KB / 1MB). Raise this if the dashboard's "Full
# Conversation" transcript panel shows a placeholder instead of the
# actual messages for long agentic conversations.
# Maximum rows in the proxy_logs SQLite table.
# Default: 100000
@@ -2384,8 +2350,7 @@ INSPECTOR_INTERNAL_INGEST_TOKEN=
# unset): path to a file whose trimmed content is the token.
# OMNIROUTE_INTERNAL_SERVICE_TOKEN_FILE=
# Quota Sharing (Group B — planos 16+22)
# sqlite | redis
QUOTA_STORE_DRIVER=sqlite
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
@@ -2661,6 +2626,10 @@ QUOTA_STORE_DRIVER=sqlite
# Used by: src/app/api/jobs/[id]/run-now/route.ts. Default: 30000 (30 seconds)
# OMNIROUTE_RUNNOW_TIMEOUT_MS=30000
# Maximum request/response body size before chat-log summarization, in KiB.
# Used by: src/lib/chatLogTruncation.ts. Default: 1024
# CHAT_LOG_MAX_BODY_KB=1024
# Adobe Firefly browser renewal and durable session cache (enabled by default).
# Used by: open-sse/services/adobeFireflySession.ts.
# ADOBE_FIREFLY_BROWSER_REFRESH=1

View File

@@ -1256,63 +1256,6 @@ jobs:
- run: npm run check:node-runtime
- run: npm run test:security
# Live-server E2E. Both suites boot a real OmniRoute via their own runner and
# drive it over HTTP; neither needs provider credentials. They were documented in
# AGENTS.md's test matrix but wired to NO workflow, and had additionally been
# unrunnable (vitest.config.ts excluded the very files their runners passed as a
# positional filter) — so nothing had executed them for as long as that was true.
test-ecosystem:
name: Ecosystem E2E (live server)
runs-on: ubuntu-latest
timeout-minutes: 20
# needs: changes (not build) — the runner boots its own dev server.
needs: changes
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run test:ecosystem
test-protocols-e2e:
name: Protocol Clients E2E (live server, advisory)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: changes
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
# ADVISORY until #10049 is resolved. Restoring this suite immediately surfaced a
# real discrepancy that had been invisible while it could not run: GET
# /api/mcp/audit answers 403 over loopback where the suite expects 200|401. That
# is a pre-existing contract question, not a defect introduced by wiring the job
# up, so it must not block every PR in the meantime. Flip to blocking (drop this
# continue-on-error) the moment #10049 lands.
continue-on-error: true
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run test:protocols:e2e
ci-summary:
name: CI Dashboard
runs-on: ubuntu-latest
@@ -1335,8 +1278,6 @@ jobs:
- test-e2e
- test-integration
- test-security
- test-ecosystem
- test-protocols-e2e
steps:
- name: Download i18n results
continue-on-error: true
@@ -1417,8 +1358,6 @@ jobs:
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Ecosystem E2E | $(status '${{ needs.test-ecosystem.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Protocol Clients E2E (advisory, #10049) | $(status '${{ needs.test-protocols-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -57,12 +57,7 @@ import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@ope
import { tool } from "@opencode-ai/plugin";
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
import { z } from "zod";
import {
createLogger,
logger as _logger,
type Logger as _Logger,
type LogLevel as _LogLevel,
} from "./logger.js";
import { logger as _logger, setLogLevel, type LogLevel as _LogLevel } from "./logger.js";
import {
PROVIDER_TAG_SEPARATOR as _PROVIDER_TAG_SEPARATOR,
shortProviderLabel as _shortProviderLabel,
@@ -722,7 +717,6 @@ export async function forceSyncOmniRouteModels(args: {
compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;
providersFetcher?: OmniRouteProvidersFetcher;
now?: () => number;
logger?: _Logger;
}): Promise<{
ok: boolean;
count: number;
@@ -743,11 +737,6 @@ export async function forceSyncOmniRouteModels(args: {
const compressionMetaFetcher =
args.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
const providersFetcher = args.providersFetcher ?? defaultOmniRouteProvidersFetcher;
const logger =
args.logger ??
createLogger(
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
);
const features = resolved.features ?? {};
const wantCombos = features.combos !== false;
const wantAutoCombos = features.autoCombos !== false;
@@ -858,8 +847,8 @@ export async function forceSyncOmniRouteModels(args: {
}
}
logger.info(
`force sync ok providerId=${resolved.providerId} ` +
console.warn(
`[omniroute-plugin] force sync ok providerId=${resolved.providerId} ` +
`models=${rawModels.length} combos=${rawCombos.length} ` +
`clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}`
);
@@ -891,7 +880,6 @@ export async function forceSyncOmniRouteModels(args: {
export function createOmniRouteSyncModelsTool(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
logger?: _Logger;
}): ReturnType<typeof tool> {
const { resolved, cache } = args;
return tool({
@@ -905,7 +893,7 @@ export function createOmniRouteSyncModelsTool(args: {
.describe("Optional reason for the sync (logging only)"),
},
async execute(toolArgs) {
const result = await forceSyncOmniRouteModels({ resolved, cache, logger: args.logger });
const result = await forceSyncOmniRouteModels({ resolved, cache });
const reason = toolArgs.reason ? ` reason=${toolArgs.reason}` : "";
if (!result.ok) {
return {
@@ -944,16 +932,10 @@ export function startOmniRouteAutoSync(args: {
resolved: ResolvedOmniRoutePluginOptions;
cache: OmniRouteFetchCache;
intervalMs?: number;
logger?: _Logger;
}): () => void {
const resolved = args.resolved;
const cache = args.cache;
const intervalMs = args.intervalMs ?? resolved.autoSyncIntervalMs;
const logger =
args.logger ??
createLogger(
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
);
if (!intervalMs || intervalMs <= 0) {
return () => {};
}
@@ -966,9 +948,11 @@ export function startOmniRouteAutoSync(args: {
if (stopped) return;
if (inFlight) return;
inFlight = (async () => {
const result = await forceSyncOmniRouteModels({ resolved, cache, logger });
const result = await forceSyncOmniRouteModels({ resolved, cache });
if (!result.ok) {
logger.error(`auto-sync failed providerId=${resolved.providerId}: ${result.error}`);
console.warn(
`[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}`
);
return;
}
if (lastCount === undefined) {
@@ -976,15 +960,15 @@ export function startOmniRouteAutoSync(args: {
return;
}
if (result.count !== lastCount) {
logger.info(
`auto-sync catalog size changed ${lastCount}${result.count} ` +
console.warn(
`[omniroute-plugin] auto-sync catalog size changed ${lastCount}${result.count} ` +
`(providerId=${resolved.providerId})`
);
lastCount = result.count;
}
})()
.catch((err) => {
logger.error(`auto-sync tick error: ${err instanceof Error ? err.message : String(err)}`);
console.warn("[omniroute-plugin] auto-sync tick error", err);
})
.finally(() => {
inFlight = null;
@@ -998,7 +982,9 @@ export function startOmniRouteAutoSync(args: {
timer.unref();
}
logger.info(`auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`);
console.warn(
`[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`
);
return () => {
stopped = true;
@@ -1008,9 +994,6 @@ export function startOmniRouteAutoSync(args: {
export const OmniRoutePlugin: Plugin = async (_input, options) => {
const resolved = resolveOmniRoutePluginOptions(coercePluginOptions(options));
const logger = createLogger(
resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")
);
// T-07: a single per-plugin-instance cache shared between the provider
// hook (T-03/T-05) and the config-shim hook (T-07). On OC ≥1.14.49 both
// hooks fire within the same Plugin invocation, so a shared cache keeps
@@ -1027,7 +1010,7 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
const _hash: string =
((globalThis as Record<string, unknown>).__PLUGIN_GIT_HASH__ as string) ?? "unknown";
const _prefixes = resolved.features?.apiFormat?.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES;
logger.info(
_logger.always(
`v${_ver} (${_hash}) initialized` +
` providerId=${resolved.providerId}` +
` baseURL=${resolved.baseURL ?? "(from auth.json)"}` +
@@ -1037,11 +1020,14 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
` logLevel=${resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn")}`
);
// Wire log level: startupDebug:true → "debug", explicit logLevel wins.
setLogLevel(resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn"));
// Background auto-discovery while the harness is running (Pi parity).
// Interval 0 disables. TTL on-demand discovery still works via modelCacheTtl.
startOmniRouteAutoSync({ resolved, cache: sharedCache, logger });
startOmniRouteAutoSync({ resolved, cache: sharedCache });
const syncTool = createOmniRouteSyncModelsTool({ resolved, cache: sharedCache, logger });
const syncTool = createOmniRouteSyncModelsTool({ resolved, cache: sharedCache });
const bareProviderId = resolved.omnirouteProviderId;
// Config hook: keep existing catalog shim, and register slash command
@@ -1049,7 +1035,6 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
// Pi-style registerCommand API; tools + command templates are the native path).
const baseConfigHook = createOmniRouteConfigHook(resolved, {
cache: sharedCache,
logger,
diskSnapshotReader: defaultDiskSnapshotReader,
diskSnapshotWriter: defaultDiskSnapshotWriter,
});
@@ -4271,7 +4256,6 @@ export function buildStaticProviderEntry(
rawAutoCombos?: OmniRouteRawAutoCombo[]
): OmniRouteStaticProviderEntry {
const models: Record<string, OmniRouteStaticModelEntry> = {};
const rawModelKeys = new Set<string>();
// usableOnly filter — compute once when feature enabled AND we have
// connection data to filter against. Soft-fail (empty connections list)
@@ -4428,9 +4412,7 @@ export function buildStaticProviderEntry(
// provider prefix (`<providerId>/<raw-id>`) is unreachable. Keys are the
// raw id verbatim; ids that already contain `/` (e.g. `cc/claude-opus-4-7`)
// keep it because the slash is part of the upstream model id itself.
const key = raw.id;
models[key] = entry;
rawModelKeys.add(key);
models[raw.id] = entry;
}
// Combo entries → stripped LCD shape. Each combo is keyed as
@@ -4627,9 +4609,8 @@ export function buildStaticProviderEntry(
// (`opencode-omniroute/opencode-omniroute/<slug>`), and `parseModel()`
// resolves credentials for the nonexistent provider `opencode-omniroute`
// instead of `omniroute`. See #7976.
const key = buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!;
models[key] = entry;
rawModelKeys.delete(key);
models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId).split("/").pop()!] =
entry;
// Make this combo's resolved entry available to parent combos
// that reference it via combo-ref. Use the friendly name since
@@ -4664,10 +4645,8 @@ export function buildStaticProviderEntry(
// Use the variant as the key: "auto", "auto/coding", etc.
const key = autoComboModelId(autoCombo.variant);
if (models[key]) {
// `/v1/models` mirrors auto combos under the same stable id. Replacing
// that expected raw twin is silent; every other collision still warns.
const isExpectedRawTwin = autoCombo.id === key && rawModelKeys.has(key);
if (!isExpectedRawTwin && !reportedCollisions.has(key)) {
// Collision with a raw model or DB combo — auto combo wins (log once)
if (!reportedCollisions.has(key)) {
reportedCollisions.add(key);
console.warn(
`[omniroute-plugin] auto combo key "${key}" collides with an existing model; auto combo wins.`
@@ -4675,7 +4654,6 @@ export function buildStaticProviderEntry(
}
}
models[key] = entry;
rawModelKeys.delete(key);
}
}
@@ -5158,13 +5136,13 @@ export const defaultReadAuthJson: OmniRouteReadAuthJson = async () => {
* `auth.json[providerId].baseURL`),
* (e) `input.provider[providerId]` is ALREADY set (operator override
* wins — we never clobber manually-curated catalogs).
* Each no-op path emits ONE debug-level breadcrumb through the leveled logger
* Each no-op path emits ONE debug-level breadcrumb to `console.warn`
* so the operator can diagnose without log spam. Malformed `auth.json`
* warns once and continues as if the file were missing.
* - Fail-open on fetcher errors: a `/v1/models` failure → still publish
* a stub `{models: {}}` provider block (so OC has a complete-shape
* entry to render). A `/api/combos` failure → publish models-only.
* Both paths emit ONE error-level logger message.
* Both paths emit ONE `console.warn`.
* - When the provider hook (T-03/T-05) has ALREADY populated the shared
* cache for this (baseURL, apiKey) tuple, we reuse the raw payloads
* directly — no second fetch. (And vice-versa: the config hook fires
@@ -5187,8 +5165,8 @@ export const defaultReadAuthJson: OmniRouteReadAuthJson = async () => {
* - `cache` — shared fetch-result cache (see
* `OmniRouteFetchCache`). Pass the same Map the
* provider hook owns to dedupe round-trips.
* - `logger` — injected sink for breadcrumb capture in tests.
* Defaults to the plugin's leveled logger.
* - `logger` — `{warn}` sink for breadcrumb capture in tests.
* Defaults to `console`.
*/
export function createOmniRouteConfigHook(
opts?: OmniRoutePluginOptions,
@@ -5204,11 +5182,7 @@ export function createOmniRouteConfigHook(
diskSnapshotWriter?: OmniRouteDiskSnapshotWriter;
now?: () => number;
cache?: OmniRouteFetchCache;
logger?: {
error?: (message: string, ...args: unknown[]) => void;
warn: (message: string, ...args: unknown[]) => void;
debug?: (message: string, ...args: unknown[]) => void;
};
logger?: { warn: (...args: unknown[]) => void };
} = {}
): (input: Config) => Promise<void> {
const resolved = resolveOmniRoutePluginOptions(opts);
@@ -5224,11 +5198,7 @@ export function createOmniRouteConfigHook(
const diskSnapshotWriter = deps.diskSnapshotWriter ?? noopDiskSnapshotWriter;
const now = deps.now ?? Date.now;
const cache: OmniRouteFetchCache = deps.cache ?? new Map();
const logger = deps.logger ?? _logger;
const logAt = (level: "error" | "warn" | "debug", message: string): void => {
const sink = logger[level] ?? logger.warn;
sink.call(logger, message);
};
const logger = deps.logger ?? console;
const features = resolved.features ?? {};
const wantAutoCombos = features.autoCombos !== false;
const wantEnrichment = features.enrichment !== false;
@@ -5243,7 +5213,9 @@ export function createOmniRouteConfigHook(
// generated block. Detect-and-respect before any I/O.
const existingProviders = (input as { provider?: Record<string, unknown> }).provider;
if (existingProviders && existingProviders[resolved.providerId] !== undefined) {
logAt("debug", `config shim skipped: provider.${resolved.providerId} already set by user`);
logger.warn(
`[omniroute-plugin] config shim skipped: provider.${resolved.providerId} already set by user`
);
return;
}
@@ -5258,7 +5230,7 @@ export function createOmniRouteConfigHook(
}
if (authJson === null) {
logAt("warn", "config shim: auth.json failed to parse; treating as missing");
logger.warn("[omniroute-plugin] config shim: auth.json failed to parse; treating as missing");
authJson = undefined;
}
@@ -5285,7 +5257,9 @@ export function createOmniRouteConfigHook(
// (c) no apiKey — silent no-op (with debug breadcrumb). The operator
// hasn't run `/connect <providerId>` yet, OR the stored credential
// isn't api-flavored. OC will handle the `/connect` flow at runtime.
logAt("debug", `config shim skipped: no apiKey for providerId=${resolved.providerId}`);
logger.warn(
`[omniroute-plugin] config shim skipped: no apiKey for providerId=${resolved.providerId}`
);
return;
}
// Management-plane catalog reads may use a narrower read-only token.
@@ -5298,7 +5272,9 @@ export function createOmniRouteConfigHook(
const storedBaseURL = entry && typeof entry.baseURL === "string" ? entry.baseURL : undefined;
const baseURL = resolved.baseURL ?? storedBaseURL ?? "";
if (!baseURL) {
logAt("debug", `config shim skipped: no baseURL for providerId=${resolved.providerId}`);
logger.warn(
`[omniroute-plugin] config shim skipped: no baseURL for providerId=${resolved.providerId}`
);
return;
}
@@ -5343,9 +5319,8 @@ export function createOmniRouteConfigHook(
// Log snapshot age (accept any age — instant beats empty).
const age = (snapshotResult as { writtenAt?: number }).writtenAt;
const ageLabel = typeof age === "number" ? `${Math.round((Date.now() - age) / 3_600_000)}h` : "unknown";
logAt(
"warn",
`config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
logger.warn(
`[omniroute-plugin] config shim: warm startup from disk snapshot (${snapshotResult.rawModels.length} models, age ${ageLabel})`
);
}
}
@@ -5371,9 +5346,9 @@ export function createOmniRouteConfigHook(
try {
localRawModels = await fetcher(baseURL, apiKey, 10_000);
} catch (err) {
logAt(
"error",
`config shim: /v1/models fetch failed; publishing stub provider entry: ${err instanceof Error ? err.message : String(err)}`
logger.warn(
"[omniroute-plugin] config shim: /v1/models fetch failed; publishing stub provider entry",
err
);
localRawModels = [];
modelsFetchThrew = true;
@@ -5384,9 +5359,9 @@ export function createOmniRouteConfigHook(
try {
localRawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logAt(
"error",
`config shim: /api/combos fetch failed; publishing models-only static catalog: ${err instanceof Error ? err.message : String(err)}`
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
err
);
}
};
@@ -5405,9 +5380,9 @@ export function createOmniRouteConfigHook(
try {
localRawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logAt(
"error",
`config shim: /api/pricing/models fetch failed; publishing raw-id static catalog: ${err instanceof Error ? err.message : String(err)}`
logger.warn(
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
err
);
}
};
@@ -5417,9 +5392,9 @@ export function createOmniRouteConfigHook(
try {
localRawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logAt(
"error",
`config shim: /api/context/combos fetch failed; publishing combos without compression suffix: ${err instanceof Error ? err.message : String(err)}`
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
err
);
}
};
@@ -5429,9 +5404,9 @@ export function createOmniRouteConfigHook(
try {
localRawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
} catch (err) {
logAt(
"error",
`config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh: ${err instanceof Error ? err.message : String(err)}`
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
err
);
}
};
@@ -5454,9 +5429,8 @@ export function createOmniRouteConfigHook(
if (modelsFetchThrew && wantDiskCache && !warmSnapshot) {
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
if (snapshot && snapshot.rawModels.length > 0) {
logAt(
"warn",
`config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
);
localRawModels = snapshot.rawModels;
localRawCombos = snapshot.rawCombos;
@@ -5478,7 +5452,6 @@ export function createOmniRouteConfigHook(
rawConnections: localRawConnections,
expiresAt: now() + resolved.modelCacheTtl,
});
});
// Startup diagnostics (file-based) — fires at startup via config hook
if (resolved.features?.startupDebug === true) {
@@ -5557,10 +5530,7 @@ export function createOmniRouteConfigHook(
} else {
const refreshP = doRefresh()
.catch((err: unknown) => {
logAt(
"error",
`config shim: background refresh failed: ${err instanceof Error ? err.message : String(err)}`
);
logger.warn("[omniroute-plugin] config shim: background refresh failed", err);
})
.finally(() => {
_inflightRefresh.delete(cacheKey);
@@ -5586,10 +5556,7 @@ export function createOmniRouteConfigHook(
} else {
const refreshP = doRefresh()
.catch((err: unknown) => {
logAt(
"error",
`config shim: refresh failed: ${err instanceof Error ? err.message : String(err)}`
);
logger.warn("[omniroute-plugin] config shim: refresh failed", err);
})
.finally(() => {
_inflightRefresh.delete(cacheKey);
@@ -5644,9 +5611,8 @@ export function createOmniRouteConfigHook(
if (features.mcpAutoEmit === true) {
const mcpKey = features.mcpToken ?? apiKey;
if (!mcpKey) {
logAt(
"debug",
`mcp auto-emit skipped: no Bearer token for providerId=${resolved.providerId}`
logger.warn(
`[omniroute-plugin] mcp auto-emit skipped: no Bearer token for providerId=${resolved.providerId}`
);
} else {
const inputWithMcp = input as { mcp?: Record<string, unknown> };
@@ -5654,7 +5620,9 @@ export function createOmniRouteConfigHook(
inputWithMcp.mcp = {};
}
if (inputWithMcp.mcp[resolved.providerId] !== undefined) {
logAt("debug", `mcp auto-emit skipped: mcp.${resolved.providerId} already set by user`);
logger.warn(
`[omniroute-plugin] mcp auto-emit skipped: mcp.${resolved.providerId} already set by user`
);
} else {
// Strip a trailing `/v1` from baseURL when present so we land on
// the MCP transport at /api/mcp/stream, not /v1/api/mcp/stream.

View File

@@ -36,47 +36,39 @@ function fmt(level: LogLevel, msg: string, tag?: string): string {
return `${prefix} [${level.toUpperCase()}] ${msg}`;
}
function buildLogger(getLevel: () => LogLevel) {
return {
error(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args);
},
warn(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args);
},
info(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args);
},
debug(msg: string, ...args: unknown[]): void {
if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args);
},
/** Always emit regardless of level (for critical init breadcrumbs). */
always(msg: string, ...args: unknown[]): void {
console.warn(TAG, msg, ...args);
},
export const logger = {
error(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
},
warn(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
},
info(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
},
debug(msg: string, ...args: unknown[]): void {
if (shouldLog(_level, "debug")) console.warn(fmt("debug", msg), ...args);
},
/** Always emit regardless of level (for critical init breadcrumbs). */
always(msg: string, ...args: unknown[]): void {
console.warn(TAG, msg, ...args);
},
// ── Tagged child loggers ────────────────────────────────────────────
child(tag: string) {
return {
error: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args),
warn: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args),
info: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args),
debug: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args),
};
},
};
}
export type Logger = ReturnType<typeof buildLogger>;
/** Create an instance-scoped logger whose level cannot be changed by other plugin instances. */
export function createLogger(level: LogLevel): Logger {
return buildLogger(() => level);
}
/** Backward-compatible module-global logger controlled by setLogLevel(). */
export const logger: Logger = buildLogger(() => _level);
// ── Tagged child loggers ──────────────────────────────────────────────
child(tag: string) {
return {
error: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "error") &&
console.error(fmt("error", msg, tag), ...args),
warn: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "warn") &&
console.warn(fmt("warn", msg, tag), ...args),
info: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "info") &&
console.warn(fmt("info", msg, tag), ...args),
debug: (msg: string, ...args: unknown[]) =>
shouldLog(_level, "debug") &&
console.warn(fmt("debug", msg, tag), ...args),
};
},
};

View File

@@ -13,22 +13,6 @@ import {
forceSyncOmniRouteModels,
type OmniRouteFetchCache,
} from "../src/index.js";
import { getLogLevel, setLogLevel } from "../src/logger.js";
async function captureConsole(run: () => Promise<void>): Promise<string[]> {
const lines: string[] = [];
const originalError = console.error;
const originalWarn = console.warn;
console.error = (...args: unknown[]) => lines.push(args.map(String).join(" "));
console.warn = (...args: unknown[]) => lines.push(args.map(String).join(" "));
try {
await run();
} finally {
console.error = originalError;
console.warn = originalWarn;
}
return lines;
}
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
@@ -51,10 +35,7 @@ test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
assert.equal(
parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs,
120_000
);
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs, 120_000);
});
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
@@ -131,76 +112,6 @@ test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
});
test("forceSyncOmniRouteModels suppresses successful lifecycle output at error level", async () => {
const previousLevel = getLogLevel();
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
features: {
autoCombos: false,
combos: false,
compressionMetadata: false,
diskCache: false,
enrichment: false,
logLevel: "error",
usableOnly: false,
},
});
try {
setLogLevel("error");
const lines = await captureConsole(async () => {
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
fetcher: async () => [{ id: "model-a", object: "model" }],
});
assert.equal(result.ok, true);
});
assert.deepEqual(lines, []);
} finally {
setLogLevel(previousLevel);
}
});
test("forceSyncOmniRouteModels preserves successful lifecycle output at info level", async () => {
const previousLevel = getLogLevel();
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
features: {
autoCombos: false,
combos: false,
compressionMetadata: false,
diskCache: false,
enrichment: false,
logLevel: "info",
usableOnly: false,
},
});
try {
setLogLevel("info");
const lines = await captureConsole(async () => {
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
fetcher: async () => [{ id: "model-a", object: "model" }],
});
assert.equal(result.ok, true);
});
assert.equal(lines.filter((line) => line.includes("force sync ok")).length, 1);
} finally {
setLogLevel(previousLevel);
}
});
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({

View File

@@ -763,37 +763,6 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
assert.ok(block.models["claude-sonnet-4-6"]);
});
test("buildStaticProviderEntry: expected raw auto twin does not warn and auto combo wins", () => {
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" "));
let block: OmniRouteStaticProviderEntry;
try {
block = buildStaticProviderEntry(
[{ id: "auto/coding" }],
[],
resolved,
"https://or.example/v1",
"sk-test",
undefined,
undefined,
undefined,
[{ id: "auto/coding", name: "Auto Coding", variant: "coding", candidateCount: 5 }]
);
} finally {
console.warn = originalWarn;
}
assert.equal(Object.keys(block.models).filter((key) => key === "auto/coding").length, 1);
assert.equal(block.models["auto/coding"].tool_call, true, "auto-combo entry wins over raw twin");
assert.deepEqual(
warnings.filter((warning) => warning.includes("collides with an existing model")),
[]
);
});
// ────────────────────────────────────────────────────────────────────────────
// Schema parity (modalities / cost / release_date / limit cleanup)
// ────────────────────────────────────────────────────────────────────────────

View File

@@ -1,218 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { Config } from "@opencode-ai/plugin";
import { createOmniRouteConfigHook, OmniRoutePlugin } from "../src/index.js";
import { getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
type ConsoleMethod = "error" | "info" | "log" | "warn";
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
const fakeInput = {} as Parameters<typeof OmniRoutePlugin>[0];
const consoleMethods: ConsoleMethod[] = ["error", "info", "log", "warn"];
async function captureConsole(run: () => Promise<void>): Promise<ConsoleEntries> {
const entries: ConsoleEntries = { error: [], info: [], log: [], warn: [] };
const originals = Object.fromEntries(
consoleMethods.map((method) => [method, console[method]])
) as Record<ConsoleMethod, typeof console.warn>;
for (const method of consoleMethods) {
console[method] = (...args: unknown[]) => {
entries[method].push(args);
};
}
try {
await run();
} finally {
for (const method of consoleMethods) console[method] = originals[method];
}
return entries;
}
function rendered(entries: ConsoleEntries): string[] {
return consoleMethods.flatMap((method) =>
entries[method].map((args) => args.map((arg) => String(arg)).join(" "))
);
}
async function capturePluginLifecycle(args: {
level: LogLevel;
autoSyncIntervalMs: number;
invokeConfig?: boolean;
}): Promise<string[]> {
const previousDataDir = process.env.OPENCODE_DATA_DIR;
const previousLevel = getLogLevel();
const dataDir = await mkdtemp(join(tmpdir(), "omniroute-log-level-"));
process.env.OPENCODE_DATA_DIR = dataDir;
try {
const entries = await captureConsole(async () => {
const hooks = await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: args.autoSyncIntervalMs,
features: { logLevel: args.level },
});
if (args.invokeConfig) {
assert.equal(typeof hooks.config, "function");
await hooks.config!({} as Config);
}
});
return rendered(entries);
} finally {
setLogLevel(previousLevel);
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = previousDataDir;
await rm(dataDir, { recursive: true, force: true });
}
}
test("logLevel error suppresses the initialization banner", async () => {
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 0 });
assert.equal(lines.filter((line) => line.includes("initialized")).length, 0);
});
test("logLevel error suppresses the auto-sync enabled lifecycle message", async () => {
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 60_000 });
assert.equal(lines.filter((line) => line.includes("auto-sync enabled")).length, 0);
});
test("logLevel error suppresses factory config-shim diagnostics", async () => {
const lines = await capturePluginLifecycle({
level: "error",
autoSyncIntervalMs: 0,
invokeConfig: true,
});
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
});
test("logLevel debug preserves startup and config-shim diagnostics", async () => {
const lines = await capturePluginLifecycle({
level: "debug",
autoSyncIntervalMs: 60_000,
invokeConfig: true,
});
assert.ok(
lines.some((line) => line.includes("initialized")),
"initialization banner emitted"
);
assert.ok(
lines.some((line) => line.includes("auto-sync enabled")),
"auto-sync message emitted"
);
assert.ok(
lines.some((line) => line.includes("config shim skipped")),
"config breadcrumb emitted"
);
});
test("debug instance retains config diagnostics after an error instance is created", async () => {
const lines = rendered(
await captureConsole(async () => {
const debugHooks = await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "debug" },
});
await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "error" },
});
await debugHooks.config!({} as Config);
})
);
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 1);
});
test("error instance keeps config diagnostics suppressed after a debug instance is created", async () => {
const lines = rendered(
await captureConsole(async () => {
const errorHooks = await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "error" },
});
await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "debug" },
});
await errorHooks.config!({} as Config);
})
);
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
});
test("error-level config fetch failures remain visible as concise injected-logger messages", async () => {
const entries: unknown[][] = [];
const hook = createOmniRouteConfigHook(
{
baseURL: "https://omniroute.example/v1",
features: {
autoCombos: false,
diskCache: false,
enrichment: false,
logLevel: "error",
},
},
{
readAuthJson: async () => ({
"opencode-omniroute": { type: "api", key: "test-key" },
}),
fetcher: async () => {
throw new Error("models unavailable");
},
combosFetcher: async () => {
throw new Error("combos unavailable");
},
logger: {
warn: (...args: unknown[]) => {
entries.push(args);
},
},
}
);
await hook({} as Config);
assert.equal(entries.length, 2, "both genuine fetch failures remain visible");
assert.deepEqual(
entries.map((args) => args.length),
[1, 1],
"each failure is emitted as one concise argument"
);
const lines = entries.map(([message]) => String(message));
assert.ok(
lines.some((line) => line.includes("/v1/models") && line.includes("models unavailable"))
);
assert.ok(
lines.some((line) => line.includes("/api/combos") && line.includes("combos unavailable"))
);
assert.equal(
entries.flat().some((arg) => arg instanceof Error),
false,
"no raw Error object emitted"
);
});
test("logger error output remains visible at error level", async () => {
const previousLevel = getLogLevel();
try {
setLogLevel("error");
const lines = rendered(
await captureConsole(async () => {
logger.error("genuine startup failure");
})
);
assert.ok(lines.some((line) => line.includes("genuine startup failure")));
} finally {
setLogLevel(previousLevel);
}
});

View File

@@ -434,16 +434,16 @@ For any non-trivial change, read the matching deep-dive first:
## Testing
| What | Command |
| ----------------------- | ----------------------------------------------------------------------------- |
| Unit tests | `npm run test:unit` |
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` (CI job `test-protocols-e2e`, advisory — #10049) |
| Ecosystem | `npm run test:ecosystem` (CI job `test-ecosystem`, blocking) |
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
| Coverage report | `npm run coverage:report` |
| What | Command |
| ----------------------- | --------------------------------------------------------------------------- |
| Unit tests | `npm run test:unit` |
| Single file | `node --import tsx/esm --test tests/unit/your-file.test.ts` |
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
| Ecosystem | `npm run test:ecosystem` |
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
| Coverage report | `npm run coverage:report` |
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR.

View File

@@ -238,7 +238,7 @@ curl http://localhost:20128/v1/chat/completions \
</div>
<p align="center">
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
<a href="https://platform.kimi.ai?aff=omniroute">
<img src="public/sponsors/kimi-k3-banner.png" width="100%" alt="Kimi K3 — Open Frontier Intelligence · 2.8T parameters · 1M-token context"/>
</a>
</p>
@@ -248,7 +248,7 @@ curl http://localhost:20128/v1/chat/completions \
<table>
<tr>
<td align="center" width="150">
<a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">
<a href="https://platform.kimi.ai?aff=omniroute">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="public/providers/kimi-logomark-dark.svg">
<img src="public/providers/kimi-logomark-light.svg" width="64" alt="Kimi (Moonshot AI)"/>
@@ -260,7 +260,7 @@ curl http://localhost:20128/v1/chat/completions \
<td>
Thanks to <b>Kimi (Moonshot AI)</b>, our founding Open Source Friend, for backing this project! Kimi is the AI lab behind the open-weight K2 and K3 model families — <b>Kimi K3</b> delivers a 1M-token context window, native vision and frontier-level coding at a fraction of closed-model prices, and works out of the box with Claude Code, Codex and every coding tool OmniRoute serves.
<br/><br/>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute"><b>Get a Kimi API key with 15% extra credits →</b></a>
<b>What Kimi's support powers:</b> Kimi's API credits power OmniRoute's AI-validated release pipeline — the <i>merge validation powered by Kimi K3</i> stage that reviews every pull request before it ships — plus day-to-day feature development. First-class Kimi support ships on both rails: the direct <a href="https://platform.kimi.ai?aff=omniroute">Kimi API</a> (<code>kimi-k3</code>) and the <a href="https://www.kimi.com/code?aff=omniroute">Kimi Code coding plan</a> (OAuth and API key). OmniRoute is also the first Brazilian open-source project in Kimi's support program. <a href="https://platform.kimi.ai?aff=omniroute"><b>Get a Kimi API key →</b></a>
</td>
</tr>
<tr>

View File

@@ -139,21 +139,6 @@ export function shouldRetryError(err, opts = {}) {
return false;
}
/**
* True when a non-2xx status means "this server does not serve this route"
* rather than "your request was wrong".
*
* Commands that keep a local SQLite fallback must not treat these as fatal:
* a CLI newer (or older) than the server it is talking to will hit routes that
* simply are not mounted, and aborting there strands the user with an
* unactionable `HTTP 404` even though the local path would have worked.
* Genuine client errors (400/401/403/409/422 …) stay fatal — retrying them
* locally would paper over a real problem.
*/
export function isRouteUnavailableStatus(status) {
return status === 404 || status === 405 || status === 501;
}
export function statusToExitCode(status) {
if (status >= 200 && status < 300) return 0;
if (status === 408) return 124;

View File

@@ -152,7 +152,6 @@ export async function runComboListCommand(opts = {}) {
return await withRuntime(async ({ kind, api, db }) => {
let combos = [];
let activeCombo = null;
let listError = null;
if (kind === "http") {
const [listRes, activeRes] = await Promise.all([
@@ -162,12 +161,6 @@ export async function runComboListCommand(opts = {}) {
if (listRes.ok) {
const data = await listRes.json();
combos = Array.isArray(data) ? data : (data.combos ?? []);
} else {
// The server answered, but not with a combo list. Falling through to
// an empty array here rendered "No combos configured" — which is
// indistinguishable from genuine emptiness and reads as real state,
// so a transport/auth failure looked like a wiped configuration.
listError = listRes.status;
}
if (activeRes.ok) {
const settings = await activeRes.json();
@@ -178,25 +171,11 @@ export async function runComboListCommand(opts = {}) {
}
if (opts.json || opts.output === "json") {
console.log(
JSON.stringify(
{ combos, active: activeCombo, error: listError && `HTTP ${listError}` },
null,
2
)
);
return listError ? 1 : 0;
console.log(JSON.stringify({ combos, active: activeCombo }, null, 2));
return 0;
}
printHeading(t("combo.title"));
if (listError) {
console.error(
t("common.error", {
message: `could not list combos from the server (HTTP ${listError})`,
})
);
return 1;
}
if (combos.length === 0) {
console.log(t("combo.noCombos"));
return 0;

View File

@@ -288,44 +288,27 @@ async function checkNodeRuntime(rootDir) {
}
}
/**
* Name of the prebuilt binary better-sqlite3 ships for this platform, e.g.
* `linux-x64.node`. Musl-based Linux uses a distinct `linuxmusl-` prefix.
* Mirrors the lookup `prebuild-install`/`node-gyp-build` perform at require time.
*/
export function prebuiltBinaryName(
platform = process.platform,
arch = process.arch,
report = process.report
) {
let prefix = platform;
if (platform === "linux") {
let isMusl = false;
try {
// glibc builds expose `glibcVersionRuntime`; musl builds do not.
isMusl = !report?.getReport?.()?.header?.glibcVersionRuntime;
} catch {
isMusl = false;
}
prefix = isMusl ? "linuxmusl" : "linux";
}
return `${prefix}-${arch}.node`;
}
async function checkNativeBinary(rootDir) {
// node-gyp layout — present only when better-sqlite3 was compiled locally.
const buildRoots = [
path.join(rootDir, "app", "node_modules", "better-sqlite3"),
path.join(rootDir, "dist", "node_modules", "better-sqlite3"),
path.join(rootDir, "node_modules", "better-sqlite3"),
];
const prebuildName = prebuiltBinaryName();
const candidates = [
...buildRoots.map((root) => path.join(root, "build", "Release", "better_sqlite3.node")),
// Prebuilt layout — what `npm i -g omniroute` actually installs. Without
// these, doctor warns on every prebuilt install even though the binary is
// present and loading fine.
...buildRoots.map((root) => path.join(root, "prebuilds", prebuildName)),
path.join(
rootDir,
"app",
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
),
path.join(
rootDir,
"dist",
"node_modules",
"better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
),
path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
];
const binaryPath = candidates.find((candidate) => fs.existsSync(candidate));
if (!binaryPath) {

View File

@@ -8,7 +8,7 @@ import {
} from "../provider-store.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { loadAvailableProviders } from "../provider-catalog.mjs";
import { apiFetch, isServerUp, isRouteUnavailableStatus } from "../api.mjs";
import { apiFetch, isServerUp } from "../api.mjs";
import { t } from "../i18n.mjs";
function getValidProviderIds() {
@@ -184,10 +184,7 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) {
console.log(t("keys.added", { provider: providerLower }));
return 0;
}
// A missing route means this server does not implement the endpoint —
// fall through to the local SQLite path below rather than stranding the
// user. Real client errors still abort.
if (res.status >= 400 && res.status < 500 && !isRouteUnavailableStatus(res.status)) {
if (res.status >= 400 && res.status < 500) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
}

View File

@@ -41,83 +41,11 @@ function toYaml(obj, indent = 0) {
.trimStart();
}
// Keys that live alongside operations inside a Path Item Object but are not
// themselves operations (OpenAPI 3.x Path Item fields).
const NON_OPERATION_PATH_KEYS = new Set([
"parameters",
"summary",
"description",
"servers",
"$ref",
]);
/**
* `GET /api/openapi/spec` answers with a compact catalog
* (`{ info, servers, tags, endpoints[], schemas }`) rather than an OpenAPI
* document with a `paths` object, while `dist/docs/openapi.yaml` is a real
* spec. Normalize either shape into the flat rows the CLI renders so the
* commands work against both instead of silently printing nothing.
*/
export function extractEndpoints(spec) {
if (!spec || typeof spec !== "object") return [];
if (spec.paths && typeof spec.paths === "object") {
const rows = [];
for (const [path, pathItem] of Object.entries(spec.paths)) {
if (!pathItem || typeof pathItem !== "object") continue;
for (const [method, def] of Object.entries(pathItem)) {
if (NON_OPERATION_PATH_KEYS.has(method)) continue;
if (!def || typeof def !== "object") continue;
rows.push({
method: method.toUpperCase(),
path,
summary: def.summary ?? def.description ?? "",
operationId: def.operationId,
});
}
}
return rows;
}
if (Array.isArray(spec.endpoints)) {
return spec.endpoints
.filter((entry) => entry && typeof entry === "object" && entry.path)
.map((entry) => ({
method: String(entry.method ?? "GET").toUpperCase(),
path: entry.path,
summary: entry.summary ?? entry.description ?? "",
operationId: entry.operationId,
}));
}
return [];
}
/** Sorted, de-duplicated list of paths across either shape. */
export function extractPaths(spec) {
return [...new Set(extractEndpoints(spec).map((row) => row.path))].sort();
}
function matchesSearch(row, query) {
if (!query) return true;
const needle = query.toLowerCase();
return row.path.includes(query) || String(row.summary).toLowerCase().includes(needle);
}
function validateBasic(spec) {
if (!spec || typeof spec !== "object") throw new Error("spec is not an object");
if (!spec.openapi && !spec.swagger) throw new Error("missing openapi/swagger version field");
if (!spec.info) throw new Error("missing info object");
// A real OpenAPI document must carry a version field and a paths object.
if (spec.openapi || spec.swagger) {
if (!spec.paths) throw new Error("missing paths object");
return;
}
// The compact catalog served by /api/openapi/spec carries endpoints[] instead.
if (Array.isArray(spec.endpoints)) return;
throw new Error("missing openapi/swagger version field and no endpoints[] catalog");
if (!spec.paths) throw new Error("missing paths object");
}
const endpointSchema = [
@@ -204,7 +132,20 @@ export function registerOpenapi(program) {
process.exit(1);
}
const spec = await res.json();
const rows = extractEndpoints(spec).filter((row) => matchesSearch(row, opts.search));
const rows = [];
for (const [path, methods] of Object.entries(spec.paths ?? {})) {
for (const [method, def] of Object.entries(methods)) {
if (["parameters", "summary"].includes(method)) continue;
const summary = def.summary ?? def.description ?? "";
if (
opts.search &&
!path.includes(opts.search) &&
!summary.toLowerCase().includes(opts.search.toLowerCase())
)
continue;
rows.push({ method: method.toUpperCase(), path, summary, operationId: def.operationId });
}
}
emit(rows, cmd.optsWithGlobals(), endpointSchema);
});
@@ -218,8 +159,9 @@ export function registerOpenapi(program) {
process.exit(1);
}
const spec = await res.json();
const paths = Object.keys(spec.paths ?? {}).sort();
emit(
extractPaths(spec).map((p) => ({ path: p })),
paths.map((p) => ({ path: p })),
cmd.optsWithGlobals()
);
});

View File

@@ -129,33 +129,9 @@ function buildTestInput(connection, apiKey) {
}
async function runProviderTest(db, connection) {
// Only API-key connections can be probed with a stored credential. OAuth /
// no-auth connections have nothing for testProviderApiKey() to send, and
// getProviderApiKey() throws for them by design — reporting that as a FAILED
// test marked perfectly healthy OAuth connections as broken *and* persisted
// that verdict to provider_connections.test_status.
if (connection.authType !== "apikey") {
return {
connection: publicConnection(connection),
valid: false,
skipped: true,
error: `No API-key probe for ${connection.authType || "unknown"} connections`,
};
}
try {
const apiKey = getProviderApiKey(connection);
const result = await testProviderApiKey(buildTestInput(connection, apiKey));
// PROVIDER_TEST_CONFIGS only knows a handful of providers; "unsupported"
// means the CLI has no probe recipe, not that the provider is unhealthy.
// Persisting it would overwrite a good test_status with a failure.
if (result.unsupported) {
return {
connection: publicConnection(connection),
...result,
skipped: true,
};
}
updateProviderTestResult(db, connection.id, result);
return {
connection: publicConnection(connection),

View File

@@ -1,9 +1,11 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const CLI_DIR = dirname(fileURLToPath(import.meta.url));
const DEFAULT_ROOT_DIR = join(CLI_DIR, "..", "..");
const require = createRequire(import.meta.url);
export const COMMON_PROVIDERS = [
{ id: "openai", name: "OpenAI" },
@@ -15,201 +17,94 @@ export const COMMON_PROVIDERS = [
];
function normalizeCatalogCategory(exportName) {
const raw = exportName.split("_PROVIDERS")[0].toLowerCase().replaceAll("_", "-");
const raw = exportName
.replace(/_PROVIDERS$/, "")
.toLowerCase()
.replaceAll("_", "-");
if (raw === "apikey") return "api-key";
return raw;
}
/**
* Advance past a string literal, template literal, or comment starting at `i`.
* Returns the index just after it, or -1 when `i` does not start one. Keeping
* the scanner string/comment aware is what lets it walk braces safely — provider
* notes routinely contain `{`, `}` and apostrophes.
*/
function skipNonCode(source, i) {
const c = source[i];
function loadTypeScript() {
try {
return require("typescript");
} catch {
return null;
}
}
if (c === '"' || c === "'" || c === "`") {
for (let j = i + 1; j < source.length; j++) {
if (source[j] === "\\") {
j++;
function getPropertyName(ts, name) {
if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
return name.text;
}
return null;
}
function getObjectProperty(ts, objectLiteral, propertyName) {
return objectLiteral.properties.find(
(property) =>
ts.isPropertyAssignment(property) && getPropertyName(ts, property.name) === propertyName
);
}
function getStringProperty(ts, objectLiteral, propertyName) {
const property = getObjectProperty(ts, objectLiteral, propertyName);
const initializer = property?.initializer;
if (!initializer) return null;
if (ts.isStringLiteral(initializer) || ts.isNoSubstitutionTemplateLiteral(initializer)) {
return initializer.text;
}
return null;
}
function getBooleanProperty(ts, objectLiteral, propertyName) {
const property = getObjectProperty(ts, objectLiteral, propertyName);
const initializer = property?.initializer;
return initializer?.kind === ts.SyntaxKind.TrueKeyword;
}
function extractProviderBlocks(source, filePath) {
const ts = loadTypeScript();
if (!ts) return [];
const providers = [];
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
sourceFile.forEachChild((node) => {
if (!ts.isVariableStatement(node)) return;
for (const declaration of node.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name)) continue;
const exportName = declaration.name.text;
if (!exportName.endsWith("_PROVIDERS")) continue;
if (!declaration.initializer || !ts.isObjectLiteralExpression(declaration.initializer)) {
continue;
}
if (source[j] === c) return j + 1;
}
return source.length;
}
if (c === "/" && source[i + 1] === "/") {
const nl = source.indexOf("\n", i);
return nl === -1 ? source.length : nl;
}
const category = normalizeCatalogCategory(exportName);
for (const property of declaration.initializer.properties) {
if (!ts.isPropertyAssignment(property)) continue;
if (!ts.isObjectLiteralExpression(property.initializer)) continue;
if (c === "/" && source[i + 1] === "*") {
const close = source.indexOf("*/", i + 2);
return close === -1 ? source.length : close + 2;
}
const key = getPropertyName(ts, property.name);
if (!key) continue;
return -1;
}
const id = getStringProperty(ts, property.initializer, "id") || key;
const name = getStringProperty(ts, property.initializer, "name") || id;
/** Index of the `}` matching the `{` at `openIdx`, or -1. */
function findMatchingBrace(source, openIdx) {
let depth = 0;
for (let i = openIdx; i < source.length; i++) {
const skipped = skipNonCode(source, i);
if (skipped !== -1) {
i = skipped - 1;
continue;
}
if (source[i] === "{") depth++;
else if (source[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
const MEMBER_KEY = /(?:([A-Za-z_$][\w$]*)|"([^"]*)"|'([^']*)')\s*:/y;
/**
* Parse the direct members of the object literal whose `{` is at `openIdx`.
* Returns `[{ key, value }]` with `value` as the raw source slice.
*/
function parseObjectMembers(source, openIdx) {
// An unbalanced literal (a missing `},` in a large data file — see #10093)
// should not blank the whole catalog: scan to end-of-source so the entries
// before the damage are still recovered.
const matching = findMatchingBrace(source, openIdx);
const close = matching === -1 ? source.length : matching;
const members = [];
let i = openIdx + 1;
while (i < close) {
if (/[\s,;]/.test(source[i])) {
i++;
continue;
}
// The key match MUST be attempted before skipNonCode: quoted keys such as
// `"duckduckgo-web":` start with a quote, and skipping them as string
// literals both loses the entry and desynchronizes the walk, which then
// reports nested keys (`notice`, …) as top-level providers.
MEMBER_KEY.lastIndex = i;
const match = MEMBER_KEY.exec(source);
if (!match) {
const skipped = skipNonCode(source, i);
i = skipped !== -1 ? skipped : i + 1;
continue;
}
const key = match[1] ?? match[2] ?? match[3];
let valueStart = MEMBER_KEY.lastIndex;
while (valueStart < close && /\s/.test(source[valueStart])) valueStart++;
let valueEnd;
if (source[valueStart] === "{" || source[valueStart] === "[") {
const openChar = source[valueStart];
const closeChar = openChar === "{" ? "}" : "]";
let depth = 0;
let j = valueStart;
for (; j < close; j++) {
const s2 = skipNonCode(source, j);
if (s2 !== -1) {
j = s2 - 1;
continue;
}
if (source[j] === openChar) depth++;
else if (source[j] === closeChar) {
depth--;
if (depth === 0) break;
}
providers.push({
id,
name,
category,
alias: getStringProperty(ts, property.initializer, "alias"),
website: getStringProperty(ts, property.initializer, "website"),
deprecated: getBooleanProperty(ts, property.initializer, "deprecated"),
hasFree: getBooleanProperty(ts, property.initializer, "hasFree"),
passthroughModels: getBooleanProperty(ts, property.initializer, "passthroughModels"),
});
}
valueEnd = j + 1;
} else {
let j = valueStart;
for (; j < close; j++) {
const s2 = skipNonCode(source, j);
if (s2 !== -1) {
j = s2 - 1;
continue;
}
if (source[j] === ",") break;
}
valueEnd = j;
}
members.push({ key, value: source.slice(valueStart, valueEnd), valueStart });
// Guarantee forward progress even on malformed input.
i = valueEnd > i ? valueEnd : i + 1;
}
return members;
}
/** First string literal in a raw value (handles `"a" + "b"` continuations). */
function readString(raw) {
if (raw == null) return null;
const match = raw.match(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/);
if (!match) return null;
return (match[1] ?? match[2]).replace(/\\(.)/g, "$1");
}
function readBoolean(raw) {
return String(raw).trim() === "true";
}
const PROVIDER_EXPORT =
/(?:export\s+)?const\s+([A-Z0-9_]*_PROVIDERS[A-Z0-9_]*)\s*(?::[^=]+)?=\s*\{/g;
/**
* Extract provider entries from a catalog source file.
*
* Deliberately dependency-free: `typescript` is a devDependency, so requiring it
* at runtime made this silently return [] on every published install (#10080).
* These files are pure data literals, so a string/comment-aware brace walk is
* both sufficient and stable.
*/
export function extractProviderBlocks(source) {
const providers = [];
PROVIDER_EXPORT.lastIndex = 0;
let exportMatch;
while ((exportMatch = PROVIDER_EXPORT.exec(source)) !== null) {
const exportName = exportMatch[1];
const openIdx = source.indexOf("{", exportMatch.index + exportMatch[0].length - 1);
if (openIdx === -1) continue;
const category = normalizeCatalogCategory(exportName);
for (const entry of parseObjectMembers(source, openIdx)) {
if (!entry.value.startsWith("{")) continue; // spread / non-object member
const fields = new Map(
parseObjectMembers(source, entry.valueStart).map((f) => [f.key, f.value])
);
const id = readString(fields.get("id")) || entry.key;
providers.push({
id,
name: readString(fields.get("name")) || id,
category,
alias: readString(fields.get("alias")),
website: readString(fields.get("website")),
deprecated: readBoolean(fields.get("deprecated")),
hasFree: readBoolean(fields.get("hasFree")),
passthroughModels: readBoolean(fields.get("passthroughModels")),
});
}
// An unbalanced literal (see #10093) yields -1 here. Resetting lastIndex to
// 0 would restart the scan from the top forever, so stop instead — the
// entries recovered above are still returned.
const closeIdx = findMatchingBrace(source, openIdx);
if (closeIdx === -1) break;
PROVIDER_EXPORT.lastIndex = closeIdx + 1;
}
});
return providers;
}
@@ -231,31 +126,9 @@ function resolveProviderCatalogPath(rootDir, options = {}) {
if (configuredPath) {
return isAbsolute(configuredPath) ? configuredPath : resolve(rootDir, configuredPath);
}
// The catalog used to be one god-file at constants/providers.ts. It was
// decomposed into constants/providers/**, leaving the barrel with nothing but
// re-exports and an empty `FREE_PROVIDERS = {}` — so parsing it alone yielded
// zero providers and the CLI silently fell back to COMMON_PROVIDERS (#10080).
// Prefer the directory; keep the legacy file for older trees.
const catalogDir = join(rootDir, "src", "shared", "constants", "providers");
if (existsSync(catalogDir)) return catalogDir;
return join(rootDir, "src", "shared", "constants", "providers.ts");
}
/** Every .ts catalog file under `dir`, one level of subdirectories deep. */
function collectCatalogFiles(dir) {
const files = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectCatalogFiles(full));
} else if (entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
files.push(full);
}
}
return files.sort();
}
export function loadAvailableProviders(options = {}) {
const rootDir = typeof options === "string" ? options : options.rootDir || DEFAULT_ROOT_DIR;
const providersPath = resolveProviderCatalogPath(rootDir, options);
@@ -265,10 +138,8 @@ export function loadAvailableProviders(options = {}) {
}
try {
const sources = statSync(providersPath).isDirectory()
? collectCatalogFiles(providersPath)
: [providersPath];
const providers = sources.flatMap((file) => extractProviderBlocks(readFileSync(file, "utf-8")));
const source = readFileSync(providersPath, "utf-8");
const providers = extractProviderBlocks(source, providersPath);
if (providers.length === 0) return fallbackAvailableProviders();
const seen = new Set();

View File

@@ -93,28 +93,6 @@ function migrateElectronServerEnv(dataDir) {
}
}
/**
* Parse a `.env` value with dotenv-compatible comment handling.
*
* Without this, `KEY=value # note` stored the comment text as part of the
* value. The shipped .env ships exactly such a line for QUOTA_STORE_DRIVER, and
* consumers compare it with `===`, so annotating a variable inline silently
* disabled it (#10100).
*
* Quoted values are returned verbatim — a `#` inside quotes is data. For
* unquoted values a `#` *preceded by whitespace* starts a comment, so
* `pass#word` is preserved.
*/
function parseEnvValue(raw) {
const value = String(raw).trim();
const quoted = value.match(/^(['"])([\s\S]*)\1\s*(?:#.*)?$/);
if (quoted) return quoted[2];
const commentIdx = value.search(/\s#/);
return (commentIdx === -1 ? value : value.slice(0, commentIdx)).trim();
}
function loadEnvFile() {
const envPaths = [];
const loadedEnvPaths = [];
@@ -150,8 +128,9 @@ function loadEnvFile() {
const eqIdx = trimmed.indexOf("=");
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
if (process.env[key] === undefined) {
process.env[key] = parseEnvValue(trimmed.slice(eqIdx + 1));
process.env[key] = value.replace(/^["']|["']$/g, "");
}
}
}

View File

@@ -1 +0,0 @@
- **feat(providers):** publish Poolside's Laguna Preview catalog statically — `poolside/laguna-xs-2.1` and `poolside/laguna-s-2.1` (262144 context, 32768 max completion, tools + reasoning, text-only), so the models are routable and visible before a key is configured instead of only after live discovery. Pins the catalog form of the XS id against the `laguna-xs.2` variant carried by third-party listings. ([#9085](https://github.com/diegosouzapw/OmniRoute/issues/9085))

View File

@@ -1 +0,0 @@
- feat(crof): advertise reasoning-effort tiers (none/low/medium/high/max) for live-discovered and seed models, so the catalog, Playground, and Combo Builder surface <model>-<tier> aliases and requests resolve max upstream

View File

@@ -1 +0,0 @@
- **perf(logging):** bound each scheduled call-log rotation pass to incremental database and filesystem work (#10125)

View File

@@ -1 +0,0 @@
- **fix(streaming):** start early SSE heartbeats when Responses or Messages requests opt into streaming through the request body (#10127)

View File

@@ -1 +0,0 @@
- **fix(combo):** scope session-stickiness bindings to their owning Combo so identical first messages cannot carry a successful target into another priority chain and bypass its configured order (fixes #10136)

View File

@@ -1 +0,0 @@
- **fix(translator):** resolve the Claude thinking output cap with the routed provider so a provider-scoped-only `max_output_tokens` override is no longer invisible to `fitThinkingToMaxTokens()`, which previously let the synthesized `max_tokens` (caller room + thinking budget) go out unbounded and 400 upstream ([#10139](https://github.com/diegosouzapw/OmniRoute/issues/10139))

View File

@@ -1 +0,0 @@
- **docs(settings):** document Thinking Budget modes (passthrough vs auto-strip); fix dashboard i18n key collision that showed Auto Combo routing copy on the thinking tab; clarify independence from compression/cache ([#10169](https://github.com/diegosouzapw/OmniRoute/pull/10169))

View File

@@ -1 +0,0 @@
- **fix(guardrails):** Vision Bridge handles OpenAI Responses `input`/`input_image` requests before combo vision filtering ([#10202](https://github.com/diegosouzapw/OmniRoute/pull/10202)) — thanks @Zartharas

View File

@@ -1 +0,0 @@
- **fix(dashboard):** model-level allowed/blocked param edits now persist when the compatibility popover is closed by clicking outside, and a failed save no longer clears the edit or reports success ([#9013](https://github.com/diegosouzapw/OmniRoute/pull/9013))

View File

@@ -1 +0,0 @@
- **fix(test):** remove live `npm pack` from MCP files unit test (it stalled concurrent `test:unit` via prepare→husky + monorepo pack walk); keep the static #3578 `files` allowlist + negation guards in unit and fold #3821 pack assertions into `check:pack-artifact` / `check:pack-policy` (already `--ignore-scripts`).

View File

@@ -1 +0,0 @@
- fix(discovery): parse upstream reasoning tiers nested under metadata.reasoning.supported_efforts (neuralwatt /v1/models shape) so synced openai-compatible models advertise effort aliases

View File

@@ -1 +0,0 @@
- **fix(providers):** when `OPENCODE_SYNTHESIZE_CLI_HEADERS=true`, a non-CLI client User-Agent (e.g. `curl/8.5.0`, SDKs) on opencode-go/opencode-zen/opencode-free requests is now REPLACED with the synthesized `opencode-cli/1.0.0` instead of being honored — opencode.ai's free tier (`/zen/v1`) returns `FreeUsageLimitError` 429 for generic client UAs egressing from datacenter IPs, which made the #5997 CLI-identity synthesis ineffective for non-CLI clients. Client UAs already matching `opencode-cli/…` are preserved (the real CLI's versioned identity stays intact); all other client-supplied `x-opencode-*` headers keep client-wins. Regression guard: `tests/unit/opencode-cli-headers-synthesis-5997.test.ts` (7, incl. non-CLI UA replaced + CLI UA preserved). (#5997 follow-up)

View File

@@ -127,6 +127,12 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
"TS2322": 2
},
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
"TS2739": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
"TS2304": 5
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx": {
"TS2322": 3,
"TS2739": 1,
@@ -141,6 +147,9 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx": {
"TS2739": 2
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
"TS2322": 1
},

View File

@@ -28,7 +28,6 @@ Simple guides for using OmniRoute — no technical background needed.
- [SETUP_GUIDE.md](guides/SETUP_GUIDE.md) — first-time setup of OmniRoute.
- [USER_GUIDE.md](guides/USER_GUIDE.md) — daily usage of the dashboard and API.
- [THINKING_BUDGET.md](guides/THINKING_BUDGET.md) — thinking/reasoning budget modes (passthrough vs auto-strip).
- [FEATURES.md](guides/FEATURES.md) — dashboard feature gallery.
- [TIERS.md](guides/TIERS.md) — OmniRoute tiers explained (user guide).
- [USAGE_QUOTA_GUIDE.md](guides/USAGE_QUOTA_GUIDE.md) — usage, quota & spend tracking.

View File

@@ -171,22 +171,6 @@ codex -c model_reasoning_effort=low "rename variable x to count"
codex -c model_reasoning_effort=xhigh "design the auth module"
```
Also set a reasoning **summary** so Desktop can render thinking text (not only encrypted blobs):
```toml
# ~/.codex/config.toml
model_reasoning_effort = "xhigh" # or ultra when supported
model_reasoning_summary = "detailed" # auto | concise | detailed | none
```
### OmniRoute Thinking Budget (server setting)
On the OmniRoute host, **Settings → AI → Thinking Budget** must be **`passthrough`** for Codex effort/summary to reach upstream. Mode **`auto` strips** all client `reasoning` / `reasoning_effort` fields and will empty thinking panels even when Codex is configured correctly.
Full guide: [THINKING_BUDGET.md](./THINKING_BUDGET.md).
Compression and prompt cache are independent and keep working under `passthrough`.
---
## Profiles — named configurations per model/workflow

View File

@@ -1,89 +0,0 @@
---
title: "Thinking Budget"
version: 3.8.49
lastUpdated: 2026-08-12
---
# Thinking Budget
> **Dashboard:** Settings → **AI** → Thinking Budget
> **API:** `GET` / `PUT` `/api/settings/thinking-budget`
> **Source:** `open-sse/services/thinkingBudget.ts`
Thinking Budget controls whether OmniRoute **rewrites client thinking/reasoning parameters** on the way to providers. It does **not** turn compression, routing, or prompt cache on or off.
## Modes
| Mode | What OmniRoute does | When to use |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`passthrough`** (default) | Leaves client fields alone (`reasoning`, `reasoning_effort`, Claude `thinking`, Gemini `thinking_config`, etc.). | **Codex / Desktop / any client that should control effort + reasoning summaries.** Required for visible thinking panels when the client requests `reasoning.summary`. |
| **`auto`** | **Strips all** thinking/reasoning fields from the request body before upstream. | Only when you deliberately want the **provider** to invent defaults and you do **not** need client-controlled thinking. **Not** “auto-show thinking”. |
| **`custom`** | Overwrites every request with a fixed thinking token budget. | Hard cap on thinking tokens for all traffic. |
| **`adaptive`** | Scales budget from a base effort using message count, tools, and prompt length. | Soft token control without fully stripping client intent. |
### What `auto` removes
When mode is `auto`, `stripThinkingConfig()` deletes (among others):
- OpenAI / Responses: `reasoning`, `reasoning_effort`
- Claude: `thinking`, and `output_config.effort` when present
- Gemini: `generationConfig.thinking_config` / `thinkingConfig`
If a client (e.g. Codex Desktop) sent `reasoning: { effort: "ultra", summary: "detailed" }`, **auto drops that object**. Upstream may still bill some reasoning tokens, but often returns **empty or encrypted-only** reasoning items — so the UI shows no useful thinking stream.
## What this is **not**
| Feature | Relationship |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| **Compression** (Caveman, RTK, stacked, …) | Separate pipeline. Works under every thinking-budget mode. |
| **Prompt / semantic cache** | Separate. Unaffected by thinking-budget mode. |
| **Combo routing / fallbacks** | Separate. Unaffected. |
| **API-key token limits / cost budgets** | Separate. Unaffected. |
| **Reasoning replay cache** | Multi-turn re-inject for strict providers (DeepSeek, Kimi, Qwen-thinking, …). Not the same as Desktop “show thinking”. |
| **Decrypting `encrypted_content`** | **Impossible.** OpenAI/Codex private reasoning blobs are opaque. OmniRoute never decrypts them (#7095 / #7176 / #7304). |
## Visible thinking (Codex / Responses clients)
For a client to show thinking text you need **all** of:
1. Thinking Budget mode = **`passthrough`** (or custom/adaptive that still leaves summary requests intact enough for the path you use).
2. Client asks for a summary, e.g. Codex `model_reasoning_summary = "detailed"` / `auto` (not `none`).
3. Upstream actually streams `response.reasoning_summary_text.*` (or a non-empty `reasoning.summary` on the item).
If you only get “encrypted private reasoning”, either:
- mode was **`auto`** (client request was stripped), or
- upstream returned `encrypted_content` without summary text (provider limitation; OmniRoute can only surface a placeholder, not plaintext).
## API examples
```bash
# Read
curl -sS https://localhost:20128/api/settings/thinking-budget \
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
# Recommended for Codex / Desktop thinking visibility
curl -sS -X PUT https://localhost:20128/api/settings/thinking-budget \
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"mode":"passthrough","customBudget":10240,"effortLevel":"medium"}'
```
Schema (`updateThinkingBudgetSchema`): `mode``passthrough|auto|custom|adaptive`; optional `customBudget`, `effortLevel`, `baseBudget`, `complexityMultiplier`.
### Persistence / restart
Value is stored under settings key `thinkingBudget` and hydrated at process start (`hydrateThinkingBudgetConfig`). After changing via DB or some non-API paths, **restart the OmniRoute process** so the in-memory singleton matches disk.
## Operator checklist
- [ ] Codex / Desktop users: mode = **passthrough**
- [ ] Compression still enabled if you want token savings on **messages**, not by stripping thinking
- [ ] Do not expect `auto` to “show more thinking”
- [ ] Encrypted-only summaries are a **provider** behavior; passthrough cannot decrypt them
## Related docs
- [REASONING_REPLAY.md](../routing/REASONING_REPLAY.md) — multi-turn `reasoning_content` cache
- [USER_GUIDE.md](./USER_GUIDE.md) — Settings dashboard tabs
- [API_REFERENCE.md](../reference/API_REFERENCE.md) — settings endpoints

View File

@@ -895,15 +895,15 @@ curl -X POST http://localhost:20128/api/db-backups/import \
The settings page is organized into **7 tabs** for easy navigation:
| Tab | Contents |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **General** | System storage tools, default behavior, Endpoint tunnel visibility |
| **Appearance** | Theme controls (light/dark/system), sidebar visibility, panel toggles for Cloudflare/Tailscale/ngrok tunnel cards |
| **AI** | Thinking budget (passthrough / auto-strip / custom / adaptive — see [THINKING_BUDGET.md](./THINKING_BUDGET.md)), global system prompt, prompt cache stats |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, Provider Blocking, prompt-injection guard |
| **Routing** | Global routing strategy (Fill First / Round Robin / P2C / Random / Least Used / Cost Optimized), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5), per-provider proxy overrides |
| Tab | Contents |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **General** | System storage tools, default behavior, Endpoint tunnel visibility |
| **Appearance** | Theme controls (light/dark/system), sidebar visibility, panel toggles for Cloudflare/Tailscale/ngrok tunnel cards |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, Provider Blocking, prompt-injection guard |
| **Routing** | Global routing strategy (Fill First / Round Robin / P2C / Random / Least Used / Cost Optimized), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5), per-provider proxy overrides |
General no longer duplicates read-only logging and cache notes. Database retention and
optimization settings are persisted through `/api/settings/database`; manual cache clearing uses

View File

@@ -4,7 +4,6 @@
"pages": [
"SETUP_GUIDE",
"USER_GUIDE",
"THINKING_BUDGET",
"DOCKER_GUIDE",
"ELECTRON_GUIDE",
"FEATURES",

View File

@@ -61,24 +61,24 @@ Content-Type: application/json
### Custom Headers
| Header | Direction | Description |
| ------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
| `x-omniroute-no-memory` | Request | Set to `true` to skip memory + skills injection for this request (mirrors no-cache; avoids the per-call token/cost overhead) |
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
| `X-Session-Id` | Request | Sticky session key for external session affinity |
| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) |
| Header | Direction | Description |
| ------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `X-OmniRoute-No-Cache` | Request | Set to `true` to bypass cache |
| `x-omniroute-no-memory` | Request | Set to `true` to skip memory + skills injection for this request (mirrors no-cache; avoids the per-call token/cost overhead) |
| `X-OmniRoute-Progress` | Request | Set to `true` for progress events |
| `X-Session-Id` | Request | Sticky session key for external session affinity |
| `x_session_id` | Request | Underscore variant also accepted (direct HTTP) |
| `X-OmniRoute-Session-Id` | Request | Caller-supplied session/conversation tag (also feeds memory). When present, persisted verbatim to `call_logs.session_tag` for per-session cost attribution (#8249) — never synthesized when absent |
| `Idempotency-Key` | Request | Dedup key (5s window) |
| `X-Request-Id` | Request | Alternative dedup key |
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
| `X-OmniRoute-Request-Id` | Response | Request correlation id (when known) |
| `X-OmniRoute-Version` | Response | OmniRoute build version (always present) |
| `X-OmniRoute-Cost-Saved` | Response | USD the cache avoided on a HIT (cache hits only) |
| `X-OmniRoute-Decision` | Response | Routing trace: `strategy=<name>; provider=<alias>; latency_ms=<n>` (`<name>` is the combo strategy, or `single` for a non-combo request) — always present on completion responses |
| `Idempotency-Key` | Request | Dedup key (5s window) |
| `X-Request-Id` | Request | Alternative dedup key |
| `X-OmniRoute-Cache` | Response | `HIT` or `MISS` (non-streaming) |
| `X-OmniRoute-Idempotent` | Response | `true` if deduplicated |
| `X-OmniRoute-Progress` | Response | `enabled` if progress tracking on |
| `X-OmniRoute-Session-Id` | Response | Effective session ID used by OmniRoute |
| `X-OmniRoute-Request-Id` | Response | Request correlation id (when known) |
| `X-OmniRoute-Version` | Response | OmniRoute build version (always present) |
| `X-OmniRoute-Cost-Saved` | Response | USD the cache avoided on a HIT (cache hits only) |
| `X-OmniRoute-Decision` | Response | Routing trace: `strategy=<name>; provider=<alias>; latency_ms=<n>` (`<name>` is the combo strategy, or `single` for a non-combo request) — always present on completion responses |
> Nginx note: if you rely on underscore headers (for example `x_session_id`), enable `underscores_in_headers on;`.
@@ -349,9 +349,9 @@ Web/search provider abstraction (Tavily, Brave, Exa, Serper, etc.).
Extract content from a URL via a configured web-fetch provider (Firecrawl, Jina
Reader, Tavily Extract, TinyFish Fetch).
| Method | Path | Description |
| ------ | --------------- | --------------------------------------------------------- |
| POST | `/v1/web/fetch` | Fetch/scrape a URL — body validated by `v1WebFetchSchema` |
| Method | Path | Description |
| ------ | -------------- | ------------------------------------------------------------------------- |
| POST | `/v1/web/fetch` | Fetch/scrape a URL — body validated by `v1WebFetchSchema` |
**Auth:** Bearer API key (`extractApiKey` + `isValidApiKey`). Policy enforced via `enforceApiKeyPolicy`.
@@ -561,28 +561,28 @@ X-OmniRoute-No-Cache: true
### Usage & Analytics
| Endpoint | Method | Description |
| -------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/usage/history` | GET | Usage history |
| `/api/usage/logs` | GET | Usage logs |
| `/api/usage/request-logs` | GET | Request-level logs |
| `/api/usage/[connectionId]` | GET | Per-connection usage |
| `/api/usage/token-limits` | GET/POST/DELETE | Per-API-key token-limit budgets |
| `/api/usage/model-latency-stats` | GET | Rolling per-provider/model latency aggregate (avg/p50/p95/p99, success rate); filters: `windowHours`/`minSamples`/`maxRows`/`provider`/`model` (#6873) |
| `/api/usage/cache-health` | GET | Prompt-cache health summary over `call_logs` — write/read ratio, p50/p90/p99 write-size distribution, heavy-write concentration, per-model split, and a `healthy`/`degraded`/`thrash`/`no-data` verdict; query params `range` (`1h`\|`24h`\|`7d`\|`30d`, default `24h`) and optional `model` (#8827) |
| Endpoint | Method | Description |
| --------------------------- | --------------- | ------------------------------- |
| `/api/usage/history` | GET | Usage history |
| `/api/usage/logs` | GET | Usage logs |
| `/api/usage/request-logs` | GET | Request-level logs |
| `/api/usage/[connectionId]` | GET | Per-connection usage |
| `/api/usage/token-limits` | GET/POST/DELETE | Per-API-key token-limit budgets |
| `/api/usage/model-latency-stats` | GET | Rolling per-provider/model latency aggregate (avg/p50/p95/p99, success rate); filters: `windowHours`/`minSamples`/`maxRows`/`provider`/`model` (#6873) |
| `/api/usage/cache-health` | GET | Prompt-cache health summary over `call_logs` — write/read ratio, p50/p90/p99 write-size distribution, heavy-write concentration, per-model split, and a `healthy`/`degraded`/`thrash`/`no-data` verdict; query params `range` (`1h`\|`24h`\|`7d`\|`30d`, default `24h`) and optional `model` (#8827) |
### Settings
| Endpoint | Method | Description |
| ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/api/settings` | GET/PUT/PATCH | General settings |
| `/api/settings/proxy` | GET/PUT | Network proxy config |
| `/api/settings/proxy/test` | POST | Test proxy connection |
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
| `/api/settings/thinking-budget` | GET/PUT | Thinking/reasoning **request** rewrite mode (passthrough / auto-strip / custom / adaptive). Independent of compression. See [THINKING_BUDGET.md](../guides/THINKING_BUDGET.md). |
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
| `/api/settings/compression` | GET/PUT | Global compression config |
| `/api/settings/purge-request-history` | POST | Clear request log rows and local call-log artifacts |
| Endpoint | Method | Description |
| ------------------------------------- | ------------- | --------------------------------------------------- |
| `/api/settings` | GET/PUT/PATCH | General settings |
| `/api/settings/proxy` | GET/PUT | Network proxy config |
| `/api/settings/proxy/test` | POST | Test proxy connection |
| `/api/settings/ip-filter` | GET/PUT | IP allowlist/blocklist |
| `/api/settings/thinking-budget` | GET/PUT | Reasoning token budget |
| `/api/settings/system-prompt` | GET/PUT | Global system prompt |
| `/api/settings/compression` | GET/PUT | Global compression config |
| `/api/settings/purge-request-history` | POST | Clear request log rows and local call-log artifacts |
### Context & Compression

View File

@@ -680,7 +680,6 @@ REQUEST_TIMEOUT_MS (global override)
| `API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS` | `0` | Raw socket timeout (0 = disabled). |
| `SHUTDOWN_TIMEOUT_MS` | `30000` | Grace period on SIGTERM/SIGINT before force-exit. |
| `OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS` | `120000` | Fallback used by `src/shared/utils/fetchTimeout.ts` when `FETCH_TIMEOUT_MS` is unset. |
| `OMNIROUTE_PROVIDER_PROBE_TIMEOUT_MS` | `8000` | Timeout (ms) for the `validationRead` and `modelsProbe` presets in `src/shared/network/safeOutboundFetch.ts`. Raise for slow endpoints (Cerebras, Cloudflare AI, Groq) to prevent flapping between active/error in the dashboard. Falls back to 8000ms for invalid (<1000) or non-numeric values. |
| `OMNIROUTE_RELAY_FETCH_TIMEOUT_MS` | `25000` | Relay-specific fetch timeout in `open-sse/utils/proxyFetch.ts` (#9158). A hung relay must fail before the client/agent timeout (~30s) so callers see a relay-specific failure instead of a generic upstream timeout. Capped at `29000` so it always fires first. |
| `OMNIROUTE_RETRY_BACKOFF_MS` | `10` | Shared retry backoff for the direct/relay/proxy retry-once paths in `open-sse/utils/proxyFetch.ts` (#9158). `0` = retry immediately. |
| `OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS` | `60000` | Wire-level timeout for the bogdanfinn/tls-client koffi binding (`chatgptTlsClient.ts`). |
@@ -715,21 +714,6 @@ Provider-level circuit breaker tuning. Defaults reflect the scaled values used s
| `OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS` | `30000` | `open-sse/config/constants.ts` | Reset window (ms) for API-key provider breaker. |
| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD` | `2` | `open-sse/config/constants.ts` | Consecutive failure threshold for local providers (Ollama, LM Studio, ...). |
| `OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS` | `15000` | `open-sse/config/constants.ts` | Reset window (ms) for local provider breaker. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD` | `10` | `open-sse/config/constants.ts` | Provider-level breaker: failures within the window before the entire OAuth provider enters cooldown. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS` | `900000` | `open-sse/config/constants.ts` | Provider-level breaker: rolling failure-count window (ms) for OAuth providers. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS` | `300000` | `open-sse/config/constants.ts` | Provider-level breaker: cooldown (ms) once the OAuth provider threshold is reached. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD` | `5` | `open-sse/config/constants.ts` | OAuth provider enters DEGRADED at this many failures. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER` | `8` | `open-sse/config/constants.ts` | OAuth provider max resetTimeout escalation multiplier. |
| `OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT` | `2` | `open-sse/config/constants.ts` | OAuth provider escalates after this many open cycles. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD` | `15` | `open-sse/config/constants.ts` | Provider-level breaker: failures within the window before the entire API-key provider enters cooldown. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS` | `1800000` | `open-sse/config/constants.ts` | Provider-level breaker: rolling failure-count window (ms) for API-key providers. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS` | `600000` | `open-sse/config/constants.ts` | Provider-level breaker: cooldown (ms) once the API-key provider threshold is reached. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD` | `7` | `open-sse/config/constants.ts` | API-key provider enters DEGRADED at this many failures. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER` | `4` | `open-sse/config/constants.ts` | API-key provider max resetTimeout escalation multiplier. |
| `OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT` | `3` | `open-sse/config/constants.ts` | API-key provider escalates after this many open cycles. |
| `OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD` | `2` | `open-sse/config/constants.ts` | Provider-level breaker: failures before the entire local provider enters cooldown. |
| `OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS` | `300000` | `open-sse/config/constants.ts` | Provider-level breaker: rolling failure-count window (ms) for local providers. |
| `OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS` | `60000` | `open-sse/config/constants.ts` | Provider-level breaker: cooldown (ms) once the local provider threshold is reached. |
| `PIN_DROP_BACKOFF_LEVEL` | `2` | `open-sse/services/combo.ts` | Backoff depth at which a context-cache pin's provider is deemed durably unhealthy and the pin is dropped for failover. |
| `PIN_DROP_GRACE_MS` | `20000` | `open-sse/services/combo.ts` | Anti-flap window (ms) tolerating brief transient cooldowns before dropping a context-cache pin. |
@@ -769,7 +753,6 @@ The logging system writes to both stdout and rotated log files. All configuratio
| `CHAT_LOG_ARRAY_TAIL_ITEMS` | `128` | Number of array items retained from the tail when truncating chat log payloads. |
| `CHAT_LOG_MAX_DEPTH` | `6` | Max nesting depth before chat log payloads are truncated. |
| `CHAT_LOG_MAX_OBJECT_KEYS` | `80` | Max object keys retained in chat log payloads (0 = unlimited). |
| `CHAT_LOG_MAX_BODY_KB` | `1024` | Whole request/response body size (KB) before it's replaced by a bare summary instead of the full clone. Raise this if long agentic conversations show a placeholder instead of the real messages in the dashboard. |
| `CHAT_DEBUG_FILE` | `false` | When true, `serializeArtifactForStorage` skips size-based truncation. Debug only. |
---
@@ -1463,6 +1446,7 @@ These settings were introduced after the previous environment-contract snapshot.
| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. |
| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). |
| `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. |
| `CHAT_LOG_MAX_BODY_KB` | `1024` | `src/lib/logEnv.ts` | Maximum request or response body size before log summarization, in KiB. |
| `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. |
| `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. |
| `ADOBE_FIREFLY_MIN_SUBMIT_GAP_MS` | `12000` | `open-sse/services/adobeFireflySession.ts` | Minimum spacing between Adobe Firefly generate submissions. |

View File

@@ -92,26 +92,6 @@ describe prompt, steering the description toward what the user actually asked
(codex-vision-proxy pattern) and asking the vision model to transcribe visible
text. With the flag off — or no user text — the base prompt is used unchanged.
#### Describe output cap (`modalityBridgeVisionMaxChars`)
| Key | Default | Range |
| ------------------------------ | ------- | ---------------- |
| `modalityBridgeVisionMaxChars` | `0` | `0` or 10050000 |
`0` (default) means **no cap** — the description returned by
`callVisionModel()` is passed through unmodified, preserving the existing
behavior. Any value in the 10050000 range truncates the description with a
`…` suffix before it is spliced back as `[Image N]: <description>`
(`VisionBridgeGuardrail.preCall()` in `src/lib/guardrails/visionBridge.ts`).
Raise this for detail-heavy OCR tasks where the downstream model needs the
full transcription; lower it to bound token usage on chatty vision models.
The dashboard field lives on the Vision tab's Advanced panel
(`modality-bridge-max-chars` in `ModalityBridgeVisionTab.tsx`) and clamps any
value between 1 and 99 up to the 100 floor while leaving an explicit `0`
untouched — `0` is a valid Zod value in its own right
(`z.union([z.literal(0), z.number().int().min(100).max(50000)])`), not merely
the "unset" default.
#### Describe cache (`modalityBridge/bridgeCache.ts`)
In-memory LRU + TTL cache for describe outputs, shared process-wide.
@@ -133,10 +113,9 @@ The new `modalityBridge*` keys are Zod-validated in `updateSettingsSchema`
(`src/shared/validation/settingsSchemas.ts`): `modalityBridgeVisionEnabled`,
`modalityBridgeVisionMode`, `modalityBridgeVisionModel`,
`modalityBridgeVisionTaskAware`, `modalityBridgeVisionPrompt`,
`modalityBridgeVisionTimeout`, `modalityBridgeVisionMaxImages`,
`modalityBridgeVisionMaxChars`, the `modalityBridgeCache*` trio, and the
`modalityBridgeAudio*` group used by the Audio Bridge. Migration
`141_modality_bridge_settings.sql` copies existing legacy
`modalityBridgeVisionTimeout`, `modalityBridgeVisionMaxImages`, the
`modalityBridgeCache*` trio, and the `modalityBridgeAudio*` group used by the
Audio Bridge. Migration `141_modality_bridge_settings.sql` copies existing legacy
`visionBridge*` values to the matching new keys (idempotent, never overwrites
an operator-set `modalityBridge*` value); the legacy keys stay accepted as a
read fallback for one release cycle.
@@ -162,8 +141,7 @@ The dedicated dashboard page is
`/dashboard/settings/modality-bridge`. Its URL-addressable `Vision`, `Audio`,
and `Video` tabs preserve query parameters while switching the `tab` value.
The Vision tab exposes enablement, mode, model selection (including the automatic
default), task-aware prompting, advanced timeout/image/description-length/cache
limits, runtime
default), task-aware prompting, advanced timeout/image/cache limits, runtime
counters, and a guarded sample request. The Audio tab is also live: it exposes
enablement, an STT-only model picker with Auto, timeout/max-clip limits, audio
counters, and an `input_audio` sample test. Video remains the explicit placeholder
@@ -457,9 +435,8 @@ store (`getSettings()`), not env vars. Vision's primary keys are
`modalityBridgeVisionEnabled`, `modalityBridgeVisionMode`,
`modalityBridgeVisionModel`, `modalityBridgeVisionTaskAware`,
`modalityBridgeVisionPrompt`, `modalityBridgeVisionTimeout`,
`modalityBridgeVisionMaxImages`, `modalityBridgeVisionMaxChars`,
`modalityBridgeCacheEnabled`, `modalityBridgeCacheTtlMinutes`, and
`modalityBridgeCacheMaxEntries`. The legacy
`modalityBridgeVisionMaxImages`, `modalityBridgeCacheEnabled`,
`modalityBridgeCacheTtlMinutes`, and `modalityBridgeCacheMaxEntries`. The legacy
`visionBridge*` keys are accepted only as the documented one-cycle read
fallback; dashboard writes use the primary keys. Defaults and the fallback
resolver live in `src/shared/constants/modalityBridgeDefaults.ts`, with legacy

View File

@@ -12,7 +12,7 @@
"electron-updater": "^6.8.9"
},
"devDependencies": {
"electron": "^43.3.0",
"electron": "^43.2.0",
"electron-builder": "^26.15.3"
},
"engines": {
@@ -297,6 +297,45 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@electron/windows-sign": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
"fs-extra": "^11.1.1",
"minimist": "^1.2.8",
"postject": "^1.0.0-alpha.6"
},
"bin": {
"electron-windows-sign": "bin/electron-windows-sign.js"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1091,6 +1130,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1367,9 +1415,9 @@
}
},
"node_modules/electron": {
"version": "43.3.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-43.3.0.tgz",
"integrity": "sha512-nLlvu0WFjftWsSaTkV2B/c4NDuJBspTyXu8vKSQ6vLvFt8uG3NgN49LLKcXddwX0GqVvAQDhciWp+4xOdTdhew==",
"version": "43.2.0",
"resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz",
"integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1411,6 +1459,19 @@
"node": ">=14.0.0"
}
},
"node_modules/electron-builder-squirrel-windows": {
"version": "26.15.3",
"resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz",
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.15.3",
"builder-util": "26.15.3",
"electron-winstaller": "5.4.0"
}
},
"node_modules/electron-publish": {
"version": "26.15.3",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz",
@@ -1445,6 +1506,66 @@
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-winstaller": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
"integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
"fs-extra": "^7.0.1",
"lodash": "^4.17.21",
"temp": "^0.9.0"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"@electron/windows-sign": "^1.1.2"
}
},
"node_modules/electron-winstaller/node_modules/fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/electron-winstaller/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"peer": true,
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-winstaller/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -2359,6 +2480,20 @@
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2622,6 +2757,36 @@
"node": ">=18"
}
},
"node_modules/postject": {
"version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
"bin": {
"postject": "dist/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/postject/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/proc-log": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
@@ -2816,6 +2981,21 @@
"node": ">= 4"
}
},
"node_modules/rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
@@ -3071,6 +3251,21 @@
"node": ">=18"
}
},
"node_modules/temp": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/temp-file": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz",

View File

@@ -28,7 +28,7 @@
"electron-updater": "^6.8.9"
},
"devDependencies": {
"electron": "^43.3.0",
"electron": "^43.2.0",
"electron-builder": "^26.15.3"
},
"overrides": {

View File

@@ -229,13 +229,13 @@ export const PROVIDER_PROFILES = {
circuitBreakerThreshold: envInt("OMNIROUTE_CIRCUIT_BREAKER_OAUTH_THRESHOLD", 8),
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_OAUTH_RESET_MS", 60000),
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_THRESHOLD", 10), // Scaled for 500+ connections (was 3)
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_FAILURE_WINDOW_MS", 900000), // 15min window (was 10min)
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_COOLDOWN_MS", 300000), // 5min cooldown when threshold reached
providerFailureThreshold: 10, // Scaled for 500+ connections (was 3)
providerFailureWindowMs: 900000, // 15min window (was 10min)
providerCooldownMs: 300000, // 5min cooldown when threshold reached
// Adaptive circuit breaker v2 settings
degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_DEGRADATION_THRESHOLD", 5), // Enter DEGRADED at this many failures
maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_MAX_BACKOFF_MULTIPLIER", 8), // Max 8x resetTimeout escalation
backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_OAUTH_BACKOFF_ESCALATION_COUNT", 2), // Escalate after 2 open cycles
degradationThreshold: 5, // Enter DEGRADED at this many failures
maxBackoffMultiplier: 8, // Max 8x resetTimeout escalation
backoffEscalationCount: 2, // Escalate after 2 open cycles
},
apikey: {
transientCooldown: 3000, // 3s (API providers recover faster)
@@ -244,12 +244,12 @@ export const PROVIDER_PROFILES = {
circuitBreakerThreshold: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_THRESHOLD", 12),
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS", 30000),
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD", 15), // Scaled for 500+ connections (was 5)
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS", 1800000), // 30min window (was 20min)
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS", 600000), // 10min cooldown when threshold reached
degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD", 7),
maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER", 4),
backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT", 3),
providerFailureThreshold: 15, // Scaled for 500+ connections (was 5)
providerFailureWindowMs: 1800000, // 30min window (was 20min)
providerCooldownMs: 600000, // 10min cooldown when threshold reached
degradationThreshold: 7,
maxBackoffMultiplier: 4,
backoffEscalationCount: 3,
},
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
// Not yet wired into getProviderProfile() — will be used when local provider_nodes
@@ -261,9 +261,9 @@ export const PROVIDER_PROFILES = {
circuitBreakerThreshold: envInt("OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD", 2),
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS", 15000),
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_THRESHOLD", 2), // 2 failures trigger provider cooldown
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_LOCAL_FAILURE_WINDOW_MS", 300000), // 5min window for counting failures
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_LOCAL_COOLDOWN_MS", 60000), // 1min cooldown when threshold reached
providerFailureThreshold: 2, // 2 failures trigger provider cooldown
providerFailureWindowMs: 300000, // 5min window for counting failures
providerCooldownMs: 60000, // 1min cooldown when threshold reached
},
};

View File

@@ -1,5 +1,4 @@
import type { RegistryEntry } from "../../shared.ts";
const CROF_REASONING_EFFORTS = ["none", "low", "medium", "high", "max"] as const;
export const crofProvider: RegistryEntry = {
id: "crof",
@@ -10,147 +9,30 @@ export const crofProvider: RegistryEntry = {
authType: "apikey",
authHeader: "bearer",
// Seed list — runtime /v1/models discovery keeps this fresh.
// Source: GET https://crof.ai/v1/models (2026-08-10; includes models absent from the 2026-05-17 roster).
// Source: GET https://crof.ai/v1/models (2026-05-17).
models: [
{
id: "deepseek-v4-pro-precision",
name: "DeepSeek V4 Pro (Precision)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "deepseek-v4-pro",
name: "DeepSeek V4 Pro",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "deepseek-v4-pro-lightning",
name: "DeepSeek V4 Pro (Lightning)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "deepseek-v4-flash",
name: "DeepSeek V4 Flash",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "deepseek-v4-flash-0731",
name: "DeepSeek V4 Flash 0731",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
{
id: "kimi-k2.6-precision",
name: "Kimi K2.6 (Precision)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k2.6",
name: "Kimi K2.6",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k2.7-code",
name: "Kimi K2.7 Code",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k3",
name: "Kimi K3",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k3-eco",
name: "Kimi K3 Eco",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k2.5-lightning",
name: "Kimi K2.5 (Lightning)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "kimi-k2.5",
name: "Kimi K2.5",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-5.1-precision",
name: "GLM 5.1 (Precision)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-5.1",
name: "GLM 5.1",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-5.2",
name: "GLM 5.2",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-4.7",
name: "GLM 4.7",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "glm-4.7-flash",
name: "GLM 4.7 Flash",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "mimo-v2.5-pro-precision",
name: "Mimo 2.5 Pro (Precision)",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "mimo-v2.5-pro",
name: "Mimo 2.5 Pro",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "gemma-4-31b-it",
name: "Gemma 4 31B",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{ id: "kimi-k2.6-precision", name: "Kimi K2.6 (Precision)", supportsReasoning: true },
{ id: "kimi-k2.6", name: "Kimi K2.6", supportsReasoning: true },
{ id: "kimi-k2.5-lightning", name: "Kimi K2.5 (Lightning)", supportsReasoning: true },
{ id: "kimi-k2.5", name: "Kimi K2.5", supportsReasoning: true },
{ id: "glm-5.1-precision", name: "GLM 5.1 (Precision)", supportsReasoning: true },
{ id: "glm-5.1", name: "GLM 5.1", supportsReasoning: true },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
{ id: "mimo-v2.5-pro-precision", name: "Mimo 2.5 Pro (Precision)", supportsReasoning: true },
{ id: "mimo-v2.5-pro", name: "Mimo 2.5 Pro", supportsReasoning: true },
{ id: "gemma-4-31b-it", name: "Gemma 4 31B", supportsReasoning: true },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{
id: "qwen3.6-27b",
name: "Qwen3.6 27B",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "qwen3.5-397b-a17b",
name: "Qwen3.5 397B A17B",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{
id: "qwen3.5-9b",
name: "Qwen3.5 9B",
supportsReasoning: true,
supportedThinkingEfforts: CROF_REASONING_EFFORTS,
},
{ id: "qwen3.6-27b", name: "Qwen3.6 27B", supportsReasoning: true },
{ id: "qwen3.5-397b-a17b", name: "Qwen3.5 397B A17B", supportsReasoning: true },
{ id: "qwen3.5-9b", name: "Qwen3.5 9B", supportsReasoning: true },
],
};

View File

@@ -1,10 +1,5 @@
import type { RegistryEntry } from "../../shared.ts";
/**
* The key is genuinely optional: probed live 2026-08-11 with no Authorization
* header, /chat/completions still answered 200 (kilo-auto/free routed to
* stepfun/step-3.7-flash) — so authType stays "optional", matching ovhcloud.
*/
export const kilo_gatewayProvider: RegistryEntry = {
id: "kilo-gateway",
alias: "kg",
@@ -12,7 +7,7 @@ export const kilo_gatewayProvider: RegistryEntry = {
executor: "default",
baseUrl: "https://api.kilo.ai/api/gateway/chat/completions",
modelsUrl: "https://api.kilo.ai/api/gateway/models",
authType: "optional",
authType: "apikey",
authHeader: "bearer",
models: [
{ id: "kilo-auto/frontier", name: "Kilo Auto Frontier" },

View File

@@ -1,8 +1,8 @@
import type { RegistryEntry } from "../../../shared.ts";
export const KIMI_WEB_STATIC_MODELS = [
{ id: "k3", name: "K3", supportsReasoning: true, toolCalling: false },
{ id: "k2d6", name: "K2.6", supportsReasoning: true, toolCalling: false },
{ id: "k3", name: "K3", supportsReasoning: true },
{ id: "k2d6", name: "K2.6", supportsReasoning: true },
];
export const kimi_webProvider: RegistryEntry = {

View File

@@ -1,46 +1,11 @@
import type { RegistryEntry } from "../../shared.ts";
import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts";
/**
* Poolside — first-party OpenAI-compatible inference host (inference.poolside.ai).
*
* Keys are self-service (`sky_…`, platform.poolside.ai). The catalog endpoint is
* authenticated: without a key `/v1/models` answers 401 with the body
* `No Authorization header provided`, which is what an earlier generic probe read
* back as "invalid key" and led to the entry being dropped (#2723, #3054).
* With a key it answers 200 and returns exactly the two Preview models below
* (authenticated probe 2026-08-07, #9085).
*
* The IDs here are the ones the live catalog returns — `poolside/laguna-xs-2.1`,
* not the `laguna-xs.2` form carried by third-party listings and by the
* aggregator catalogs in this repo (routeway, cline), whose IDs are namespaced by
* the aggregator and do not address this host. Both models are text-only, report
* `tools` and `reasoning`, and are free during Preview. `passthroughModels` stays
* on so live discovery keeps admitting models the Preview adds later; upstream
* publishes no rate-limit headers.
*/
export const poolsideProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({
id: "poolside",
alias: "poolside",
baseUrl: "https://inference.poolside.ai/v1/chat/completions",
modelsUrl: "https://inference.poolside.ai/v1/models",
models: [
{
id: "poolside/laguna-xs-2.1",
name: "Laguna XS 2.1",
toolCalling: true,
supportsReasoning: true,
contextLength: 262144,
maxOutputTokens: 32768,
},
{
id: "poolside/laguna-s-2.1",
name: "Laguna S 2.1",
toolCalling: true,
supportsReasoning: true,
contextLength: 262144,
maxOutputTokens: 32768,
},
],
models: [],
passthroughModels: true,
});

View File

@@ -4,7 +4,6 @@ import {
KIMI_CODING_ANTHROPIC_URL,
KIMI_CODING_OPENAI_URL,
} from "../config/providers/registry/kimi/coding/runtime.ts";
import { flattenOpenAIToolRootAnyOf } from "../services/toolSchemaSanitizer.ts";
import { FORMATS } from "../translator/formats.ts";
import { DefaultExecutor } from "./default.ts";
import type { ProviderCredentials } from "./base.ts";
@@ -183,7 +182,6 @@ function normalizeOpenAIRequest(
delete next.max_tokens;
applyOpenAIThinking(next, policy);
if (Array.isArray(next.tools)) next.tools = flattenOpenAIToolRootAnyOf(next.tools);
if (stream) {
next.stream_options = {

View File

@@ -1,4 +1,3 @@
import { flattenOpenAIToolRootAnyOf } from "../services/toolSchemaSanitizer.ts";
import { DefaultExecutor } from "./default.ts";
import type { ProviderCredentials } from "./base.ts";
@@ -104,7 +103,6 @@ export function normalizeMoonshotRequest(model: string, body: unknown): unknown
if (!normalizedModel.startsWith("kimi-")) return body;
const next: JsonRecord = { ...record };
if (Array.isArray(next.tools)) next.tools = flattenOpenAIToolRootAnyOf(next.tools);
const isK3 = /^kimi-k3(?:$|-)/.test(normalizedModel);
const isK27 = /^kimi-k2\.7-code(?:$|-)/.test(normalizedModel);
const isK26 = /^kimi-k2\.6(?:$|-)/.test(normalizedModel);

View File

@@ -367,9 +367,7 @@ export class OpencodeExecutor extends BaseExecutor {
// value risks upstream rejection (#5720 regressed with "opencode/local"), and this
// is deployment-specific. So it stays OFF by default and the VPS operator enables it
// with OPENCODE_SYNTHESIZE_CLI_HEADERS=true (values env-overridable). Client-supplied
// headers take precedence, EXCEPT User-Agent: a non-CLI client UA (curl/SDK) is
// replaced with the synthesized CLI UA because opencode.ai's free tier rejects
// generic client UAs from datacenter IPs (FreeUsageLimitError 429).
// headers always take precedence.
const synthesizeCli = /^(1|true|yes|on)$/i.test(
process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? ""
);

View File

@@ -148,36 +148,6 @@ export interface PplxBlock {
}>;
goals?: Array<{ description?: string }>;
};
// Workflow API (`intended_usage: "workflow_root"`). Perplexity moved the answer
// text here from markdown_block: it now arrives as one WORKFLOW_ITEM_TEXT item
// whose `text_payload.variant` is "answer", nested under a workflow step. Other
// variants ("thinking") and item types (queries, sources) are not answer text.
workflow_block?: PplxWorkflowBlock;
}
export interface PplxWorkflowTextPayload {
text?: string;
chunks?: string[];
variant?: string;
is_streaming?: boolean;
}
export interface PplxWorkflowItem {
type?: string;
variant?: string;
payload?: { text_payload?: PplxWorkflowTextPayload };
}
export interface PplxWorkflowStep {
status?: string;
title?: string;
tool_name?: string;
items?: PplxWorkflowItem[];
}
export interface PplxWorkflowBlock {
status?: string;
steps?: PplxWorkflowStep[];
}
export interface PplxUpsellInformation {
@@ -457,134 +427,6 @@ export function applyMarkdownDiff(acc: MarkdownAccumulator, patches: PplxDiffPat
}
}
/** Answer-text items carry this `variant`; "thinking" and friends are not answer text. */
const WORKFLOW_ANSWER_VARIANT = "answer";
/**
* mdState key for one workflow answer item. Keyed per step+item so the
* `/chunks/<k>` indices of two concurrent items can never overwrite each other.
*/
function workflowUsageKey(stepIdx: number, itemIdx: number): string {
return `workflow_root:${stepIdx}:${itemIdx}`;
}
function isAnswerItem(item: PplxWorkflowItem | undefined): boolean {
if (!item) return false;
const payloadVariant = item.payload?.text_payload?.variant;
return (payloadVariant ?? item.variant) === WORKFLOW_ANSWER_VARIANT;
}
/**
* Seed an accumulator from a materialized answer item. Chunks win over `text`:
* the terminal frame can carry a `text` that lags the chunk track (same
* precedence markdown_block already uses for `chunks` over `answer`).
*/
function seedFromAnswerItem(acc: MarkdownAccumulator, item: PplxWorkflowItem): void {
const tp = item.payload?.text_payload;
if (!tp) return;
if (Array.isArray(tp.chunks) && tp.chunks.length > 0) {
acc.chunks = tp.chunks.map((c) => String(c));
} else if (typeof tp.text === "string" && tp.text.length > 0) {
acc.chunks = [tp.text];
}
}
function ensureAcc(mdState: Map<string, MarkdownAccumulator>, key: string): MarkdownAccumulator {
let acc = mdState.get(key);
if (!acc) {
acc = { chunks: [] };
mdState.set(key, acc);
}
return acc;
}
/**
* Apply a `field: "workflow_block"` diff patch set.
*
* Live shapes (Aug 2026 capture, pplx-auto / mode=copilot):
* {op:"add", path:"/steps/1", value:{items:[…]}}
* {op:"add", path:"/steps/0/items/1", value:{…}}
* {op:"add", path:"/steps/1/items/0/payload/text_payload/chunks/2", value:"…"}
* {op:"replace", path:"/steps/1/items/0/payload/text_payload/text", value:"…"}
*
* Only answer-variant items are accumulated; step/status patches are ignored.
*/
export function applyWorkflowDiff(
mdState: Map<string, MarkdownAccumulator>,
patches: PplxDiffPatch[]
): void {
for (const patch of patches) {
const path = patch.path ?? "";
// Whole step materialized — pick up every answer item it carries.
const stepMatch = /^\/steps\/(\d+)$/.exec(path);
if (stepMatch) {
const stepIdx = Number.parseInt(stepMatch[1], 10);
const step = (patch.value ?? {}) as PplxWorkflowStep;
(step.items ?? []).forEach((item, itemIdx) => {
if (!isAnswerItem(item)) return;
seedFromAnswerItem(ensureAcc(mdState, workflowUsageKey(stepIdx, itemIdx)), item);
});
continue;
}
// Single item appended to an existing step.
const itemMatch = /^\/steps\/(\d+)\/items\/(\d+)$/.exec(path);
if (itemMatch) {
const item = (patch.value ?? {}) as PplxWorkflowItem;
if (!isAnswerItem(item)) continue;
const key = workflowUsageKey(
Number.parseInt(itemMatch[1], 10),
Number.parseInt(itemMatch[2], 10)
);
seedFromAnswerItem(ensureAcc(mdState, key), item);
continue;
}
// Incremental chunk append — the streaming hot path.
const chunkMatch = /^\/steps\/(\d+)\/items\/(\d+)\/payload\/text_payload\/chunks\/(\d+)$/.exec(
path
);
if (chunkMatch && typeof patch.value === "string") {
const key = workflowUsageKey(
Number.parseInt(chunkMatch[1], 10),
Number.parseInt(chunkMatch[2], 10)
);
// Only extend a track already seeded by an answer item: a chunk patch
// carries no variant, so an unseeded key could be a "thinking" track.
const acc = mdState.get(key);
if (!acc) continue;
acc.chunks[Number.parseInt(chunkMatch[3], 10)] = patch.value;
continue;
}
// Terminal `text` materialization — only used when no chunks arrived.
const textMatch = /^\/steps\/(\d+)\/items\/(\d+)\/payload\/text_payload\/text$/.exec(path);
if (textMatch && typeof patch.value === "string" && patch.value.length > 0) {
const key = workflowUsageKey(
Number.parseInt(textMatch[1], 10),
Number.parseInt(textMatch[2], 10)
);
const acc = mdState.get(key);
if (!acc || acc.chunks.join("").length > 0) continue;
acc.chunks = [patch.value];
}
}
}
/** Accumulate every answer item of a materialized workflow_block. */
export function applyWorkflowBlock(
mdState: Map<string, MarkdownAccumulator>,
workflow: PplxWorkflowBlock
): void {
(workflow.steps ?? []).forEach((step, stepIdx) => {
(step.items ?? []).forEach((item, itemIdx) => {
if (!isAnswerItem(item)) return;
seedFromAnswerItem(ensureAcc(mdState, workflowUsageKey(stepIdx, itemIdx)), item);
});
});
}
/**
* Extract the assistant answer from the COMPLETED frame's `text` step-blob.
*
@@ -804,18 +646,6 @@ export async function* extractContent(
}
}
// Content: workflow_block answer items. Perplexity migrated the answer text
// here from markdown_block, so this must run BEFORE the isAnswerTextUsage
// gate — the carrying usage is "workflow_root", which that gate rejects.
if (block.workflow_block) {
applyWorkflowBlock(mdState, block.workflow_block);
continue;
}
if (block.diff_block?.field === "workflow_block") {
applyWorkflowDiff(mdState, block.diff_block.patches ?? []);
continue;
}
// Content: answer-text blocks (schematized diff frames OR materialized
// markdown_block on the final COMPLETED frame).
if (!isAnswerTextUsage(usage)) continue;

View File

@@ -46,21 +46,11 @@ import {
} from "../shared/zedAuth.ts";
import { resolveSuppressThinkClose, THINKING_MARKER_HEADER } from "../utils/thinkCloseMarker.ts";
// Wire values for the `provider` field of POST /completions. These are NOT
// display names: cloud.zed.dev matches them exactly, and an unrecognized value
// fails the whole request with `500 {"message":"An internal server error
// occurred."}` before the model is ever looked at — which is why every model id,
// including invalid ones, produced an identical 500.
//
// The spellings come from Zed's own GET /models catalog, which reports
// `anthropic`, `open_ai` and `google` (note the underscore); `x_ai` follows the
// same convention. Feeding a catalog value back through normalizeZedProvider is
// therefore identity, as it must be.
const ZED_PROVIDER = {
anthropic: "anthropic",
openai: "open_ai",
google: "google",
xai: "x_ai",
anthropic: "Anthropic",
openai: "OpenAi",
google: "Google",
xai: "XAi",
} as const;
type ZedProviderName = (typeof ZED_PROVIDER)[keyof typeof ZED_PROVIDER];

View File

@@ -1,7 +1,4 @@
import {
extractRequestToolIdentityMap,
toToolNameAliasMap,
} from "./chatCore/requestToolIdentity.ts";
import { extractRequestToolIdentityMap } from "./chatCore/requestToolIdentity.ts";
import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts";
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
@@ -147,11 +144,7 @@ import {
getExplicitModelOutputCap,
resolveInputTokenCapForGate,
} from "@/lib/modelCapabilities.ts";
import {
checkRequestCapabilityFit,
deriveRequestCapabilityRequirements,
buildCapabilityMismatchMessage,
} from "@/shared/constants/capabilities/capabilityFilter.ts";
import { checkRequestCapabilityFit, deriveRequestCapabilityRequirements, buildCapabilityMismatchMessage } from "@/shared/constants/capabilities/capabilityFilter.ts";
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts";
import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts";
import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts";
@@ -425,7 +418,6 @@ export async function handleChatCore({
comboStrategy = null,
isCombo = false,
routingComboId = null,
sessionAffinityKey = null,
comboStepId = null,
comboExecutionKey = null,
cachedSettings = null,
@@ -889,10 +881,6 @@ export async function handleChatCore({
"x-omniroute-session-id"
)) || null;
const pipelineSessionId = explicitSessionIdHeader || skillRequestId;
const reasoningReplaySessionKey = sessionAffinityKey || explicitSessionIdHeader;
const reasoningCacheScope = reasoningReplaySessionKey
? `api-key:${String(apiKeyInfo?.id ?? "local")}\x1f${String(reasoningReplaySessionKey)}`
: null;
// persistAttemptLogs extracted to chatCore/attemptLogging.ts (#3501); bind the per-request context
// once so the 16 call sites keep passing only the per-attempt args (byte-identical).
const persistAttemptLogs = (args: PersistAttemptLogsArgs) =>
@@ -2051,7 +2039,6 @@ export async function handleChatCore({
preserveDeveloperRole,
preserveCacheControl,
copilotClient: copilotCompatibleReasoning,
reasoningCacheScope,
}
);
}
@@ -2216,7 +2203,6 @@ export async function handleChatCore({
preserveCacheControl,
signatureNamespace: connectionId,
copilotClient: copilotCompatibleReasoning,
reasoningCacheScope,
...(preCompressionBody ? { preCompressionBody } : {}),
}
);
@@ -2331,8 +2317,13 @@ export async function handleChatCore({
// response toolNameMap so the response translator can restore tool names
// from their lowercased form (#9568). Only merge string-valued entries
// (tool name aliases), not object-valued namespace identities (#7936).
if (!toolNameMap) {
toolNameMap = toToolNameAliasMap(requestToolIdentityMap);
if (!toolNameMap && requestToolIdentityMap instanceof Map && requestToolIdentityMap.size > 0) {
const hasStringValues = [...requestToolIdentityMap.values()].every(
(v: unknown) => typeof v === "string"
);
if (hasStringValues) {
toolNameMap = requestToolIdentityMap;
}
}
delete translatedBody._toolNameMap;
delete translatedBody._disableToolPrefix;
@@ -2646,11 +2637,8 @@ export async function handleChatCore({
}
// === /Quota Share enforcement PRE-hook ===
if (isFeatureFlagEnabled("CAPABILITY_FILTER_ENABLED")) {
const fit = checkRequestCapabilityFit(
getResolvedModelCapabilities({ provider, model: effectiveModel }),
deriveRequestCapabilityRequirements(body as Record<string, unknown>),
provider
);
const fit = checkRequestCapabilityFit(getResolvedModelCapabilities({ provider, model: effectiveModel }),
deriveRequestCapabilityRequirements(body as Record<string, unknown>), provider);
if (!fit.compatible) {
const msg = buildCapabilityMismatchMessage(fit.terminalReason!, provider, effectiveModel);
log?.warn?.("CAPABILITY", msg);
@@ -4392,23 +4380,16 @@ export async function handleChatCore({
// Reasoning Replay Cache (#1628): Capture reasoning_content from non-streaming responses
// with tool_calls so it can be replayed on subsequent turns (DeepSeek V4, Kimi K2, etc.)
try {
const cacheResponse = translatedResponse?.choices?.[0]
? translatedResponse
: needsTranslation(responsePayloadFormat, FORMATS.OPENAI)
? translateNonStreamingResponse(
responseBody,
responsePayloadFormat,
FORMATS.OPENAI,
responseToolNameMap
)
: responseBody;
const firstChoice = cacheResponse?.choices?.[0];
const firstChoice = translatedResponse?.choices?.[0];
const msg = firstChoice?.message;
const historyMessages = (translatedBody as { messages?: unknown[] } | null | undefined)
?.messages;
// The response being cached now will be replayed as history on the *next*
// turn, where the read side (translator/index.ts) keys the lookup by the
// message's real position in that future `messages` array — i.e. right
// after everything the client sent this turn.
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
cacheReasoningFromAssistantMessage(msg, provider, model, {
scope: reasoningCacheScope,
historyMessages: Array.isArray(historyMessages) ? historyMessages : [],
requestId: skillRequestId,
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
});
} catch {
// Cache capture is non-critical — never block the response
@@ -4834,24 +4815,14 @@ export async function handleChatCore({
if (normalizedStreamStatus === 200 && streamResponseBody) {
try {
const streamBody = streamResponseBody as Record<string, unknown>;
const cacheStreamBody = Array.isArray(streamBody.choices)
? streamBody
: needsTranslation(clientResponseFormat, FORMATS.OPENAI)
? (translateNonStreamingResponse(
streamBody,
clientResponseFormat,
FORMATS.OPENAI,
responseToolNameMap
) as Record<string, unknown>)
: streamBody;
const choices = cacheStreamBody.choices as
{ message?: Record<string, unknown> }[] | undefined;
const choices = streamBody.choices as { message?: Record<string, unknown> }[] | undefined;
const msg = choices?.[0]?.message;
const historyMessages = (translatedBody as { messages?: unknown[] } | null | undefined)
?.messages;
// See the non-streaming capture above: messageIndex must match the
// position this message will occupy in the *next* turn's history.
const bodyMessages = (body as { messages?: unknown[] } | null | undefined)?.messages;
cacheReasoningFromAssistantMessage(msg, provider, model, {
scope: reasoningCacheScope,
historyMessages: Array.isArray(historyMessages) ? historyMessages : [],
requestId: skillRequestId,
messageIndex: Array.isArray(bodyMessages) ? bodyMessages.length : 0,
});
} catch {
// Cache capture is non-critical — never block the stream

View File

@@ -60,9 +60,9 @@ export function cloneBoundedChatLogPayload(value: unknown, depth = 0): unknown {
/**
* Truncate a large object for logging. If its JSON representation exceeds
* getChatLogMaxBodyBytes() (default 1MB; CHAT_LOG_MAX_BODY_KB env override),
* return a lightweight summary instead of the full clone. This prevents
* persistAttemptLogs from holding unbounded references to translatedBody
* the configured max body size (getChatLogMaxBodyBytes()), return a
* lightweight summary instead of the full clone. This prevents
* persistAttemptLogs from holding multi-MB references to translatedBody
* across 17 call sites per request.
*
* When the summarized object carries a `tools` definition, re-attach it
@@ -77,9 +77,6 @@ export function truncateForLog(value: unknown): Record<string, unknown> | null |
if (value === null || value === undefined) return value as null | undefined;
if (typeof value !== "object") return value as unknown as Record<string, unknown>;
const maxBodyBytes = getChatLogMaxBodyBytes();
// Pass maxBodyBytes as the early-exit point — otherwise estimateSizeFast's
// own default 256KB early-exit caps what it can ever report, silently
// making any configured threshold above 256KB unreachable (#trunc-limit-config).
const estimatedSize = estimateSizeFast(value, maxBodyBytes);
if (estimatedSize <= maxBodyBytes) return value as Record<string, unknown>;
// Object is too large — return a summary instead of a deep clone
@@ -91,11 +88,6 @@ export function truncateForLog(value: unknown): Record<string, unknown> | null |
if (typeof obj.model === "string") summary.model = obj.model;
if (typeof obj.provider === "string") summary.provider = obj.provider;
if (Array.isArray(obj.messages)) summary.messageCount = obj.messages.length;
// Responses API bodies use `input[]`, not `messages[]` (OpenAI-chat/Gemini-only
// field name) — without this, a large /v1/responses request got summarized
// with no count at all, leaving the dashboard's "Full Conversation" panel
// nothing to base its "N messages not shown" placeholder on.
else if (Array.isArray(obj.input)) summary.messageCount = obj.input.length;
if (Array.isArray(obj.contents)) summary.contentCount = obj.contents.length;
if (typeof obj.stream === "boolean") summary.stream = obj.stream;
if (Array.isArray(obj.tools)) summary.tools = cloneBoundedChatLogPayload(obj.tools);

View File

@@ -5,27 +5,14 @@
* Extracted from handleChatCore's non-streaming success path: assemble the context object passed to
* `guardrailRegistry.runPostCallHooks`. Pure value builder — no side effects, no early-returns. The
* `disabledGuardrails` field is resolved via `resolveDisabledGuardrails` (injectable for tests).
* Preserves the previous field mapping and constants while narrowing values from
* the untyped request boundary to the public guardrail contract.
* Behaviour is byte-identical to the previous inline literal, including the `method: "POST"` /
* `stream: false` constants and the headers/endpoint null-coalescing.
*/
import {
resolveDisabledGuardrails as defaultResolveDisabled,
type GuardrailContext,
} from "@/lib/guardrails";
import { resolveDisabledGuardrails as defaultResolveDisabled } from "@/lib/guardrails";
type LoggerLike = GuardrailContext["log"];
type LoggerLike = unknown;
type HeadersLike = Headers | Record<string, unknown> | null;
function optionalRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function optionalString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
export function buildPostCallGuardrailContext(
args: {
apiKeyInfo: unknown;
@@ -38,24 +25,23 @@ export function buildPostCallGuardrailContext(
clientResponseFormat: unknown;
},
resolveDisabledGuardrails: typeof defaultResolveDisabled = defaultResolveDisabled
): GuardrailContext {
) {
const headers = (args.clientRawRequest?.headers as HeadersLike) ?? null;
const apiKeyInfo = optionalRecord(args.apiKeyInfo);
return {
apiKeyInfo,
apiKeyInfo: args.apiKeyInfo,
disabledGuardrails: resolveDisabledGuardrails({
apiKeyInfo,
apiKeyInfo: (args.apiKeyInfo as Record<string, unknown> | null) ?? null,
body: args.body,
headers,
}),
endpoint: optionalString(args.clientRawRequest?.endpoint),
endpoint: args.clientRawRequest?.endpoint || null,
headers,
log: args.log,
method: "POST",
model: args.model,
provider: args.provider,
sourceFormat: optionalString(args.responsePayloadFormat),
sourceFormat: args.responsePayloadFormat,
stream: false,
targetFormat: optionalString(args.clientResponseFormat),
};
targetFormat: args.clientResponseFormat,
} as const;
}

View File

@@ -1,25 +1,4 @@
export type NamespaceIdentity = { namespace: string; name: string };
/**
* Return a string-valued copy only when the complete map is an alias ledger.
*
* The legacy `_toolNameMap` side channel can carry either response aliases or
* namespace identities. Checking every value before copying keeps those two
* contracts separate and gives callers a real `Map<string, string>` instead of
* asserting an identity map into the alias shape.
*/
export function toToolNameAliasMap(
map: ReadonlyMap<string, unknown> | null
): Map<string, string> | null {
if (!map || map.size === 0) return null;
const aliases = new Map<string, string>();
for (const [wireName, originalName] of map) {
if (typeof originalName !== "string") return null;
aliases.set(wireName, originalName);
}
return aliases;
}
type NamespaceIdentity = { namespace: string; name: string };
/**
* Extract the #7936 request-tool identity map from the translated body and

View File

@@ -85,29 +85,18 @@ describe("runChaosPanel", () => {
});
describe("serializeChaosPart", () => {
it("emits a comment + omni-chaos-part event envelope when custom event is requested", () => {
it("emits a comment + omni-chaos-part event envelope", () => {
const part: ChaosPart = { model: "a/gpt", index: 0, ok: true, text: "hi" };
const s = serializeChaosPart(part, false, true);
const s = serializeChaosPart(part, false);
expect(s).toContain("event: omni-chaos-part");
expect(s).toContain('"type":"omni-chaos-part"');
expect(s).toContain('"model":"a/gpt"');
expect(s).toContain(": chaos 0 ok a/gpt");
});
it("emits ONLY the SSE comment (no event/data) by default for OpenAI-compatible clients", () => {
const part: ChaosPart = { model: "a/gpt", index: 0, ok: true, text: "hi" };
const s = serializeChaosPart(part, false);
// comment line kept (ignored by every SSE parser by spec)
expect(s).toContain(": chaos 0 ok a/gpt");
// NO custom event/data — those break openai-node / @ai-sdk validators
expect(s).not.toContain("event: omni-chaos-part");
expect(s).not.toContain('"type":"omni-chaos-part"');
expect(s).not.toMatch(/^data:/m);
});
});
describe("handleChaosChat", () => {
it("emits ONLY SSE comments (no custom event) by default + final OpenAI chunk", async () => {
it("emits broadcast events + final OpenAI chunk", async () => {
const handle = fakeHandle(async (model) => textResponse(`ans-${model}`));
const res = await handleChaosChat({
body: { messages: [] },
@@ -117,23 +106,9 @@ describe("handleChaosChat", () => {
expect(res.headers.get("X-OmniRoute-Chaos")).toBe("true");
expect(res.headers.get("X-OmniRoute-Chaos-Panel")).toBe("2");
const body = await res.text();
// NO custom event by default — OpenAI-compatible parsers choke on it
expect(body.match(/event: omni-chaos-part/g)?.length ?? 0).toBe(0);
expect(body.match(/^: chaos /gm)?.length ?? 0).toBe(2);
// final canonical chunk carries the primary answer
expect(body).toContain("ans-b/opus");
expect(body).toContain("[DONE]");
});
it("emits omni-chaos-part events when stream_options.include_chaos_parts is set", async () => {
const handle = fakeHandle(async (model) => textResponse(`ans-${model}`));
const res = await handleChaosChat({
body: { messages: [], stream_options: { include_chaos_parts: true } },
models: ["a/gpt", "b/opus"],
handleSingleModel: handle,
});
const body = await res.text();
// each model gets a broadcast event
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
// final canonical chunk carries the primary answer
expect(body).toContain("ans-b/opus");
expect(body).toContain("[DONE]");
});
@@ -163,8 +138,8 @@ describe("handleChaosChat", () => {
// client learns via the error final chunk rather than a bare 503.
expect(res.status).toBe(200);
const body = await res.text();
// NO custom events by default (comments only), error conveyed via final chunk
expect(body.match(/event: omni-chaos-part/g)?.length ?? 0).toBe(0);
// each model gets a broadcast fail event
expect(body.match(/event: omni-chaos-part/g)?.length).toBe(2);
expect(body).toContain("All chaos panel models failed");
expect(body).toContain("[DONE]");
});

View File

@@ -53,28 +53,16 @@ export type ChaosPart = {
};
/**
* Build the SSE wrapper for one chaos panel part.
*
* By DEFAULT only an SSE comment (`: chaos ...`) is emitted — comments are
* ignored by every SSE parser per spec, so OpenAI-compatible clients
* (openai-node, @ai-sdk/openai-compatible, …) never see a non-`choices`
* `data:` payload and their schema validation cannot fail with an
* `invalid_union` error.
*
* When `emitCustomEvent` is true (opt-in via
* `stream_options.include_chaos_parts`), the custom event name
* `omni-chaos-part` + metadata `data:` block is also emitted so a
* protocol-aware IDE can split panels out.
* Build the SSE comment/event wrapper for one chaos panel part.
* We emit a custom event name `omni-chaos-part` so a protocol-aware IDE can
* split it out; non-aware clients reading OpenAI-style SSE will simply ignore
* the unknown event and use the final `data:` chunk below.
*
* The part's text is NOT included in the metadata event — it arrives in the
* final `data:` chunk for the primary model. This keeps each broadcast event
* small (metadata-only) so SSE buffering stays predictable.
*/
export function serializeChaosPart(
part: ChaosPart,
isFinal: boolean,
emitCustomEvent = false
): string {
export function serializeChaosPart(part: ChaosPart, isFinal: boolean): string {
const meta = {
type: "omni-chaos-part",
model: part.model,
@@ -83,11 +71,11 @@ export function serializeChaosPart(
final: isFinal,
...(part.error ? { error: part.error } : {}),
};
const comment = `: chaos ${part.index} ${part.ok ? "ok" : "fail"} ${part.model}\n`;
if (!emitCustomEvent) {
return comment + "\n";
}
return comment + `event: omni-chaos-part\n` + `data: ${JSON.stringify(meta)}\n\n`;
return (
`: chaos ${part.index} ${part.ok ? "ok" : "fail"} ${part.model}\n` +
`event: omni-chaos-part\n` +
`data: ${JSON.stringify(meta)}\n\n`
);
}
/**
@@ -342,11 +330,9 @@ function concatSseText(sse: string): string {
* `config.chaos.enabled` flag is set (the `auto/chaos` virtual combo).
*
* Returns a single Response whose body is an SSE stream:
* - one SSE comment (`: chaos N ...`) per panel model, enqueued
* PROGRESSIVELY as each model lands (comments are ignored by every SSE
* parser, so OpenAI-compatible clients see only the final chunk)
* - when `stream_options.include_chaos_parts: true` is set, the per-panel
* `omni-chaos-part` custom event is emitted instead of the bare comment
* - one `omni-chaos-part` event per panel model, enqueued PROGRESSIVELY as
* each model lands (so the client starts receiving answers immediately,
* without waiting for the whole panel to finish)
* - a final `data:` OpenAI-style chunk carrying the primary model's answer
* (so non-aware clients / IDEs still get a usable completion)
* - a terminating `data: [DONE]`
@@ -368,14 +354,6 @@ export async function handleChaosChat(opts: {
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
const hardTimeout = tuning?.panelHardTimeoutMs ?? CHAOS_DEFAULTS.panelHardTimeoutMs;
const minPanel = tuning?.minPanel ?? CHAOS_DEFAULTS.minPanel;
// Opt-in gate: only protocol-aware clients request the custom event. OpenAI
// SDK validators choke on any `data:` payload without `choices`/`error`, so
// the default MUST be comment-only output.
const streamOptions = (body as Record<string, unknown> | null | undefined)?.stream_options;
const emitCustomEvent =
typeof streamOptions === "object" &&
streamOptions !== null &&
(streamOptions as Record<string, unknown>).include_chaos_parts === true;
if (panel.length === 0) {
return errorResponse(400, "Chaos combo has no models");
}
@@ -418,7 +396,7 @@ export async function handleChaosChat(opts: {
hardTimeout,
log,
onResult: async (part) => {
await safeEnqueue(serializeChaosPart(part, false, emitCustomEvent));
await safeEnqueue(serializeChaosPart(part, false));
},
});
});

View File

@@ -1908,23 +1908,7 @@ export async function handleComboChat({
!isTokenLimitBreach &&
!scopedFailure &&
[408, 429, 500, 502, 503, 504].includes(result.status);
// failoverBeforeRetry means what it says: prefer the next sibling
// target over hammering this one again. Without this check, a
// transient error always re-hit the SAME model up to maxRetries
// times regardless of the setting — config.failoverBeforeRetry was
// threaded through to skipUpstreamRetry (a different, lower-level
// retry mechanism) but never consulted here, so a rate-limited
// model got maxRetries+1 back-to-back attempts on itself before
// this loop's own fallback-to-next-target ever ran (#2417). Only
// skip the same-model retry when `nextTarget` (computed above)
// actually gives us somewhere to fail over to — with no sibling
// left, skipping just burns the last attempt for nothing.
if (
retry < maxRetries &&
isTransient &&
!providerExhausted &&
(!config.failoverBeforeRetry || !nextTarget)
) {
if (retry < maxRetries && isTransient && !providerExhausted) {
if (
!protectedPriorityTarget &&
provider &&
@@ -2012,24 +1996,6 @@ export async function handleComboChat({
strategy,
target: toRecordedTarget(target),
});
// LKGP (#919) mirror of the success-path set below: a just-failed target
// must not keep re-pinning itself as the "last known good" choice for the
// *next* separate request. Circuit breaker / model lockout deliberately
// don't react to request-scoped failure classes (see scopedFailure below),
// so nothing else clears this stale pin.
void (async () => {
try {
const { clearLKGP } = await import("../../src/lib/localDb");
await Promise.all([
clearLKGP(combo.name, target.executionKey),
clearLKGP(combo.name, combo.id || combo.name),
]);
} catch (err) {
log.warn("COMBO", "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
recordedAttempts++;
lastError = errorText || String(result.status);
comboErrors.push({
@@ -2660,8 +2626,7 @@ async function handleRoundRobinCombo({
filteredTargets,
// #7270: normalize both wire shapes (.messages / Responses-API .input) so RR
// stickiness engages on the /v1/responses surface, not just Chat Completions.
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
combo.name
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
);
const rrAffinity = applyPromptCacheAffinity(
filteredTargets,
@@ -3158,18 +3123,7 @@ async function handleRoundRobinCombo({
!isTokenLimitBreach &&
!scopedFailure &&
[408, 429, 500, 502, 503, 504].includes(result.status);
// See the same guard's comment in the "auto" strategy loop above —
// failoverBeforeRetry must prevent this same-model retry too, not
// just the lower-level skipUpstreamRetry mechanism. Only skip when
// `offset + 1 < modelCount` means a sibling target is actually left
// in this rotation; with none left, skipping just wastes the attempt.
const hasNextRrTarget = offset + 1 < modelCount;
if (
retry < maxRetries &&
isTransient &&
!providerExhausted &&
(!config.failoverBeforeRetry || !hasNextRrTarget)
) {
if (retry < maxRetries && isTransient && !providerExhausted) {
continue;
}
@@ -3181,22 +3135,6 @@ async function handleRoundRobinCombo({
strategy: "round-robin",
target: toRecordedTarget(target),
});
// LKGP (#919) mirror of handleComboChat's failure-path clear above — see
// that comment for why this must happen (nothing else clears a pin left
// by a request-scoped failure class like a stream-readiness timeout).
void (async () => {
try {
const { clearLKGP } = await import("../../src/lib/localDb");
await Promise.all([
clearLKGP(combo.name, target.executionKey),
clearLKGP(combo.name, combo.id || combo.name),
]);
} catch (err) {
log.warn("COMBO-RR", "Failed to clear Last Known Good Provider. This is non-fatal.", {
err,
});
}
})();
recordedAttempts++;
lastError = errorText || String(result.status);
lastStatus = result.status;

View File

@@ -8,8 +8,7 @@
*
* Design
* ──────
* • Hash key: SHA-256 of the FIRST user message, namespaced by Combo identity
* at production call sites → first 16 hex chars.
* • Hash key: SHA-256 of the FIRST user message → first 16 hex chars.
* Using only the first message gives a stable key that does not change as
* the conversation grows, yet still identifies the conversation reliably.
* • Headroom gate: before reusing the sticky connection we re-check that its
@@ -318,23 +317,6 @@ export function deriveMessageHash(
return createHash("sha256").update(text).digest("hex").slice(0, 16);
}
/**
* Keep one conversation's prompt-cache affinity local to the Combo that learned
* it. Without this namespace, two different Combos receiving the same first
* user message share a binding and can silently reorder each other's targets.
* The unscoped form remains available for direct callers and backwards-compatible
* unit seams; production dispatchers always provide their Combo name.
*/
function scopeMessageHash(messageHash: string, namespace?: string): string {
if (!namespace) return messageHash;
return createHash("sha256")
.update(namespace)
.update("\0")
.update(messageHash)
.digest("hex")
.slice(0, 16);
}
/** Evict expired entries and enforce the hard cap. */
function evict(): void {
const now = Date.now();
@@ -442,22 +424,19 @@ export interface ApplyStickinessResult {
*
* @param orderedTargets Targets already ordered by the combo strategy.
* @param messages Request body.messages.
* @param namespace Combo identity that owns this sticky binding.
* @returns Result with (possibly reordered) targets.
*/
export async function applySessionStickiness(
orderedTargets: ResolvedComboTarget[],
messages: Array<{ role?: string; content?: unknown }> | null | undefined,
namespace?: string
messages: Array<{ role?: string; content?: unknown }> | null | undefined
): Promise<ApplyStickinessResult> {
const noOp: ApplyStickinessResult = { targets: orderedTargets, messageHash: null, stuck: false };
try {
if (orderedTargets.length <= 1) return noOp;
const rawMessageHash = deriveMessageHash(messages);
if (!rawMessageHash) return noOp;
const messageHash = scopeMessageHash(rawMessageHash, namespace);
const messageHash = deriveMessageHash(messages);
if (!messageHash) return noOp;
const existing = stickyMap.get(messageHash);
if (!existing) return { targets: orderedTargets, messageHash, stuck: false };

View File

@@ -498,8 +498,7 @@ async function applyContinuityFilters(
initialOrderedTargets,
// #7270: normalize both wire shapes (.messages / Responses-API .input) so the
// stickiness key is derivable on the /v1/responses surface, not just Chat Completions.
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown }),
combo.name
normalizeStickinessMessages(body as { messages?: unknown; input?: unknown })
);
let orderedTargets = sticky.targets;
if (!cacheStrategyAffinityApplied) {

View File

@@ -13,7 +13,6 @@
* @see Issue #1628
*/
import { createHash } from "node:crypto";
import {
clearAllReasoningCache,
cleanupExpiredReasoning,
@@ -138,8 +137,8 @@ type AssistantMessageLike = {
};
type AssistantMessageCacheContext = {
scope?: string;
historyMessages?: AssistantMessageLike[];
requestId?: string;
messageIndex?: number;
};
type ToolCallLike = {
@@ -235,79 +234,8 @@ export function cacheReasoningByKey(
}
}
function stableCacheValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableCacheValue);
if (!value || typeof value !== "object") return value;
const record = value as Record<string, unknown>;
return Object.fromEntries(
Object.keys(record)
.filter((key) => key !== "reasoning" && key !== "reasoning_content")
.sort()
.map((key) => [key, stableCacheValue(record[key])])
);
}
function canonicalizeMessageContent(content: unknown): unknown {
if (!Array.isArray(content)) return stableCacheValue(content ?? null);
const textParts: string[] = [];
for (const part of content) {
if (typeof part === "string") {
textParts.push(part);
continue;
}
if (!part || typeof part !== "object") return stableCacheValue(content);
const record = part as Record<string, unknown>;
if (
(record.type === "text" || record.type === "input_text" || record.type === "output_text") &&
typeof record.text === "string"
) {
textParts.push(record.text);
continue;
}
return stableCacheValue(content);
}
return textParts.join("");
}
function canonicalizeHistoryMessage(message: AssistantMessageLike): unknown {
const record = message as Record<string, unknown>;
const toolCalls = Array.isArray(record.tool_calls)
? record.tool_calls.map((toolCall) => {
const call = toolCall as Record<string, unknown>;
const fn = (call.function ?? {}) as Record<string, unknown>;
return stableCacheValue({
type: call.type,
function: { name: fn.name, arguments: fn.arguments },
});
})
: undefined;
return stableCacheValue({
role: record.role,
name: record.name,
content: canonicalizeMessageContent(record.content),
tool_calls: toolCalls,
});
}
export function buildAssistantMessageCacheKey(
scope: string | null | undefined,
messages: AssistantMessageLike[],
messageIndex: number
): string {
const normalizedScope = scope?.trim();
if (!normalizedScope || !Number.isInteger(messageIndex) || messageIndex < 0) return "";
const message = messages[messageIndex];
if (!message || message.role !== "assistant") return "";
const transcript = messages.slice(0, messageIndex + 1).map(canonicalizeHistoryMessage);
const digest = createHash("sha256")
.update(normalizedScope)
.update("\x1f")
.update(JSON.stringify(transcript))
.digest("hex");
return `conversation:${digest}`;
function buildAssistantMessageCacheKey(requestId: string, messageIndex: number): string {
return `request:${requestId}:message:${messageIndex}`;
}
/**
@@ -354,15 +282,18 @@ export function cacheReasoningFromAssistantMessage(
.filter((id) => id.length > 0)
: [];
if (toolCallIds.length === 0) {
const scope = context?.scope?.trim();
const historyMessages = context?.historyMessages;
if (!scope || !Array.isArray(historyMessages)) return 0;
const requestId = context?.requestId?.trim();
const messageIndex = context?.messageIndex;
if (!requestId || typeof messageIndex !== "number" || !Number.isInteger(messageIndex)) {
return 0;
}
const messages = [...historyMessages, message];
const cacheKey = buildAssistantMessageCacheKey(scope, messages, messages.length - 1);
if (!cacheKey) return 0;
cacheReasoningByKey(cacheKey, provider, model, reasoning);
cacheReasoningByKey(
buildAssistantMessageCacheKey(requestId, messageIndex),
provider,
model,
reasoning
);
return 1;
}
@@ -398,8 +329,7 @@ export function lookupReasoning(toolCallId: string): string | null {
}
// 2. Fallback to DB
let dbResult: { reasoning: string; provider: string; model: string; expiresAt: string } | null =
null;
let dbResult: { reasoning: string; provider: string; model: string } | null = null;
try {
dbResult = getReasoningCache(toolCallId);
} catch {
@@ -411,11 +341,6 @@ export function lookupReasoning(toolCallId: string): string | null {
misses++;
return null;
}
const persistedExpiresAt = Date.parse(dbResult.expiresAt);
if (!Number.isFinite(persistedExpiresAt) || persistedExpiresAt <= Date.now()) {
misses++;
return null;
}
hits++;
let promotedReasoning = dbResult.reasoning;
if (promotedReasoning.length > MAX_ENTRY_BYTES) {
@@ -426,7 +351,7 @@ export function lookupReasoning(toolCallId: string): string | null {
reasoning: promotedReasoning,
provider: dbResult.provider,
model: dbResult.model,
expiresAt: persistedExpiresAt,
expiresAt: Date.now() + TTL_MS,
createdAt: Date.now(),
});
return promotedReasoning;

View File

@@ -1,23 +1,14 @@
/**
* Thinking Budget Control — Phase 2
*
* Proxy-level control of **client thinking/reasoning request fields**
* (`reasoning`, `reasoning_effort`, Claude `thinking`, Gemini thinking_config).
*
* Modes (see Dashboard → Settings → AI → Thinking Budget):
* - passthrough: leave client fields unchanged (required for Codex visible thinking)
* - auto: STRIP all thinking/reasoning fields before upstream (not “auto-show thinking”)
* - custom: force a fixed token budget on every request
* - adaptive: scale budget from a base effort by request complexity
*
* Independent of compression, prompt cache, combo routing, and API-key token limits.
* Does **not** decrypt OpenAI/Codex `encrypted_content` reasoning blobs.
* Provides proxy-level control over AI thinking/reasoning budgets.
* Modes: auto, passthrough, custom, adaptive
*/
// Thinking budget modes
export const ThinkingMode = {
AUTO: "auto", // Strip all client thinking/reasoning fields (provider invents defaults)
PASSTHROUGH: "passthrough", // No changes — client fully controls thinking
AUTO: "auto", // Let provider decide (remove client's budget)
PASSTHROUGH: "passthrough", // No changes (current behavior)
CUSTOM: "custom", // Set fixed budget
ADAPTIVE: "adaptive", // Scale based on request complexity
};
@@ -256,9 +247,7 @@ export function applyThinkingBudget(
}
/**
* AUTO mode: strip all thinking/reasoning configuration from the request body.
* Upstream then runs without client-requested effort/summary — this can hide
* thinking panels in Codex/Desktop and is the opposite of “show thinking”.
* AUTO mode: strip all thinking configuration, let provider decide
*/
function stripThinkingConfig(body: unknown) {
const result: JsonRecord = { ...toRecord(body) };

View File

@@ -163,20 +163,3 @@ export function sanitizeOpenAITool(tool: unknown): unknown {
export function sanitizeOpenAITools(tools: unknown[]): unknown[] {
return tools.map(sanitizeOpenAITool);
}
export function flattenOpenAIToolRootAnyOf(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => {
if (!isPlainObject(tool)) return tool;
const next = { ...tool };
const fn = isPlainObject(next.function) ? { ...next.function } : next;
if (!isPlainObject(fn.parameters) || !hasOwn(fn.parameters, "anyOf")) return tool;
const parameters = { ...fn.parameters };
delete parameters.anyOf;
fn.parameters = parameters;
if (fn !== next) next.function = fn;
return next;
});
}

View File

@@ -209,12 +209,6 @@ export function createResponsesApiTransformStream(
funcItemTypes: {},
funcArgsDone: {},
funcItemDone: {},
// Cached at first computation (see toolCallOutputIndexBase) so every
// added/delta/done event for a given tool call — including ones emitted
// later from the finish_reason handler or flush(), where the reasoning/
// message state used to derive the base is no longer meaningful to
// recompute — shares exactly the same output_index.
funcOutputIndex: {} as Record<string, number>,
completedOutputItems: [] as Array<{
output_index: number;
item: Record<string, unknown>;
@@ -386,27 +380,6 @@ export function createResponsesApiTransformStream(
}
};
// Tool calls sit after reasoning (if any) AND after a text message (if one
// was actually emitted this turn). The provider's own tool_calls[].index is
// scoped only to the tool_calls array and legitimately restarts at 0 — using
// it directly as the Responses API output_index collides with whatever
// reasoning/message item already claimed that slot, and a client that
// tracks response items by output_index silently drops the tool call.
//
// Computed once per tcIdx (from the chunk's own choice index, `chunkIdx`)
// and cached in state.funcOutputIndex so every added/delta/done event for
// that call — including ones emitted later from the finish_reason handler
// or flush(), which have no fresh chunk/reasoning/message state to
// recompute from — shares exactly the same output_index.
const computeToolCallOutputIndex = (chunkIdx, tcIdx) => {
if (state.funcOutputIndex[tcIdx] === undefined) {
const msgIdx = state.reasoningId ? state.reasoningIndex + 1 : chunkIdx;
const base = state.msgItemAdded[msgIdx] ? msgIdx + 1 : msgIdx;
state.funcOutputIndex[tcIdx] = base + normalizeOutputIndex(tcIdx);
}
return state.funcOutputIndex[tcIdx];
};
const emitToolCallAdded = (controller, idx) => {
if (state.funcItemAdded[idx] || !state.funcCallIds[idx]) return false;
@@ -417,7 +390,7 @@ export function createResponsesApiTransformStream(
emit(controller, "response.output_item.added", {
type: "response.output_item.added",
output_index: state.funcOutputIndex[idx],
output_index: idx,
item: {
id: `fc_${state.funcCallIds[idx]}`,
type: itemType,
@@ -433,7 +406,7 @@ export function createResponsesApiTransformStream(
const closeToolCall = (controller, idx, recordAsCompleted = true) => {
const callId = state.funcCallIds[idx];
if (callId && !state.funcItemDone[idx]) {
const normalizedIndex = state.funcOutputIndex[idx];
const normalizedIndex = normalizeOutputIndex(idx);
let args = state.funcArgsBuf[idx] || "{}";
const toolName = state.funcNames[idx] || "";
emitToolCallAdded(controller, idx);
@@ -777,7 +750,6 @@ export function createResponsesApiTransformStream(
for (const tc of delta.tool_calls) {
const tcIdx = tc.index ?? 0;
const outputIndex = computeToolCallOutputIndex(idx, tcIdx);
const newCallId = tc.id;
const funcName = tc.function?.name;
@@ -793,10 +765,6 @@ export function createResponsesApiTransformStream(
delete state.funcItemTypes[tcIdx];
delete state.funcArgsDone[tcIdx];
delete state.funcItemDone[tcIdx];
// Deliberately keep funcOutputIndex[tcIdx]: the replacement call
// reuses the same positional slot, so it should keep the same
// output_index rather than recomputing (which could drift if
// msgItemAdded state shifted mid-turn).
}
if (funcName) state.funcNames[tcIdx] = funcName;
@@ -818,7 +786,7 @@ export function createResponsesApiTransformStream(
emit(controller, "response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${state.funcCallIds[tcIdx]}`,
output_index: outputIndex,
output_index: tcIdx,
delta: state.funcArgsBuf[tcIdx],
});
}
@@ -857,7 +825,7 @@ export function createResponsesApiTransformStream(
emit(controller, "response.function_call_arguments.delta", {
type: "response.function_call_arguments.delta",
item_id: `fc_${refCallId}`,
output_index: outputIndex,
output_index: tcIdx,
delta: emittedDelta,
});
}

View File

@@ -30,15 +30,11 @@ import { getResolvedModelCapabilities, supportsReasoning } from "../services/mod
import { normalizeRoles } from "../services/roleNormalizer.ts";
import { hoistLeadingSystemMessage } from "./helpers/strictSystemHoist.ts";
import {
buildAssistantMessageCacheKey,
lookupReasoning,
recordReplay,
requiresReasoningReplay,
} from "../services/reasoningCache.ts";
import {
normalizeResponsesReasoningEffort,
RESPONSES_STORE_MARKER,
} from "./request/openai-responses/helpers.ts";
import { normalizeResponsesReasoningEffort } from "./request/openai-responses/helpers.ts";
bootstrapTranslatorRegistry();
export { register } from "./registry.ts";
@@ -150,6 +146,25 @@ function normalizeOpenAIResponsesRequest(body) {
return normalized;
}
function getReasoningCacheRequestId(body: Record<string, unknown> | null | undefined): string {
if (!body || typeof body !== "object") return "";
const requestId =
body._reasoningCacheRequestId ??
body.reasoningCacheRequestId ??
body.request_id ??
body.requestId;
return typeof requestId === "string" ? requestId.trim() : "";
}
function getAssistantMessageCacheKey(
body: Record<string, unknown> | null | undefined,
messageIndex: number
): string {
const requestId = getReasoningCacheRequestId(body);
return requestId ? `request:${requestId}:message:${messageIndex}` : "";
}
function hasNonEmptyReasoningContent(message: Record<string, unknown>): boolean {
return typeof message.reasoning_content === "string" && message.reasoning_content.length > 0;
}
@@ -235,7 +250,6 @@ export function translateRequest(
preserveCacheControl?: boolean;
signatureNamespace?: string | null;
preCompressionBody?: Record<string, unknown> | null;
reasoningCacheScope?: string | null;
/** UA-detected GitHub Copilot client. Forwarded to translators via the
* transient `_copilotClient` credential flag (see openai-responses → openai). */
copilotClient?: boolean;
@@ -251,6 +265,13 @@ export function translateRequest(
const normalizedModel = String(model ?? "");
const isKimiCoding =
normalizedProvider === "kimi-coding" || normalizedProvider === "kimi-coding-apikey";
const requiresExplicitReasoningReplay = requiresReasoningReplay({
provider: normalizedProvider,
model: normalizedModel,
allowLegacyFallback: false,
});
const preserveResponsesReasoning =
sourceFormat === FORMATS.OPENAI_RESPONSES && requiresExplicitReasoningReplay;
// Phase 2: Apply thinking budget control before normalization
result = applyThinkingBudget(result);
@@ -261,29 +282,6 @@ export function translateRequest(
// Normalize thinking config: remove if lastMessage is not user
normalizeThinkingConfig(result);
// Resolve the replay contract before Responses input is converted: conversion
// must know whether reasoning items are protocol history rather than display metadata.
const resolvedCapabilities = getResolvedModelCapabilities({
provider: normalizedProvider,
model: normalizedModel,
});
const replayRequirements = {
provider: normalizedProvider,
model: normalizedModel,
thinkingEnabled: hasThinkingConfig(result),
supportsReasoning: supportsReasoning({
provider: normalizedProvider,
model: normalizedModel,
}),
interleavedField: resolvedCapabilities?.interleavedField ?? null,
};
const isReasoner = requiresReasoningReplay(replayRequirements);
const requiresExplicitReasoningReplay = requiresReasoningReplay({
...replayRequirements,
allowLegacyFallback: false,
});
const preserveResponsesReasoning = sourceFormat === FORMATS.OPENAI_RESPONSES && isReasoner;
// Ensure tool_calls have id; optionally normalize to 9-char for providers like Mistral
ensureToolCallIds(result, { use9CharId });
@@ -423,6 +421,24 @@ export function translateRequest(
}
}
// Resolve reasoning-replay status up-front: it gates both the reasoning_content
// strip in filterToOpenAIFormat below (#4849 must NOT strip client reasoning for
// replay providers) and the cache re-injection further down.
const resolvedCapabilities = getResolvedModelCapabilities({
provider: normalizedProvider,
model: normalizedModel,
});
const isReasoner = requiresReasoningReplay({
provider: normalizedProvider,
model: normalizedModel,
thinkingEnabled: hasThinkingConfig(result),
supportsReasoning: supportsReasoning({
provider: normalizedProvider,
model: normalizedModel,
}),
interleavedField: resolvedCapabilities?.interleavedField ?? null,
});
// Always normalize to clean OpenAI format when target is OpenAI
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
if (targetFormat === FORMATS.OPENAI) {
@@ -634,11 +650,7 @@ export function translateRequest(
const cacheKey = hasToolCalls
? msg.tool_calls[0]?.id
: buildAssistantMessageCacheKey(
options?.reasoningCacheScope,
result.messages,
messageIndex
);
: getAssistantMessageCacheKey(result, messageIndex);
if (cacheKey) {
const cached = lookupReasoning(cacheKey);
if (cached) {
@@ -688,19 +700,6 @@ export function translateRequest(
}
}
// #<store-marker-leak>: a Responses-source request stashes the client's
// `store` intent under this internal marker (see the Responses -> OpenAI
// step above) so a later OpenAI -> Responses re-conversion can restore it
// as `store`. When the destination stays in Chat Completions shape (no
// such re-conversion happens), nothing else consumes the marker, and it
// was leaking verbatim into the real upstream request body — e.g. OpenAI
// itself rejects it with "Unknown parameter: '_omnirouteResponsesStore'".
// Always drop it here: any handler that still needs the client's original
// `store` value would have already read the marker before this point.
if (RESPONSES_STORE_MARKER in result) {
delete result[RESPONSES_STORE_MARKER];
}
return result;
}

View File

@@ -235,23 +235,21 @@ export function openaiResponsesToOpenAIRequest(
if (itemType === "message") {
const role = toString(item.role);
if (role !== "assistant") {
if (currentAssistantMsg) {
messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
if (pendingReasoningContent) {
messages.push({
role: "assistant",
content: null,
reasoning_content: pendingReasoningContent,
});
pendingReasoningContent = "";
}
// Flush pending assistant message with tool calls
if (currentAssistantMsg) {
messages.push(currentAssistantMsg);
currentAssistantMsg = null;
}
if (role !== "assistant" && pendingReasoningContent) {
messages.push({
role: "assistant",
content: null,
reasoning_content: pendingReasoningContent,
});
pendingReasoningContent = "";
}
// Flush pending tool results before the next explicit message boundary.
// Flush pending tool results
if (pendingToolResults.length > 0) {
for (const toolResult of pendingToolResults) {
messages.push(toolResult);
@@ -294,29 +292,12 @@ export function openaiResponsesToOpenAIRequest(
})
: item.content;
if (role === "assistant") {
if (!currentAssistantMsg) {
currentAssistantMsg = { role, content };
} else if (currentAssistantMsg.content == null && content != null) {
currentAssistantMsg.content = content;
} else if (content != null) {
const existingContent = currentAssistantMsg.content;
currentAssistantMsg.content = [
...(Array.isArray(existingContent) ? existingContent : [existingContent]),
...(Array.isArray(content) ? content : [content]),
];
}
if (pendingReasoningContent) {
currentAssistantMsg.reasoning_content = appendReasoningContent(
currentAssistantMsg.reasoning_content,
pendingReasoningContent
);
pendingReasoningContent = "";
}
continue;
const message: JsonRecord = { role, content };
if (role === "assistant" && pendingReasoningContent) {
message.reasoning_content = pendingReasoningContent;
pendingReasoningContent = "";
}
messages.push({ role, content });
messages.push(message);
continue;
}

View File

@@ -240,12 +240,7 @@ export function openaiToClaudeRequest(model, body, stream, credentials = null) {
// could exceed model caps (e.g. Opus 4.7's 128000 ceiling) and trigger
// HTTP 400 from Anthropic.
if (!isKimiCoding) {
const fitted = fitThinkingToMaxTokens(
model,
Number(result.max_tokens) || 0,
result.thinking,
routedProvider
);
const fitted = fitThinkingToMaxTokens(model, Number(result.max_tokens) || 0, result.thinking);
result.max_tokens = fitted.maxTokens;
if (fitted.thinking === undefined) {
delete result.thinking;

View File

@@ -7,9 +7,9 @@ import { capMaxOutputTokens } from "../../../../src/lib/modelCapabilities.ts";
const MIN_CLAUDE_THINKING_BUDGET = 1024;
const MIN_RESPONSE_ROOM = 1024;
function safeCapMaxOutputTokens(model: string, provider?: string | null): number | null {
function safeCapMaxOutputTokens(model: string): number | null {
try {
const cap = capMaxOutputTokens(provider ? { provider, model } : model);
const cap = capMaxOutputTokens(model);
return typeof cap === "number" && cap > 0 ? cap : null;
} catch {
return null;
@@ -31,12 +31,6 @@ function safeCapMaxOutputTokens(model: string, provider?: string | null): number
* responseRoom shrunk to MIN_RESPONSE_ROOM; if still below MIN, disable
* thinking entirely (cap too tight for any reasoning).
*
* `provider` scopes the cap lookup to a provider-specific override (e.g. a
* dashboard-set `max_output_tokens` for `opencode-go/qwen3.7-plus`) when the
* model-only entry has no cap of its own. Without it, a model whose real
* ceiling is only known per-provider resolves to no cap at all and the
* synthesized `max_tokens` goes out unbounded (#10139).
*
* Worked example (real-world Opus 4.7 case that previously 400'd):
* caller max_tokens = 32000, reasoning_effort=high → budget = 131072,
* model cap = 128000.
@@ -48,10 +42,9 @@ function safeCapMaxOutputTokens(model: string, provider?: string | null): number
export function fitThinkingToMaxTokens(
model: string,
callerMaxTokens: number,
thinking: Record<string, unknown> | undefined,
provider?: string | null
thinking: Record<string, unknown> | undefined
): { maxTokens: number; thinking: Record<string, unknown> | undefined } {
const modelCap = safeCapMaxOutputTokens(model, provider);
const modelCap = safeCapMaxOutputTokens(model);
const requestedBudget = Number(thinking?.budget_tokens) || 0;
// No budgeted thinking — just cap max_tokens to the model output ceiling.

View File

@@ -528,21 +528,9 @@ function emitToolCall(state, emit, tc) {
// Custom tools are surfaced as custom_tool_call items and stream raw input instead of the
// function_call_arguments.* events used for regular function tools. (#1007)
//
// apply_patch defaults to custom (native Codex CLI convention: the model emits it
// without the client ever declaring it as a tool) UNLESS the client's own request
// explicitly declared it with a `parameters` JSON schema — i.e. as a plain
// `type:"function"` tool (state.toolSchemas, populated from body.tools by
// extractToolSchemaMap()). Live incident: a client that registers apply_patch as a
// function tool and only implements function_call dispatch never recognized the
// custom_tool_call item this produced, so the tool call was silently never executed
// and no follow-up request ever carried a result back. PR #7905 already intended this
// precedence ("...while preserving explicit function-tool precedence") but its
// unconditional `toolName === "apply_patch"` OR never actually implemented the carve-out.
const toolName = state.funcNames[tcIdx] || funcName || "";
const isCustomTool =
(toolName === "apply_patch" && !state.toolSchemas?.has?.(toolName)) ||
state.customToolNames?.has?.(toolName) === true;
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId;
const callId = state.funcCallIds[tcIdx];
@@ -609,11 +597,8 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) {
const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx);
const args = state.funcArgsBuf[idx] || "{}";
const toolName = state.funcNames[idx] || "";
// See emitToolCall()'s isCustomTool comment — must stay in sync (both compute the
// same classification independently for their respective add/close call sites).
const isCustomTool =
(toolName === "apply_patch" && !state.toolSchemas?.has?.(toolName)) ||
state.customToolNames?.has?.(toolName) === true;
toolName === "apply_patch" || state.customToolNames?.has?.(toolName) === true;
let funcItem;
if (isCustomTool) {

View File

@@ -47,10 +47,7 @@ function findHeader(headers: Record<string, string>, name: string): string | und
* the OpenCode CLI identity headers that Cloudflare requires on VPS egress
* (User-Agent, x-opencode-client, x-opencode-project) plus fresh request/session
* UUIDs, but ONLY for keys the client did not already supply. Client values always
* win; these defaults only fill gaps. User-Agent is the one exception: a client UA
* that is not already the OpenCode CLI (e.g. curl/8.5.0) is REPLACED with the
* synthesized CLI UA, because opencode.ai's free tier rejects generic client UAs
* from datacenter IPs with FreeUsageLimitError 429. (#5997, follow-up #10229)
* win; these defaults only fill gaps. (#5997)
*/
export function forwardOpencodeClientHeaders(
headers: Record<string, string>,
@@ -103,22 +100,14 @@ export function forwardOpencodeClientHeaders(
}
/**
* Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress. For
* x-opencode-* headers, client values always win (defaults only fill gaps). The
* User-Agent is the exception: a non-CLI client UA (curl, python, SDKs) is replaced
* with the synthesized CLI UA, because opencode.ai's free tier flags generic client
* UAs from datacenter IPs (FreeUsageLimitError 429). A client UA that already looks
* like the OpenCode CLI (opencode-cli/...) is preserved so the real CLI's versioned
* identity stays intact. (#5997, follow-up)
* Fill the OpenCode CLI identity headers Cloudflare requires on VPS egress, but only for
* keys the client did not already supply (client values always win). (#5997)
*/
function applyCliDefaults(
headers: Record<string, string>,
cliDefaults: { userAgent: string; client: string; project: string }
): void {
const existingUa = headers["User-Agent"] || headers["user-agent"];
const clientUaIsCliLike =
typeof existingUa === "string" && /^opencode-cli\//i.test(existingUa.trim());
if (!clientUaIsCliLike) {
if (!headers["User-Agent"] && !headers["user-agent"]) {
setUserAgentHeader(headers, cliDefaults.userAgent);
}
headers["x-opencode-client"] ||= cliDefaults.client;

View File

@@ -74,7 +74,6 @@ import {
hasUnsupportedReasoningSignal,
} from "./reasoningFields.ts";
import { applyThinkTag, flushThink, initThinkState } from "./thinkTagParser.ts";
import { sseCommentsEnabled } from "./sseHeartbeat.ts";
import {
caseInsensitiveToolNameLookup,
restoreOpenAIToolNames,
@@ -788,29 +787,6 @@ export function createSSEStream(options: StreamOptions = {}) {
let upstreamErrorForwarded = false;
const providerPayloadCollector = createStructuredSSECollector({
stage: "provider_response",
// #9315: compute the summary live from every pushed chunk (not just the
// ones that survive the storage cap below) so a long stream never shows a
// stale/incomplete "provider response" in the dashboard.
//
// Real bug: this was unconditionally `sourceFormat` (the CLIENT's wire
// format — see this function's own @param doc above). In TRANSLATE mode
// the chunks pushed here are the RAW PROVIDER response, whose format is
// `targetFormat` (@param "Provider format (for translate mode)"), not
// sourceFormat. Whenever a client's format differs from the provider's
// (e.g. a Responses-API client routed to a plain-OpenAI-chat-completions
// upstream — the OpenClaw/opencode-zen case that surfaced this live), the
// reducer picked for `sourceFormat` could never recognize the provider's
// actual event shape, so it never left its empty initial state — the
// dashboard's "Provider Response" panel permanently showed
// `output: []`/empty while "Client Response" (built from
// separately-accumulated state, unaffected by this) correctly showed full
// content, reading as if the two panels simply disagreed. PASSTHROUGH
// mode has no separate provider/client format split — nothing gets
// translated, so the provider's raw chunks genuinely ARE in sourceFormat
// (and real passthrough callers, e.g. createPassthroughStreamWithLogger,
// don't even pass targetFormat) — keep using sourceFormat there.
format: mode === STREAM_MODE.TRANSLATE ? targetFormat : sourceFormat,
fallbackModel: model,
});
const clientPayloadCollector = createStructuredSSECollector({
stage: "client_response",
@@ -1665,9 +1641,7 @@ export function createSSEStream(options: StreamOptions = {}) {
// retry." with finish_reason: "stop" — clients (Goose/opencode) feed that
// text back as a turn and spin in a retry loop. This restores the #3400
// behavior that #3422 inadvertently reverted (regression #3388/#3502).
if (
Array.isArray(parsed.choices) &&
(parsed.choices.length === 0 ||
if (Array.isArray(parsed.choices) && (parsed.choices.length === 0 ||
(parsed.choices.length === 1 &&
parsed.choices[0]?.delta &&
typeof parsed.choices[0].delta === "object" &&
@@ -2509,11 +2483,7 @@ export function createSSEStream(options: StreamOptions = {}) {
// #9315 switched the summary to the accumulated responseBody to avoid
// stale/truncated event data — but responseBody here is synthesized in
// chat-completion shape, which loses the Responses API `response` object.
// Keep the events-derived summary for OPENAI_RESPONSES only. responseBody
// itself never carries an `object` marker (it's built purely for the
// client, which doesn't need one) — the dashboard's Provider Response
// panel does, so stamp `object: "chat.completion"` on a shallow copy
// used only for this summary, leaving responseBody itself untouched.
// Keep the events-derived summary for OPENAI_RESPONSES only.
providerPayload: providerPayloadCollector.build(
sourceFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
@@ -2521,7 +2491,7 @@ export function createSSEStream(options: StreamOptions = {}) {
sourceFormat,
model
)
: { object: "chat.completion", ...responseBody },
: responseBody,
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {
@@ -2630,7 +2600,11 @@ export function createSSEStream(options: StreamOptions = {}) {
error: err.message,
errorCode: err.code,
providerPayload: providerPayloadCollector.build(
providerPayloadCollector.getSummary(),
buildStreamSummaryFromEvents(
providerPayloadCollector.getEvents(),
targetFormat,
model
),
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(errorBody, {
@@ -2809,11 +2783,7 @@ export function createSSEStream(options: StreamOptions = {}) {
usage: state?.usage,
responseBody,
// Same OPENAI_RESPONSES carve-out as the passthrough branch above —
// the synthesized chat-shaped responseBody drops the `response` object,
// and (like the passthrough branch) never carries an `object` marker at
// all — stamp `object: "chat.completion"` on a shallow copy used only
// for this summary; responseBody itself (sent to the client / below)
// stays untouched.
// the synthesized chat-shaped responseBody drops the `response` object.
providerPayload: providerPayloadCollector.build(
targetFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
@@ -2821,7 +2791,7 @@ export function createSSEStream(options: StreamOptions = {}) {
targetFormat,
model
)
: { object: "chat.completion", ...responseBody },
: responseBody,
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {

View File

@@ -12,16 +12,6 @@ type CollectorOptions = {
maxEvents?: number;
maxBytes?: number;
stage?: string;
// When set, every pushed payload — even ones dropped from the retained
// `events` array once maxEvents/maxBytes is hit — is also fed to a live
// per-format summary reducer, so build()'s summary reflects the FULL
// stream, not just the surviving (possibly truncated) event slice.
// See #9315: reconstructing the summary from getEvents() after the fact
// means a long stream that exceeds the cap gets a stale/incomplete
// "provider response" (missing tool_calls, wrong finish_reason, cut-off
// content) even though the actual served response was correct.
format?: string | null;
fallbackModel?: string | null;
};
type BuildOptions = {
@@ -30,11 +20,6 @@ type BuildOptions = {
type JsonRecord = Record<string, unknown>;
interface SummaryReducer {
ingest(payload: JsonRecord): void;
finalize(): unknown;
}
function getEventName(payload: unknown): string | undefined {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
@@ -128,15 +113,13 @@ function tryParseJson(raw: string): unknown {
}
}
// ─── Per-format live reducers ────────────────────────────────────────────────
// Each reducer mirrors the corresponding build*Summary()'s original for-loop
// body exactly (ingest = one loop iteration, finalize = the post-loop return),
// just restructured so it can be fed one payload at a time as chunks arrive —
// including chunks that will later be dropped from the retained event array
// once the collector's storage cap is hit.
function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const payloads = events
.map((evt) => asRecord(evt.data))
.filter((payload) => Object.keys(payload).length);
if (payloads.length === 0) return null;
function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
let first: JsonRecord | null = null;
const first = payloads[0];
const contentParts: string[] = [];
const reasoningParts: string[] = [];
type ToolCall = {
@@ -173,126 +156,124 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
return `seq:${unknownToolCallSeq}`;
};
return {
ingest(chunk: JsonRecord) {
if (Object.keys(chunk).length === 0) return;
if (!first) first = chunk;
for (const chunk of payloads) {
const choice = asRecord(Array.isArray(chunk.choices) ? chunk.choices[0] : null);
const delta = asRecord(choice.delta);
const choice = asRecord(Array.isArray(chunk.choices) ? chunk.choices[0] : null);
const delta = asRecord(choice.delta);
if (typeof delta.content === "string" && delta.content.length > 0) {
contentParts.push(delta.content);
}
if (Array.isArray(delta.content)) {
for (const part of delta.content) {
const partObj = asRecord(part);
if (typeof partObj.text === "string" && partObj.text.length > 0) {
contentParts.push(partObj.text);
}
if (typeof delta.content === "string" && delta.content.length > 0) {
contentParts.push(delta.content);
}
if (Array.isArray(delta.content)) {
for (const part of delta.content) {
const partObj = asRecord(part);
if (typeof partObj.text === "string" && partObj.text.length > 0) {
contentParts.push(partObj.text);
}
}
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
reasoningParts.push(delta.reasoning_content);
}
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
if (
typeof delta.reasoning === "string" &&
delta.reasoning.length > 0 &&
!delta.reasoning_content
) {
reasoningParts.push(delta.reasoning);
}
}
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
reasoningParts.push(delta.reasoning_content);
}
// Normalize `reasoning` alias (NVIDIA kimi-k2.5 etc.)
if (
typeof delta.reasoning === "string" &&
delta.reasoning.length > 0 &&
!delta.reasoning_content
) {
reasoningParts.push(delta.reasoning);
}
if (Array.isArray(delta.tool_calls)) {
for (const item of delta.tool_calls) {
const toolCall = asRecord(item);
const key = getToolCallKey(toolCall);
const existing = toolCalls.get(key);
const deltaArgs =
typeof asRecord(toolCall.function).arguments === "string"
? String(asRecord(toolCall.function).arguments)
: "";
if (Array.isArray(delta.tool_calls)) {
for (const item of delta.tool_calls) {
const toolCall = asRecord(item);
const key = getToolCallKey(toolCall);
const existing = toolCalls.get(key);
const deltaArgs =
typeof asRecord(toolCall.function).arguments === "string"
? String(asRecord(toolCall.function).arguments)
: "";
if (!existing) {
toolCalls.set(key, {
id: typeof toolCall.id === "string" ? toolCall.id : null,
index: Number.isInteger(toolCall.index) ? Number(toolCall.index) : toolCalls.size,
type: toString(toolCall.type, "function"),
function: {
name: toString(asRecord(toolCall.function).name, "unknown"),
arguments: deltaArgs,
},
});
continue;
}
existing.id = existing.id || (typeof toolCall.id === "string" ? toolCall.id : null);
if (
(!Number.isInteger(existing.index) || existing.index < 0) &&
Number.isInteger(toolCall.index)
) {
existing.index = Number(toolCall.index);
}
if (typeof asRecord(toolCall.function).name === "string" && !existing.function.name) {
existing.function.name = String(asRecord(toolCall.function).name);
}
existing.function.arguments += deltaArgs;
if (!existing) {
toolCalls.set(key, {
id: typeof toolCall.id === "string" ? toolCall.id : null,
index: Number.isInteger(toolCall.index) ? Number(toolCall.index) : toolCalls.size,
type: toString(toolCall.type, "function"),
function: {
name: toString(asRecord(toolCall.function).name, "unknown"),
arguments: deltaArgs,
},
});
continue;
}
existing.id = existing.id || (typeof toolCall.id === "string" ? toolCall.id : null);
if (
(!Number.isInteger(existing.index) || existing.index < 0) &&
Number.isInteger(toolCall.index)
) {
existing.index = Number(toolCall.index);
}
if (typeof asRecord(toolCall.function).name === "string" && !existing.function.name) {
existing.function.name = String(asRecord(toolCall.function).name);
}
existing.function.arguments += deltaArgs;
}
}
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
finishReason = choice.finish_reason;
}
if (chunk.usage && typeof chunk.usage === "object") {
usage = { ...asRecord(chunk.usage) };
}
},
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
finishReason = choice.finish_reason;
}
if (chunk.usage && typeof chunk.usage === "object") {
usage = { ...asRecord(chunk.usage) };
}
}
finalize(): unknown {
if (!first) return null;
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : null;
const joinedReasoning = reasoningParts.length > 0 ? reasoningParts.join("").trim() : null;
const message: JsonRecord = {
role: "assistant",
content: joinedContent || null,
};
if (joinedReasoning) {
message.reasoning_content = joinedReasoning;
}
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
if (finalToolCalls.length > 0) {
finishReason = "tool_calls";
message.tool_calls = finalToolCalls;
}
const result: JsonRecord = {
id: toString(first.id, `chatcmpl-${Date.now()}`),
object: "chat.completion",
created: toNumber(first.created, Math.floor(Date.now() / 1000)),
model: toString(first.model, fallbackModel || "unknown"),
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
};
if (usage && Object.keys(usage).length > 0) {
result.usage = usage;
}
return result;
},
const joinedContent = contentParts.length > 0 ? contentParts.join("").trim() : null;
const joinedReasoning = reasoningParts.length > 0 ? reasoningParts.join("").trim() : null;
const message: JsonRecord = {
role: "assistant",
content: joinedContent || null,
};
if (joinedReasoning) {
message.reasoning_content = joinedReasoning;
}
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
if (finalToolCalls.length > 0) {
finishReason = "tool_calls";
message.tool_calls = finalToolCalls;
}
const result: JsonRecord = {
id: toString(first.id, `chatcmpl-${Date.now()}`),
object: "chat.completion",
created: toNumber(first.created, Math.floor(Date.now() / 1000)),
model: toString(first.model, fallbackModel || "unknown"),
choices: [
{
index: 0,
message,
finish_reason: finishReason,
},
],
};
if (usage && Object.keys(usage).length > 0) {
result.usage = usage;
}
return result;
}
function createResponsesReducer(fallbackModel?: string | null): SummaryReducer {
let sawAny = false;
function buildResponsesSummary(
events: StructuredSSEEvent[],
fallbackModel?: string | null
): unknown {
const payloads = events
.map((evt) => asRecord(evt.data))
.filter((payload) => Object.keys(payload).length);
if (payloads.length === 0) return null;
let completed: JsonRecord | null = null;
let latestResponse: JsonRecord | null = null;
let usage: JsonRecord | null = null;
@@ -308,72 +289,67 @@ function createResponsesReducer(fallbackModel?: string | null): SummaryReducer {
]
: [];
for (const payload of payloads) {
const eventType = toString(payload.type);
if (
eventType === "response.completed" &&
payload.response &&
typeof payload.response === "object"
) {
completed = asRecord(payload.response);
}
if (payload.response && typeof payload.response === "object") {
latestResponse = asRecord(payload.response);
} else if (payload.object === "response") {
latestResponse = payload;
}
if (
eventType === "response.output_text.delta" &&
typeof payload.delta === "string" &&
payload.delta.length > 0
) {
textParts.push(payload.delta);
}
if (payload.usage && typeof payload.usage === "object") {
usage = { ...asRecord(payload.usage) };
} else if (payload.response && typeof asRecord(payload.response).usage === "object") {
usage = { ...asRecord(asRecord(payload.response).usage) };
}
}
const picked = completed || latestResponse;
if (picked && Object.keys(picked).length > 0) {
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
return {
id: toString(picked.id, `resp_${Date.now()}`),
object: "response",
model: toString(picked.model, fallbackModel || "unknown"),
output: pickedOutput.length > 0 ? pickedOutput : buildOutputFromText(),
usage: picked.usage ?? usage ?? null,
status: toString(picked.status, completed ? "completed" : "in_progress"),
created_at: toNumber(picked.created_at, Math.floor(Date.now() / 1000)),
metadata: asRecord(picked.metadata),
};
}
return {
ingest(payload: JsonRecord) {
if (Object.keys(payload).length === 0) return;
sawAny = true;
const eventType = toString(payload.type);
if (
eventType === "response.completed" &&
payload.response &&
typeof payload.response === "object"
) {
completed = asRecord(payload.response);
}
if (payload.response && typeof payload.response === "object") {
latestResponse = asRecord(payload.response);
} else if (payload.object === "response") {
latestResponse = payload;
}
if (
eventType === "response.output_text.delta" &&
typeof payload.delta === "string" &&
payload.delta.length > 0
) {
textParts.push(payload.delta);
}
if (payload.usage && typeof payload.usage === "object") {
usage = { ...asRecord(payload.usage) };
} else if (payload.response && typeof asRecord(payload.response).usage === "object") {
usage = { ...asRecord(asRecord(payload.response).usage) };
}
},
finalize(): unknown {
if (!sawAny) return null;
const picked = completed || latestResponse;
if (picked && Object.keys(picked).length > 0) {
const pickedOutput = Array.isArray(picked.output) ? picked.output : [];
return {
id: toString(picked.id, `resp_${Date.now()}`),
object: "response",
model: toString(picked.model, fallbackModel || "unknown"),
output: pickedOutput.length > 0 ? pickedOutput : buildOutputFromText(),
usage: picked.usage ?? usage ?? null,
status: toString(picked.status, completed ? "completed" : "in_progress"),
created_at: toNumber(picked.created_at, Math.floor(Date.now() / 1000)),
metadata: asRecord(picked.metadata),
};
}
return {
id: `resp_${Date.now()}`,
object: "response",
model: fallbackModel || "unknown",
output: buildOutputFromText(),
usage: usage ?? null,
status: "completed",
created_at: Math.floor(Date.now() / 1000),
metadata: {},
};
},
id: `resp_${Date.now()}`,
object: "response",
model: fallbackModel || "unknown",
output: buildOutputFromText(),
usage: usage ?? null,
status: "completed",
created_at: Math.floor(Date.now() / 1000),
metadata: {},
};
}
function createClaudeReducer(fallbackModel?: string | null): SummaryReducer {
let sawAny = false;
function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const payloads = events
.map((evt) => asRecord(evt.data))
.filter((payload) => Object.keys(payload).length);
if (payloads.length === 0) return null;
type ClaudeBlock =
| { type: "text"; index: number; text: string }
| { type: "thinking"; index: number; thinking: string; signature?: string }
@@ -403,177 +379,172 @@ function createClaudeReducer(fallbackModel?: string | null): SummaryReducer {
// non-streaming JSON path. Last-writer-wins: the final snapshot is authoritative.
let contextManagement: JsonRecord | null = null;
return {
ingest(payload: JsonRecord) {
if (Object.keys(payload).length === 0) return;
sawAny = true;
for (const payload of payloads) {
const eventType = toString(payload.type);
if (
payload.context_management &&
typeof payload.context_management === "object" &&
!Array.isArray(payload.context_management)
) {
contextManagement = asRecord(payload.context_management);
}
if (eventType === "message_start") {
const message = asRecord(payload.message);
messageId = toString(message.id, messageId || `msg_${Date.now()}`);
model = toString(message.model, model);
role = toString(message.role, role);
mergeUsage(usage, message.usage);
continue;
}
const eventType = toString(payload.type);
if (
payload.context_management &&
typeof payload.context_management === "object" &&
!Array.isArray(payload.context_management)
) {
contextManagement = asRecord(payload.context_management);
}
if (eventType === "message_start") {
const message = asRecord(payload.message);
messageId = toString(message.id, messageId || `msg_${Date.now()}`);
model = toString(message.model, model);
role = toString(message.role, role);
mergeUsage(usage, message.usage);
return;
if (eventType === "content_block_start") {
const index = toNumber(payload.index, blocks.size);
const contentBlock = asRecord(payload.content_block);
const blockType = toString(contentBlock.type);
if (blockType === "thinking") {
blocks.set(index, {
type: "thinking",
index,
thinking: toString(contentBlock.thinking),
signature:
typeof contentBlock.signature === "string" ? contentBlock.signature : undefined,
});
} else if (blockType === "tool_use") {
blocks.set(index, {
type: "tool_use",
index,
id: toString(contentBlock.id, `toolu_${Date.now()}_${index}`),
name: toString(contentBlock.name),
input: cloneLogPayload(contentBlock.input ?? {}),
inputJson: "",
});
} else {
blocks.set(index, {
type: "text",
index,
text: toString(contentBlock.text),
});
}
continue;
}
if (eventType === "content_block_start") {
const index = toNumber(payload.index, blocks.size);
const contentBlock = asRecord(payload.content_block);
const blockType = toString(contentBlock.type);
if (eventType === "content_block_delta") {
const index = toNumber(payload.index, 0);
const delta = asRecord(payload.delta);
const deltaType = toString(delta.type);
const existing = blocks.get(index);
if (blockType === "thinking") {
blocks.set(index, {
type: "thinking",
index,
thinking: toString(contentBlock.thinking),
signature:
typeof contentBlock.signature === "string" ? contentBlock.signature : undefined,
});
} else if (blockType === "tool_use") {
blocks.set(index, {
type: "tool_use",
index,
id: toString(contentBlock.id, `toolu_${Date.now()}_${index}`),
name: toString(contentBlock.name),
input: cloneLogPayload(contentBlock.input ?? {}),
inputJson: "",
});
} else {
blocks.set(index, {
type: "text",
index,
text: toString(contentBlock.text),
});
}
return;
}
if (eventType === "content_block_delta") {
const index = toNumber(payload.index, 0);
const delta = asRecord(payload.delta);
const deltaType = toString(delta.type);
const existing = blocks.get(index);
if (deltaType === "input_json_delta") {
const toolUse =
existing && existing.type === "tool_use"
? existing
: {
type: "tool_use" as const,
index,
id: `toolu_${Date.now()}_${index}`,
name: "",
input: {},
inputJson: "",
};
toolUse.inputJson += toString(delta.partial_json);
blocks.set(index, toolUse);
return;
}
if (deltaType === "thinking_delta" || typeof delta.thinking === "string") {
const thinking =
existing && existing.type === "thinking"
? existing
: { type: "thinking" as const, index, thinking: "", signature: undefined };
thinking.thinking += toString(delta.thinking);
blocks.set(index, thinking);
return;
}
const textBlock =
existing && existing.type === "text"
if (deltaType === "input_json_delta") {
const toolUse =
existing && existing.type === "tool_use"
? existing
: {
type: "text" as const,
type: "tool_use" as const,
index,
text: "",
id: `toolu_${Date.now()}_${index}`,
name: "",
input: {},
inputJson: "",
};
textBlock.text += toString(delta.text);
blocks.set(index, textBlock);
return;
toolUse.inputJson += toString(delta.partial_json);
blocks.set(index, toolUse);
continue;
}
if (eventType === "message_delta") {
const delta = asRecord(payload.delta);
stopReason = toString(delta.stop_reason, stopReason);
stopSequence =
typeof delta.stop_sequence === "string" ? String(delta.stop_sequence) : stopSequence;
mergeUsage(usage, payload.usage);
return;
if (deltaType === "thinking_delta" || typeof delta.thinking === "string") {
const thinking =
existing && existing.type === "thinking"
? existing
: { type: "thinking" as const, index, thinking: "", signature: undefined };
thinking.thinking += toString(delta.thinking);
blocks.set(index, thinking);
continue;
}
const textBlock =
existing && existing.type === "text"
? existing
: {
type: "text" as const,
index,
text: "",
};
textBlock.text += toString(delta.text);
blocks.set(index, textBlock);
continue;
}
if (eventType === "message_delta") {
const delta = asRecord(payload.delta);
stopReason = toString(delta.stop_reason, stopReason);
stopSequence =
typeof delta.stop_sequence === "string" ? String(delta.stop_sequence) : stopSequence;
mergeUsage(usage, payload.usage);
},
continue;
}
finalize(): unknown {
if (!sawAny) return null;
mergeUsage(usage, payload.usage);
}
const content = [...blocks.values()]
.sort((a, b) => a.index - b.index)
.flatMap<ClaudeContentBlock>((block) => {
if (block.type === "text") {
return block.text
? [
{
type: "text",
text: block.text,
},
]
: [];
}
if (block.type === "thinking") {
return block.thinking
? [
{
type: "thinking",
thinking: block.thinking,
...(block.signature ? { signature: block.signature } : {}),
},
]
: [];
}
const content = [...blocks.values()]
.sort((a, b) => a.index - b.index)
.flatMap<ClaudeContentBlock>((block) => {
if (block.type === "text") {
return block.text
? [
{
type: "text",
text: block.text,
},
]
: [];
}
if (block.type === "thinking") {
return block.thinking
? [
{
type: "thinking",
thinking: block.thinking,
...(block.signature ? { signature: block.signature } : {}),
},
]
: [];
}
const parsedInput =
block.inputJson.trim().length > 0
? tryParseJson(block.inputJson)
: cloneLogPayload(block.input);
return [
{
type: "tool_use",
id: block.id,
name: block.name,
input: parsedInput,
},
];
});
const parsedInput =
block.inputJson.trim().length > 0
? tryParseJson(block.inputJson)
: cloneLogPayload(block.input);
return [
{
type: "tool_use",
id: block.id,
name: block.name,
input: parsedInput,
},
];
});
return {
id: messageId || `msg_${Date.now()}`,
type: "message",
role,
model,
content,
stop_reason: stopReason,
...(stopSequence ? { stop_sequence: stopSequence } : {}),
...(Object.keys(usage).length > 0 ? { usage } : {}),
...(contextManagement ? { context_management: contextManagement } : {}),
};
},
return {
id: messageId || `msg_${Date.now()}`,
type: "message",
role,
model,
content,
stop_reason: stopReason,
...(stopSequence ? { stop_sequence: stopSequence } : {}),
...(Object.keys(usage).length > 0 ? { usage } : {}),
...(contextManagement ? { context_management: contextManagement } : {}),
};
}
function createGeminiReducer(fallbackModel?: string | null): SummaryReducer {
let sawAny = false;
function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const payloads = events
.map((evt) => asRecord(evt.data))
.filter((payload) => Object.keys(payload).length);
if (payloads.length === 0) return null;
const parts: JsonRecord[] = [];
const usageMetadata: JsonRecord = {};
let modelVersion = fallbackModel || "gemini";
@@ -594,108 +565,52 @@ function createGeminiReducer(fallbackModel?: string | null): SummaryReducer {
parts.push(part);
};
return {
ingest(payload: JsonRecord) {
if (Object.keys(payload).length === 0) return;
sawAny = true;
for (const payload of payloads) {
if (typeof payload.modelVersion === "string" && payload.modelVersion.length > 0) {
modelVersion = payload.modelVersion;
}
mergeUsage(usageMetadata, payload.usageMetadata);
if (typeof payload.modelVersion === "string" && payload.modelVersion.length > 0) {
modelVersion = payload.modelVersion;
const candidate = asRecord(Array.isArray(payload.candidates) ? payload.candidates[0] : null);
if (typeof candidate.finishReason === "string" && candidate.finishReason.length > 0) {
finishReason = candidate.finishReason;
}
const content = asRecord(candidate.content);
if (typeof content.role === "string" && content.role.length > 0) {
role = content.role;
}
if (!Array.isArray(content.parts)) continue;
for (const item of content.parts) {
const part = asRecord(item);
if (part.functionCall && typeof part.functionCall === "object") {
parts.push({
functionCall: cloneLogPayload(part.functionCall),
});
} else if (typeof part.text === "string" && part.text.length > 0) {
appendPart({
text: part.text,
...(part.thought === true ? { thought: true } : {}),
});
}
mergeUsage(usageMetadata, payload.usageMetadata);
const candidate = asRecord(Array.isArray(payload.candidates) ? payload.candidates[0] : null);
if (typeof candidate.finishReason === "string" && candidate.finishReason.length > 0) {
finishReason = candidate.finishReason;
}
const content = asRecord(candidate.content);
if (typeof content.role === "string" && content.role.length > 0) {
role = content.role;
}
if (!Array.isArray(content.parts)) return;
for (const item of content.parts) {
const part = asRecord(item);
if (part.functionCall && typeof part.functionCall === "object") {
parts.push({
functionCall: cloneLogPayload(part.functionCall),
});
} else if (typeof part.text === "string" && part.text.length > 0) {
appendPart({
text: part.text,
...(part.thought === true ? { thought: true } : {}),
});
}
}
},
finalize(): unknown {
if (!sawAny) return null;
return {
candidates: [
{
index: 0,
content: {
role,
parts,
},
finishReason,
},
],
...(Object.keys(usageMetadata).length > 0 ? { usageMetadata } : {}),
modelVersion,
};
},
};
}
function createSummaryReducer(
format: string | null | undefined,
fallbackModel?: string | null
): SummaryReducer | undefined {
const normalized = normalizeFormat(format);
if (!normalized) return undefined;
switch (normalized) {
case FORMATS.OPENAI_RESPONSES:
return createResponsesReducer(fallbackModel);
case FORMATS.CLAUDE:
return createClaudeReducer(fallbackModel);
case FORMATS.GEMINI:
case FORMATS.ANTIGRAVITY:
return createGeminiReducer(fallbackModel);
default:
return createOpenAIReducer(fallbackModel);
}
}
}
function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createOpenAIReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
function buildResponsesSummary(
events: StructuredSSEEvent[],
fallbackModel?: string | null
): unknown {
const reducer = createResponsesReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createClaudeReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createGeminiReducer(fallbackModel);
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
return {
candidates: [
{
index: 0,
content: {
role,
parts,
},
finishReason,
},
],
...(Object.keys(usageMetadata).length > 0 ? { usageMetadata } : {}),
modelVersion,
};
}
export function buildStreamSummaryFromEvents(
@@ -751,25 +666,19 @@ export function compactStructuredStreamPayload(payload: unknown): unknown {
}
export function createStructuredSSECollector(options: CollectorOptions = {}) {
const { maxEvents = 200, maxBytes = 49152, stage, format, fallbackModel } = options;
const { maxEvents = 200, maxBytes = 49152, stage } = options;
const events: StructuredSSEEvent[] = [];
let usedBytes = 0;
let droppedEvents = 0;
// Live-updated on every push() regardless of the storage cap above — see
// the CollectorOptions.format doc comment for why (#9315).
const reducer = createSummaryReducer(format, fallbackModel);
return {
push(payload: unknown, explicitEvent?: string) {
if (payload === null || payload === undefined) return;
const clonedData = cloneLogPayload(payload);
reducer?.ingest(asRecord(clonedData));
const event: StructuredSSEEvent = {
index: events.length + droppedEvents,
timestamp: new Date().toISOString(),
data: clonedData,
data: cloneLogPayload(payload),
};
const eventName = explicitEvent || getEventName(payload);
@@ -791,17 +700,6 @@ export function createStructuredSSECollector(options: CollectorOptions = {}) {
return events.map((event) => cloneLogPayload(event));
},
// The reducer-computed summary, built incrementally from EVERY pushed
// payload (see CollectorOptions.format) — unlike
// buildStreamSummaryFromEvents(getEvents(), ...), this is correct even
// once the collector has truncated its retained event array. Returns
// undefined if no format was configured (e.g. the client-response
// collector, which builds its summary from independently-accumulated
// response state instead).
getSummary(): unknown {
return reducer?.finalize();
},
build(summary?: unknown, buildOptions: BuildOptions = {}) {
const { includeEvents = true } = buildOptions;
return {

2050
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -350,54 +350,54 @@
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@cyclonedx/cyclonedx-npm": "6.0.0",
"@playwright/test": "^1.62.1",
"@size-limit/file": "^13.0.3",
"@playwright/test": "^1.60.0",
"@size-limit/file": "^12.1.0",
"@stryker-mutator/core": "^9.6.1",
"@stryker-mutator/tap-runner": "^9.6.1",
"@tailwindcss/postcss": "^4.3.0",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/better-sqlite3": "^9.6.0",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "latest",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/node": "^26.1.0",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@types/safe-regex": "^1.1.6",
"@types/ws": "^8.18.0",
"@vitejs/plugin-react": "^6.0.5",
"@vitejs/plugin-react": "^6.0.2",
"bun": "1.3.14",
"c8": "^12.0.0",
"concurrently": "^10.0.4",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"ctrf": "^0.2.1",
"dpdm": "^4.3.0",
"dpdm": "^4.2.0",
"eslint": "^9.39.4",
"eslint-config-next": "16.3.0",
"eslint-config-next": "16.2.10",
"eslint-plugin-sonarjs": "^4.1.0",
"fast-check": "^4.8.0",
"fumadocs-mdx": "^15.2.2",
"fumadocs-mdx": "^15.0.7",
"glob": "^13.0.6",
"httpyac": "^6.16.7",
"husky": "^9.1.7",
"jscpd": "^4.2.5",
"jsdom": "^30.0.1",
"jsdom": "^29.1.1",
"junit-to-ctrf": "^0.0.14",
"knip": "^6.32.0",
"knip": "^6.18.0",
"license-checker-rseidelsohn": "^5.0.1",
"lint-staged": "^17.3.0",
"lint-staged": "^17.0.8",
"lockfile-lint": "^5.0.0",
"node-loader": "^2.1.0",
"opencode-ai": "1.18.15",
"opencode-ai": "1.18.8",
"playwright-ctrf-json-reporter": "^0.0.29",
"prettier": "^3.9.6",
"promptfoo": "^0.122.0",
"size-limit": "^13.0.3",
"prettier": "^3.8.3",
"promptfoo": "^0.121.18",
"size-limit": "^12.1.0",
"tailwindcss": "^4.3.0",
"type-coverage": "^2.30.1",
"type-coverage": "^2.29.7",
"typescript": "^6.0.3",
"typescript-eslint": "^8.66.0",
"typescript-eslint": "^8.59.4",
"vitest": "^4.1.7",
"wait-on": "^9.1.0",
"wait-on": "^9.0.10",
"wtfnode": "^0.10.1"
},
"lint-staged": {

View File

@@ -1,132 +0,0 @@
/**
* Shared MCP publish-path helpers (#3578 / #3821).
*
* Unit tests use the static `files` allowlist walker (no subprocess).
* The pack-artifact gate uses the same helpers against a real
* `npm pack --dry-run --ignore-scripts` file list so concurrent unit
* suites never shell out to `npm pack`.
*/
import fs from "node:fs";
import path from "node:path";
import { normalizeArtifactPath } from "./pack-artifact-policy.ts";
/** Co-located test / spec paths that must never ship in the npm tarball. */
export const PACK_ARTIFACT_TEST_FILE_RE = /(?:^|\/)__tests__\/|\.(?:test|spec)\.[cm]?[jt]sx?$/;
/** Negations that must stay in package.json `files` (static unit guard). */
export const REQUIRED_PACKAGE_FILES_TEST_NEGATIONS: readonly string[] = [
"!**/__tests__/**",
"!**/*.test.ts",
"!**/*.test.tsx",
"!**/*.test.js",
"!**/*.test.mjs",
"!**/*.spec.ts",
"!**/*.spec.tsx",
];
/** Spot-check file from the original #3578 bug report. */
export const MCP_CLOSURE_SPOT_CHECK_PATH = "src/lib/combos/steps.ts";
function resolveImport(root: string, fromFile: string, spec: string): string | null {
let base: string;
if (spec.startsWith("@/")) base = path.join("src", spec.slice(2));
else if (spec.startsWith("@omniroute/open-sse/"))
base = path.join("open-sse", spec.slice("@omniroute/open-sse/".length));
else if (spec === "@omniroute/open-sse") base = path.join("open-sse", "index");
else if (spec.startsWith("./") || spec.startsWith("../"))
base = path.join(path.dirname(fromFile), spec);
else return null; // bare package — not our source
base = base.replace(/\.(ts|tsx|js|mjs)$/, "");
const cands = [
base + ".ts",
base + ".tsx",
path.join(base, "index.ts"),
path.join(base, "index.tsx"),
base + ".js",
base + ".mjs",
];
for (const c of cands) if (fs.existsSync(path.join(root, c))) return c;
return null;
}
/**
* Transitive import closure of the MCP server entrypoints under `src/` + `open-sse/`.
*/
export function computeMcpClosure(root: string = process.cwd()): string[] {
const roots: string[] = [];
for (const f of fs.readdirSync(path.join(root, "open-sse/mcp-server"))) {
if (f.endsWith(".ts")) roots.push("open-sse/mcp-server/" + f);
}
for (const d of ["open-sse/mcp-server/tools", "open-sse/mcp-server/schemas"]) {
const abs = path.join(root, d);
if (fs.existsSync(abs))
for (const f of fs.readdirSync(abs)) if (f.endsWith(".ts")) roots.push(d + "/" + f);
}
const seen = new Set<string>();
const stack = [...roots];
const importRe =
/(?:import|export)[^"']*?from\s*["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g;
while (stack.length) {
const f = stack.pop() as string;
if (seen.has(f)) continue;
seen.add(f);
let src: string;
try {
src = fs.readFileSync(path.join(root, f), "utf8");
} catch {
continue;
}
let m: RegExpExecArray | null;
while ((m = importRe.exec(src))) {
const spec = m[1] || m[2];
if (!spec) continue;
const r = resolveImport(root, f, spec);
if (r && !seen.has(r)) stack.push(r);
}
}
return [...seen].filter((f) => f.startsWith("src/") || f.startsWith("open-sse/"));
}
/** Whether `file` is covered by a package.json `files` allowlist entry. */
export function isCoveredByFiles(file: string, filesEntries: string[]): boolean {
for (const entry of filesEntries) {
if (entry.startsWith("!")) continue; // negations are not positive coverage
if (entry.endsWith("/")) {
if (file === entry.slice(0, -1) || file.startsWith(entry)) return true;
} else if (file === entry || file.startsWith(entry + "/")) {
return true;
}
}
return false;
}
/** Packed paths that look like test / spec files (over-inclusion). */
export function findLeakedTestArtifactPaths(filePaths: string[]): string[] {
return filePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter((filePath) => PACK_ARTIFACT_TEST_FILE_RE.test(filePath))
.sort();
}
/** MCP closure members missing from a packed (or candidate) path set. */
export function findMissingMcpClosurePaths(
packedPaths: string[],
closurePaths: string[] = computeMcpClosure()
): string[] {
const packed = new Set(packedPaths.map(normalizeArtifactPath).filter(Boolean));
return closurePaths
.map(normalizeArtifactPath)
.filter(Boolean)
.filter((filePath) => !packed.has(filePath))
.sort();
}
/** Required `files` negation entries that are absent from package.json. */
export function findMissingPackageFilesTestNegations(filesEntries: string[]): string[] {
const present = new Set(filesEntries);
return REQUIRED_PACKAGE_FILES_TEST_NEGATIONS.filter((entry) => !present.has(entry));
}

View File

@@ -228,59 +228,6 @@ export function normalizeArtifactPath(filePath: string): string {
.replace(/\/{2,}/g, "/");
}
/** Extract complete JSON values from npm's mixed stdout/stderr-style output. */
export function parseJsonValuesOutput(output: string): unknown[] {
const values: unknown[] = [];
for (let start = 0; start < output.length; start++) {
if (output[start] !== "[" && output[start] !== "{") continue;
const stack: string[] = [];
let inString = false;
let escaped = false;
for (let end = start; end < output.length; end++) {
const char = output[end];
if (inString) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') inString = false;
continue;
}
if (char === '"') {
inString = true;
} else if (char === "[" || char === "{") {
stack.push(char);
} else if (char === "]" || char === "}") {
const expectedOpen = char === "]" ? "[" : "{";
if (stack.at(-1) !== expectedOpen) break;
stack.pop();
if (stack.length === 0) {
try {
const parsed: unknown = JSON.parse(output.slice(start, end + 1));
values.push(parsed);
start = end;
} catch {
// This bracket pair was not a complete JSON value; continue scanning.
}
break;
}
}
}
}
return values;
}
/** Extract the first matching JSON array from npm's mixed stdout/stderr-style output. */
export function parseJsonArrayOutput(
output: string,
matches: (parsed: unknown[]) => boolean = () => true
): unknown[] {
const parsed = parseJsonValuesOutput(output).find(
(value): value is unknown[] => Array.isArray(value) && matches(value)
);
if (!parsed) throw new Error("Expected a valid JSON array in command output.");
return parsed;
}
/**
* Paths that are NEVER publishable, whatever the allowlist says.
*

View File

@@ -1,23 +1,16 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
MCP_CLOSURE_SPOT_CHECK_PATH,
computeMcpClosure,
findLeakedTestArtifactPaths,
findMissingMcpClosurePaths,
} from "./mcpPublishedFilesClosure.ts";
import {
PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
PACK_ARTIFACT_REQUIRED_PATHS,
findMissingArtifactPaths,
findUnexpectedArtifactPaths,
parseJsonValuesOutput,
} from "./pack-artifact-policy.ts";
const __filename: string = fileURLToPath(import.meta.url);
@@ -31,29 +24,12 @@ function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string {
const command = npmExecPath && !isBunRuntime ? process.execPath : npmCommand;
const commandArgs = npmExecPath && !isBunRuntime ? [npmExecPath, ...args] : args;
if (stdio === "inherit") {
execFileSync(command, commandArgs, {
cwd: ROOT,
encoding: "utf8",
stdio: "inherit",
maxBuffer: 64 * 1024 * 1024,
});
return "";
}
const result = spawnSync(command, commandArgs, {
return execFileSync(command, commandArgs, {
cwd: ROOT,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"],
maxBuffer: 64 * 1024 * 1024,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(
(result.stderr || result.stdout || `npm exited with status ${result.status}`).trim()
);
}
return `${result.stdout || ""}\n${result.stderr || ""}`;
}
function ensureAppStagingReady(): void {
@@ -67,39 +43,15 @@ function ensureAppStagingReady(): void {
runNpm(["run", "build:cli"], "inherit");
}
type PackReport = {
files: Array<{ path: string }>;
filename?: string;
entryCount?: number;
size?: number;
unpackedSize?: number;
};
function findPackReport(value: unknown): PackReport | null {
if (Array.isArray(value)) {
for (const item of value) {
const report = findPackReport(item);
if (report) return report;
}
return null;
}
if (typeof value !== "object" || value === null) return null;
const record = value as Record<string, unknown>;
if (Array.isArray(record.files)) return record as unknown as PackReport;
for (const child of Object.values(record)) {
const report = findPackReport(child);
if (report) return report;
}
return null;
}
function runPackDryRun(): PackReport {
function runPackDryRun(): any {
const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]);
const packReport = parseJsonValuesOutput(output)
.map(findPackReport)
.find((report): report is PackReport => report !== null);
const jsonStart = output.indexOf("[");
const jsonEnd = output.lastIndexOf("]");
const jsonPayload =
jsonStart >= 0 && jsonEnd > jsonStart ? output.slice(jsonStart, jsonEnd + 1) : output;
const parsed = JSON.parse(jsonPayload);
const packReport = Array.isArray(parsed) ? parsed[0] : null;
if (!packReport || !Array.isArray(packReport.files)) {
throw new Error("npm pack --dry-run --json did not return the expected files[] payload.");
@@ -126,17 +78,17 @@ function formatBytes(bytes: number): string {
}
// --policy-only: skip the build (ensureAppStagingReady → build:cli) and the
// required-runtime-files check (which needs the built dist/). Source-side policy checks
// still run against the real `npm pack --dry-run` file list: unexpected files (e.g. stray
// bin/*.sh), test/spec leaks, and missing MCP closure files. This catches source regressions
// cheaply on the fast-path (PR→release), instead of only on the release PR's full Package
// Artifact job. See incident v3.8.36 (#5029).
// required-runtime-files check (which needs the built dist/), running ONLY the
// unexpected-files allowlist check. The unexpected files (e.g. stray bin/*.sh) are
// SOURCE files that `npm pack --dry-run` lists regardless of build, so this catches
// the "new file leaked into the tarball" regression cheaply on the fast-path (PR→release),
// instead of only on the release PR's full Package Artifact job. See incident v3.8.36 (#5029).
const POLICY_ONLY = process.argv.includes("--policy-only");
try {
if (!POLICY_ONLY) ensureAppStagingReady();
const packReport = runPackDryRun();
const artifactPaths: string[] = packReport.files.map((file) => file.path);
const artifactPaths: string[] = packReport.files.map((file: any) => file.path);
const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, {
exactPaths: PACK_ARTIFACT_ALLOWED_EXACT_PATHS,
prefixPaths: PACK_ARTIFACT_ALLOWED_PATH_PREFIXES,
@@ -145,20 +97,11 @@ try {
? []
: findMissingArtifactPaths(artifactPaths, PACK_ARTIFACT_REQUIRED_PATHS);
// #3821 — broad `files` prefixes (open-sse/, src/lib/, ...) would otherwise allow
// co-located *.test.* / __tests__ leaks; ban them explicitly on the real pack list.
const leakedTestPaths: string[] = findLeakedTestArtifactPaths(artifactPaths);
// #3578 — MCP runs from published TypeScript source; every reachable file must pack.
const mcpClosure: string[] = computeMcpClosure(ROOT);
const missingMcpPaths: string[] = findMissingMcpClosurePaths(artifactPaths, mcpClosure);
console.log("📦 npm pack artifact summary");
console.log(` File: ${packReport.filename}`);
console.log(` Entry count: ${packReport.entryCount}`);
console.log(` Packed size: ${formatBytes(packReport.size)}`);
console.log(` Unpacked size: ${formatBytes(packReport.unpackedSize)}`);
console.log(` MCP closure: ${mcpClosure.length} source files checked`);
if (unexpectedPaths.length > 0) {
console.error("\n❌ Unexpected files were found in the npm publish artifact:");
@@ -174,33 +117,7 @@ try {
}
}
if (leakedTestPaths.length > 0) {
console.error(
"\n❌ Test/spec files leaked into the npm publish artifact (tighten package.json files negations):"
);
for (const leakedPath of leakedTestPaths) {
console.error(` - ${leakedPath}`);
}
}
if (missingMcpPaths.length > 0) {
console.error(
"\n❌ MCP-reachable source files are missing from the npm publish artifact (would 404 --mcp):"
);
for (const missingPath of missingMcpPaths) {
console.error(` - ${missingPath}`);
}
if (missingMcpPaths.includes(MCP_CLOSURE_SPOT_CHECK_PATH)) {
console.error(` (includes the #3578 bug file ${MCP_CLOSURE_SPOT_CHECK_PATH})`);
}
}
if (
unexpectedPaths.length > 0 ||
missingRequiredPaths.length > 0 ||
leakedTestPaths.length > 0 ||
missingMcpPaths.length > 0
) {
if (unexpectedPaths.length > 0 || missingRequiredPaths.length > 0) {
process.exit(1);
}

View File

@@ -74,15 +74,7 @@ async function main() {
const vitestProcess = spawn(
process.execPath,
[
"./node_modules/vitest/vitest.mjs",
"run",
// Without --config, Vitest loads vitest.config.ts, whose exclude list drops
// this file — the run then dies with "No test files found".
"--config",
"vitest.e2e-live.config.ts",
"tests/e2e/ecosystem.test.ts",
],
["./node_modules/vitest/vitest.mjs", "run", "tests/e2e/ecosystem.test.ts"],
{
stdio: "inherit",
env: testEnv,

View File

@@ -73,11 +73,8 @@ async function main() {
[
"./node_modules/vitest/vitest.mjs",
"run",
// Without --config, Vitest loads vitest.config.ts, whose exclude list drops
// this file — the run then dies with "No test files found". The config also
// sets environment: node, so the flag is no longer needed here.
"--config",
"vitest.e2e-live.config.ts",
"--environment",
"node",
"tests/e2e/protocol-clients.test.ts",
],
{

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
// One-shot: FASE 3 helper, safe to delete after merge.
//
// Moves existing i18n mirror docs from `docs/i18n/<lang>/docs/X.md` into the
// matching subfolder `docs/i18n/<lang>/docs/<sub>/X.md`, mirroring the new
// docs/ layout. Uses `git mv` to preserve history.
//
// Usage:
// node scripts/docs/move-i18n-mirrors.mjs [--dry]
//
// Notes:
// - Skips files that don't appear in DOC_TO_SUBFOLDER (e.g., the legacy
// `cloudflare-zero-trust-guide.md` or `features/` subfolder — those will be
// handled in FASE 5 when translations are regenerated).
// - Idempotent: if the target already lives under a subfolder, the entry is
// skipped.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const I18N_DIR = path.join(ROOT, "docs", "i18n");
const DRY = process.argv.includes("--dry");
const DOC_TO_SUBFOLDER = {
// architecture
"ARCHITECTURE.md": "architecture",
"CODEBASE_DOCUMENTATION.md": "architecture",
"REPOSITORY_MAP.md": "architecture",
"AUTHZ_GUIDE.md": "architecture",
"RESILIENCE_GUIDE.md": "architecture",
// guides
"SETUP_GUIDE.md": "guides",
"USER_GUIDE.md": "guides",
"DOCKER_GUIDE.md": "guides",
"ELECTRON_GUIDE.md": "guides",
"TERMUX_GUIDE.md": "guides",
"PWA_GUIDE.md": "guides",
"TROUBLESHOOTING.md": "guides",
"UNINSTALL.md": "guides",
"I18N.md": "guides",
"FEATURES.md": "guides",
// reference
"API_REFERENCE.md": "reference",
"PROVIDER_REFERENCE.md": "reference",
"openapi.yaml": "reference",
"ENVIRONMENT.md": "reference",
"CLI-TOOLS.md": "reference",
"FREE_TIERS.md": "reference",
// frameworks
"MCP-SERVER.md": "frameworks",
"A2A-SERVER.md": "frameworks",
"AGENT_PROTOCOLS_GUIDE.md": "frameworks",
"CLOUD_AGENT.md": "frameworks",
"SKILLS.md": "frameworks",
"MEMORY.md": "frameworks",
"WEBHOOKS.md": "frameworks",
"EVALS.md": "frameworks",
// routing
"AUTO-COMBO.md": "routing",
"REASONING_REPLAY.md": "routing",
// security
"GUARDRAILS.md": "security",
"COMPLIANCE.md": "security",
"STEALTH_GUIDE.md": "security",
// compression
"COMPRESSION_GUIDE.md": "compression",
"COMPRESSION_ENGINES.md": "compression",
"COMPRESSION_RULES_FORMAT.md": "compression",
"COMPRESSION_LANGUAGE_PACKS.md": "compression",
"RTK_COMPRESSION.md": "compression",
// ops
"RELEASE_CHECKLIST.md": "ops",
"COVERAGE_PLAN.md": "ops",
"FLY_IO_DEPLOYMENT_GUIDE.md": "ops",
"VM_DEPLOYMENT_GUIDE.md": "ops",
"PROXY_GUIDE.md": "ops",
"TUNNELS_GUIDE.md": "ops",
};
let moved = 0;
let skipped = 0;
const seenLocales = [];
for (const locale of fs.readdirSync(I18N_DIR)) {
const localeDir = path.join(I18N_DIR, locale);
const stat = fs.statSync(localeDir);
if (!stat.isDirectory()) continue;
const docsDir = path.join(localeDir, "docs");
if (!fs.existsSync(docsDir)) continue;
seenLocales.push(locale);
for (const fname of fs.readdirSync(docsDir)) {
const sub = DOC_TO_SUBFOLDER[fname];
if (!sub) continue; // not in our mapping (e.g. features/, cloudflare-zero-trust-guide.md)
const src = path.join(docsDir, fname);
if (!fs.statSync(src).isFile()) continue;
const subDir = path.join(docsDir, sub);
const dst = path.join(subDir, fname);
if (fs.existsSync(dst)) {
skipped++;
continue;
}
if (DRY) {
console.log(`would move: ${path.relative(ROOT, src)} -> ${path.relative(ROOT, dst)}`);
moved++;
continue;
}
if (!fs.existsSync(subDir)) fs.mkdirSync(subDir, { recursive: true });
const relSrc = path.relative(ROOT, src);
const relDst = path.relative(ROOT, dst);
try {
execFileSync("git", ["mv", "-k", "--", relSrc, relDst], {
cwd: ROOT,
stdio: "pipe",
});
moved++;
} catch {
// fallback: copy + delete; emulate `|| true` for the rm by ignoring its failure
fs.renameSync(src, dst);
try {
execFileSync("git", ["rm", "--cached", "--", relSrc], { cwd: ROOT, stdio: "pipe" });
} catch {
// file may not be tracked yet — safe to ignore
}
execFileSync("git", ["add", "--", relDst], { cwd: ROOT, stdio: "pipe" });
moved++;
}
}
}
console.log(
`[i18n-mirrors] locales=${seenLocales.length} moved=${moved} skipped=${skipped}${DRY ? " (dry-run)" : ""}`
);

View File

@@ -319,15 +319,7 @@ curl -X PUT https://localhost:20128/api/settings/system-prompt \
Get thinking budget configuration
Returns proxy-level thinking/reasoning **request rewrite** settings:
| Field | Meaning |
|-------|---------|
| `mode` | `passthrough` (leave client reasoning alone — **required for Codex visible thinking**), `auto` (**strips** all client thinking fields), `custom`, `adaptive` |
| `customBudget` | Fixed budget when `mode=custom` |
| `effortLevel` | Base effort when `mode=adaptive` |
**Not** compression and **not** “decrypt encrypted reasoning”. Full guide: `docs/guides/THINKING_BUDGET.md`.
Returns the current thinking/reasoning budget settings for AI models.
```bash
curl https://localhost:20128/api/settings/thinking-budget \
@@ -338,17 +330,13 @@ curl https://localhost:20128/api/settings/thinking-budget \
Update thinking budget configuration
Example — keep client-controlled reasoning (Codex/Desktop):
```bash
curl -X PUT https://localhost:20128/api/settings/thinking-budget \
-H "Authorization: Bearer $OMNIROUTE_TOKEN" \
-H "Authorization: Bearer $OMNIROUTE_TOKEN"
-H "Content-Type: application/json" \
-d '{"mode":"passthrough","customBudget":10240,"effortLevel":"medium"}'
-d '{}'
```
Warning: `mode=auto` deletes `reasoning` / `reasoning_effort` / Claude `thinking` from the outbound body before upstream. That can empty thinking panels even when the client requested Ultra + summary.
### GET /api/tags
List Ollama-compatible model tags

View File

@@ -12,11 +12,7 @@ import { shouldShowKimiSponsorBanner } from "./kimiSponsorBannerGate";
// plan (kimi.com/code) to the API platform at Moonshot's request: coding plan
// subscriptions are closed to most new users, so that traffic could not
// convert.
// Dedicated tracked link issued by Moonshot 2026-08 for the 15% first-top-up
// bonus campaign (offer valid through 2026-09-30 — revisit the 15% copy in the
// i18n `kimiSponsorBanner.description` strings after that date if not renewed).
const KIMI_PLATFORM_AFF_URL =
"https://platform.kimi.ai?track_id=track-8197581fdd7d4139a0f562e4a03c3798&aff=omniroute";
const KIMI_PLATFORM_AFF_URL = "https://platform.kimi.ai?aff=omniroute";
// Versioned dismissal key — bump the suffix (e.g. `-v3`) if the banner's
// offer/copy ever changes materially enough to warrant re-showing it to

View File

@@ -713,8 +713,6 @@ export default function CustomModelsSection({
</button>
<ModelCompatPopover
t={t}
providerId={providerId}
modelId={model.id!}
effectiveModelNormalize={(p) =>
effectiveNormalizeForProtocol(model.id!, p, customMap, overrideMap)
}

View File

@@ -27,34 +27,6 @@ function recordToHeaderRows(rec: Record<string, string>, genId: () => string): H
return entries.map(([name, value]) => ({ id: genId(), name, value }));
}
// Bounded re-run budget for the model param-filter save: if the draft changed while the PUT was
// in flight, the save repeats with the newer draft instead of clearing the dirty flag on a
// payload that no longer matches what the user typed (#8910).
const PARAM_SAVE_MAX_ATTEMPTS = 3;
// Param filters are stored as one document per provider. Model rows render independent popover
// instances, so their GET -> whole-document PUT transactions must share a provider-level queue;
// instance-local saving refs cannot prevent sibling rows from overwriting each other's updates.
const paramFilterSaveQueues = new Map<string, Promise<void>>();
async function serializeProviderParamFilterSave<T>(
providerId: string,
save: () => Promise<T>
): Promise<T> {
const previous = paramFilterSaveQueues.get(providerId) ?? Promise.resolve();
const result = previous.then(save);
const tail = result.then(
() => undefined,
() => undefined
);
paramFilterSaveQueues.set(providerId, tail);
try {
return await result;
} finally {
if (paramFilterSaveQueues.get(providerId) === tail) paramFilterSaveQueues.delete(providerId);
}
}
function parseCommaList(text: string): string[] {
return text
? text
@@ -71,21 +43,6 @@ interface ParamFilterConfigLike {
autoLearn?: boolean;
}
// An unsaved param-filter draft, bound to the provider/model it was typed for. The save path
// writes through THIS target instead of the props the callback happens to close over, so a draft
// can never be persisted under a provider/model the user never edited (#8910).
interface ParamFilterDraft {
key: string;
providerId: string;
modelId: string;
block: string;
allow: string;
}
function paramTargetKeyOf(providerId: string, modelId: string): string {
return `${providerId}\u0000${modelId}`;
}
// Builds the PUT body for the model-level block/allow save. Extracted so the
// caller's async handler stays simple — this is pure payload-shaping logic.
function buildModelParamFilterPayload(
@@ -140,8 +97,6 @@ export interface ModelCompatPopoverProps {
export default function ModelCompatPopover({
t,
providerId,
modelId,
effectiveModelNormalize,
effectiveModelPreserveDeveloper,
getUpstreamHeadersRecord,
@@ -155,8 +110,8 @@ export default function ModelCompatPopover({
const [headerRows, setHeaderRows] = useState<HeaderDraftRow[]>([]);
const [blockText, setBlockText] = useState("");
const [allowText, setAllowText] = useState("");
const [paramDirty, setParamDirty] = useState(false);
const [paramSaving, setParamSaving] = useState(false);
const [paramSaveFailed, setParamSaveFailed] = useState(false);
const [valuePeekRowId, setValuePeekRowId] = useState<string | null>(null);
const [valueFocusRowId, setValueFocusRowId] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
@@ -171,78 +126,6 @@ export default function ModelCompatPopover({
const headerRowsRef = useRef<HeaderDraftRow[]>([]);
headerRowsRef.current = headerRows;
// Param-filter drafts are mirrored into a ref so the close/unmount save path reads the
// latest typed values instead of the values captured when the handler was created (#8910).
const paramSavingRef = useRef(false);
// Every unsaved draft, keyed by the provider/model it was typed for. A per-target map (rather
// than a single slot) is required because a live popover can be re-pointed at another target
// while a draft is still unsaved: with one slot the next keystroke on the new target destroyed
// the previous target's unsaved work, and the new target's successful save then cleared the
// failure indicator — a green UI over data that was never written, i.e. exactly the silent
// data loss reported in #8910. Drafts are recorded when edited — never derived from a completed
// load — so an unsaved draft survives even when no load ever succeeded for that target, and
// every write lands on the draft's own target rather than whatever the popover now points at.
const paramDraftsRef = useRef<Map<string, ParamFilterDraft>>(new Map());
const paramTargetKey = paramTargetKeyOf(providerId, modelId);
const paramTargetRef = useRef<{ key: string; providerId: string; modelId: string }>({
key: paramTargetKey,
providerId,
modelId,
});
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
// Mirrors of the displayed text, so an edit can snapshot both fields synchronously.
const blockTextRef = useRef("");
const allowTextRef = useRef("");
blockTextRef.current = blockText;
allowTextRef.current = allowText;
// Which target the values currently in the fields belong to. Guards the invariant that
// blockTextRef/allowTextRef never hold content belonging to a target other than the one being
// displayed — the desync that let one model's server values be saved under another (#8910).
const fieldsTargetKeyRef = useRef<string | null>(null);
const applyParamFields = useCallback((targetKey: string, block: string, allow: string) => {
fieldsTargetKeyRef.current = targetKey;
blockTextRef.current = block;
allowTextRef.current = allow;
setBlockText(block);
setAllowText(allow);
}, []);
// Every edit rewrites the draft for the CURRENT target. The counterpart field is only trusted
// when the values on screen belong to this target; otherwise it is taken from this target's own
// pending draft (or empty), so another target's value can never be captured into this draft and
// then persisted here (#8910). Object identity doubles as the draft revision an in-flight save
// compares against.
const editParamDraft = useCallback(
(field: "block" | "allow", value: string) => {
const target = paramTargetRef.current;
const fieldsOwned = fieldsTargetKeyRef.current === target.key;
const pending = paramDraftsRef.current.get(target.key);
const block =
field === "block" ? value : fieldsOwned ? blockTextRef.current : (pending?.block ?? "");
const allow =
field === "allow" ? value : fieldsOwned ? allowTextRef.current : (pending?.allow ?? "");
applyParamFields(target.key, block, allow);
paramDraftsRef.current.set(target.key, {
key: target.key,
providerId: target.providerId,
modelId: target.modelId,
block,
allow,
});
},
[applyParamFields]
);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const genHeaderRowId = () => {
headerRowIdRef.current += 1;
return `uh-${headerRowIdRef.current}`;
@@ -285,140 +168,40 @@ export default function ModelCompatPopover({
// Load model-level block/allow from param-filters API
useEffect(() => {
if (!open) return;
const draftKey = paramTargetKeyOf(providerId, modelId);
const draftForThisTarget = () => paramDraftsRef.current.get(draftKey);
// The fields must always show THIS target's content, and nothing else may ever be read back
// out of them. A draft that is still pending for this exact provider/model was never persisted
// (failed or exhausted save): restore it into the inputs instead of loading server state over
// it, because reloading here would silently revert it — the very complaint behind #8910.
const pending = draftForThisTarget();
if (pending) {
applyParamFields(draftKey, pending.block, pending.allow);
return;
}
// No draft for this target: drop whatever the previously displayed target left on screen so
// the fields can never present (or contribute) another target's values.
if (fieldsTargetKeyRef.current !== draftKey) applyParamFields(draftKey, "", "");
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/providers/${providerId}/param-filters`);
if (!res.ok) throw new Error(`param-filters GET failed: ${res.status}`);
const data = await res.json();
if (cancelled || !mountedRef.current) return;
// Re-check after the await: the user may have typed while the GET was in flight, and a
// load result must never overwrite (or acknowledge) a draft that is not on the server.
if (draftForThisTarget()) return;
const modelCfg = data?.models?.[modelId];
applyParamFields(
draftKey,
modelCfg ? (modelCfg.block ?? []).join(", ") : "",
modelCfg ? (modelCfg.allow ?? []).join(", ") : ""
);
// A load never clears a draft: any draft still pending here belongs to a DIFFERENT
// target and is still owed a write to that target (#8910). The failure indicator is
// only cleared once nothing is left unsaved anywhere.
if (paramDraftsRef.current.size === 0) setParamSaveFailed(false);
setBlockText(modelCfg ? (modelCfg.block ?? []).join(", ") : "");
setAllowText(modelCfg ? (modelCfg.allow ?? []).join(", ") : "");
} catch {
// Keep whatever the user has in the fields (and its dirty flag) on load failure.
setBlockText("");
setAllowText("");
}
setParamDirty(false);
})();
return () => {
cancelled = true;
};
// Reload only when opening or when the popover targets a different provider/model.
}, [open, providerId, modelId, applyParamFields]);
}, [open]);
// Drains EVERY pending draft, each written through its OWN provider/model — never the props this
// callback happens to be bound to — so a draft typed for target A can never be persisted under a
// target B the user never edited, and re-pointing the popover cannot destroy target A's unsaved
// work (#8910). Each draft is re-read after every await for the same reason the load effect
// re-checks its guard: the user can keep typing while a write is in flight.
const saveModelParamFilters = useCallback(async () => {
if (paramDraftsRef.current.size === 0 || paramSavingRef.current) return;
paramSavingRef.current = true;
if (mountedRef.current) setParamSaving(true);
// Returns true once nothing is owed for this target any more.
const saveDraftForTarget = async (key: string): Promise<boolean> => {
// Re-run while the draft changed under the in-flight write: the payload is snapshotted
// before the PUT resolves, so a keystroke landing in that window would otherwise be
// acknowledged (draft dropped) but never persisted — the #8910 lost update.
for (let attempt = 0; attempt < PARAM_SAVE_MAX_ATTEMPTS; attempt += 1) {
const draft = paramDraftsRef.current.get(key);
if (!draft) return true;
try {
const wroteDraft = await serializeProviderParamFilterSave(draft.providerId, async () => {
const res = await fetch(`/api/providers/${draft.providerId}/param-filters`);
if (!res.ok) throw new Error(`param-filters GET failed: ${res.status}`);
const current = await res.json();
// The fetched config belongs to draft.providerId; if the draft was replaced by a newer
// one while the GET (or this instance's queue wait) was in flight, restart fresh.
if (paramDraftsRef.current.get(key) !== draft) return false;
const payload = buildModelParamFilterPayload(
current,
draft.modelId,
draft.block,
draft.allow
);
const putRes = await fetch(`/api/providers/${draft.providerId}/param-filters`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!putRes.ok) throw new Error(`param-filters PUT failed: ${putRes.status}`);
return true;
});
if (!wroteDraft) continue;
// Only the exact draft that was written may be discarded.
if (paramDraftsRef.current.get(key) === draft) {
paramDraftsRef.current.delete(key);
return true;
}
} catch {
// Save failed — the draft (and the target it belongs to) is intentionally preserved so
// the next blur/close/unmount save retries it against its own provider/model.
return false;
}
}
// Budget exhausted (the user is still typing): stay dirty for this target.
return false;
};
if (!paramDirty) return;
setParamSaving(true);
try {
// Do not snapshot the keys once: a second target can become dirty while an earlier target's
// PUT is in flight. Keep selecting live drafts until only revisions that already failed in
// this drain remain. Remember the failed object (not just its key), so a newer edit for that
// target that lands while another request is pending still gets one save attempt.
const failedDrafts = new Map<string, ParamFilterDraft>();
while (true) {
const next = Array.from(paramDraftsRef.current.entries()).find(
([key, draft]) => failedDrafts.get(key) !== draft
);
if (!next) break;
const [key] = next;
if (!(await saveDraftForTarget(key))) {
const failedDraft = paramDraftsRef.current.get(key);
if (failedDraft) failedDrafts.set(key, failedDraft);
}
}
// The indicator tracks unsaved work across ALL targets: a successful write for the target
// now on screen must not signal "saved" while another target's draft is still owed a write.
if (mountedRef.current) setParamSaveFailed(paramDraftsRef.current.size > 0);
const res = await fetch(`/api/providers/${providerId}/param-filters`);
const current = await res.json();
const payload = buildModelParamFilterPayload(current, modelId, blockText, allowText);
await fetch(`/api/providers/${providerId}/param-filters`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
setParamDirty(false);
} catch {
// Silently ignore save error
} finally {
paramSavingRef.current = false;
if (mountedRef.current) setParamSaving(false);
setParamSaving(false);
}
}, []);
// Persist pending param-filter drafts when the popover closes, unmounts, or is re-pointed at a
// different provider/model (#8910).
useEffect(() => {
if (!open) return;
return () => {
void saveModelParamFilters();
};
}, [open, paramTargetKey, saveModelParamFilters]);
}, [paramDirty, blockText, allowText]);
useEffect(() => {
setValuePeekRowId(null);
@@ -577,7 +360,10 @@ export default function ModelCompatPopover({
<input
type="text"
value={blockText}
onChange={(e) => editParamDraft("block", e.target.value)}
onChange={(e) => {
setBlockText(e.target.value);
setParamDirty(true);
}}
onBlur={() => saveModelParamFilters()}
placeholder={t("compatBlockedParamsPlaceholder")}
disabled={disabled}
@@ -590,22 +376,16 @@ export default function ModelCompatPopover({
"Blocked params (stripped from requests)"
)}
{paramSaving && `${t("compatSaving")}`}
{paramSaveFailed && !paramSaving && (
<span
role="alert"
className="ml-1 font-medium text-red-600 dark:text-red-400"
title={t("failedSaveConnectionRetry")}
>
{t("failed")}
</span>
)}
</p>
</div>
<div>
<input
type="text"
value={allowText}
onChange={(e) => editParamDraft("allow", e.target.value)}
onChange={(e) => {
setAllowText(e.target.value);
setParamDirty(true);
}}
onBlur={() => saveModelParamFilters()}
placeholder={t("compatAllowedParamsPlaceholder")}
disabled={disabled}

View File

@@ -1,208 +0,0 @@
// @vitest-environment jsdom
// Regression coverage for the concurrency defects found while fixing #8910:
// 1. an edit landing after the PUT payload snapshot but before the PUT resolves must still
// be persisted (lost update);
// 2. a save that failed must not be silently reverted on reopen, and the failure must be
// visible in the panel.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models?: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects() {
await act(async () => {
for (let i = 0; i < 8; i += 1) await Promise.resolve();
});
}
function blockInput() {
return document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement | null;
}
function renderPopover() {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
}
async function openPopover() {
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
}
async function closePopoverByOutsideClick() {
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
}
describe("ModelCompatPopover param-filter save concurrency (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("persists an edit typed after the payload snapshot but before the PUT resolves", async () => {
let serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let releasePut: (() => void) | null = null;
let holdPut = false;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
serverState = JSON.parse(String(init.body)) as ParamFilterState;
if (holdPut) {
await new Promise<void>((resolve) => {
releasePut = resolve;
});
}
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
await openPopover();
await act(async () => setInputValue(blockInput()!, "temperature"));
holdPut = true;
// Blur starts the save: GET resolves, payload is snapshotted, PUT is issued and held open.
await act(async () => {
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
expect(releasePut).not.toBeNull();
// The user keeps typing while that PUT is still in flight, then closes the popover.
await act(async () => setInputValue(blockInput()!, "temperature, seed"));
await closePopoverByOutsideClick();
holdPut = false;
await act(async () => {
releasePut?.();
await Promise.resolve();
});
await flushEffects();
expect(serverState.models).toEqual({
"gpt-test": { block: ["temperature", "seed"], allow: [] },
});
});
it("keeps the draft and surfaces the failure when the save fails, instead of reverting on reopen", async () => {
const serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let putAttempts = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putAttempts += 1;
return { ok: false, status: 500, json: async () => ({}) } as Response;
}
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
await openPopover();
await act(async () => setInputValue(blockInput()!, "temperature"));
await closePopoverByOutsideClick();
expect(putAttempts).toBe(1);
// Reopening must NOT clobber the unsaved draft with server state (#8910 complaint class).
await openPopover();
expect(blockInput()!.value).toBe("temperature");
// The failure is visible to the user rather than silently swallowed.
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
// ...and the retained draft is actually retryable through the normal close path.
await closePopoverByOutsideClick();
expect(putAttempts).toBe(2);
});
it("clears the failure indicator once a later save succeeds", async () => {
let serverState: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let failNextPut = true;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
if (failNextPut) return { ok: false, status: 500, json: async () => ({}) } as Response;
serverState = JSON.parse(String(init.body)) as ParamFilterState;
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
await openPopover();
await act(async () => setInputValue(blockInput()!, "temperature"));
await act(async () => {
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
expect(document.querySelector('[role="alert"]')).not.toBeNull();
failNextPut = false;
await act(async () => setInputValue(blockInput()!, "temperature, seed"));
await act(async () => {
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
expect(document.querySelector('[role="alert"]')).toBeNull();
expect(serverState.models).toEqual({
"gpt-test": { block: ["temperature", "seed"], allow: [] },
});
});
});

View File

@@ -1,137 +0,0 @@
// @vitest-environment jsdom
// Regression coverage for #8910: sibling model-row popovers for the same provider must serialize
// their whole-document param-filter updates so one successful save cannot erase the other.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
type ParamFilterState = {
block: string[];
allow: string[];
models?: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
};
let container: HTMLDivElement;
let root: Root;
let releaseFirstPut: (() => void) | null;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 60) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
releaseFirstPut = null;
});
afterEach(async () => {
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("preserves both model updates when sibling popovers save the same provider concurrently", async () => {
let server: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let putCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putCount += 1;
const body = JSON.parse(String(init.body)) as ParamFilterState;
if (putCount === 1) {
await new Promise<void>((resolve) => {
releaseFirstPut = resolve;
});
}
server = { ...body, models: body.models ?? {} };
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
const props = (modelId: string) => ({
t: (key: string) => key,
providerId: "openai",
modelId,
effectiveModelNormalize: () => false,
effectiveModelPreserveDeveloper: () => true,
getUpstreamHeadersRecord: () => ({}),
onCompatPatch: vi.fn(),
});
act(() => {
root.render(
<div>
<div id="model-a">
<ModelCompatPopover {...props("model-a")} />
</div>
<div id="model-b">
<ModelCompatPopover {...props("model-b")} />
</div>
</div>
);
});
const triggerA = container.querySelector("#model-a button") as HTMLButtonElement;
const triggerB = container.querySelector("#model-b button") as HTMLButtonElement;
const blockInput = () =>
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
await act(async () => triggerA.click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "aaa"));
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
expect(releaseFirstPut).not.toBeNull();
await act(async () => {
triggerB.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
await act(async () => triggerB.click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "bbb"));
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
await flushEffects();
expect(server.models).toEqual({
"model-a": { block: ["aaa"], allow: [] },
"model-b": { block: ["bbb"], allow: [] },
});
});

View File

@@ -1,192 +0,0 @@
// @vitest-environment jsdom
// Regression coverage for the cross-target write defect found while fixing #8910.
//
// ModelCompatPopover instances are not always keyed by a stable identity (CompatibleModelsSection
// keys by `${alias}:${modelId}`, PassthroughModelsSection by the full model string, and providerId
// is threaded from route/page state), so a re-render can re-point a LIVE, mounted popover at a
// different provider/model. When the draft for the old target failed to save, the save callback —
// now bound to the new target — used to PUT the old draft into the new target's config,
// destructively overwriting a model/provider the user never edited.
//
// Contract asserted here: a write always lands on the provider/model the draft was typed for, and
// an orphaned draft whose target is no longer displayed is preserved (retried later) rather than
// silently dropped or redirected.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
function blockInput() {
return document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement | null;
}
function renderPopover(props: { providerId: string; modelId: string }) {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId={props.providerId}
modelId={props.modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
}
async function openPopover() {
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
}
async function closePopoverByOutsideClick() {
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
}
describe("ModelCompatPopover param-filter cross-target writes (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
try {
act(() => root.unmount());
} catch {
// already unmounted by the test
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("never writes a draft typed for one model under a different model", async () => {
const server: ParamFilterState = {
block: [],
allow: [],
models: { "model-b": { block: ["bval"], allow: [] } },
autoLearn: false,
};
const puts: { url: string; models?: Record<string, unknown> }[] = [];
let getFails = true;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (init?.method === "PUT") {
const body = JSON.parse(String(init.body));
puts.push({ url, models: body.models });
server.models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
if (getFails) return { ok: false, status: 503, json: async () => ({}) } as Response;
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
renderPopover({ providerId: "openai", modelId: "model-a" });
await openPopover();
// The load failed, so the fields are empty; the user types a draft for model-a.
await act(async () => setInputValue(blockInput()!, "aaa"));
// A re-render re-points this live popover at model-b while model-a's draft is still dirty.
// The close-time save for model-a runs here and fails (its GET is still 503).
renderPopover({ providerId: "openai", modelId: "model-b" });
await flushEffects();
// The network recovers and the popover closes: the retried save must target model-a.
getFails = false;
await closePopoverByOutsideClick();
// model-b's real server config is untouched...
expect(server.models["model-b"]).toEqual({ block: ["bval"], allow: [] });
// ...and the orphaned model-a draft is not silently dropped either — it lands on model-a.
expect(server.models["model-a"]).toEqual({ block: ["aaa"], allow: [] });
expect(puts.every((p) => p.url.includes("/openai/"))).toBe(true);
});
it("never writes a draft typed for one provider under a different provider", async () => {
const byProvider: Record<string, ParamFilterState> = {
alpha: { block: [], allow: [], models: {}, autoLearn: false },
beta: {
block: [],
allow: [],
models: { "gpt-test": { block: ["betaval"], allow: [] } },
autoLearn: false,
},
};
const puts: { providerId: string; models?: Record<string, unknown> }[] = [];
let getFails = true;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const providerId = url.match(/providers\/([^/]+)\//)![1];
if (init?.method === "PUT") {
const body = JSON.parse(String(init.body));
puts.push({ providerId, models: body.models });
byProvider[providerId].models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
if (getFails) return { ok: false, status: 503, json: async () => ({}) } as Response;
return { ok: true, json: async () => structuredClone(byProvider[providerId]) } as Response;
})
);
renderPopover({ providerId: "alpha", modelId: "gpt-test" });
await openPopover();
await act(async () => setInputValue(blockInput()!, "alpha-only"));
// Re-point the live popover at provider beta while alpha's draft is dirty and its save fails.
renderPopover({ providerId: "beta", modelId: "gpt-test" });
await flushEffects();
getFails = false;
await closePopoverByOutsideClick();
// beta must receive no write at all; its stored config survives intact.
expect(puts.filter((p) => p.providerId === "beta")).toEqual([]);
expect(byProvider.beta.models).toEqual({ "gpt-test": { block: ["betaval"], allow: [] } });
// The alpha draft is preserved and eventually persisted under alpha.
expect(byProvider.alpha.models).toEqual({ "gpt-test": { block: ["alpha-only"], allow: [] } });
});
});

View File

@@ -1,186 +0,0 @@
// @vitest-environment jsdom
// Regression coverage for the load-effect clobber defects found while fixing #8910:
// 1. a draft typed while the initial load GET is still in flight must survive the load
// result and still be persisted on close (otherwise keystrokes vanish silently);
// 2. after a failed initial load, the retained draft must not be destroyed by the next
// successful reopen load — that reopen is the user's retry.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models?: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 12) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
function blockInput() {
return document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement | null;
}
function renderPopover() {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
}
async function openPopover() {
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
}
async function closePopoverByOutsideClick() {
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
}
describe("ModelCompatPopover param-filter load clobber (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
try {
act(() => root.unmount());
} catch {
// already unmounted by the test
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("keeps and saves a draft typed while the initial load GET is still in flight", async () => {
const serverState: ParamFilterState = {
block: [],
allow: [],
models: { "gpt-test": { block: ["old"], allow: [] } },
autoLearn: false,
};
const puts: ParamFilterState[] = [];
let releaseGet: (() => void) | null = null;
let heldFirstGet = false;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
puts.push(JSON.parse(String(init.body)) as ParamFilterState);
return { ok: true, json: async () => ({ success: true }) } as Response;
}
if (!heldFirstGet) {
heldFirstGet = true;
await new Promise<void>((resolve) => {
releaseGet = resolve;
});
}
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
// The field is already rendered while the load GET is still pending — the user types into it.
await act(async () => setInputValue(blockInput()!, "temperature"));
await act(async () => {
releaseGet?.();
await Promise.resolve();
});
await flushEffects();
// The load result must not overwrite the dirty draft.
expect(blockInput()!.value).toBe("temperature");
await closePopoverByOutsideClick();
expect(puts.length).toBe(1);
expect(puts[0]?.models).toEqual({ "gpt-test": { block: ["temperature"], allow: [] } });
});
it("does not clobber a retained draft with the successful reopen load after a failed initial load", async () => {
const serverState: ParamFilterState = {
block: [],
allow: [],
models: { "gpt-test": { block: ["serverval"], allow: [] } },
autoLearn: false,
};
const puts: ParamFilterState[] = [];
let failGet = true;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
puts.push(JSON.parse(String(init.body)) as ParamFilterState);
return { ok: true, json: async () => ({ success: true }) } as Response;
}
if (failGet) return { ok: false, status: 503, json: async () => ({}) } as Response;
return { ok: true, json: async () => structuredClone(serverState) } as Response;
})
);
renderPopover();
// First open: the load GET fails, so nothing is loaded and the field stays empty.
await openPopover();
expect(blockInput()!.value).toBe("");
await act(async () => setInputValue(blockInput()!, "temperature"));
// Close: the save's own GET still fails, so the draft is retained and flagged as failed.
await closePopoverByOutsideClick();
expect(puts.length).toBe(0);
// The network recovers and the user reopens the popover to retry the save.
failGet = false;
await openPopover();
expect(blockInput()!.value).toBe("temperature");
// The unsaved-state indicator must still be visible — nothing was persisted.
expect(document.querySelector('[role="alert"]')).not.toBeNull();
await closePopoverByOutsideClick();
expect(puts.length).toBe(1);
expect(puts[0]?.models).toEqual({ "gpt-test": { block: ["temperature"], allow: [] } });
});
});

View File

@@ -1,109 +0,0 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
it("preserves a new target edited while the older target save is in flight", async () => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let server = {
block: [] as string[],
allow: [] as string[],
models: {} as Record<string, { block: string[]; allow: string[] }>,
autoLearn: false,
};
let releaseFirstPut: (() => void) | null = null;
let putCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putCount += 1;
const body = JSON.parse(String(init.body)) as typeof server;
if (putCount === 1) {
await new Promise<void>((resolve) => {
releaseFirstPut = resolve;
});
}
server = body;
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
const renderFor = (modelId: string) => {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId={modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
};
const blockInput = () =>
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
const blurBlock = async () => {
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
};
renderFor("model-a");
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "aaa"));
await blurBlock();
expect(releaseFirstPut).not.toBeNull();
renderFor("model-b");
await flushEffects();
expect(blockInput().value).toBe("");
await act(async () => setInputValue(blockInput(), "bbb"));
await blurBlock();
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
await flushEffects();
expect(server.models).toEqual({
"model-a": { block: ["aaa"], allow: [] },
"model-b": { block: ["bbb"], allow: [] },
});
expect(document.querySelector('[role="alert"]')).toBeNull();
act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});

View File

@@ -1,110 +0,0 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
it("does not lose the new target when unmounted during the older target save", async () => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let server = {
block: [] as string[],
allow: [] as string[],
models: {} as Record<string, { block: string[]; allow: string[] }>,
autoLearn: false,
};
let releaseFirstPut: (() => void) | null = null;
let putCount = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
putCount += 1;
const body = JSON.parse(String(init.body)) as typeof server;
if (putCount === 1) {
await new Promise<void>((resolve) => {
releaseFirstPut = resolve;
});
}
server = body;
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
const renderFor = (modelId: string) => {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId={modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
};
const blockInput = () =>
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
const blurBlock = async () => {
await act(async () => {
blockInput().dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
};
renderFor("model-a");
await act(async () => (container.querySelector("button") as HTMLButtonElement).click());
await flushEffects();
await act(async () => setInputValue(blockInput(), "aaa"));
await blurBlock();
expect(releaseFirstPut).not.toBeNull();
renderFor("model-b");
await flushEffects();
await act(async () => setInputValue(blockInput(), "bbb"));
await blurBlock();
// Every close/unmount save is rejected while model-a owns paramSavingRef. The active save took
// its key snapshot before model-b existed, so model-b has no later save scheduled.
act(() => root.unmount());
await act(async () => {
releaseFirstPut?.();
await Promise.resolve();
});
await flushEffects();
expect(server.models).toEqual({
"model-a": { block: ["aaa"], allow: [] },
"model-b": { block: ["bbb"], allow: [] },
});
document.body.innerHTML = "";
vi.unstubAllGlobals();
});

View File

@@ -1,261 +0,0 @@
// @vitest-environment jsdom
// Regression coverage for the two target-re-point defects found while fixing #8910.
//
// A ModelCompatPopover instance is not always keyed by a stable identity, so a re-render can
// re-point a LIVE, still-open popover at a different provider/model while a draft for the previous
// target is unsaved. Two failures followed from that:
//
// 1. (C10) The inputs are driven by blockText/allowText, which used to be written only by the
// load effect — and that effect early-returned whenever a draft was dirty. Re-pointing
// A -> B -> A therefore left B's server values on screen under A, and the next keystroke
// snapshotted them into A's draft, persisting B's content into A's entry.
// 2. (C11) The pending draft lived in a single slot that every edit overwrote, so typing into
// the new target destroyed the previous target's unsaved work, and the new target's
// successful save cleared the failure indicator — a green UI over data never written.
//
// Contract asserted here: the fields always show the displayed target's own content (its pending
// draft when it has one), never another target's; and every target's unsaved draft survives until
// it is actually persisted to its own provider/model.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects(rounds = 40) {
await act(async () => {
for (let i = 0; i < rounds; i += 1) await Promise.resolve();
});
}
function blockInput() {
return document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement | null;
}
function renderPopover(modelId: string) {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId={modelId}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
}
// React 19 delegates onBlur through focusout — a bare "blur" event does not reach the handler.
async function blurBlockInput() {
await act(async () => {
blockInput()!.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
});
await flushEffects();
}
describe("ModelCompatPopover param-filter target re-point (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
try {
act(() => root.unmount());
} catch {
// already unmounted by the test
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("restores the dirty draft into the fields on return, instead of showing the other model's values", async () => {
const server: ParamFilterState = {
block: [],
allow: [],
models: { "model-b": { block: ["secret-b"], allow: [] } },
autoLearn: false,
};
let putFails = true;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
if (putFails) return { ok: false, status: 500, json: async () => ({}) } as Response;
const body = JSON.parse(String(init.body));
server.models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
renderPopover("model-a");
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
// model-a has no server entry, so the field loads empty; the user types a draft whose save fails.
await act(async () => setInputValue(blockInput()!, "aaa"));
await blurBlockInput();
expect(blockInput()!.value).toBe("aaa");
// The live popover is re-pointed at model-b, which does have a server value...
renderPopover("model-b");
await flushEffects();
expect(blockInput()!.value).toBe("secret-b");
// ...and back to model-a, whose draft is still unsaved: the field must show model-a's draft.
renderPopover("model-a");
await flushEffects();
expect(blockInput()!.value).toBe("aaa");
// The user appends to what they can see and closes; the write must stay inside model-a.
putFails = false;
await act(async () => setInputValue(blockInput()!, `${blockInput()!.value}, extra`));
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
expect(server.models["model-a"]).toEqual({ block: ["aaa", "extra"], allow: [] });
expect(JSON.stringify(server.models["model-a"])).not.toContain("secret-b");
expect(server.models["model-b"]).toEqual({ block: ["secret-b"], allow: [] });
});
it("keeps one model's unsaved draft alive while the user edits another model", async () => {
const server: ParamFilterState = { block: [], allow: [], models: {}, autoLearn: false };
let putFails = true;
vi.stubGlobal(
"fetch",
vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.method === "PUT") {
if (putFails) return { ok: false, status: 500, json: async () => ({}) } as Response;
const body = JSON.parse(String(init.body));
server.models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(server) } as Response;
})
);
renderPopover("model-a");
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
await act(async () => setInputValue(blockInput()!, "aaa"));
await blurBlockInput();
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
// Re-pointed at model-b; the network recovers and the user edits model-b.
renderPopover("model-b");
await flushEffects();
putFails = false;
await act(async () => setInputValue(blockInput()!, "bbb"));
await blurBlockInput();
// model-a's draft must not have been destroyed by the model-b edit: both are persisted...
expect(server.models["model-a"]).toEqual({ block: ["aaa"], allow: [] });
expect(server.models["model-b"]).toEqual({ block: ["bbb"], allow: [] });
// ...and with nothing left unsaved anywhere the failure indicator is finally cleared.
expect(document.querySelector('[role="alert"]')).toBeNull();
});
it("does not report success while another target's draft is still unsaved", async () => {
const byProvider: Record<string, ParamFilterState> = {
alpha: { block: [], allow: [], models: {}, autoLearn: false },
beta: { block: [], allow: [], models: {}, autoLearn: false },
};
let failing = new Set(["alpha"]);
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const providerId = url.match(/providers\/([^/]+)\//)![1];
if (init?.method === "PUT") {
if (failing.has(providerId)) {
return { ok: false, status: 503, json: async () => ({}) } as Response;
}
const body = JSON.parse(String(init.body));
byProvider[providerId].models = body.models ?? {};
return { ok: true, json: async () => ({ success: true }) } as Response;
}
return { ok: true, json: async () => structuredClone(byProvider[providerId]) } as Response;
})
);
const renderFor = (providerId: string) => {
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId={providerId}
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
};
renderFor("alpha");
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
await act(async () => setInputValue(blockInput()!, "alpha-draft"));
await blurBlockInput();
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
// beta saves fine, but alpha is still broken: the indicator must stay up.
renderFor("beta");
await flushEffects();
await act(async () => setInputValue(blockInput()!, "beta-draft"));
await blurBlockInput();
expect(byProvider.beta.models).toEqual({ "gpt-test": { block: ["beta-draft"], allow: [] } });
expect(byProvider.alpha.models).toEqual({});
expect(document.querySelector('[role="alert"]')?.textContent).toContain("failed");
// Once alpha recovers, the preserved draft is written to alpha and the indicator clears.
failing = new Set();
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
expect(byProvider.alpha.models).toEqual({ "gpt-test": { block: ["alpha-draft"], allow: [] } });
});
});

View File

@@ -1,146 +0,0 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelCompatPopover from "../ModelCompatPopover";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
interface ParamFilterState {
block: string[];
allow: string[];
models: Record<string, { block: string[]; allow: string[] }>;
autoLearn: boolean;
}
let container: HTMLDivElement;
let root: Root;
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function flushEffects() {
await act(async () => {
await Promise.resolve();
});
}
async function openPopover() {
const trigger = container.querySelector("button") as HTMLButtonElement;
await act(async () => trigger.click());
await flushEffects();
}
describe("ModelCompatPopover model param filters (#8910)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("persists the latest block and allow drafts when an outside mousedown closes the popover", async () => {
const apiPath = "/api/providers/openai/param-filters";
let serverState: ParamFilterState = {
block: ["provider-block"],
allow: ["provider-allow"],
models: {
"other-model": { block: ["keep-block"], allow: ["keep-allow"] },
},
autoLearn: true,
};
const putBodies: unknown[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) !== apiPath) throw new Error(`Unexpected param-filter URL: ${input}`);
if (init?.method === "PUT") {
const body = JSON.parse(String(init.body)) as ParamFilterState;
putBodies.push(body);
serverState = body;
}
return {
ok: true,
json: async () => structuredClone(serverState),
} as Response;
});
vi.stubGlobal("fetch", fetchMock);
act(() => {
root.render(
<ModelCompatPopover
t={(key) => key}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
onCompatPatch={vi.fn()}
/>
);
});
await openPopover();
const blockInput = document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement;
const allowInput = document.querySelector(
'input[placeholder="compatAllowedParamsPlaceholder"]'
) as HTMLInputElement;
await act(async () => {
setInputValue(blockInput, "temperature, top_p");
setInputValue(allowInput, "tools, response_format");
});
await act(async () => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
});
await flushEffects();
expect(
document.querySelector('input[placeholder="compatBlockedParamsPlaceholder"]')
).toBeNull();
expect(putBodies).toEqual([
{
block: ["provider-block"],
allow: ["provider-allow"],
models: {
"other-model": { block: ["keep-block"], allow: ["keep-allow"] },
"gpt-test": {
block: ["temperature", "top_p"],
allow: ["tools", "response_format"],
},
},
autoLearn: true,
},
]);
await openPopover();
expect(
(
document.querySelector(
'input[placeholder="compatBlockedParamsPlaceholder"]'
) as HTMLInputElement
).value
).toBe("temperature, top_p");
expect(
(
document.querySelector(
'input[placeholder="compatAllowedParamsPlaceholder"]'
) as HTMLInputElement
).value
).toBe("tools, response_format");
});
});

View File

@@ -177,8 +177,6 @@ describe("phase-1d extractions (#3501)", () => {
const c = renderComponent(
<ModelCompatPopover
t={(k: string) => k}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
@@ -192,8 +190,6 @@ describe("phase-1d extractions (#3501)", () => {
const c = renderComponent(
<ModelCompatPopover
t={(k: string) => k}
providerId="openai"
modelId="gpt-test"
effectiveModelNormalize={() => true}
effectiveModelPreserveDeveloper={() => false}
getUpstreamHeadersRecord={() => ({ "X-Custom": "value" })}

View File

@@ -123,7 +123,7 @@ export default function EditConnectionModal({
accountId: "",
codexReasoningEffort: "medium",
codexServiceTier: "default" as CodexServiceTier,
openaiResponsesStoreEnabled: false,
codexOpenaiStoreEnabled: false,
preserveEncryptedReasoning: false,
consoleApiKey: "",
newApiUserId: "",
@@ -330,7 +330,7 @@ export default function EditConnectionModal({
accountId: existingAccountId,
codexReasoningEffort: codexRequestDefaults.reasoningEffort,
codexServiceTier: codexRequestDefaults.serviceTier ?? "default",
openaiResponsesStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
preserveEncryptedReasoning:
connection.providerSpecificData?.preserveEncryptedReasoning === true,
consoleApiKey: existingConsoleApiKey,
@@ -634,6 +634,8 @@ export default function EditConnectionModal({
? { serviceTier: formData.codexServiceTier }
: {}),
};
updates.providerSpecificData.openaiStoreEnabled =
formData.codexOpenaiStoreEnabled === true;
}
if (isAntigravityFamily) {
updates.providerSpecificData.projectId = trimmedCloudCodeProjectId || null;
@@ -660,8 +662,6 @@ export default function EditConnectionModal({
if (isResponsesConnection && updates.providerSpecificData) {
updates.providerSpecificData.preserveEncryptedReasoning =
formData.preserveEncryptedReasoning === true;
updates.providerSpecificData.openaiStoreEnabled =
formData.openaiResponsesStoreEnabled === true;
}
const freeOnlyChanged =
showFreeModelsToggle &&
@@ -704,16 +704,6 @@ export default function EditConnectionModal({
)}
/>
) : null;
const openaiResponsesStoreToggle = isResponsesConnection ? (
<Toggle
checked={formData.openaiResponsesStoreEnabled}
onChange={(checked) =>
setFormData({ ...formData, openaiResponsesStoreEnabled: checked })
}
label={t("openaiResponsesStoreLabel")}
description={t("openaiResponsesStoreDescription")}
/>
) : null;
return (
<Modal isOpen={isOpen} title={t("editConnection")} onClose={onClose}>
<div className="flex flex-col gap-4">
@@ -769,6 +759,12 @@ export default function EditConnectionModal({
"Default uses the normal Codex tier. Priority shows as Fast; Flex uses the flex service tier when available."
)}
/>
<Toggle
checked={formData.codexOpenaiStoreEnabled}
onChange={(checked) => setFormData({ ...formData, codexOpenaiStoreEnabled: checked })}
label={t("openaiResponsesStoreLabel")}
description={t("openaiResponsesStoreDescription")}
/>
</div>
)}
{isClaude && (
@@ -802,7 +798,6 @@ export default function EditConnectionModal({
/>
)}
{preserveEncryptedReasoningToggle}
{openaiResponsesStoreToggle}
<Toggle
checked={formData.disableCooling}
onChange={(checked) => setFormData({ ...formData, disableCooling: checked })}

View File

@@ -4,31 +4,29 @@ import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
// Dedicated i18n keys — do NOT reuse settings.auto / autoDesc (those are routing
// "Auto Combo" strings and previously made Thinking Budget look like a routing mode).
const MODES = [
{
value: "passthrough",
labelKey: "thinkingModePassthrough",
descKey: "thinkingModePassthroughDesc",
labelKey: "passthrough",
descKey: "passthroughDesc",
icon: "arrow_forward",
},
{
value: "auto",
labelKey: "thinkingModeAuto",
descKey: "thinkingModeAutoDesc",
labelKey: "auto",
descKey: "autoDesc",
icon: "auto_awesome",
},
{
value: "custom",
labelKey: "thinkingModeCustom",
descKey: "thinkingModeCustomDesc",
labelKey: "custom",
descKey: "customDesc",
icon: "tune",
},
{
value: "adaptive",
labelKey: "thinkingModeAdaptive",
descKey: "thinkingModeAdaptiveDesc",
labelKey: "adaptive",
descKey: "adaptiveDesc",
icon: "trending_up",
},
];
@@ -96,9 +94,6 @@ export default function ThinkingBudgetTab() {
<div>
<h3 className="text-lg font-semibold">{t("thinkingBudgetTitle")}</h3>
<p className="text-sm text-text-muted">{t("thinkingBudgetDesc")}</p>
<p className="text-xs text-text-muted mt-1 leading-relaxed">
{t("thinkingBudgetIndependenceHint")}
</p>
</div>
{status === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">

View File

@@ -23,7 +23,6 @@ interface VisionState {
modalityBridgeVisionPrompt: string;
modalityBridgeVisionTimeout: number;
modalityBridgeVisionMaxImages: number;
modalityBridgeVisionMaxChars: number;
modalityBridgeCacheEnabled: boolean;
modalityBridgeCacheTtlMinutes: number;
modalityBridgeCacheMaxEntries: number;
@@ -40,7 +39,6 @@ function fromApi(data: Record<string, unknown>): VisionState {
modalityBridgeVisionPrompt: runtime.prompt,
modalityBridgeVisionTimeout: runtime.timeoutMs,
modalityBridgeVisionMaxImages: runtime.maxImages,
modalityBridgeVisionMaxChars: runtime.maxChars,
modalityBridgeCacheEnabled: runtime.cacheEnabled,
modalityBridgeCacheTtlMinutes: runtime.cacheTtlMinutes,
modalityBridgeCacheMaxEntries: runtime.cacheMaxEntries,
@@ -109,18 +107,6 @@ export default function ModalityBridgeVisionTab() {
void update({ [key]: value });
};
const commitMaxChars = (raw: string) => {
const parsed = Number.parseInt(raw, 10);
// 0 disables the cap and is a valid value in its own right — only values
// between 1 and 99 (below the schema's floor) get pulled up to 100.
const value =
Number.isFinite(parsed) && parsed <= 0
? 0
: clampNumber(raw, 100, 50000, MODALITY_BRIDGE_DEFAULTS.visionMaxChars);
setLocal({ modalityBridgeVisionMaxChars: value });
void update({ modalityBridgeVisionMaxChars: value });
};
return (
<Card
title={t("modalityBridgeVisionTitle")}
@@ -241,19 +227,6 @@ export default function ModalityBridgeVisionTab() {
)
}
/>
<div>
<NumberField
testId="modality-bridge-max-chars"
label={t("visionMaxCharsLabel")}
min={0}
max={50000}
placeholder="0"
value={settings.modalityBridgeVisionMaxChars}
onChange={(value) => setLocal({ modalityBridgeVisionMaxChars: value })}
onBlur={(raw) => commitMaxChars(raw)}
/>
<p className="mt-1 text-xs text-text-muted">{t("visionMaxCharsHint")}</p>
</div>
<div className="md:col-span-2">
<Toggle
checked={settings.modalityBridgeCacheEnabled}
@@ -318,19 +291,9 @@ interface NumberFieldProps {
value: number;
onChange: (value: number) => void;
onBlur: (raw: string) => void;
placeholder?: string;
}
function NumberField({
testId,
label,
min,
max,
value,
onChange,
onBlur,
placeholder,
}: NumberFieldProps) {
function NumberField({ testId, label, min, max, value, onChange, onBlur }: NumberFieldProps) {
return (
<label className="block text-sm font-medium">
{label}
@@ -339,7 +302,6 @@ function NumberField({
data-testid={testId}
min={min}
max={max}
placeholder={placeholder}
value={value}
onChange={(event) => onChange(Number.parseInt(event.currentTarget.value, 10) || 0)}
onBlur={(event) => onBlur(event.currentTarget.value)}

View File

@@ -7,7 +7,6 @@ import {
ANTHROPIC_PING_FRAME,
} from "@omniroute/open-sse/utils/earlyStreamKeepalive";
import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold";
import { resolveStreamFlag } from "@omniroute/open-sse/utils/aiSdkCompat";
let initialized = false;
@@ -55,27 +54,22 @@ async function postHandler(request: any, context: any, preParsedBody: any = null
// /v1/responses (#2544). Anthropic clients ignore SSE comments for their watchdog, so
// emit a real `event: ping` (ANTHROPIC_PING_FRAME). Non-streaming callers keep the
// verbatim path.
let body = preParsedBody;
if (body == null) {
const accept = String(request.headers?.get?.("accept") || "").toLowerCase();
if (accept.includes("text/event-stream")) {
let model;
try {
body = await request
.clone()
.json()
.catch(() => null);
const body = preParsedBody ?? (await request.clone().json().catch(() => null));
model = body?.model;
} catch {
// body unavailable / non-JSON — handleChat will return its normal validation error
// body unavailable / non-JSON — fall back to the default keepalive threshold
}
}
const accept = String(request.headers?.get?.("accept") || "");
const wantsStreaming = resolveStreamFlag(body?.stream, accept, "claude");
if (wantsStreaming) {
return await withEarlyStreamKeepalive(handleChat(request, null, body), {
return await withEarlyStreamKeepalive(handleChat(request, null, preParsedBody), {
signal: request.signal,
thresholdMs: resolveKeepaliveThreshold(body?.model),
thresholdMs: resolveKeepaliveThreshold(model),
keepaliveFrame: ANTHROPIC_PING_FRAME,
});
}
return await handleChat(request, null, body);
return await handleChat(request, null, preParsedBody);
}
export const POST = withInjectionGuard(postHandler);

View File

@@ -6,9 +6,9 @@ import {
} from "@omniroute/open-sse/utils/earlyStreamKeepalive";
import { withInjectionGuard } from "@/middleware/promptInjectionGuard";
import { resolveResponsesApiModel } from "@/app/api/internal/codex-responses-ws/modelResolution";
import { getModelInfo, getComboForModel } from "@/sse/services/model";
import { getModelInfo } from "@/sse/services/model";
import { getComboByName } from "@/lib/db/combos";
import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold";
import { resolveStreamFlag } from "@omniroute/open-sse/utils/aiSdkCompat";
// NOTE: We do NOT call initTranslators() here — the translator registry is
// bootstrapped at module level inside open-sse/translator/index.ts when it
@@ -57,7 +57,7 @@ export async function withCodexPreferredModel(
const { model, changed } = await resolveResponsesApiModel(
body.model,
getModelInfo,
async (name) => !!(await getComboForModel(name))
async (name) => !!(await getComboByName(name))
);
if (!changed) return { request, body };
@@ -92,9 +92,8 @@ async function postHandler(request: any, context: any, preParsedBody: any = null
request,
preParsedBody
);
const accept = String(request.headers?.get?.("accept") || "");
const wantsStreaming = resolveStreamFlag(resolvedBody?.stream, accept, "openai-responses");
if (wantsStreaming) {
const accept = String(request.headers?.get?.("accept") || "").toLowerCase();
if (accept.includes("text/event-stream")) {
// Adaptive threshold: web-session and anonymous-fallback providers are slower
// to produce the first byte, so use a longer keepalive threshold (15s vs 2s).
// Reuse resolvedBody.model — no extra clone/parse needed (#4041).

View File

@@ -0,0 +1,5 @@
export {
getVscodeModelDisplayName,
getVscodeModelGroupingKey,
resolveVscodeModelMetadata,
} from "@/lib/vscode/modelPresentation";

View File

@@ -6821,17 +6821,8 @@
"chars": "{count} حرف",
"thinkingBudgetTitle": "ميزانية التفكير",
"thinkingBudgetDesc": "التحكم في استخدام الرمز المميز لاستدلال الذكاء الاصطناعي عبر جميع الطلبات",
"thinkingBudgetIndependenceHint": "تستمر ميزات الضغط والتوجيه وحدود الرموز في العمل في كل وضع. الوضع التلقائي لا يعني \"إظهار التفكير تلقائيًا\" - بل يقوم بإزالة حقول التفكير الخاصة بالعميل.",
"passthrough": "العبور",
"passthroughDesc": "لا توجد تغييرات - يتحكم العميل في ميزانية التفكير",
"thinkingModePassthrough": "تمرير",
"thinkingModePassthroughDesc": "اترك سبب العميل دون تغيير (الجهد، الملخص، كتل التفكير). مطلوب لرؤية التفكير في Codex/Desktop. افتراضي.",
"thinkingModeAuto": "تلقائي (شريط)",
"thinkingModeAutoDesc": "قم بإزالة جميع حقول تفكير/تفكير العميل (reasoning, reasoning_effort, Claude thinking, Gemini thinking_config) ودع المزود يخترع القيم الافتراضية. يمكن إخفاء لوحات التفكير وكسر طلبات ملخص العميل.",
"thinkingModeCustom": "مخصص",
"thinkingModeCustomDesc": "استبدل كل طلب بميزانية ثابتة من رموز التفكير التي تحددها أدناه.",
"thinkingModeAdaptive": "تكييفي",
"thinkingModeAdaptiveDesc": "قم بتوسيع ميزانية التفكير من جهد أساسي باستخدام عدد الرسائل، والأدوات، وطول المطالبة.",
"auto": "تلقائي",
"autoDesc": "قم بتجريد كل تكوينات التفكير - دع مقدم الخدمة يقرر",
"custom": "مخصص",
@@ -8040,8 +8031,6 @@
"modalityBridgeAdvanced": "متقدم",
"modalityBridgeTimeoutMs": "مهلة (مللي ثانية)",
"modalityBridgeMaxImages": "أقصى عدد من الصور لكل طلب",
"visionMaxCharsLabel": "أقصى عدد من أحرف الوصف",
"visionMaxCharsHint": "حدد حد طول وصف نموذج الرؤية. 0 = غير محدود — ارفع للمهام التي تتطلب تفاصيل كثيفة في التعرف الضوئي على الحروف.",
"modalityBridgeCacheEnabled": "وصف التخزين المؤقت",
"modalityBridgeCacheEnabledDesc": "إعادة استخدام الأوصاف للصور المتطابقة (مفتاح SHA-256، في الذاكرة).",
"modalityBridgeCacheTtlMinutes": "مدة صلاحية التخزين المؤقت (دقائق)",
@@ -13066,7 +13055,7 @@
},
"kimiSponsorBanner": {
"foundingFriendTitle": "Kimi (Moonshot AI) هو صديق المصدر المفتوح المؤسس لـ OmniRoute",
"description": "المستخدمون الجدد يحصلون على 15% رصيد API إضافي عند أول شحن. استخدم Kimi K3 في OmniRoute عبر واجهة Kimi API الرسمية.",
"description": "استخدم Kimi K3 في OmniRoute عبر واجهة Kimi API الرسمية. استمتع بذكاء متقدم بتكلفة أقل.",
"cta": "احصل على مفتاح Kimi API",
"partnerLinkNote": "رابط شريك",
"dismissAriaLabel": "تجاهل"

Some files were not shown because too many files have changed in this diff Show More