Compare commits

..

1 Commits

Author SHA1 Message Date
Markus Hartung
9493a53edd fix(command-code): surface reasoning-only output as content when no text-delta (#10986) 2026-08-21 19:54:14 -03:00
284 changed files with 3450 additions and 6662 deletions

View File

@@ -46,7 +46,6 @@ jobs:
env:
PORT: "20128"
INJECTION_GUARD_MODE: block
REQUIRE_API_KEY: "false"
run: |
node dist/server.js > server.log 2>&1 &
echo $! > server.pid
@@ -65,20 +64,16 @@ jobs:
# those 302s as "the API accepted a schema-violating request" and the configured-off
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
# still right (operators need it); fuzzing it is not what this smoke is for.
# /api/auth/login has brute-force rate limiting: repeated failed logins return 429,
# which Schemathesis flags as rejection of schema-compliant requests.
schemathesis run docs/openapi.yaml --url http://localhost:20128 \
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
--exclude-path-regex '^/api/auth/(oidc/|login)' \
--exclude-path-regex '^/api/auth/oidc/' \
--max-examples 8 --workers 4 --checks all --max-response-time 30 \
--request-timeout 20 --suppress-health-check all --no-color
- name: Install promptfoo
run: npm install -g promptfoo@0.122.0
- name: promptfoo injection-guard (blocking)
env:
OMNIROUTE_URL: http://localhost:20128
OMNIROUTE_API_KEY: not-needed-blocked-before-upstream
run: promptfoo eval -c promptfooconfig.yaml --no-cache
run: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
- name: Stop server
if: always()
run: kill "$(cat server.pid)" || true

View File

@@ -1,89 +0,0 @@
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
FROM oven/bun:1.3.14-slim AS base
WORKDIR /app
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends \
build-essential \
python3 \
python-is-python3 \
make \
g++ \
libsecret-1-0 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# ── Builder stage (100% Bun Native Install & Build) ─────────────────────────
FROM base AS builder
WORKDIR /app
COPY . .
# Fast Bun native package install
RUN bun install --include=optional --quiet
# Compile native better-sqlite3 Node-API addon under Bun
RUN if [ -d "node_modules/better-sqlite3" ]; then \
(cd node_modules/better-sqlite3 && bunx node-gyp rebuild); \
fi
# Fetch tls-client-node native binary if script exists
RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ]; then \
bun node_modules/tls-client-node/scripts/postinstall.js || true; \
fi
# Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node)
ENV OMNIROUTE_USE_TURBOPACK=0
ARG OMNIROUTE_BASE_PATH=""
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
ARG DASHBOARD_ALLOW_EMBED=""
ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Bun native Next.js build execution
RUN bun run --quiet build
# ── Runner stage (100% Bun Native Production Runtime) ──────────────────────
FROM oven/bun:1.3.14-slim AS runner
LABEL org.opencontainers.image.title="omniroute" \
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint (Bun Native)" \
org.opencontainers.image.url="https://omniroute.online" \
org.opencontainers.image.source="https://github.com/diegosouzapw/OmniRoute" \
org.opencontainers.image.licenses="MIT"
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libsecret-1-0 \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
ENV PORT=20128
ENV HOSTNAME=0.0.0.0
ENV OMNIROUTE_MEMORY_MB=1024
ENV DATA_DIR=/app/data
RUN mkdir -p /app/data
COPY --from=builder /app/.build/next/standalone ./
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
EXPOSE 20128
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD bun healthcheck.mjs || exit 1
ENTRYPOINT ["bun", "bin/omniroute.mjs", "serve", "--no-open"]

View File

@@ -1009,19 +1009,6 @@ Full table: [Docker Guide — runtime RAM](docs/guides/DOCKER_GUIDE.md#runtime-r
> are **not supported for production**. See
> [Docker Release Channels](docs/guides/DOCKER_GUIDE.md#release-channels).
**🥟 Bun**
Standard `bun install` and global installation (`bun install -g omniroute`) are supported via Bun runtime detection:
- **Built-in `bun:sqlite`**: OmniRoute uses Bun's built-in `bun:sqlite` driver when running under Bun, falling back to `better-sqlite3` on Node.js or `sql.js`.
- **Automatic Webpack bundler selection**: Development (`bun run dev`) and production builds (`bun run build`) automatically detect Bun and disable Turbopack in favor of Webpack to prevent native V8 binding incompatibilities.
- **Dedicated Bun Dockerfile**: Multi-stage `Dockerfile.bun` for native Bun production deployments (`docker build -f Dockerfile.bun -t omniroute:bun .`).
```bash
# Install and run with Bun
bun install
bun run dev
```
**🛠️ From source**
```bash

View File

@@ -9,13 +9,10 @@ import { discoverPlugins } from "../plugins.mjs";
// (instead of string-interpolating into `execSync`) prevents a malicious plugin
// name like `foo; rm -rf ~` or `` foo`id` `` from being interpreted by the shell.
function runNpm(args) {
const isBun = Boolean(process.versions.bun);
const pm = isBun ? "bun" : "npm";
const cmdArgs = isBun && args[0] === "install" ? ["add", ...args.slice(1)] : args;
const res = spawnSync(pm, cmdArgs, { stdio: "inherit", shell: false });
const res = spawnSync("npm", args, { stdio: "inherit", shell: false });
if (res.error) throw res.error;
if (typeof res.status === "number" && res.status !== 0) {
throw new Error(`${pm} exited with code ${res.status}`);
throw new Error(`npm exited with code ${res.status}`);
}
}

View File

@@ -114,30 +114,30 @@ export function isBetterSqliteBinaryValid() {
export function npmInstallRuntime(pkgs, opts = {}) {
const cwd = ensureRuntimeDir();
// Persist to the runtime package.json (exact version) instead of --no-save so a later
// install of a sibling runtime dep (e.g. systray2 from trayRuntime.ts, which writes to the
// same runtime dir) does not prune this package as "extraneous" — that pruning otherwise
// reproduces "No SQLite driver available" after a tray install removes better-sqlite3.
// npm 12+ defaults `allowScripts` to off, silently skipping lifecycle/install
// scripts (e.g. better-sqlite3's node-gyp/prebuild-install rebuild) unless the
// package has a matching `allowScripts` entry — and still exits 0, masking the
// failure (#10713). The runtime dir is a CLI-owned, non-user package.json, so
// explicitly allowing scripts for the packages we are installing here is safe.
const npmArgs = [
"install",
...pkgs,
"--no-audit",
"--no-fund",
"--prefer-online",
"--save-exact",
...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
];
// On Windows .cmd files cannot be executed without a shell; use cmd.exe /c explicitly
// so we never set shell:true (which would propagate env and enable injection).
const isWin = platform() === "win32";
const isBun = Boolean(process.versions.bun);
let exe, args, displayCmd;
if (isBun) {
const bunArgs = ["add", ...pkgs, "--trust"];
[exe, args] = isWin ? ["cmd.exe", ["/c", "bun", ...bunArgs]] : ["bun", bunArgs];
displayCmd = `bun ${bunArgs.join(" ")}`;
} else {
const npmArgs = [
"install",
...pkgs,
"--no-audit",
"--no-fund",
"--prefer-online",
"--save-exact",
...pkgs.map((pkg) => `--allow-scripts=${pkg}`),
];
[exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
displayCmd = `npm ${npmArgs.join(" ")}`;
}
const [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
if (!opts.silent) {
process.stdout.write(`[omniroute][runtime] ${displayCmd}\n`);
process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`);
}
const res = spawnSync(exe, args, {
cwd,

View File

@@ -5,14 +5,10 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./
async function loadSqlite() {
if (process.versions.bun) {
try {
return { Database: (await import("bun:sqlite")).Database, driver: "bun:sqlite" };
} catch (bunError) {
// fall through to better-sqlite3 if bun:sqlite fails
}
return { Database: (await import("bun:sqlite")).Database };
}
try {
return { Database: (await import("better-sqlite3")).default, driver: "better-sqlite3" };
return { Database: (await import("better-sqlite3")).default };
} catch (error) {
return { error };
}
@@ -90,14 +86,12 @@ export function normalizeBunSqliteParams(params) {
export function createSqliteNativeError(error) {
const message = error instanceof Error ? error.message : String(error);
const isBun = Boolean(process.versions.bun);
const rebuildCmd = isBun ? "bun add better-sqlite3 --trust" : "npm rebuild better-sqlite3";
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
return new Error(
`better-sqlite3 native binding is incompatible with this runtime. ` +
`Run \`${rebuildCmd}\` in the OmniRoute project and try again. ` +
`Or run: omniroute runtime repair ` +
`(rebuilds into a user-writable runtime; works without a C++ toolchain).`
"better-sqlite3 native binding is incompatible with this Node.js runtime. " +
"Run `npm rebuild better-sqlite3` in the OmniRoute project and try again. " +
"Or run: omniroute runtime repair " +
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
);
}
if (
@@ -106,9 +100,10 @@ export function createSqliteNativeError(error) {
message.includes("Cannot find module 'better-sqlite3'")
) {
return new Error(
`better-sqlite3 native binding could not be found (no prebuilt addon for this platform). ` +
`Run: omniroute runtime repair ` +
`(rebuilds into a user-writable runtime; works without a C++ toolchain).`
"better-sqlite3 native binding could not be found (no prebuilt addon for this platform). " +
"This is common under `npx`, which runs a fresh, ephemeral install that never built the addon. " +
"Run: omniroute runtime repair " +
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
);
}
return error;
@@ -116,7 +111,7 @@ export function createSqliteNativeError(error) {
async function openSqliteDatabase(dbPath, options = {}) {
const loaded = await loadSqlite();
if (loaded.driver === "bun:sqlite" || (process.versions.bun && !loaded.Database)) {
if (process.versions.bun) {
if (options.fileMustExist && !fs.existsSync(dbPath)) {
throw new Error(`SQLite file does not exist: ${dbPath}`);
}

View File

@@ -94,15 +94,10 @@ export function ensureAndroidCacheDir(options = {}) {
*/
export function isFatalInstrumentationHookFailure(text) {
if (!text) return false;
// Next.js wraps ANY throw inside instrumentation.register() with the generic
// "An error occurred while loading instrumentation hook:" prefix, on every
// platform (node_modules/next/dist/server/web/globals.js). That prefix alone
// therefore cannot identify the Android/Termux cache-probe failure — a bare
// generic instrumentation error on win32/desktop would be misreported as the
// Android bug and hide the real cause. Only match when the text actually
// carries the Android platform marker that Next's getCacheDirectory() emits.
// #10028
return /Unsupported platform:\s*android/i.test(text);
return (
/Unsupported platform:\s*android/i.test(text) ||
/error occurred while loading instrumentation hook/i.test(text)
);
}
/**

View File

@@ -44,18 +44,6 @@ export function getSecureFloorForMajor(major) {
}
export function getNodeRuntimeSupport(version = process.versions.node) {
if (process.versions.bun) {
return {
nodeVersion: `bun-${process.versions.bun} (Node.js API ${version})`,
nodeCompatible: true,
reason: "supported-bun",
supportedRange: SUPPORTED_NODE_RANGE + " || Bun >=1.1.0",
supportedDisplay: SUPPORTED_NODE_DISPLAY + ", or Bun 1.1+",
recommendedVersion: `v${RECOMMENDED_NODE_VERSION}`,
minimumSecureVersion: null,
};
}
const parsed = parseNodeVersion(version);
const secureFloor = getSecureFloorForMajor(parsed.major);
const nodeCompatible = secureFloor ? compareNodeVersions(parsed, secureFloor) >= 0 : false;

View File

@@ -17,12 +17,7 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
let updateNotifier = null;
try {
updateNotifier = (await import("update-notifier")).default;
} catch {
// update-notifier is optional in pruned standalone environments
}
import updateNotifier from "update-notifier";
import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs";
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";
import { getDefaultDataDir } from "./cli/data-dir.mjs";
@@ -256,9 +251,8 @@ if (shouldProvisionStorageKey(process.argv)) {
// Register update notifier — checks npm once per 24h, notifies on exit via stderr.
const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
const _notifier = updateNotifier ? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }) : null;
const _notifier = updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 });
process.on("exit", () => {
if (!_notifier || !_notifier.update) return;
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
if (process.env.CI) return;
if (process.argv.includes("--quiet") || process.argv.includes("-q")) return;

View File

@@ -1 +0,0 @@
- **feat(providers):** let operators declare per-provider error rules through `settings.providerErrorRules` instead of patching the catalog — an operator-supplied rule for a provider is consulted before the built-in `providerRuleRegistry`, receives the raw error text, and has its declared scope/cooldown/reason actually honored end to end, for any provider (declaring the rule is the opt-in — no extra allowlist entry needed). Matches are plain case-insensitive substrings (never RegExp) and bounded to 50 rules to keep the hot path safe ([#11104](https://github.com/diegosouzapw/OmniRoute/pull/11104))

View File

@@ -1 +0,0 @@
- fix(cli): stop diagnosing every Next.js instrumentation-hook failure as the Android/Termux cache bug — only the Android "Unsupported platform: android" signal now triggers the Android hint, so a win32/desktop instrumentation error surfaces its real cause instead of a useless `mkdir -p ~/.cache` (#10028)

View File

@@ -1 +0,0 @@
- fix(command-code): route chat to the documented /provider/v1/chat/completions endpoint instead of the CLI-only /alpha/generate, which Command Code gates/blocks for external callers (#10265)

View File

@@ -1 +0,0 @@
- fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop the order-dependent flake (#10523)

View File

@@ -1 +0,0 @@
- **Static model catalog for v0-vercel-web:** seed a static catalog for the v0-vercel-web web-cookie provider (v0-1.0-md, v0-1.5-lg, v0-1.5-md) so its dashboard "Available Models" / "Import from /models" UI serves a usable list instead of falling through to the route's 400 "does not support models listing" ([#10990](https://github.com/diegosouzapw/OmniRoute/issues/10990)).

View File

@@ -1 +0,0 @@
- fix(providers): mark the blackbox provider deprecated — api.blackbox.ai returns HTTP 404 on every path variant (sweep 2026-08-21), so the public inference surface is dead and the catalog entry now carries a deprecation notice. ([#10997](https://github.com/diegosouzapw/OmniRoute/issues/10997))

View File

@@ -1 +0,0 @@
- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)

View File

@@ -1 +0,0 @@
- **fix(webhooks):** remove 3 declared-but-never-emitted events (`provider.error`, `provider.recovered`, `combo.switched`) from `WebhookEvent` — catalog now `request.completed | request.failed | quota.exceeded | test.ping`; `POST /api/webhooks` and `PUT /api/webhooks/[id]` reject ghost values with 400; OpenAPI webhook description updated across 43 locales ([11050](https://github.com/diegosouzapw/OmniRoute/pull/11050))

View File

@@ -1 +0,0 @@
- fix(providers): filter Perplexity model import to the Sonar family so Agent-API catalog ids stop surfacing as routable chat models (#11060)

View File

@@ -1 +0,0 @@
- fix(install): make the ONNX dependency chain optional so Termux/Android installs succeed again (#11095)

View File

@@ -1 +0,0 @@
- **fix(providers):** Reject silent validation degradation on provider connection patch — unknown `rateLimitOverrides` keys (e.g. a typo'd `tpm`) and empty/non-numeric values now return `400` with the rejected key list instead of being silently dropped ([#11101](https://github.com/diegosouzapw/OmniRoute/pull/11101))

View File

@@ -1 +0,0 @@
- **Autopilot suggestion counter:** the combo health autopilot summary now reports `suggestionCount` (the real number of suggested actions across all issues) instead of conflating it with link counts, while keeping `actionableCount` as a deprecated alias for backward compatibility. The `run_combo_test` action now links to the dashboard with the combo id (`/dashboard/combos?test=<comboId>`) rather than the read-only API route, so operators can actually trigger a test from the UI ([#11102](https://github.com/diegosouzapw/OmniRoute/pull/11102)).

View File

@@ -1 +0,0 @@
- **Config audit persistence:** persist the configuration audit trail to SQLite (`config_audit_log`) instead of an in-memory buffer capped at 1000 volatile entries, and bound its growth with `cleanupConfigAudit()` driven by the `retention.configAudit` setting (default 30 days), wired into `runAutoCleanup` ([#11103](https://github.com/diegosouzapw/OmniRoute/pull/11103)).

View File

@@ -1 +0,0 @@
- fix(sse): resume mid-stream recovery after a _completed_ tool call — `finish_reason: "tool_calls"` is now tracked per-call instead of as a general terminal marker, so truncation of trailing prose after a fully-delivered tool call is recoverable while in-flight calls stay blocked ([#11109](https://github.com/diegosouzapw/OmniRoute/pull/11109))

View File

@@ -1 +0,0 @@
- **fix(providers):** `reasoning_effort` now learns the accepted values from a provider's own 400/422 response and clamps to the highest one instead of forwarding an unsupported `xhigh`/`max` (or a hardcoded `"high"` fallback) — fixes custom OpenAI-compatible connections and registered providers with no reasoning metadata ([#11116](https://github.com/diegosouzapw/OmniRoute/pull/11116)) — thanks @maxmad64bis

View File

@@ -1 +0,0 @@
- **fix(sse):** parallel `function_call` items in a Responses API stream (e.g. several tool calls dispatched in the same turn) now each get a stable, distinct `index`/`id` when translated to Chat Completions streaming deltas, instead of colliding on index 0 and tripping strict stream parsers with `Expected 'id' to be a string.` ([#11144](https://github.com/diegosouzapw/OmniRoute/pull/11144))

View File

@@ -1 +0,0 @@
- fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864)

View File

@@ -1 +0,0 @@
- fix(ssrf): make `getProviderOutboundGuard()` (used for search-provider connection validation, image generation and remote image fetch) honor the local-first default `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` the same way the chat validation guard already does, so a LAN-hosted SearXNG/Brave search provider works with only the LOCAL flag set instead of silently requiring `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS` ([#9123](https://github.com/diegosouzapw/OmniRoute/issues/9123)).

View File

@@ -1 +0,0 @@
- fix(i18n): complete Vietnamese translations for recently added UI strings (#9985)

View File

@@ -1,3 +0,0 @@
- fix(api): repair broken `@/lib/db/connections` import in the usage utilization route that failed the production build (#10939 follow-up)
- chore(docs): regenerate PROVIDER_REFERENCE and refresh README diagram SVGs to the real provider count (347)
- chore(lint): prune ESLint suppressions orphaned on the release branch

View File

@@ -1 +0,0 @@
- **test(db):** replace three empty `test.skip` placeholders in the critical DB-state suite with real assertions — `resetDbInstance` must swap the singleton while the on-disk row survives, the on-disk DB must open in WAL journal mode, and `db_meta` must hold the seeded `schema_version` — so a regression in any of those invariants can no longer pass as silently green ([#10906](https://github.com/diegosouzapw/OmniRoute/pull/10906))

View File

@@ -1 +0,0 @@
- fix(quality): rebaseline file-size for modelCapabilities.ts (1016->1072) drift from merged tip fixes (#11034 et al)

View File

@@ -1 +0,0 @@
- fix(quality): register `tests/unit/authz/oauth-autoimport-local-only.test.ts` in stryker `tap.testFiles` (residual of #11053)

View File

@@ -1 +0,0 @@
- fix(i18n): translate the 14 `providers.harImport*` keys into Vietnamese (parity gap left by #11069)

View File

@@ -1,6 +1,9 @@
{
"open-sse/services/payloadRules.ts": {
"TS2677": 1
},
"src/app/(dashboard)/dashboard/HomePageClient.tsx": {
"TS2339": 10
"TS2339": 16
},
"src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": {
"TS2503": 3
@@ -117,6 +120,10 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx": {
"TS2741": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": {
"TS2345": 3,
"TS2322": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
"TS2322": 2
},
@@ -134,6 +141,12 @@
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
"TS2322": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts": {
"TS2339": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": {
"TS2339": 15
},
@@ -177,6 +190,9 @@
"src/lib/combos/builderDraft.ts": {
"TS2741": 1
},
"src/lib/providers/codexFastTier.ts": {
"TS2367": 1
},
"src/lib/services/htmlRewriter.ts": {
"TS2322": 2,
"TS2345": 2
@@ -203,7 +219,14 @@
"src/shared/hooks/useElectron.ts": {
"TS2339": 19
},
"src/shared/providers/webSessionCredentials.ts": {
"TS2353": 1,
"TS2322": 1
},
"src/shared/schemas/cliCatalog.ts": {
"TS2554": 2
},
"src/shared/services/opencodeConfig.ts": {
"TS2345": 1
}
}

View File

@@ -443,14 +443,13 @@
"src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1250
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1080,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1067,
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
"src/shared/components/ModelSelectModal.tsx": 1138,
"src/shared/constants/providers/apikey/gateways.ts": 1298,
"open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387,
"_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).",
"src/lib/modelCapabilities.ts": 1072,
"_rebaseline_2026_08_21_11034_effort_variants": "DRIFT do tip (base-red #9985): modelCapabilities.ts 1016->1072 (+56) acumulado por PRs ja mergeadas no release/v3.8.50 — principalmente #11034 (resolve effort-variant capabilities a partir do modelo base), alem de #10963/#11040/#10987 growth dos catalogos. Tip puro ficou vermelho neste gate; rebaseline no tip por push direto (owner pre-autorizou crescimento legitimo). Nao tocou no arquivo da #11038.",
"src/lib/modelCapabilities.ts": 1016,
"src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
"open-sse/config/imageRegistry.ts": 1034,
"src/sse/handlers/chatHelpers.ts": 1019,
@@ -459,8 +458,7 @@
"open-sse/executors/commandCode.ts": 1059,
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
"_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts."
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming)."
},
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",

View File

@@ -1,4 +1,11 @@
{
"open-sse/handlers/chatCore/clientUsageBuffer.ts": {
"TS2345": 2
},
"open-sse/utils/stream.ts": {
"TS2345": 2,
"TS2322": 2
},
"src/lib/guardrails/videoBridgeHelpers.ts": {
"TS2488": 1,
"TS2365": 2,

View File

@@ -448,14 +448,14 @@ classification rules pick the fallback `reason` and lock `scope`
Classification rules only see full error **text** (needed to match body
markers like `额度不足`) for providers listed in the `FULL_TEXT_RULE_PROVIDERS`
allowlist in `providerErrorRules.ts` — currently only `"agentrouter"`. For
every other **built-in catalog** provider, `checkFallbackError` hands
`getProviderErrorRuleMatch` only the structured error (`{code, type}`), which
is enough for header/status/code-based rules but blind to body-text markers.
The helper `resolveRuleMatchBody()` performs this selection: full error text
for allowlisted providers, the structured error otherwise. Adding a
**built-in** provider to `FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider
opt-in — it exists so that the default path for every provider not on the
list stays byte-for-byte unchanged.
every other provider, `checkFallbackError` hands `getProviderErrorRuleMatch`
only the structured error (`{code, type}`), which is enough for
header/status/code-based rules but blind to body-text markers. The helper
`resolveRuleMatchBody()` performs this selection: full error text for
allowlisted providers, the structured error otherwise. Adding a provider to
`FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider opt-in — it exists so
that the default path for every provider not on the list stays
byte-for-byte unchanged.
A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in
from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as
@@ -466,31 +466,6 @@ honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota
errors" above for what a `scope: "connection"` match actually does once a
provider is on that allowlist.
**#11104 — operator-declared rules bypass both allowlists.** An operator can
declare a per-provider rule at runtime via `settings.providerErrorRules`
(`open-sse/config/providerErrorRules.ts::setOperatorProviderErrorRules`)
without editing this file. Gating an operator rule behind
`FULL_TEXT_RULE_PROVIDERS`/`HONORS_RULE_LOCK_SCOPE_PROVIDERS` — allowlists
meant to protect the **default** behavior of built-in catalog rules — would
make the settings mechanism inert for every provider except the ones already
listed there, since declaring the rule is already the operator's explicit
opt-in. `resolveRuleMatchBody()` and `honorsRuleLockScope()` both check
`hasOperatorRuleForProvider()` first: a provider with an operator rule gets
the raw error text and has its declared `scope` honored, regardless of
whether it also appears in either allowlist.
**Known gap — `providerRuleRegistry` is never consulted for HTTP 400.**
`checkFallbackError`'s `BAD_REQUEST` branch classifies status 400 entirely
through its own pattern arrays (`MODEL_ACCESS_DENIED_PATTERNS`,
`CONTEXT_OVERFLOW_PATTERNS`, etc. in `accountFallback.ts`) and returns before
the `configuredRule`/`getProviderErrorRuleMatch` branch above it is reached.
A built-in catalog rule (or an operator rule) with `status: 400` is
syntactically valid but will never fire. No existing rule targets 400 today,
so nothing in production is affected — but a future 400 rule needs this
branch touched first, which is a larger change than adding a rule (it
reclassifies 400 for every provider already relying on the pattern-array
behavior) and is out of scope for a single-provider rule addition.
### Adding a new quota-misstating gateway
1. Register one rule array in `statusRestatementRegistry`

View File

@@ -171,7 +171,6 @@ export const HTTP_STATUS = {
FORBIDDEN: 403,
NOT_FOUND: 404,
NOT_ACCEPTABLE: 406,
UNPROCESSABLE_ENTITY: 422,
REQUEST_TIMEOUT: 408,
GONE: 410,
RATE_LIMITED: 429,
@@ -264,17 +263,11 @@ export const PROVIDER_PROFILES = {
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)
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
),
backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT", 3),
},
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
// Not yet wired into getProviderProfile() — will be used when local provider_nodes

View File

@@ -1,16 +0,0 @@
/**
* Models declared identically in both the `opencode-zen` and `opencode-go` provider
* registries (same upstream family, opencode.ai/zen/*). Mirrors the GLM_SHARED_MODELS
* pattern in glmProvider.ts: one array, spread into each sibling RegistryEntry, so a
* metadata fix (targetFormat, supportsReasoning, ...) only has to land in one file
* instead of drifting out of sync across registries.
*
* Only entries that are byte-identical across both registries belong here — a model
* with tier-specific flags (e.g. go's effort variants, or a flag only one tier needs)
* stays local to that registry's own `models` array.
*/
export const OPENCODE_ZEN_GO_SHARED_MODELS = Object.freeze([
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
]);

View File

@@ -30,63 +30,21 @@ export type ProviderErrorRule = {
export type ProviderErrorRuleMatch = {
reason: ConfiguredErrorReason;
/**
* Intended lock scope. #10334: for a BUILT-IN catalog rule, this field is
* CONSUMED end-to-end only for providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS`
* (agentrouter-exclusive today, gated by `honorsRuleLockScope()`) — for those,
* `checkFallbackError` surfaces it as `ruleScope` on its return value for the
* persistence layer to honor instead of re-deriving scope from
* `hasPerModelQuota()`. For every other built-in-rule provider it remains
* INFORMATIONAL. #11104: an OPERATOR-declared rule (`OperatorProviderErrorRule`)
* is exempt from this allowlist — `honorsRuleLockScope()` always returns true
* when the provider has one, since the operator already opted in by declaring
* the rule. Widening `HONORS_RULE_LOCK_SCOPE_PROVIDERS` itself (for a new
* built-in catalog rule) is tracked as a follow-up — see
* `docs/architecture/RESILIENCE_GUIDE.md` §7.
* Intended lock scope. #10334: this field is CONSUMED end-to-end only for
* providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS` (agentrouter-exclusive
* today, gated by `honorsRuleLockScope()`) — for those, `checkFallbackError`
* surfaces it as `ruleScope` on its return value for the persistence layer
* to honor instead of re-deriving scope from `hasPerModelQuota()`. For
* every other provider it remains INFORMATIONAL: `getProviderErrorRuleMatch`
* callers still read only `reason`/`cooldownMs`, and the actual lock scope
* is decided independently by each call site. Widening the allowlist is
* tracked as a follow-up — see `docs/architecture/RESILIENCE_GUIDE.md` §7.
*/
scope: "model" | "provider" | "connection";
/** Optional explicit cooldown; falls back to the existing per-reason defaults. */
cooldownMs?: number;
};
/**
* Operator-declared per-provider error rule (settings-driven).
*
* Mirrors the catalog `ProviderErrorRule` but is data-only so an operator can
* add a scope/cooldown/reason override for a provider without editing this
* file. `match` is a plain case-insensitive SUBSTRING of the error body — never
* a RegExp — so an operator-supplied pattern can never introduce a ReDoS on the
* error-classification hot path. Bounded to <= 50 rules total by the settings
* schema. An operator rule is consulted BEFORE the built-in `providerRuleRegistry`
* and wins on the first status+substring match for a provider.
*/
export type OperatorProviderErrorRule = {
status: number;
match: string;
scope: "model" | "provider" | "connection";
reason?: ConfiguredErrorReason;
cooldownMs?: number;
};
let operatorProviderErrorRules: Record<string, OperatorProviderErrorRule[]> = {};
/**
* Inject operator-declared rules. Called from the runtime-settings applier
* (`applyRuntimeSettings`) once at boot and on every settings update, with the
* value validated by the settings schema. Pass `undefined`/empty/null to clear.
* Provider keys are lowercased so lookups are case-insensitive.
*/
export function setOperatorProviderErrorRules(
rules: Record<string, OperatorProviderErrorRule[]> | undefined | null
): void {
operatorProviderErrorRules = {};
if (!rules) return;
for (const [provider, list] of Object.entries(rules)) {
if (Array.isArray(list) && list.length > 0) {
operatorProviderErrorRules[provider.toLowerCase()] = list;
}
}
}
// ─── Opencode ───────────────────────────────────────────────────────────────────
// Opencode Go uses an account-wide quota. The body usually says "rate limit
// reached" but the presence of `x-ratelimit-remaining-requests: 0` is the
@@ -314,21 +272,11 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
* FULL_TEXT_RULE_PROVIDERS: that set controls what body a rule matches against
* (input), this one controls whether the matched scope changes caller behavior
* (output). A provider could need one without the other.
*
* Providers with an operator-declared rule (`setOperatorProviderErrorRules`)
* are honored too, without being added here: the allowlist exists to gate
* BUILT-IN catalog rules, which change default behavior for every operator
* running that provider — an operator rule is already an explicit, per-operator
* opt-in, so gating it a second time behind this list would make the settings
* mechanism (#11104) silently inert for every provider except the ones listed
* below. See `hasOperatorRuleForProvider`.
*/
const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]);
export function honorsRuleLockScope(provider: string | null | undefined): boolean {
if (!provider) return false;
const key = provider.toLowerCase();
return HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(key) || hasOperatorRuleForProvider(key);
return !!provider && HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(provider.toLowerCase());
}
/**
@@ -362,51 +310,28 @@ export function egressBucketedLockProviders(): string[] {
}
/**
* Providers whose BUILT-IN catalog rules match on the FULL upstream error
* text. checkFallbackError's rule lookup normally passes only the structured
* Providers whose rules match on the FULL upstream error text.
* checkFallbackError's rule lookup normally passes only the structured
* error ({code, type} — message stripped by the combo callers), which is
* enough for header/status/code rules but blind to body-text markers like
* agentrouter's "额度不足". Providers in this set get the raw error text as
* the match body instead. EXCLUSIVE allowlist by owner decision (2026-08-13):
* adding a provider here is an explicit opt-in — the default path for every
* other provider must remain byte-for-byte unchanged.
*
* Operator-declared rules bypass this allowlist entirely (see
* `hasOperatorRuleForProvider`): the operator's `match` is a literal substring
* of the error body by construction, so a rule that never sees body text could
* never match anything, defeating the point of declaring it.
*/
const FULL_TEXT_RULE_PROVIDERS = new Set(["agentrouter"]);
/**
* True when an operator has declared at least one rule for this provider via
* `settings.providerErrorRules` (injected through `setOperatorProviderErrorRules`).
* Presence of the rule IS the opt-in — no separate allowlist to maintain, and
* no widening decision needed as new operators configure new providers.
*/
export function hasOperatorRuleForProvider(provider: string | null | undefined): boolean {
if (!provider) return false;
const rules = operatorProviderErrorRules[provider.toLowerCase()];
return !!rules && rules.length > 0;
}
/**
* Resolve the body handed to getProviderErrorRuleMatch inside
* checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS or any
* provider with an operator-declared rule, the structured error for everyone
* else.
* checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS,
* the structured error for everyone else.
*/
export function resolveRuleMatchBody(
provider: string | null | undefined,
structuredError: unknown,
errorText: string | null | undefined
): unknown {
if (
provider &&
(FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) ||
hasOperatorRuleForProvider(provider)) &&
errorText
) {
if (provider && FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) && errorText) {
return errorText;
}
return structuredError ?? null;
@@ -421,32 +346,10 @@ export function getProviderErrorRuleMatch(
provider: string | null | undefined,
status: number,
headers: Headers | Record<string, string> | null | undefined,
body?: unknown,
operatorRules?: Record<string, OperatorProviderErrorRule[]>
body?: unknown
): ProviderErrorRuleMatch | null {
if (!provider) return null;
const key = provider.toLowerCase();
// Operator-declared rules win first: an operator can override any catalog
// rule for a provider without editing this file. `operatorRules` is the
// injected source (tests / direct callers); when omitted we fall back to the
// settings-backed cache populated by `setOperatorProviderErrorRules`.
const opRules = (operatorRules ?? operatorProviderErrorRules)?.[key];
if (opRules && opRules.length > 0) {
const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
const lowered = text.toLowerCase();
for (const r of opRules) {
if (r.status === status && lowered.includes(r.match.toLowerCase())) {
return {
reason: r.reason ?? "quota_exhausted",
scope: r.scope,
cooldownMs: r.cooldownMs,
};
}
}
}
const rules = providerRuleRegistry.get(key);
const rules = providerRuleRegistry.get(provider.toLowerCase());
if (!rules) return null;
// Normalize headers: accept either a `Headers` object (from `fetch()`) or
// a plain record. Provider rules access headers via plain object indexing.

View File

@@ -10,7 +10,6 @@ export {
} from "./providers/registry/alibaba/index.ts";
export { REGISTRY } from "./providers/index.ts";
import { REGISTRY } from "./providers/index.ts";
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
import {
RegistryModel,
REASONING_UNSUPPORTED,
@@ -133,8 +132,11 @@ export function isLocalProvider(baseUrl?: string | null): boolean {
try {
const url = new URL(baseUrl);
const hostname = url.hostname;
if (!hostname) return false;
return LOCAL_HOSTNAMES.has(hostname) || isPrivateHost(hostname);
// Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening
return (
LOCAL_HOSTNAMES.has(hostname) ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname)
);
} catch {
return false;
}

View File

@@ -5,12 +5,6 @@ export const blackboxProvider: RegistryEntry = {
alias: "bb",
format: "openai",
executor: "default",
// NOTE: api.blackbox.ai returns HTTP 404 on /v1/chat/completions and /v1/models
// (empty body, all path variants) since sweep 2026-08-21; the public inference
// surface has moved to the gated enterprise.blackbox.ai/v1 endpoint. The provider
// is marked deprecated in src/shared/constants/providers/apikey/frontier-labs.ts —
// this registry entry is kept intact (registration/execution unaffected), so
// existing configured keys keep working if a restored/enterprise host is reachable.
baseUrl: "https://api.blackbox.ai/v1/chat/completions",
modelsUrl: "https://api.blackbox.ai/v1/models",
authType: "apikey",

View File

@@ -27,7 +27,7 @@ export const clineProvider: RegistryEntry = {
// the official free bucket and text-output models advertised as zero-cost.
models: [
{
id: "z-ai/glm-5.2",
id: "zai/glm-5.2",
name: "GLM 5.2",
toolCalling: true,
supportsReasoning: true,

View File

@@ -8,11 +8,7 @@ export const command_codeProvider: RegistryEntry = {
format: "openai",
executor: "command-code",
baseUrl: "https://api.commandcode.ai",
// Chat uses the documented /provider/v1/chat/completions (OpenAI-format)
// endpoint — NOT the CLI-only /alpha/generate endpoint, which Command Code
// version-gates and proxy-blocks for external callers (#10265). Discovery
// already targets the sibling /provider/v1/models endpoint.
chatPath: "/provider/v1/chat/completions",
chatPath: "/alpha/generate",
modelsUrl: "https://api.commandcode.ai/provider/v1/models",
// The discovery response is a partial routing catalog; static registry
// entries omitted from it can still be accepted by the gateway.

View File

@@ -5,11 +5,7 @@ export const difyProvider: RegistryEntry = {
alias: "dify",
format: "openai",
executor: "default",
// Dify does not serve /chat/completions — its native completion route is
// POST /v1/chat-messages (validated via the dedicated dify validator, #11002).
// Keep this as the bare API root so route suffixes build correctly and
// self-hosted instances can override the base URL per connection.
baseUrl: "https://api.dify.ai",
baseUrl: "https://api.dify.ai/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
models: [{ id: "auto", name: "Auto" }],

View File

@@ -16,7 +16,7 @@ export const hailuo_webProvider: RegistryEntry = {
alias: "hailuo-web",
format: "openai",
executor: "hailuo-web",
baseUrl: "https://chat.minimax.io",
baseUrl: "https://www.hailuo.ai",
authType: "apikey",
authHeader: "bearer",
models: HAILUO_WEB_STATIC_MODELS,

View File

@@ -1,5 +1,4 @@
import type { RegistryEntry } from "../../../shared.ts";
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
export const opencode_goProvider: RegistryEntry = {
id: "opencode-go",
@@ -24,13 +23,9 @@ export const opencode_goProvider: RegistryEntry = {
{ id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true },
{ id: "glm-5.2-high", name: "GLM-5.2 (high effort)", supportsReasoning: true },
{ id: "glm-5.2-max", name: "GLM-5.2 (max effort)", supportsReasoning: true },
...OPENCODE_ZEN_GO_SHARED_MODELS,
// models[0] (glm-5.2) is the dashboard default (LlmChatCard/ProviderTestSlideOver take models[0]).
{ id: "glm-5.1", name: "GLM-5.1" },
{ id: "glm-5", name: "GLM-5" },
// kimi-k2.7-code declared identically on opencode-zen — see OPENCODE_ZEN_GO_SHARED_MODELS.
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
// #8353: Kimi K3 base + max-effort alias from the OpenCode Go registry.
@@ -94,8 +89,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: false,
supportsReasoning: true,
},
// qwen3.6-plus / qwen3.5-plus base ids declared identically on opencode-zen — see
// OPENCODE_ZEN_GO_SHARED_MODELS.
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
{
id: "qwen3.6-plus-high",
name: "Qwen3.6 Plus (high effort)",
@@ -110,6 +104,7 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: false,
supportsReasoning: true,
},
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
// #8353: hy3 is the Go-tier base id (distinct from hy3-preview / hy3-free).
{ id: "hy3", name: "Hunyuan3", contextLength: 256000, supportsReasoning: true },
{
@@ -143,7 +138,6 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-minimal",
@@ -154,7 +148,6 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-low",
@@ -165,7 +158,6 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-medium",
@@ -176,7 +168,6 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-high",
@@ -187,7 +178,6 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
{
id: "muse-spark-1.2-contributor-xhigh",
@@ -198,7 +188,6 @@ export const opencode_goProvider: RegistryEntry = {
supportsVision: true,
supportsAudio: true,
supportsVideo: true,
targetFormat: "openai-responses",
},
// #8353: Grok 4.5 + effort tiers from the OpenCode Go registry.
{ id: "grok-4.5", name: "Grok 4.5", supportsReasoning: true },

View File

@@ -1,5 +1,4 @@
import type { RegistryEntry } from "../../../shared.ts";
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
export const opencode_zenProvider: RegistryEntry = {
id: "opencode-zen",
@@ -26,10 +25,6 @@ export const opencode_zenProvider: RegistryEntry = {
supportsReasoning: true,
interleavedField: "reasoning_content",
},
...OPENCODE_ZEN_GO_SHARED_MODELS,
// models[0] (big-pickle) is the dashboard default; SHARED spread kept after it.
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna" },
@@ -56,27 +51,7 @@ export const opencode_zenProvider: RegistryEntry = {
{ id: "grok-4.6", name: "Grok 4.6" },
// ── Muse ───────────────────────────────────────────────────
// Muse Spark is served by OpenCode Zen only on the OpenAI Responses API
// endpoint, not /chat/completions (see the opencode provider's own
// muse-spark entries, #10874/#10867) — this provider is a separate
// registry entry for the same upstream and never got the same
// targetFormat declaration, so requests routed here still hit
// /chat/completions with a mismatched or unanswerable body and the
// upstream returns an empty message.
{
id: "muse-spark-1.2",
name: "Muse Spark 1.2",
supportsReasoning: true,
targetFormat: "openai-responses",
},
// Explicit wire-format overlay of the base opencode provider's muse-spark entry
// (targetFormat: openai-responses). Keep in sync with base on catalog syncs.
{
id: "muse-spark-1.2-contributor-free",
name: "Muse Spark 1.2 Contributor Free",
supportsReasoning: true,
targetFormat: "openai-responses",
},
{ id: "muse-spark-1.2", name: "Muse Spark 1.2" },
// ── DeepSeek ────────────────────────────────────────────────
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
@@ -91,7 +66,7 @@ export const opencode_zenProvider: RegistryEntry = {
// ── Kimi / Moonshot ────────────────────────────────────────
{ id: "kimi-k3", name: "Kimi K3" },
// kimi-k2.7-code declared identically on opencode-go — see OPENCODE_ZEN_GO_SHARED_MODELS.
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
// ── Qwen ───────────────────────────────────────────────────
// Issue #2292: Qwen models return Claude-format SSE bodies even
@@ -99,8 +74,8 @@ export const opencode_zenProvider: RegistryEntry = {
// through /messages and the Claude translator.
// Issue #2822: These models are text-only — supportsVision: false
// ensures combo routing skips them on image-bearing requests.
// qwen3.5-plus / qwen3.6-plus declared identically on opencode-go — see
// OPENCODE_ZEN_GO_SHARED_MODELS.
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
// ── Free Tier ──────────────────────────────────────────────
// #6998 (2026-07-14): upstream free tier rotated — minimax-m2.5-free,

View File

@@ -25,7 +25,6 @@ import {
GLMT_TIMEOUT_MS,
GLM_SHARED_MODELS,
} from "../glmProvider.ts";
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../opencodeZenGoSharedModels.ts";
import { MARITALK_DEFAULT_BASE_URL } from "../maritalk.ts";
import {
CURSOR_REGISTRY_VERSION,
@@ -720,7 +719,6 @@ export {
GLM_TIMEOUT_MS,
GLMT_TIMEOUT_MS,
GLM_SHARED_MODELS,
OPENCODE_ZEN_GO_SHARED_MODELS,
MARITALK_DEFAULT_BASE_URL,
CURSOR_REGISTRY_VERSION,
getAntigravityProviderHeaders,

View File

@@ -20,10 +20,6 @@ import {
recordLearnedThinkingCap,
parseThinkingBudgetMax,
} from "../services/learnedThinkingCaps.ts";
import {
recordLearnedReasoningEffort,
parseReasoningEffortEnum,
} from "../services/learnedReasoningEffortCaps.ts";
import {
getParamFilterConfig,
addParamToBlocklist,
@@ -108,12 +104,6 @@ import {
import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting";
import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth";
import { isProbeContext } from "@/shared/utils/probeOrigin";
import {
parseAndValidatePublicUrl,
parseAndValidateNonMetadataUrl,
} from "@/shared/network/outboundUrlGuard";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
import { isLocalProvider, isSelfHostedChatProvider } from "@/shared/constants/providers";
// Header helpers extracted to a pure leaf; re-exported for external importers
// (executors + tests) that import them from "./base.ts".
export {
@@ -407,29 +397,6 @@ export class BaseExecutor {
return fallback || this.config.baseUrl || "";
}
/**
* SSRF guard for the runtime dispatch path (GHSA-4f49-hj64-448x). A persisted,
* caller-supplied `providerSpecificData.baseUrl` reaches the fetch() calls
* below, so a `manage`-scope actor (or, on a keyless install, an anonymous
* one) could point a provider at loopback / internal / cloud-metadata hosts
* and exfiltrate the stored upstream key. Mirror the provider VALIDATION
* guard so runtime dispatch makes the same decision the validation layer
* already makes: local / self-hosted providers are exempt (they legitimately
* use private URLs, and the OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS opt-in still
* applies through the guard), and for everything else `public-only` mode
* blocks private + metadata while the default `block-metadata` mode blocks the
* cloud-metadata IMDS pivot. Throws on a blocked URL.
*/
protected assertOutboundUrlAllowed(url: string): void {
if (!url) return;
if (isLocalProvider(this.provider) || isSelfHostedChatProvider(this.provider)) return;
if (getProviderValidationGuard() === "public-only") {
parseAndValidatePublicUrl(url);
return;
}
parseAndValidateNonMetadataUrl(url);
}
/**
* Alternate protocol selected on this connection, if the provider declares one
* that matches. Centralizes the registry lookup so every call-site resolves the
@@ -648,7 +615,6 @@ export class BaseExecutor {
async countTokens({ model, body, credentials, signal, log }: CountTokensInput) {
const url = this.buildCountTokensUrl(model, credentials);
if (!url) return null;
this.assertOutboundUrlAllowed(url); // GHSA-4f49
const headers = this.buildHeaders(credentials, false);
const requestBody =
@@ -830,9 +796,6 @@ export class BaseExecutor {
// loop. The learned cap is also recorded process-wide via
// recordLearnedThinkingCap so future requests skip the 400 entirely.
let thinkingBudgetClampedMax: number | null = null;
// Set by the reasoning_effort 4xx clamp-and-retry below — guards the same
// "fires at most once per URL" invariant as thinkingBudgetClampedMax above.
let reasoningEffortClamped = false;
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const requestCredentials = withForcedResponsesUpstream(
@@ -906,9 +869,6 @@ export class BaseExecutor {
// Timeout only covers response start; stream stalls are handled downstream.
const fetchStartTimeoutMs = this.getTimeoutMs();
const fetchWithStartTimeout = async (requestUrl: string, requestOptions: RequestInit) => {
// GHSA-4f49: guard here (not only next to the first buildUrl) so retries
// and fallback URLs are validated too, before any bytes leave the host.
this.assertOutboundUrlAllowed(requestUrl);
const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
if (timeoutController) {
@@ -1536,49 +1496,6 @@ export class BaseExecutor {
}
}
// Reasoning-effort enum 4xx clamp-and-retry (any provider/model without a
// declared reasoning_effort capability — custom OpenAI-compatible
// connections, or a registered provider the registry hasn't caught up
// with). Mirrors the thinking_budget clamp-and-retry above: parse the
// upstream-advertised accepted values, record them process-wide (so
// FUTURE requests clamp proactively via sanitizeReasoningEffortForProvider
// → getLearnedReasoningEffort), clamp the live transformedBody by
// re-running the sanitizer, and retry the same URL once.
if (
(response.status === HTTP_STATUS.BAD_REQUEST ||
response.status === HTTP_STATUS.UNPROCESSABLE_ENTITY) &&
!reasoningEffortClamped &&
transformedBody &&
typeof transformedBody === "object"
) {
const errText = await response
.clone()
.text()
.catch(() => "");
const acceptedValues = parseReasoningEffortEnum(errText);
if (acceptedValues) {
reasoningEffortClamped = true;
const learned = recordLearnedReasoningEffort(this.provider, model, acceptedValues);
if (learned) {
transformedBody = sanitizeReasoningEffortForProvider(
transformedBody,
this.provider,
model,
log
);
let retryBody = JSON.stringify(transformedBody);
if (usesClaudeCodeProtocol || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.info?.(
"REASONING_SANITIZE",
`Upstream ${response.status} rejected reasoning_effort on ${url} — clamped to ${learned} and retrying (learned for ${this.provider}/${model})`
);
response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody });
}
}
}
// Generic reactive 400 field-downgrade; each field is stripped at most once.
if (
response.status === HTTP_STATUS.BAD_REQUEST &&

View File

@@ -8,10 +8,6 @@ import {
getProviderModel,
getProviderModels,
} from "../../config/providerModels.ts";
import {
getLearnedReasoningEffort,
REASONING_EFFORT_ORDER,
} from "../../services/learnedReasoningEffortCaps.ts";
/**
* Sanitize reasoning_effort for providers that don't accept all values.
@@ -342,24 +338,10 @@ export function sanitizeReasoningEffortForProvider(
const supportsXHigh = supportsXHighEffort(provider, modelStr);
const supportsMax = supportsMaxEffortForProvider(provider, modelStr);
// Highest value we've actually seen this provider+model accept in a real
// upstream 4xx (learnedReasoningEffortCaps.ts) — takes priority over the
// static registry (which defaults to "supports everything" when there's no
// entry, e.g. custom OpenAI-compatible connections) and over the hardcoded
// "high" fallback below (which isn't always valid either).
const learnedCap = getLearnedReasoningEffort(provider, modelStr);
const learnedRank = learnedCap ? REASONING_EFFORT_ORDER.indexOf(learnedCap) : -1;
// ── xhigh handling ──────────────────────────────────────────────────────
// xhigh is OmniRoute-internal. Map it to the best effort the model accepts.
if (effortStr === "xhigh") {
if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("xhigh")) {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: clamped reasoning_effort xhigh → ${learnedCap} (learned)`
);
return writeEffortValue(b, learnedCap, c);
}
if (supportsXHigh) return body; // model accepts xhigh natively
if (supportsMax) {
log?.info?.(
@@ -384,13 +366,6 @@ export function sanitizeReasoningEffortForProvider(
// upstream, and if it 400s the user gets a clear signal. This prevents
// new models from being unusable for weeks until they're whitelisted (#8057).
if (effortStr === "max") {
if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("max")) {
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: clamped reasoning_effort max → ${learnedCap} (learned)`
);
return writeEffortValue(b, learnedCap, c);
}
if (supportsMax) return body; // explicitly known to accept max
// A model that explicitly advertises its accepted tiers is safe to normalize.

File diff suppressed because it is too large Load Diff

View File

@@ -30,45 +30,17 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const;
export const KEEPALIVE_PING = { type: 6 } as const;
/**
* Allowed message types observed in a 2026-08-21 live capture of a working
* `m365.cloud.microsoft/chat` session (issue: "Stream ended before producing a
* non-ping SSE event" on every individual/consumer M365 Copilot call). The
* #10718 6-entry shape above no longer produces a `type:1 target:"update"`
* frame at all — the socket only replies with SignalR keepalive pings and then
* closes, which is exactly what surfaces client-side as that generic stream
* error. 30 entries, up from 6.
* Allowed message types observed in the 2026-08 recapture of the working
* `m365.cloud.microsoft/chat` client (#10718). The old 11-entry list is no longer
* seen on the wire — the stale shape gets closed immediately after the type:4.
*/
export const ALLOWED_MESSAGE_TYPES = [
"Chat",
"Suggestion",
"InternalSearchQuery",
"Disengaged",
"InternalLoaderMessage",
"Progress",
"GeneratedCode",
"RenderCardRequest",
"AdsQuery",
"SemanticSerp",
"GenerateContentQuery",
"GenerateGraphicArt",
"SearchQuery",
"ConfirmationCard",
"AuthError",
"DeveloperLogs",
"TriggerPlugin",
"HintInvocation",
"MemoryUpdate",
"EndOfRequest",
"TriggerConfirmation",
"ResumeInvokeAction",
"ResumeUserInputRequest",
"TriggerUserInputRequest",
"EscapeHatch",
"TriggerPluginAuth",
"ResumePluginAuth",
"SideBySide",
"ReferencesListComplete",
"SwitchRespondingEndpoint",
"InternalLoaderMessage",
] as const;
/**
@@ -106,26 +78,19 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [
] as const;
/**
* Individual / EDU option sets from a 2026-08-21 live capture34 entries, up
* from the #10718 14-entry shape (which itself superseded an earlier 25-entry
* shape). Each recapture so far has been additive/reshuffled rather than a
* wholesale replacement — treat this as the protocol continuing to drift, not
* a one-time fix; a future capture may again need to update this list.
* Individual / EDU option sets from the 2026-08 recapture (#10718)14 entries.
* The previous 25-entry consumer/MSA set (enable_msa_user, pdnascan, cwc_code_*,
* …) is no longer observed on the wire and belongs to the shape the substrate
* now drops silently.
*/
export const M365_DEFAULT_OPTION_SETS = [
"search_result_progress_messages_with_search_queries",
"update_textdoc_response_after_streaming",
"deepleo_networking_timeout_10minutes_canmore",
"cwc_flux_image",
"cwc_code_interpreter",
"cwc_code_interpreter_amsfix",
"cwcfluxgptv",
"flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch",
"gptvnorm2048",
"cwc_code_interpreter_citation_fix",
"code_interpreter_interactive_charts",
"cwc_code_interpreter_interactive_charts_inline_image",
"code_interpreter_matplotlib_patching",
"cwc_fileupload_odb",
"update_memory_plugin",
"add_custom_instructions",
@@ -133,20 +98,6 @@ export const M365_DEFAULT_OPTION_SETS = [
"flux_v3_progress_messages",
"enable_batch_token_processing",
"enable_gg_gpt",
"async_client_interaction",
"flux_v3_references",
"flux_v3_references_entities",
"flux_v3_references_ci",
"add_filestore_filetype",
"cwc_code_interpreter_citation_sourceannotations",
"cdxcwc_code_interpreter_hallucinated_url_filter",
"flux_v3_image_gen_enable_dimensions",
"flux_v3_image_gen_enable_non_watermarked_storage",
"flux_v3_image_gen_enable_icon_dimensions",
"flux_v3_image_gen_enable_system_text_with_params",
"flux_v3_image_gen_enable_designer_dimensions_meta_prompting_in_system_prompts",
"flux_v3_image_gen_enable_story",
"rich_responses",
] as const;
/** Append the record separator to a JSON-serializable frame. */
@@ -482,14 +433,12 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
}
return {
optionsSets: [...M365_DEFAULT_OPTION_SETS],
// 2026-08-21 capture — the individual/consumer surface now sends "Magic"
// (capitalized), matching the enterprise tone literal. The #10718
// lowercase "magic" is part of the shape that gets silently dropped.
tone: "Magic",
// #10718 — the 2026-08 recapture sends tone:"magic" (lowercase) on the
// individual/EDU surface; the old "" default is part of the dropped shape.
tone: "magic",
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
// 2026-08-21 capture — disconnectBehavior:"continue" is now present on the
// individual/consumer wire too, not just enterprise (see ChatInvocationOptions).
disconnectBehavior: "continue",
// Omitted entirely on the individual/EDU wire (see ChatInvocationOptions).
disconnectBehavior: undefined,
};
}
@@ -518,33 +467,16 @@ export function resolveToneForModel(model: string | undefined): string | undefin
/**
* Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated).
* Base shape from the #10718 recapture (populated `clientInfo` +
* `productThreadType:"Office"`, a `conversationId` matching the WS URL query, a
* rich `message` object), extended per a 2026-08-21 live capture that found the
* #10718 shape alone no longer produces a `type:1 target:"update"` frame — the
* socket only replies with keepalive pings and closes. The additions below
* (richer `clientInfo`, non-empty `plugins`, `extraExtensionParameters`,
* `isSbsSupported`, `renderReferencesBehindEOS`,
* `message.connectedFederatedConnections`, and `disconnectBehavior` on every
* tier) are exactly the fields the 2026-08-21 capture had that this shape was
* missing; the #10718 fields (`conversationId`, `productThreadType`,
* `toolChoice`, `message.attachments`) are kept as-is since removing them was
* not verified against a live socket.
* Mirrors the argument shape recaptured from a working `m365.cloud.microsoft/chat`
* client in 2026-08 (#10718). Notable differences from the pre-#10718 shape: a
* populated `clientInfo` + `productThreadType:"Office"`, a `conversationId`
* matching the WS URL query, a rich `message` object, and no
* `spokenTextMode` / `extraExtensionParameters` / `isSbsSupported` /
* `renderReferencesBehindEOS` / `disconnectBehavior` — none of those are still
* observed on the wire, and the stale shape gets closed immediately after the
* invocation.
*/
export function buildChatInvocation(opts: ChatInvocationOptions): Record<string, unknown> {
const clientInfo = {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
clientEntrypoint: "mcmcopilot-officeweb",
clientSessionId: opts.sessionId,
ProductCategory: "Chat",
clientAppType: "Web",
productEntryPoint: "ChatPanel",
deviceOS: "Windows",
deviceType: "Desktop",
clientPlatformVersion: "10",
};
return {
type: 4,
target: "chat",
@@ -555,17 +487,17 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
? [...opts.allowedMessageTypes]
: [...ALLOWED_MESSAGE_TYPES],
clientCorrelationId: opts.clientCorrelationId ?? opts.traceId,
clientInfo,
clientInfo: {
clientAppName: "Office",
clientPlatform: "mcmcopilot-web",
},
conversationId: opts.conversationId,
extraExtensionParameters: {},
isStartOfSession: opts.isStartOfSession ?? true,
message: {
adaptiveCards: [],
attachments: null,
author: "user",
clientInfo,
clientPreferences: {},
connectedFederatedConnections: ["dummyId"],
entityAnnotationTypes: ["People", "File", "Event", "Email", "TeamsMessage"],
experienceType: "Default",
inputMethod: "Keyboard",
@@ -578,27 +510,22 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
requestId: opts.requestId,
text: opts.text,
},
isSbsSupported: true,
options: {},
optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS],
// 2026-08-21 capture (#11069): BingWebSearch is now the universal
// BuiltIn plugin on individual/consumer tier; keep an opt-out override.
plugins: opts.plugins ?? [{ Id: "BingWebSearch", Source: "BuiltIn" }],
plugins: opts.plugins ?? [],
...(opts.customInstructions ? { customInstructions: opts.customInstructions } : {}),
productThreadType: "Office",
renderReferencesBehindEOS: true,
sessionId: opts.sessionId,
sliceIds: [],
source: "officeweb",
streamingMode: "ConciseWithPadding",
threadLevelGptId: {},
// 2026-08-21 capture (#11069): tone is now capitalized "Magic" on both tiers.
tone: opts.tone ?? "Magic",
tone: opts.tone ?? "magic",
toolChoice: opts.toolChoice ?? null,
traceId: opts.traceId,
// 2026-08-21 capture — disconnectBehavior:"continue" is sent on every
// tier now, not gated to enterprise as the #8971 comment described.
disconnectBehavior: opts.disconnectBehavior ?? "continue",
// #8971 keeps "continue" for the enterprise tier; the individual/EDU wire
// omits the key, so only include it when actually set (#10718).
...(opts.disconnectBehavior ? { disconnectBehavior: opts.disconnectBehavior } : {}),
},
],
};

View File

@@ -430,7 +430,6 @@ export class GlmExecutor extends DefaultExecutor {
let response: Response;
try {
this.assertOutboundUrlAllowed(url); // GHSA-4f49: glm has its own fetch path
response = await fetch(url, {
method: "POST",
headers,

View File

@@ -33,7 +33,7 @@ import { createHash } from "node:crypto";
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult, sanitizeErrorMessage } from "../utils/error.ts";
const BASE_URL = "https://chat.minimax.io";
const BASE_URL = "https://www.hailuo.ai";
const API_PATH = "/v4/api/chat/msg";
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";

View File

@@ -471,7 +471,6 @@ export class NlpCloudExecutor extends BaseExecutor {
}
try {
this.assertOutboundUrlAllowed(url); // GHSA-4f49: nlpcloud has its own fetch path
const response = await fetch(url, {
method: "POST",
headers,

View File

@@ -1,6 +1,6 @@
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
import { PROVIDERS } from "../config/constants.ts";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
import { getModelTargetFormat } from "../config/providerModels.ts";
import {
injectReasoningContentForThinkingModel,
isThinkingMessageModel,
@@ -125,24 +125,6 @@ export function isPremiumOpencodeModel(model: string, provider: string): boolean
return !OPENCODE_FREE_MODELS.has(model);
}
/**
* Resolves the registry `targetFormat` for a model, aliasing `provider` first.
*
* `PROVIDER_MODELS` is keyed by the provider's public ALIAS (e.g. `"oc"`), not its
* raw registry id (e.g. `"opencode"`) — mirrors `resolveChatCoreTargetFormat()`
* (`handlers/chatCore/targetFormat.ts`), which already aliases before calling
* `getModelTargetFormat()`. Calling it with the raw id here made every entry miss
* silently (fell through to `"openai"`), while chatCore's own request-body
* translation (correctly aliased) still switched to the Responses API shape for
* `targetFormat:"openai-responses"` models — sending a Responses-shaped body to
* the `/chat/completions` URL this executor's own `buildUrl()` kept selecting.
* Exported for testability.
*/
export function resolveOpencodeTargetFormat(provider: string, model: string): string {
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
return getModelTargetFormat(alias, model) || "openai";
}
export class OpencodeExecutor extends BaseExecutor {
/** Delegates to `isPremiumOpencodeModel`. Exported for testability. */
static isPremiumModel(model: string, provider: string): boolean {
@@ -211,10 +193,7 @@ export class OpencodeExecutor extends BaseExecutor {
return pickRotatableAccount(this.accounts, this);
}
private markCooldown(
account: OpencodeAccountState,
kind: "transient" | "terminal" = "transient"
): void {
private markCooldown(account: OpencodeAccountState, kind: "transient" | "terminal" = "transient"): void {
markAccountCooldown(account, kind);
}
@@ -223,7 +202,7 @@ export class OpencodeExecutor extends BaseExecutor {
}
async execute(input: ExecuteInput) {
this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model);
this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai";
// #8681: Gate premium opencode models behind a usable API key.
// When the connection is keyless (no apiKey, no accessToken) and the model

View File

@@ -10,24 +10,16 @@ import {
import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts";
/**
* Resolve the memory owner id for an MCP tool call.
*
* The authenticated caller's principal ALWAYS wins over a caller-supplied
* `apiKeyId` — otherwise any MCP caller could read, write, or delete another
* principal's memories by putting a different id in the tool arguments
* (GHSA-cpv3-xr7r-xf8q, IDOR). The caller is resolved from the per-request HTTP
* auth headers on SSE / Streamable HTTP transports, or from OMNIROUTE_API_KEY on
* stdio. The explicit argument is only honored as a fallback when no caller can
* be resolved (a bare local stdio process with no configured key — already
* trusted), preserving the local-tooling flow. Keeps MCP-stored memories under
* the same owner id that chat-context memory uses, so retrieval in the chat
* pipeline finds entries written via MCP.
* Resolve the memory owner id for an MCP tool call:
* explicit arg wins, otherwise fall back to the authenticated caller's
* principal id (HTTP auth headers on SSE/Streamable HTTP transports,
* OMNIROUTE_API_KEY env var on stdio). Keeps MCP-stored memories under
* the same owner id that chat-context memory uses, so retrieval in the
* chat pipeline finds entries written via MCP.
*/
async function resolveMemoryOwnerId(explicit?: string): Promise<string> {
const caller = await resolveMcpCallerApiKeyId().catch(() => undefined);
if (caller) return caller;
if (explicit && explicit.trim() !== "") return explicit.trim();
return "mcp";
return (await resolveMcpCallerApiKeyId().catch(() => undefined)) || "mcp";
}
export const MemorySearchSchema = z.object({

View File

@@ -21,7 +21,7 @@ import {
honorsRuleLockScope,
} from "../config/providerErrorRules.ts";
import * as rot from "./rotationConfig.ts";
import { getPassthroughProviders, getProviderCategory, isLocalProvider } from "../config/providerRegistry.ts";
import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts";
import {
DEFAULT_RESILIENCE_SETTINGS,
resolveResilienceSettings,
@@ -37,7 +37,7 @@ import {
type FailureKind,
} from "../../src/shared/utils/classify429";
import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts";
import { resolveProviderId, isLocalProvider as isLocalProviderId, isSelfHostedChatProvider } from "../../src/shared/constants/providers";
import { resolveProviderId } from "../../src/shared/constants/providers";
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
import { getCodexModelScope } from "../config/codexQuotaScopes.ts";
import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts";
@@ -791,14 +791,12 @@ export function hasPerModelQuota(
return connectionPassthroughModels;
}
if (!provider) return false;
const canonicalId = resolveProviderId(provider);
if (getCanonicalLockProvider(canonicalId) === "antigravity") return true;
if (getCanonicalLockProvider(canonicalId) === "codex") return true;
if (canonicalId === "gemini" || canonicalId === "github") return true;
if (canonicalId === "antigravity" || canonicalId === "agy") return true;
if (getPassthroughProviders().has(canonicalId)) return true;
if (isCompatibleProvider(canonicalId)) return true;
if (isLocalProviderId(canonicalId) || isSelfHostedChatProvider(canonicalId)) return true;
if (getCanonicalLockProvider(provider) === "antigravity") return true;
if (getCanonicalLockProvider(provider) === "codex") return true;
if (provider === "gemini" || provider === "github") return true;
if (provider === "antigravity" || provider === "agy") return true;
if (getPassthroughProviders().has(provider)) return true;
if (isCompatibleProvider(provider)) return true;
return false;
}

View File

@@ -607,7 +607,7 @@ export async function prepareVirtualAutoComboInputs(
// remaining allowance as a percentage, and a raw ">0" comparison would
// let a reading of e.g. 0.3% (rounding noise, not real headroom) pass.
minRemainingAllowance: 1,
maxStateAgeMs: (Number(settings.autoRefreshProviderQuotaInterval) || 180) * 1000,
maxStateAgeMs: (settings.autoRefreshProviderQuotaInterval ?? 180) * 1000,
});
if (strictFilteredPool !== pool) pool = strictFilteredPool;

View File

@@ -1,126 +0,0 @@
/**
* Learned Reasoning-Effort Caps — reactive capability memory for providers/models
* OmniRoute has no static registry entry for (custom OpenAI-compatible connections,
* or any registered provider whose registry entry carries no reasoning metadata).
*
* Same shape as `learnedThinkingCaps.ts` (thinking_budget), generalized from a
* numeric budget to an ordinal reasoning_effort scale: on a 4xx whose body
* enumerates the accepted values, `base.ts`'s executor calls
* `recordLearnedReasoningEffort`, which stores the highest recognized value in a
* module-level Map keyed "provider:model" (lowercased). Subsequent requests for
* the same provider+model read the cap via `getLearnedReasoningEffort` (consulted
* by `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
* so the 4xx→retry round-trip is paid at most once per process per provider+model.
*
* In-memory only (same operator-accepted tradeoff as the thinking-budget cache):
* restart resets, the first request after a restart may re-learn at the cost of
* one upstream 4xx.
*/
export const REASONING_EFFORT_ORDER: readonly string[] = [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
];
// key: `${provider}:${model}` lowercased → highest value known to be accepted.
const learnedCaps = new Map<string, string>();
function buildKey(provider: string | null | undefined, model: string | null | undefined): string {
const p = typeof provider === "string" ? provider.trim().toLowerCase() : "";
const m = typeof model === "string" ? model.trim().toLowerCase() : "";
if (!p || !m) return "";
return `${p}:${m}`;
}
function rankOf(value: string): number {
return REASONING_EFFORT_ORDER.indexOf(value);
}
/**
* Return the learned cap for provider+model, or null when nothing has been
* learned yet (no upstream 4xx recorded). Keyed case-insensitively.
*/
export function getLearnedReasoningEffort(
provider: string | null | undefined,
model: string | null | undefined
): string | null {
const key = buildKey(provider, model);
if (!key) return null;
return learnedCaps.get(key) ?? null;
}
/**
* Record that `acceptedValues` is the enum the upstream advertised for
* provider+model, and store the highest recognized value as the learned cap.
* Returns the stored value, or null when `acceptedValues` contained no token
* from `REASONING_EFFORT_ORDER` (nothing usable to learn) or the key is unusable.
*
* Always monotonically decreases: if a cap already stored ranks lower than the
* newly computed highest, the stored (lower) value wins and is returned
* unchanged. This keeps a later, laxer-looking response (or a race between
* concurrent requests) from ratcheting the cap back up.
*/
export function recordLearnedReasoningEffort(
provider: string | null | undefined,
model: string | null | undefined,
acceptedValues: string[]
): string | null {
const key = buildKey(provider, model);
if (!key) return null;
let best: string | null = null;
let bestRank = -1;
for (const raw of acceptedValues) {
const rank = rankOf(raw);
if (rank > bestRank) {
bestRank = rank;
best = raw;
}
}
if (best === null) return null;
const existing = learnedCaps.get(key);
if (existing !== undefined && rankOf(existing) <= bestRank) {
return existing; // already learned an equal-or-lower cap; keep it
}
learnedCaps.set(key, best);
return best;
}
// Matches both prose shapes observed: OVH's `@ai-sdk/openai-compatible`
// deserializer ("expected one of `a`, `b`") and a generic vendor prose form
// ("Supported types are a, b, and c").
const LIST_INTRO = /(?:expected one of|supported (?:types|values) are)[:\s]*([^.]+)/i;
/**
* Extract the upstream-advertised accepted reasoning_effort values from a 4xx
* error body. Returns only tokens present in REASONING_EFFORT_ORDER (unknown
* tokens are dropped defensively) in the order they appeared, or null when the
* text names no recognized enum member.
*/
export function parseReasoningEffortEnum(errText: unknown): string[] | null {
if (typeof errText !== "string" || !errText) return null;
const match = LIST_INTRO.exec(errText);
if (!match) return null;
const tokens = match[1]
.split(/,|\band\b|&/i)
.map((t) =>
t
.replace(/`/g, "")
.replace(/\([^)]*\)/g, "")
.trim()
.toLowerCase()
)
.filter((t) => t.length > 0 && REASONING_EFFORT_ORDER.includes(t));
return tokens.length > 0 ? tokens : null;
}
/** Test-only: clear the learned-cap Map between tests. */
export function __test_resetLearnedReasoningEffortCaps(): void {
learnedCaps.clear();
}

View File

@@ -1,6 +1,5 @@
import { REGISTRY } from "../config/providerRegistry.ts";
import type { ReasoningTransport } from "../config/providerRegistry.ts";
import { isValidResponsesItemId } from "./responsesItemId.ts";
type JsonRecord = Record<string, unknown>;
@@ -280,27 +279,13 @@ function sanitizeResponsesInput(
if (!hasPlaintext && !hasOpaque && (!hasDisplaySummary(next) || stripOrphanedSummaries)) {
continue;
}
// `id` is only worth keeping on an opaque item with a valid string value —
// non-opaque items don't replay their id, and a malformed value (e.g. `null`,
// observed on opencode/zen) must not survive either way (#11108).
if (!hasOpaque || !isValidResponsesItemId(next.id)) delete next.id;
// Some upstreams (e.g. opencode/zen) omit `summary` entirely on opaque
// reasoning items instead of sending an empty array. Replaying that shape
// verbatim trips strict Responses-API validators that require the field
// to be present on every `input[]` item of type `reasoning` (#11108).
// Plaintext-only items intentionally have no `summary` key and must stay
// untouched.
if (hasOpaque && next.summary === undefined) next.summary = [];
if (!hasOpaque && typeof next.id === "string") delete next.id;
filtered.push(next);
continue;
}
const cloned = { ...record };
// Strip `id` whenever present, valid or not: these items don't need a
// replayed server id, and a malformed one (e.g. `null`, same opencode/zen
// omission pattern as the reasoning branch above) must not survive either
// (#11108).
if (cloned.id !== undefined) delete cloned.id;
if (typeof cloned.id === "string") delete cloned.id;
filtered.push(cloned);
}
return filtered;

View File

@@ -1,5 +1,3 @@
import { isValidResponsesItemId } from "./responsesItemId.ts";
type JsonRecord = Record<string, unknown>;
type SanitizeResponsesInputOptions = {
dropInternalAssistantMessages?: boolean;
@@ -42,12 +40,7 @@ function sanitizeFunctionName(name: string): string {
}
function sanitizeInputItemId(record: JsonRecord): JsonRecord {
if (record.id === undefined) return record;
if (!isValidResponsesItemId(record.id)) {
const next = { ...record };
delete next.id;
return next;
}
if (typeof record.id !== "string") return record;
const type = typeof record.type === "string" ? record.type : "";
const expectedPrefix = SERVER_ITEM_ID_PREFIX_BY_TYPE[type];

View File

@@ -1,7 +0,0 @@
// Shared by reasoningInputPolicy.ts and responsesInputSanitizer.ts: both strip a
// Responses-API `input[]` item's `id` field when it isn't a valid string before
// replay, so a malformed value (e.g. `null`, observed on opencode/zen) never
// survives to trip a strict upstream with "Expected 'id' to be a string." (#11108).
export function isValidResponsesItemId(id: unknown): id is string {
return typeof id === "string";
}

View File

@@ -185,21 +185,7 @@ export interface OpenAiSseScan {
text: string;
/** True if any `choices[].delta.tool_calls` appeared — NEVER continue those. */
sawToolCall: boolean;
/**
* True only when `tool_calls` appeared in this scan AND its own
* `finish_reason: "tool_calls"` has NOT also appeared in the same scan — i.e. the
* call is still being streamed (arguments may be mid-flight). Once
* `finish_reason: "tool_calls"` closes it, the call is complete, not in flight: the
* client has the full arguments and a truncation past this point only drops
* trailing prose, which continuation can safely recover.
*/
sawToolCallInFlight: boolean;
/**
* True if a terminal marker for the OVERALL stream appeared: `[DONE]`, or a
* `finish_reason` other than `"tool_calls"`. A `finish_reason: "tool_calls"` ends
* that one choice but is not terminal for continuation purposes — the model turn
* (and the client-visible SSE) is still eligible to be resumed past it.
*/
/** True if a terminal marker (`[DONE]` or a non-null `finish_reason`) appeared. */
terminal: boolean;
/** True if at least one OpenAI-shaped `choices[].delta` was parsed (format gate). */
parsedOpenAi: boolean;
@@ -213,11 +199,10 @@ export interface OpenAiSseScan {
export function scanOpenAiSseText(sse: string): OpenAiSseScan {
let text = "";
let sawToolCall = false;
let toolCallFinished = false;
let terminal = false;
let parsedOpenAi = false;
if (typeof sse !== "string" || sse.length === 0) {
return { text, sawToolCall, sawToolCallInFlight: false, terminal, parsedOpenAi };
return { text, sawToolCall, terminal, parsedOpenAi };
}
for (const line of sse.split("\n")) {
const trimmed = line.trimStart();
@@ -246,17 +231,10 @@ export function scanOpenAiSseText(sse: string): OpenAiSseScan {
if (Array.isArray(toolCalls) && toolCalls.length > 0) sawToolCall = true;
}
const finishReason = (choice as { finish_reason?: unknown })?.finish_reason;
if (finishReason === "tool_calls") {
// Ends this one choice, but the overall stream/turn stays continuable —
// never counts as the general terminal marker (see OpenAiSseScan.terminal).
toolCallFinished = true;
} else if (finishReason != null) {
terminal = true;
}
if (finishReason != null) terminal = true;
}
}
const sawToolCallInFlight = sawToolCall && !toolCallFinished;
return { text, sawToolCall, sawToolCallInFlight, terminal, parsedOpenAi };
return { text, sawToolCall, terminal, parsedOpenAi };
}
export interface ContinuableBody {
@@ -391,7 +369,7 @@ export function createRecoverableStream(
let emittedTail = ""; // raw SSE not yet scanned (awaiting an event boundary)
let emittedText = ""; // assistant text already delivered to the client
let emittedTerminal = false;
let emittedToolCallInFlight = false;
let emittedToolCall = false;
let emittedParsedOpenAi = false;
// Enqueue to the client and, when continuation is enabled, fold the chunk into the
@@ -410,7 +388,7 @@ export function createRecoverableStream(
const scan = scanOpenAiSseText(complete);
emittedText += scan.text;
if (scan.terminal) emittedTerminal = true;
if (scan.sawToolCallInFlight) emittedToolCallInFlight = true;
if (scan.sawToolCall) emittedToolCall = true;
if (scan.parsedOpenAi) emittedParsedOpenAi = true;
};
@@ -424,7 +402,7 @@ export function createRecoverableStream(
continueEnabled &&
continuations < maxContinuations &&
emittedParsedOpenAi &&
!emittedToolCallInFlight &&
!emittedToolCall &&
!emittedTerminal &&
emittedText.length > 0;

View File

@@ -201,14 +201,6 @@ export function openaiToOpenAIResponsesRequest(
input.push({
type: "reasoning",
content: [{ type: "reasoning_text", text: reasoning }],
// Strict Responses-API upstreams (e.g. opencode/zen) require `summary`
// on every `input[]` item of type "reasoning", plaintext or opaque —
// omitting it rejects the request with `input[N] missing required
// field summary`. This item is always freshly built from a chat
// client's plaintext reasoning, so there is no source summary to
// preserve; default to an empty array like the replay sanitizer does
// for opaque items in reasoningInputPolicy.ts (#11108).
summary: [],
});
}

View File

@@ -866,25 +866,21 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!chunk) {
// Iterate every still-open call needing schema-aware normalization, not just a
// single one — multiple parallel calls can each be pending here if the stream
// ends before their output_item.done arrives.
const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
if (state.toolCallByCallId instanceof Map) {
for (const entry of state.toolCallByCallId.values()) {
if (entry.needsNormalization && entry.argsBuffer) {
const toolSchema = state.toolSchemas?.get(entry.name);
const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
pendingNormalized.push({
index: entry.index,
argsStr: typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {}),
});
entry.argsBuffer = "";
entry.needsNormalization = false;
}
}
}
if (pendingNormalized.length > 0) {
if (
state.currentToolCallNeedsNormalization &&
state.currentToolCallArgsBuffer &&
state.currentToolCallName
) {
const toolSchema = state.toolSchemas?.get(state.currentToolCallName);
const argsToEmit = stripEmptyOptionalToolArgs(
state.currentToolCallArgsBuffer,
state.currentToolCallName,
toolSchema
);
const argsStr =
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {});
state.currentToolCallArgsBuffer = "";
state.currentToolCallNeedsNormalization = false;
state.finishReasonSent = true;
state.finishReason = "tool_calls";
const common = {
@@ -893,21 +889,24 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
created: state.created,
model: state.model || "gpt-4",
};
const chunks: Record<string, unknown>[] = pendingNormalized.map(({ index, argsStr }) => ({
...common,
choices: [
{
index: 0,
delta: { tool_calls: [{ index, function: { arguments: argsStr } }] },
finish_reason: null,
},
],
}));
chunks.push({
...common,
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
});
return chunks;
return [
{
...common,
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsStr } }],
},
finish_reason: null,
},
],
},
{
...common,
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
},
];
}
// Flush: send final chunk with finish_reason
if (!state.finishReasonSent && state.started) {
@@ -953,23 +952,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
state.chatId = `chatcmpl-${Date.now()}`;
state.created = Math.floor(Date.now() / 1000);
state.toolCallIndex = 0;
// Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility —
// that snapshot path mutates it directly and expects it to exist. In a turn with
// multiple parallel calls this only ever reflects the LAST one opened/closed, so
// it must never be used to identify a specific call — only as the "is at least
// one tool call in flight this turn" signal computeFinishReason needs, which
// toolCallIndex > 0 already covers on its own once any call has been added.
state.currentToolCallId = null;
// Per-call state keyed by call_id (replaces the old singular
// currentToolCallId/ArgsBuffer/Name/NeedsNormalization/Deferred fields, which
// assumed only one function_call could ever be in flight at a time).
state.toolCallByCallId = new Map();
// response.function_call_arguments.delta carries `item_id`/`output_index`, not
// `call_id` — resolve either one back to the call_id key used by
// toolCallByCallId (two independent reverse maps, since some upstreams omit
// item_id on delta events but still send output_index).
state.toolCallItemToCallId = new Map();
state.toolCallOutputIndexToCallId = new Map();
}
// Text content delta
@@ -1000,48 +983,22 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// Function call started
if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
const item = data.item;
const callId = item.call_id || fallbackToolCallId();
// Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility.
state.currentToolCallId = callId;
const toolName = normalizeToolName(item.name);
// Assign this call's index NOW, at .added, not at .done — two calls opened before
// either closes (a genuine parallel dispatch) must never share an index. Deferred
// (still-nameless) calls are the one exception: they don't claim an index until
// .done resolves a real name, so a call that never gets one never burns a slot
// another call could have used.
let index: number | null = null;
if (toolName) {
index = state.toolCallIndex ?? 0;
state.toolCallIndex = index + 1;
}
if (!(state.toolCallByCallId instanceof Map)) state.toolCallByCallId = new Map();
state.toolCallByCallId.set(callId, {
index,
name: toolName,
argsBuffer: "",
deferred: !toolName,
needsNormalization: toolName === "Agent",
});
if (!(state.toolCallItemToCallId instanceof Map)) state.toolCallItemToCallId = new Map();
if (item.id) state.toolCallItemToCallId.set(item.id, callId);
// `output_index` is a top-level field on every Responses API streamed event
// (response.output_item.added/.done AND function_call_arguments.delta alike) —
// an identifier independent of item_id, for upstreams that omit item_id on delta
// events.
if (!(state.toolCallOutputIndexToCallId instanceof Map)) {
state.toolCallOutputIndexToCallId = new Map();
}
if (data.output_index != null) state.toolCallOutputIndexToCallId.set(data.output_index, callId);
state.currentToolCallId = item.call_id || fallbackToolCallId();
state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer
state.currentToolCallDeferred = false;
// Track this call_id so response.completed doesn't synthesize a duplicate
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
state.toolCallIdsSeen.add(callId);
if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId);
const toolName = normalizeToolName(item.name);
state.currentToolName = toolName; // track for schema lookup at done time
state.currentToolCallName = toolName;
state.currentToolCallNeedsNormalization = toolName === "Agent";
if (!toolName) {
// Some Responses providers briefly emit placeholder/empty tool names.
// Defer emission until output_item.done in case the final name is populated there.
state.currentToolCallDeferred = true;
return null;
}
@@ -1056,8 +1013,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
delta: {
tool_calls: [
{
index,
id: callId,
index: state.toolCallIndex,
id: state.currentToolCallId,
type: "function",
function: {
name: toolName,
@@ -1080,26 +1037,11 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
const argsDelta = data.delta || "";
if (!argsDelta) return null;
// Resolve which in-flight call this delta belongs to. Try item_id first (the
// field the Responses API documents for this event), then output_index (also a
// top-level field on this event, and independent of item_id — covers upstreams
// that omit item_id on delta events but still send output_index). Only once both
// identifying fields are absent/unresolved do we fall back to guessing (the
// single open call, or the most recently opened one as a last resort).
const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null;
let callId = data.item_id ? state.toolCallItemToCallId?.get(data.item_id) : undefined;
if (!callId && data.output_index != null) {
callId = state.toolCallOutputIndexToCallId?.get(data.output_index);
}
if (!callId && map) {
callId = map.size === 1 ? [...map.keys()][0] : state.currentToolCallId;
}
const entry = callId ? map?.get(callId) : undefined;
if (!entry) return null;
state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta;
if (state.currentToolCallDeferred || state.currentToolCallNeedsNormalization) return null;
// #9168: buffer arguments until output_item.done for schema-aware null normalization
// Previously emitted raw null values for optional enum fields (e.g. isolation: null).
entry.argsBuffer = (entry.argsBuffer || "") + argsDelta;
return null;
}
@@ -1119,30 +1061,13 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
// carry the complete arguments only in output_item.done (no preceding delta events).
if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
const item = data.item;
const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null;
let callId = item.call_id;
if (!callId && item.id) callId = state.toolCallItemToCallId?.get(item.id);
if (!callId) callId = state.currentToolCallId || fallbackToolCallId();
const trackedEntry = callId ? map?.get(callId) : undefined;
// Some upstreams (e.g. Codex) send the complete payload only in output_item.done,
// with no preceding output_item.added at all — there is no tracked entry to read an
// index from.
const entry = trackedEntry || { index: null, argsBuffer: "", deferred: false };
const buffered = entry.argsBuffer || "";
const buffered = state.currentToolCallArgsBuffer || "";
const currentIndex = state.toolCallIndex; // capture before increment
const callId = item.call_id || state.currentToolCallId || fallbackToolCallId();
const toolName = normalizeToolName(item.name);
// Claim (and advance) this call's index now if it wasn't assigned at .added — either
// a deferred call whose name has just now resolved, or a Codex-style done-only
// payload that never had an .added at all. A deferred call whose name is STILL empty
// never claims an index (nothing was ever emitted for it either way).
if (entry.index == null && toolName) {
entry.index = state.toolCallIndex ?? 0;
state.toolCallIndex = entry.index + 1;
}
const currentIndex = entry.index;
const toolSchema = state.toolSchemas?.get(toolName);
const shouldNormalizeArguments = toolName === "Agent";
state.currentToolCallNeedsNormalization = shouldNormalizeArguments;
if (toolName && state.toolCalls instanceof Map) {
const completedArguments =
@@ -1152,9 +1077,6 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
toolName,
toolSchema
);
// Keyed by index, not insertion order — readers that need call order for
// parallel calls closed out of order should sort by this key rather than
// relying on Map iteration order.
state.toolCalls.set(currentIndex, {
id: callId,
index: currentIndex,
@@ -1173,17 +1095,17 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
if (callId) state.toolCallIdsSeen.add(callId);
// This call is fully closed — remove it from the in-flight map (bounds the map
// to genuinely in-flight calls, and keeps the single-open-call fallback in the
// function_call_arguments.delta handler correct for whichever call opens next).
if (map && callId) map.delete(callId);
if (state.currentToolCallId === callId) state.currentToolCallId = null;
if (state.currentToolCallDeferred) {
state.currentToolCallDeferred = false;
state.currentToolCallArgsBuffer = "";
state.currentToolCallId = null;
if (entry.deferred) {
if (!toolName) {
return null;
}
state.toolCallIndex++;
const terminalArguments =
typeof item.arguments === "string"
? item.arguments.length > 0
@@ -1226,7 +1148,12 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
};
}
const needsNormalization = shouldNormalizeArguments;
state.toolCallIndex++;
state.currentToolCallArgsBuffer = ""; // reset for next tool call
state.currentToolCallId = null;
const needsNormalization = state.currentToolCallNeedsNormalization === true;
state.currentToolCallNeedsNormalization = false;
state.currentToolCallName = "";
// Nullable omission sentinels must be normalized before any argument bytes reach the client.
// Other tool calls retain immediate argument streaming.

View File

@@ -128,75 +128,6 @@ function tryParseJson(raw: string): unknown {
}
}
/**
* Splits a tool_call `arguments` string that is actually multiple back-to-back JSON
* objects glued together with no separator, into its individual object substrings.
*
* Root cause (observed on opencode/muse-spark-1.2-contributor-free via the zen
* provider): some upstreams never vary `index`/`id` across a 2nd/3rd/… tool_call of
* the SAME name emitted in one turn, so every delta in `buildOpenAISummary` above
* resolves to the same accumulator key and `arguments` ends up as N JSON objects
* concatenated with no delimiter — invalid as a single JSON value, but each object is
* individually well-formed. Structural, not provider-specific: applies to whichever
* upstream exhibits the same index-collision streaming bug.
*
* Returns `null` when `raw` is empty, already valid single JSON, or does not scan as
* ≥2 back-to-back valid JSON values — callers must leave `arguments` untouched in
* that case (never regress a value that used to reach the client as-is).
*/
export function splitConcatenatedToolCallArguments(raw: string): string[] | null {
if (!raw) return null;
try {
JSON.parse(raw);
return null; // Already a single valid JSON value — nothing to split.
} catch {
// Fall through to the multi-value scan below.
}
const parts: string[] = [];
let depth = 0;
let inString = false;
let escaped = false;
let start = -1;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (start === -1) {
if (ch === " " || ch === "\n" || ch === "\r" || ch === "\t") continue;
if (ch !== "{" && ch !== "[") return null; // Not a value boundary — bail, leave untouched.
start = i;
}
if (inString) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === "{" || ch === "[") depth++;
else if (ch === "}" || ch === "]") {
depth--;
if (depth === 0) {
parts.push(raw.slice(start, i + 1));
start = -1;
}
}
}
if (start !== -1 || depth !== 0 || parts.length < 2) return null;
for (const part of parts) {
try {
JSON.parse(part);
} catch {
return null; // One of the scanned segments isn't valid JSON — bail entirely.
}
}
return parts;
}
// ─── 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),
@@ -331,28 +262,7 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
message.reasoning_content = joinedReasoning;
}
const mergedToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
// Expand any entry whose accumulated `arguments` turned out to be multiple
// concatenated JSON objects (upstream never varied index/id across repeated
// same-name tool_calls) into its own separate tool_calls entries.
const finalToolCalls: ToolCall[] = [];
let nextIndex = 0;
// Normalize tool_call indexes to contiguous 0-based (OpenAI contract).
for (const tc of mergedToolCalls) {
const splitArgs = splitConcatenatedToolCallArguments(tc.function.arguments);
if (!splitArgs) {
finalToolCalls.push({ ...tc, index: nextIndex++ });
continue;
}
for (const [i, args] of splitArgs.entries()) {
finalToolCalls.push({
id: tc.id ? `${tc.id}_split${i}` : null,
index: nextIndex++,
type: tc.type,
function: { name: tc.function.name, arguments: args },
});
}
}
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
if (finalToolCalls.length > 0) {
finishReason = "tool_calls";
message.tool_calls = finalToolCalls;

72
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@huggingface/transformers": "^4.2.0",
"@lobehub/icons": "^5.16.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",
@@ -60,6 +61,7 @@
"next-themes": "^0.4.6",
"node-machine-id": "^1.1.12",
"omniglyph": "^1.4.0",
"onnxruntime-node": "1.24.3",
"open": "^11.0.1",
"ora": "^9.4.1",
"parse5": "^8.0.1",
@@ -154,11 +156,9 @@
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "3.0.0",
"@huggingface/transformers": "^4.2.0",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
"onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0",
"wreq-js": "^3.0.0"
@@ -4510,7 +4510,6 @@
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
"integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -4519,15 +4518,13 @@
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz",
"integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==",
"license": "Apache-2.0",
"optional": true
"license": "Apache-2.0"
},
"node_modules/@huggingface/transformers": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz",
"integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@huggingface/jinja": "^0.5.6",
"@huggingface/tokenizers": "^0.1.3",
@@ -9486,35 +9483,30 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"devOptional": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
@@ -9524,28 +9516,24 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"devOptional": true,
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/number": {
@@ -12749,7 +12737,6 @@
"version": "26.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -14011,7 +13998,6 @@
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14.0"
}
@@ -14985,8 +14971,7 @@
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/bottleneck": {
"version": "2.19.5",
@@ -17950,7 +17935,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
@@ -17980,7 +17964,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
@@ -18081,8 +18064,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/detect-node-es": {
"version": "1.1.0",
@@ -18926,8 +18908,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/es6-promisify": {
"version": "7.0.0",
@@ -20419,8 +20400,7 @@
"version": "25.9.23",
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
"license": "Apache-2.0",
"optional": true
"license": "Apache-2.0"
},
"node_modules/flatted": {
"version": "3.4.2",
@@ -21246,7 +21226,6 @@
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"es6-error": "^4.1.1",
@@ -21264,7 +21243,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -21313,7 +21291,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"define-properties": "^1.2.1",
@@ -21668,8 +21645,7 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/hachure-fill": {
"version": "0.5.2",
@@ -21703,7 +21679,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
@@ -24815,8 +24790,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/json5": {
"version": "2.2.3",
@@ -26680,7 +26654,6 @@
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
"license": "MIT",
"optional": true,
"dependencies": {
"escape-string-regexp": "^4.0.0"
},
@@ -29452,7 +29425,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -29660,8 +29632,7 @@
"version": "1.24.3",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/onnxruntime-node": {
"version": "1.24.3",
@@ -29669,7 +29640,6 @@
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"win32",
"darwin",
@@ -29686,7 +29656,6 @@
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
"integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==",
"license": "MIT",
"optional": true,
"dependencies": {
"flatbuffers": "^25.1.24",
"guid-typescript": "^1.0.9",
@@ -29700,15 +29669,13 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0",
"optional": true
"license": "Apache-2.0"
},
"node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
"version": "1.24.0-dev.20251116-b39e144322",
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz",
"integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/open": {
"version": "11.0.1",
@@ -30939,8 +30906,7 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.62.1",
@@ -31895,7 +31861,6 @@
"version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
"devOptional": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -31919,7 +31884,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/proxy-addr": {
@@ -33316,7 +33280,6 @@
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"boolean": "^3.0.1",
"detect-node": "^2.0.4",
@@ -33692,8 +33655,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
@@ -33726,7 +33688,6 @@
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
"license": "MIT",
"optional": true,
"dependencies": {
"type-fest": "^0.13.1"
},
@@ -33742,7 +33703,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
"license": "(MIT OR CC0-1.0)",
"optional": true,
"engines": {
"node": ">=10"
},
@@ -34514,8 +34474,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause",
"optional": true
"license": "BSD-3-Clause"
},
"node_modules/sql.js": {
"version": "1.14.2",
@@ -36228,7 +36187,6 @@
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unicode-emoji-modifier-base": {

View File

@@ -265,6 +265,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@huggingface/transformers": "^4.2.0",
"@lobehub/icons": "^5.16.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@monaco-editor/react": "^4.7.0",
@@ -307,6 +308,7 @@
"next-themes": "^0.4.6",
"node-machine-id": "^1.1.12",
"omniglyph": "^1.4.0",
"onnxruntime-node": "1.24.3",
"open": "^11.0.1",
"ora": "^9.4.1",
"parse5": "^8.0.1",
@@ -341,11 +343,9 @@
},
"optionalDependencies": {
"@atjsh/llmlingua-2": "3.0.0",
"@huggingface/transformers": "^4.2.0",
"better-sqlite3": "^13.0.2",
"js-tiktoken": "^1.0.20",
"keytar": "^7.9.0",
"onnxruntime-node": "1.24.3",
"sqlite-vec": "^0.1.9",
"tls-client-node": "^0.2.0",
"wreq-js": "^3.0.0"

View File

@@ -131,12 +131,12 @@ function runNextBuild() {
}
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
// Turbopack is the default on Node.js; on Bun or when explicitly disabled (=0),
// use Webpack (--webpack) to avoid Turbopack V8 internal worker API mismatches.
if (process.versions.bun || baseEnv.OMNIROUTE_USE_TURBOPACK === "0") {
return "--webpack";
}
return "--turbopack";
// Turbopack is the default production bundler (Next 16 stable). Benchmarked on
// this codebase: 2-3x faster than the single-threaded webpack pass (17min -> 9min
// on a 32-core box; ~20min -> 7min on ubuntu-latest), artifact validated
// end-to-end (standalone smoke + e2e/package/electron CI jobs). Webpack stays as
// the explicit escape hatch (=0) for bundler-compat regressions.
return baseEnv.OMNIROUTE_USE_TURBOPACK === "0" ? "--webpack" : "--turbopack";
}
/**

View File

@@ -15,12 +15,6 @@ if (!support.nodeCompatible) {
process.exit(1);
}
if (process.versions.bun) {
console.log(
`Bun ${process.versions.bun} (${support.nodeVersion}) satisfies OmniRoute secure runtime policy.`
);
} else {
console.log(
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
);
}
console.log(
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
);

View File

@@ -83,10 +83,8 @@ const { dashboardPort } = runtimePorts;
const hostname = process.env.HOST || "0.0.0.0";
// Turbopack by default in dev (matches the Next 16 CLI default and the production
// build default in build-next-isolated.mjs); OMNIROUTE_USE_TURBOPACK=0 is the
// webpack escape hatch. Under Bun, Turbopack native V8 bindings are unavailable,
// so Bun automatically disables Turbopack and uses Webpack.
const isBun = Boolean(process.versions.bun);
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0" && !isBun;
// webpack escape hatch.
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0";
process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID();
// Per-process secret used to prove the trusted peer-IP stamp came from this
// server (read by the authz middleware in the same process). See peer-stamp.mjs.

View File

@@ -1,12 +1,12 @@
---
name: omni-webhooks
description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.
---
<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->
## Overview
Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.
## Authentication

View File

@@ -291,9 +291,7 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
icon="monitor_heart"
label={t("comboHealthIssues")}
value={report.summary.issueCount.toLocaleString()}
subValue={t("comboHealthActionable", {
count: report.summary.suggestionCount ?? report.summary.actionableCount ?? 0,
})}
subValue={t("comboHealthActionable", { count: report.summary.actionableCount })}
/>
<MetricBlock
icon="error"

View File

@@ -12,13 +12,8 @@ import {
} from "@/lib/combos/intelligentRouting";
import { AI_PROVIDERS } from "@/shared/constants/providers";
function getI18nOrFallback(
t: any,
key: string,
fallback: string,
values?: Record<string, unknown>
) {
if (typeof t?.has === "function" && t.has(key)) return t(key, values);
function getI18nOrFallback(t: any, key: string, fallback: string) {
if (typeof t?.has === "function" && t.has(key)) return t(key);
return fallback;
}
@@ -99,9 +94,10 @@ export default function IntelligentComboPanel({
const updatedCombo = await response.json();
onComboUpdated?.(updatedCombo);
notify.success(
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.", {
pack: modePackId,
}).replace("{pack}", modePackId)
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.").replace(
"{pack}",
modePackId
)
);
} catch (error: any) {
notify.error(error?.message || "Failed to update mode pack.");
@@ -188,9 +184,10 @@ export default function IntelligentComboPanel({
</div>
{savingModePack && (
<span className="text-[11px] text-text-muted">
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…", {
pack: savingModePack,
}).replace("{pack}", savingModePack)}
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
"{pack}",
savingModePack
)}
</span>
)}
</div>

View File

@@ -533,9 +533,9 @@ function getStrategyBadgeClass(strategy) {
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
}
function getI18nOrFallback(t, key, fallback, values) {
function getI18nOrFallback(t, key, fallback) {
try {
if (typeof t.has === "function" && t.has(key)) return t(key, values);
if (typeof t.has === "function" && t.has(key)) return t(key);
} catch {}
return fallback;
}
@@ -1565,8 +1565,7 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
{getI18nOrFallback(
t,
"recommendationsUpdated",
"Recommendations updated for {strategy}.",
{ strategy: strategyLabel }
"Recommendations updated for {strategy}."
).replace("{strategy}", strategyLabel)}
</div>
)}

View File

@@ -1,165 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import {
extractM365CredentialFromHar,
describeHarImportExpiry,
type M365HarImportResult,
} from "@/shared/utils/m365HarImport";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
type HarImporter = (text: string) => M365HarImportResult;
// One entry per web-session provider that can offer HAR import. Add a new
// key here (and its own extractor in src/shared/utils/) to support another
// provider — the button renders nothing for any provider not listed.
const HAR_IMPORTERS: Record<string, HarImporter> = {
"copilot-m365-web": extractM365CredentialFromHar,
};
const ERROR_MESSAGE_KEYS: Record<string, [string, string]> = {
notJson: ["harImportErrorNotJson", "That file isn't valid JSON — is it really a .har export?"],
noEntries: ["harImportErrorNoEntries", "This HAR has no network entries recorded."],
noChathubUrl: [
"harImportErrorNoChathubUrl",
"No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.",
],
unparsableUrl: [
"harImportErrorUnparsableUrl",
"Found the chat connection, but couldn't read its URL.",
],
missingFields: [
"harImportErrorMissingFields",
"Found the chat connection, but the token was missing from it.",
],
};
export interface HarImportButtonProps {
provider: string;
onImport: (apiKey: string) => void;
}
export default function HarImportButton({ provider, onImport }: HarImportButtonProps) {
const t = useTranslations("providers") as ProviderMessageTranslator;
const importer = HAR_IMPORTERS[provider];
const fileInputRef = useRef<HTMLInputElement>(null);
const [state, setState] = useState<
| { phase: "idle" }
| { phase: "reading" }
| { phase: "error"; message: string }
| { phase: "success"; expiresAt: number | null }
>({ phase: "idle" });
if (!importer) return null;
async function handleFile(file: File | undefined) {
if (!file) return;
setState({ phase: "reading" });
let text: string;
try {
text = await file.text();
} catch {
setState({
phase: "error",
message: providerText(t, "harImportErrorReadFailed", "Couldn't read that file."),
});
return;
}
const result = importer(text);
if (!result.ok) {
const [key, fallback] = ERROR_MESSAGE_KEYS[result.error] ?? [
"harImportErrorUnknown",
"Couldn't extract a credential from that HAR file.",
];
setState({ phase: "error", message: providerText(t, key, fallback) });
return;
}
onImport(result.apiKey);
setState({ phase: "success", expiresAt: result.expiresAt });
}
const expiry = state.phase === "success" ? describeHarImportExpiry(state.expiresAt) : null;
const expiryText =
expiry?.tone === "unknown"
? providerText(t, "harImportStatusUnknownExpiry", "Imported. Couldn't read its expiry.")
: expiry?.tone === "bad"
? providerText(
t,
"harImportStatusExpired",
"Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.",
{ minutes: Math.abs(expiry.minutesRemaining ?? 0) }
)
: expiry?.tone === "warn"
? providerText(
t,
"harImportStatusExpiringSoon",
"Imported — valid for only ~{minutes}m more.",
{ minutes: expiry.minutesRemaining ?? 0 }
)
: expiry?.tone === "ok"
? providerText(t, "harImportStatusValid", "Imported — valid for ~{minutes}m.", {
minutes: expiry.minutesRemaining ?? 0,
})
: null;
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={state.phase === "reading"}
data-testid="har-import-button"
className="inline-flex items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-xs font-medium text-text-main hover:bg-surface-hover disabled:opacity-50"
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
upload_file
</span>
{state.phase === "reading"
? providerText(t, "harImportButtonBusy", "Importing…")
: providerText(t, "harImportButtonLabel", "Import .har file")}
</button>
<span className="text-xs text-text-muted">
{providerText(
t,
"harImportButtonHint",
"Export from DevTools Network tab after sending at least one chat message."
)}
</span>
<input
ref={fileInputRef}
type="file"
accept=".har,application/json"
data-testid="har-import-input"
className="hidden"
onChange={(event) => {
void handleFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
</div>
{state.phase === "error" && (
<p className="text-xs text-red-600 dark:text-red-400" data-testid="har-import-error">
{state.message}
</p>
)}
{state.phase === "success" && expiryText && (
<p
className={
expiry?.tone === "bad"
? "text-xs text-red-600 dark:text-red-400"
: expiry?.tone === "warn"
? "text-xs text-amber-700 dark:text-amber-300"
: "text-xs text-emerald-700 dark:text-emerald-300"
}
data-testid="har-import-status"
>
{expiryText}
</p>
)}
</div>
);
}

View File

@@ -31,7 +31,6 @@ import {
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { buildAddProviderSpecificData } from "./connectionProviderSpecificData";
import { getCommandCodeAuthPhaseLabel } from "./commandCodeAuthPhase";
@@ -156,7 +155,7 @@ export default function AddApiKeyModal({
if (!isOpen || wasOpen) return;
// On open, reset baseUrl and assign a unique default name so a second API key
// for the same provider doesn't reuse "main" and trigger the backend
// name-based upsert that would silently overwrite the first connection (#6499, #11033).
// name-based upsert that would silently overwrite the first connection (#6499).
setFormData((current) => ({
...current,
name: computeConnectionDefaultName(existingConnectionCount),
@@ -210,13 +209,13 @@ export default function AddApiKeyModal({
? "Freebuff uses an authentic CLI auth token obtained via codebuff CLI login or automated harvester."
: isWebSessionCredential
? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false)
: isLocalSelfHostedProvider
? t("localProviderApiKeyOptionalHint", {
provider: localProviderMetadata?.name || providerName || provider || "",
})
: apiKeyOptional
? t("apiKeyOptionalHint")
: undefined;
: isLocalSelfHostedProvider
? t("localProviderApiKeyOptionalHint", {
provider: localProviderMetadata?.name || providerName || provider || "",
})
: apiKeyOptional
? t("apiKeyOptionalHint")
: undefined;
const credentialValidationFailedMessage = isWebSessionCredential
? providerText(
t,
@@ -751,12 +750,6 @@ export default function AddApiKeyModal({
t={t}
/>
)}
{provider && (
<HarImportButton
provider={provider}
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
/>
)}
{!isNoAuthWebSessionCredential && (
<div className="flex gap-2">
<Input
@@ -764,12 +757,6 @@ export default function AddApiKeyModal({
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter" && !validating && !saving) {
e.preventDefault();
handleValidate();
}
}}
className="flex-1"
placeholder={apiCredentialPlaceholder}
hint={apiCredentialHint}

View File

@@ -49,7 +49,6 @@ import {
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
import HarImportButton from "../HarImportButton";
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
import { CodexConnectionFields } from "./CodexFingerprintFields";
import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData";
@@ -910,12 +909,6 @@ export default function EditConnectionModal({
t={t}
/>
)}
{provider && (
<HarImportButton
provider={provider}
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
/>
)}
{!isNoAuthWebSessionCredential && (
<div className="flex gap-2">
<Input

View File

@@ -4,23 +4,7 @@
// connection. Deriving a unique default from the existing connection count keeps
// the first connection ("main") backward-compatible while giving each subsequent
// one a distinct name ("main-2", "main-3", …).
export function computeConnectionDefaultName(
existingConnectionCountOrConnections?: number | string[] | { name?: string }[]
): string {
if (Array.isArray(existingConnectionCountOrConnections)) {
const names = new Set(
existingConnectionCountOrConnections
.map((item) => (typeof item === "string" ? item : item?.name ?? ""))
.filter(Boolean)
);
if (!names.has("main")) return "main";
let index = 2;
while (names.has(`main-${index}`)) {
index++;
}
return `main-${index}`;
}
const count = existingConnectionCountOrConnections ?? 0;
export function computeConnectionDefaultName(existingConnectionCount?: number): string {
const count = existingConnectionCount ?? 0;
return count <= 0 ? "main" : `main-${count + 1}`;
}

View File

@@ -17,8 +17,6 @@ import { logRoutingDecision } from "@/lib/a2a/routingLogger";
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
import { getSettings } from "@/lib/db/settings";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
@@ -138,25 +136,14 @@ function tokensMatch(provided: string, expected: string): boolean {
return timingSafeEqual(a, b);
}
async function authenticate(req: NextRequest): Promise<boolean> {
// /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
// pipeline enforces for /v1 never ran here — the route accepted every caller
// whenever OMNIROUTE_API_KEY was unset, which is the shipped default
// (GHSA-v54m-6rm3-p565). Apply the same posture directly: when a client key is
// required, demand a valid OmniRoute key; otherwise honor the legacy explicit
// A2A key; otherwise stay keyless (the same local-first default as /v1).
const apiKey = extractApiKey(req);
if (isRequireApiKeyEnabled()) {
return apiKey ? await isValidApiKey(apiKey) : false;
}
function authenticate(req: NextRequest): boolean {
// If no API key is configured, allow all requests
const configuredKey = process.env.OMNIROUTE_API_KEY;
if (configuredKey) {
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
}
if (!configuredKey) return true;
// No API key required and none configured — allow (keyless local-first).
return true;
const authHeader = req.headers.get("authorization") || "";
const token = authHeader.replace(/^Bearer\s+/i, "");
return tokensMatch(token, configuredKey);
}
// ============ JSON-RPC Helpers ============
@@ -192,7 +179,7 @@ async function rejectIfA2ADisabled(id: string | number | null) {
export async function POST(req: NextRequest) {
// Auth check
if (!(await authenticate(req))) {
if (!authenticate(req)) {
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
}

View File

@@ -1,7 +1,5 @@
import { NextResponse } from "next/server";
import { z } from "zod";
export const dynamic = "force-dynamic";
import {
type CliAgentInfo,
detectInstalledAgents,

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { getAllRateLimitStatus } from "@omniroute/open-sse/services/rateLimitManager.ts";
import {
getStats as getSemaphoreStats,

View File

@@ -6,7 +6,6 @@
*/
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { getCompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { issueDashboardCsrfToken } from "@/server/authz/csrf";

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { cookies } from "next/headers";
import { jwtVerify } from "jose";

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { getBatch } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

View File

@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { listBatches } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";

View File

@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
listSemanticCacheEntries,

View File

@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
clearReasoningCacheAll,

View File

@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import {
getCacheStats,
clearCache,

View File

@@ -1,5 +1,4 @@
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
import { clearMemoryCache, getMemoryCacheStats } from "@/lib/semanticCache";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";

View File

@@ -38,14 +38,7 @@ export async function GET(request: NextRequest) {
const url = new URL(request.url);
const scope: LeaderboardScope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
const rawLimit = url.searchParams.get("limit");
const limit = rawLimit === null ? 100 : Number(rawLimit);
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
return NextResponse.json(
{ error: "'limit' must be an integer between 1 and 200" },
{ status: 400, headers: CORS_HEADERS }
);
}
const limit = Number(url.searchParams.get("limit") || 100);
const entries = await getTopN(scope, limit);

View File

@@ -18,18 +18,10 @@ export async function GET(request: NextRequest) {
const url = new URL(request.url);
const scope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
const rawLimit = url.searchParams.get("limit");
const limit = rawLimit === null ? 50 : Number(rawLimit);
const limit = Number(url.searchParams.get("limit") || 50);
const apiKeyId = url.searchParams.get("apiKeyId");
if (!Number.isInteger(limit) || limit < 1 || limit > 200) {
return NextResponse.json(
{ error: "'limit' must be an integer between 1 and 200" },
{ status: 400, headers: CORS_HEADERS }
);
}
const entries = await getTopN(scope, limit);
const entries = await getTopN(scope, Math.min(limit, 200));
let myRank: number | null = null;
let neighbors = null;

View File

@@ -5,7 +5,6 @@ import { readRunningBuildSha } from "@/lib/monitoring/buildSha";
import { APP_CONFIG } from "@/shared/constants/config";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
/**
* GET /api/monitoring/health — System health overview
@@ -21,25 +20,10 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null;
const HEALTH_PAYLOAD_TTL_MS = 1000;
// GHSA-mvf8-qc78-5mxm: the full health payload fingerprints the host (version,
// node version, pid, memory, provider config). An anonymous caller — the common
// case on a keyless install, and what a liveness/load-balancer probe needs — gets
// only the liveness verdict; the detail is reserved for a management principal.
function publicHealthView(payload: unknown): Record<string, unknown> {
const p = (payload ?? {}) as Record<string, unknown>;
return {
status: p.status ?? "unknown",
...(p.setupComplete !== undefined ? { setupComplete: p.setupComplete } : {}),
};
}
export async function GET(request: Request) {
const fullView = (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
export async function GET() {
const cachedNow = Date.now();
if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) {
return NextResponse.json(
fullView ? healthPayloadCache.payload : publicHealthView(healthPayloadCache.payload)
);
return NextResponse.json(healthPayloadCache.payload);
}
const readHealthValue = <T>(label: string, reader: () => T, fallback: T): T => {
@@ -203,7 +187,7 @@ export async function GET(request: Request) {
});
healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS };
return NextResponse.json(fullView ? payload : publicHealthView(payload));
return NextResponse.json(payload);
} catch (error) {
console.error("[API] GET /api/monitoring/health error:", error);
return NextResponse.json({

View File

@@ -24,7 +24,6 @@ import {
} from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
import { syncToCloud } from "@/lib/cloudSync";
import { startLocalServer } from "@/lib/oauth/utils/server";
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
@@ -222,16 +221,6 @@ export async function GET(
(requestDeviceCode as any)(provider, null, providerOverrideConfig)
);
} else if ((provider === "kiro" || provider === "amazon-q") && startUrl) {
// GHSA-7x63: `region` is interpolated into the AWS OIDC endpoint URLs
// below, which requestDeviceCode() then fetches. Validate it against the
// canonical AWS region shape before it can steer the outbound host to an
// attacker-chosen target (userinfo/fragment tricks → SSRF / metadata).
if (!AWS_REGION_PATTERN.test(region)) {
return NextResponse.json(
{ error: "region must be a valid AWS region (e.g. us-east-1)" },
{ status: 400 }
);
}
const providerOverrideConfig = {
...providerData.config,
startUrl,

View File

@@ -3,7 +3,7 @@ import path from "path";
import { NextResponse } from "next/server";
import { createProviderConnection } from "@/models";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import {
scanCliProxyAuthDir,
@@ -23,9 +23,9 @@ function cliProxyConfigDir(): string {
}
async function requireImportAuth(request: Request) {
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
// require management scope (or a dashboard session), not any valid client key.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
export async function GET(request: Request) {

View File

@@ -3,7 +3,7 @@ import { z } from "zod";
import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport";
import { parseCodexSessionJson } from "@/lib/oauth/utils/codexSessionImport";
import { createProviderConnection } from "@/models";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
/**
@@ -93,11 +93,10 @@ async function parseRequestBody(
return { ok: true, resolved: resolved.resolved };
}
async function requireAuth(request: Request): Promise<Response | null> {
// GHSA-mg76: importing a provider connection is a state-mutating admin action.
// Require management scope (or a dashboard session) rather than accepting any
// valid client key, which the PUBLIC /api/oauth/ classification otherwise allows.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
async function requireAuth(request: Request): Promise<NextResponse | null> {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json(buildErrorBody(401, "Unauthorized"), { status: 401 });
}
export async function POST(request: Request) {

View File

@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { normalizeCodexImportRecord, flattenCodexImportPayload } from "@/lib/oauth/services/codexImport";
import { createProviderConnection } from "@/models";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { refreshCodexToken, isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts";
@@ -82,10 +82,10 @@ const bodySchema = z.object({
}),
});
async function requireAuth(request: Request): Promise<Response | null> {
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
// require management scope (or a dashboard session), not any valid client key.
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
async function requireAuth(request: Request): Promise<NextResponse | null> {
if (!(await isAuthRequired(request))) return null;
if (await isAuthenticated(request)) return null;
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
export async function POST(request: Request) {

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