mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 07:02:16 +03:00
Compare commits
27 Commits
fix/10986-
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cd4d38e21 | ||
|
|
b6412c6fed | ||
|
|
e06f8b7ec0 | ||
|
|
5a60a46e22 | ||
|
|
7ddbaf69a4 | ||
|
|
7ffa3efaf0 | ||
|
|
7c39e95972 | ||
|
|
8643e0f57c | ||
|
|
7e48be8061 | ||
|
|
9b801b7e09 | ||
|
|
0ff0490ada | ||
|
|
f968496cc6 | ||
|
|
c9775366f9 | ||
|
|
666e4aaca2 | ||
|
|
1c920eb8b8 | ||
|
|
02a6c3d90b | ||
|
|
ae2de4511b | ||
|
|
d01a4ae6cf | ||
|
|
9349af29c4 | ||
|
|
eb4fd74b13 | ||
|
|
99111f39fb | ||
|
|
4e3e53ee4d | ||
|
|
a928fad895 | ||
|
|
d61eec63b5 | ||
|
|
861ac69e4b | ||
|
|
484cb6e562 | ||
|
|
2ab16d3214 |
9
.github/workflows/dast-smoke.yml
vendored
9
.github/workflows/dast-smoke.yml
vendored
@@ -46,6 +46,7 @@ jobs:
|
||||
env:
|
||||
PORT: "20128"
|
||||
INJECTION_GUARD_MODE: block
|
||||
REQUIRE_API_KEY: "false"
|
||||
run: |
|
||||
node dist/server.js > server.log 2>&1 &
|
||||
echo $! > server.pid
|
||||
@@ -64,16 +65,20 @@ 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/' \
|
||||
--exclude-path-regex '^/api/auth/(oidc/|login)' \
|
||||
--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: npx --yes promptfoo@latest eval -c promptfooconfig.yaml --no-cache
|
||||
run: promptfoo eval -c promptfooconfig.yaml --no-cache
|
||||
- name: Stop server
|
||||
if: always()
|
||||
run: kill "$(cat server.pid)" || true
|
||||
|
||||
89
Dockerfile.bun
Normal file
89
Dockerfile.bun
Normal file
@@ -0,0 +1,89 @@
|
||||
# ── 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"]
|
||||
13
README.md
13
README.md
@@ -1009,6 +1009,19 @@ 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
|
||||
|
||||
@@ -9,10 +9,13 @@ 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 res = spawnSync("npm", args, { stdio: "inherit", shell: false });
|
||||
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 });
|
||||
if (res.error) throw res.error;
|
||||
if (typeof res.status === "number" && res.status !== 0) {
|
||||
throw new Error(`npm exited with code ${res.status}`);
|
||||
throw new Error(`${pm} exited with code ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [exe, args] = isWin ? ["cmd.exe", ["/c", "npm", ...npmArgs]] : ["npm", npmArgs];
|
||||
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(" ")}`;
|
||||
}
|
||||
|
||||
if (!opts.silent) {
|
||||
process.stdout.write(`[omniroute][runtime] npm ${npmArgs.join(" ")}\n`);
|
||||
process.stdout.write(`[omniroute][runtime] ${displayCmd}\n`);
|
||||
}
|
||||
const res = spawnSync(exe, args, {
|
||||
cwd,
|
||||
|
||||
@@ -5,10 +5,14 @@ import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./
|
||||
|
||||
async function loadSqlite() {
|
||||
if (process.versions.bun) {
|
||||
return { Database: (await import("bun:sqlite")).Database };
|
||||
try {
|
||||
return { Database: (await import("bun:sqlite")).Database, driver: "bun:sqlite" };
|
||||
} catch (bunError) {
|
||||
// fall through to better-sqlite3 if bun:sqlite fails
|
||||
}
|
||||
}
|
||||
try {
|
||||
return { Database: (await import("better-sqlite3")).default };
|
||||
return { Database: (await import("better-sqlite3")).default, driver: "better-sqlite3" };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
@@ -86,12 +90,14 @@ 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 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)."
|
||||
`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).`
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -100,10 +106,9 @@ 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). " +
|
||||
"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)."
|
||||
`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).`
|
||||
);
|
||||
}
|
||||
return error;
|
||||
@@ -111,7 +116,7 @@ export function createSqliteNativeError(error) {
|
||||
|
||||
async function openSqliteDatabase(dbPath, options = {}) {
|
||||
const loaded = await loadSqlite();
|
||||
if (process.versions.bun) {
|
||||
if (loaded.driver === "bun:sqlite" || (process.versions.bun && !loaded.Database)) {
|
||||
if (options.fileMustExist && !fs.existsSync(dbPath)) {
|
||||
throw new Error(`SQLite file does not exist: ${dbPath}`);
|
||||
}
|
||||
|
||||
@@ -94,10 +94,15 @@ export function ensureAndroidCacheDir(options = {}) {
|
||||
*/
|
||||
export function isFatalInstrumentationHookFailure(text) {
|
||||
if (!text) return false;
|
||||
return (
|
||||
/Unsupported platform:\s*android/i.test(text) ||
|
||||
/error occurred while loading instrumentation hook/i.test(text)
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,18 @@ 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;
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import updateNotifier from "update-notifier";
|
||||
let updateNotifier = null;
|
||||
try {
|
||||
updateNotifier = (await import("update-notifier")).default;
|
||||
} catch {
|
||||
// update-notifier is optional in pruned standalone environments
|
||||
}
|
||||
import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.mjs";
|
||||
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";
|
||||
import { getDefaultDataDir } from "./cli/data-dir.mjs";
|
||||
@@ -251,8 +256,9 @@ 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({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 });
|
||||
const _notifier = updateNotifier ? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }) : null;
|
||||
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;
|
||||
|
||||
1
changelog.d/fixes/10028-windows-instrumentation-hook.md
Normal file
1
changelog.d/fixes/10028-windows-instrumentation-hook.md
Normal file
@@ -0,0 +1 @@
|
||||
- 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)
|
||||
1
changelog.d/fixes/10265-command-code-provider-api.md
Normal file
1
changelog.d/fixes/10265-command-code-provider-api.md
Normal file
@@ -0,0 +1 @@
|
||||
- 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)
|
||||
1
changelog.d/fixes/10523-servicesupervisor-port-flake.md
Normal file
1
changelog.d/fixes/10523-servicesupervisor-port-flake.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(services): isolate probeBeforeSpawn adoption tests on distinct ports to stop the order-dependent flake (#10523)
|
||||
1
changelog.d/fixes/10986-reasoning-only-content.md
Normal file
1
changelog.d/fixes/10986-reasoning-only-content.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(command-code): surface reasoning-only output as content when a model emits no text-delta (#10986)
|
||||
1
changelog.d/fixes/10990-v0-vercel-web-static-catalog.md
Normal file
1
changelog.d/fixes/10990-v0-vercel-web-static-catalog.md
Normal file
@@ -0,0 +1 @@
|
||||
- **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)).
|
||||
1
changelog.d/fixes/10997-blackbox-deprecation.md
Normal file
1
changelog.d/fixes/10997-blackbox-deprecation.md
Normal file
@@ -0,0 +1 @@
|
||||
- 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))
|
||||
1
changelog.d/fixes/11002-dify-key-validation.md
Normal file
1
changelog.d/fixes/11002-dify-key-validation.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): validate Dify keys against its native /v1/chat-messages endpoint (#11002)
|
||||
1
changelog.d/fixes/11050-remove-ghost-webhook-events.md
Normal file
1
changelog.d/fixes/11050-remove-ghost-webhook-events.md
Normal file
@@ -0,0 +1 @@
|
||||
- **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))
|
||||
1
changelog.d/fixes/8864-uncloseai-noauth.md
Normal file
1
changelog.d/fixes/8864-uncloseai-noauth.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(dashboard): treat UncloseAI as a no-auth provider so the connect form no longer forces a fake API key (#8864)
|
||||
@@ -0,0 +1 @@
|
||||
- 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)).
|
||||
3
changelog.d/fixes/release-v3850-basereds.md
Normal file
3
changelog.d/fixes/release-v3850-basereds.md
Normal file
@@ -0,0 +1,3 @@
|
||||
- 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
|
||||
@@ -0,0 +1 @@
|
||||
- **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))
|
||||
1
changelog.d/maintenance/11038-filesize-baseline-fix.md
Normal file
1
changelog.d/maintenance/11038-filesize-baseline-fix.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(quality): rebaseline file-size for modelCapabilities.ts (1016->1072) drift from merged tip fixes (#11034 et al)
|
||||
@@ -1,9 +1,6 @@
|
||||
{
|
||||
"open-sse/services/payloadRules.ts": {
|
||||
"TS2677": 1
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/HomePageClient.tsx": {
|
||||
"TS2339": 16
|
||||
"TS2339": 10
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": {
|
||||
"TS2503": 3
|
||||
@@ -120,10 +117,6 @@
|
||||
"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
|
||||
},
|
||||
@@ -141,12 +134,6 @@
|
||||
"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
|
||||
},
|
||||
@@ -190,9 +177,6 @@
|
||||
"src/lib/combos/builderDraft.ts": {
|
||||
"TS2741": 1
|
||||
},
|
||||
"src/lib/providers/codexFastTier.ts": {
|
||||
"TS2367": 1
|
||||
},
|
||||
"src/lib/services/htmlRewriter.ts": {
|
||||
"TS2322": 2,
|
||||
"TS2345": 2
|
||||
@@ -219,14 +203,7 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,21 +443,24 @@
|
||||
"src/shared/components/ModelSelectModal.tsx": 1138,
|
||||
"src/shared/constants/providers/apikey/gateways.ts": 1250
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1067,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1080,
|
||||
"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": 1016,
|
||||
"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/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014,
|
||||
"open-sse/config/imageRegistry.ts": 1034,
|
||||
"src/sse/handlers/chatHelpers.ts": 1019,
|
||||
"src/shared/middleware/chatBodyAdmission.ts": 1005,
|
||||
"_rebaseline_2026_08_20_10668_tabitoken_gateway": "#10668 (yawar-aquil) own catalog growth: src/shared/constants/providers/apikey/gateways.ts 1268->1283 (+15, entirely this PR diff -- one new tabitoken gateway entry, data lines only; base moved from 1255 to 1268 via other merges since the PR forked). Not combination drift: reproducible on the PR branch alone, so the WS5.5 release-captain rule does not apply. Extraction is not available -- the file is pure data (own header: \"Pure data; merged by apikey/index.ts via spread\") and already split into 6 family files under apikey/. Same precedent as _rebaseline_2026_08_14_imagetotext_servicekinds (#10275/#10291, gateways.ts 1250->1255, data lines only) and _rebaseline_2026_08_11_v3850_merge_storm_provider_registry (owner-authorized for this same file).",
|
||||
"open-sse/executors/commandCode.ts": 1038,
|
||||
"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_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_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).",
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
{
|
||||
"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,
|
||||
|
||||
16
open-sse/config/opencodeZenGoSharedModels.ts
Normal file
16
open-sse/config/opencodeZenGoSharedModels.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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 },
|
||||
]);
|
||||
@@ -5,6 +5,12 @@ 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",
|
||||
|
||||
@@ -8,7 +8,11 @@ export const command_codeProvider: RegistryEntry = {
|
||||
format: "openai",
|
||||
executor: "command-code",
|
||||
baseUrl: "https://api.commandcode.ai",
|
||||
chatPath: "/alpha/generate",
|
||||
// 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",
|
||||
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.
|
||||
|
||||
@@ -5,7 +5,11 @@ export const difyProvider: RegistryEntry = {
|
||||
alias: "dify",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://api.dify.ai/v1/chat/completions",
|
||||
// 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",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: [{ id: "auto", name: "Auto" }],
|
||||
|
||||
@@ -16,7 +16,7 @@ export const hailuo_webProvider: RegistryEntry = {
|
||||
alias: "hailuo-web",
|
||||
format: "openai",
|
||||
executor: "hailuo-web",
|
||||
baseUrl: "https://www.hailuo.ai",
|
||||
baseUrl: "https://chat.minimax.io",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: HAILUO_WEB_STATIC_MODELS,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RegistryEntry } from "../../../shared.ts";
|
||||
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
|
||||
|
||||
export const opencode_goProvider: RegistryEntry = {
|
||||
id: "opencode-go",
|
||||
@@ -13,6 +14,8 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
authPrefix: "Bearer",
|
||||
defaultContextLength: 200000,
|
||||
models: [
|
||||
...OPENCODE_ZEN_GO_SHARED_MODELS,
|
||||
|
||||
// Port from decolua/9router 8efacc11: align with official Go endpoints —
|
||||
// glm-5.2 is now advertised and Kimi chat traffic must route through
|
||||
// `kimi-k2.7-code` (the live API rejects the plain `kimi-k2.7` alias for
|
||||
@@ -25,7 +28,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
{ id: "glm-5.2-max", name: "GLM-5.2 (max effort)", supportsReasoning: true },
|
||||
{ id: "glm-5.1", name: "GLM-5.1" },
|
||||
{ id: "glm-5", name: "GLM-5" },
|
||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
|
||||
// kimi-k2.7-code declared identically on opencode-zen — see OPENCODE_ZEN_GO_SHARED_MODELS.
|
||||
{ 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.
|
||||
@@ -89,7 +92,8 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
|
||||
// qwen3.6-plus / qwen3.5-plus base ids declared identically on opencode-zen — see
|
||||
// OPENCODE_ZEN_GO_SHARED_MODELS.
|
||||
{
|
||||
id: "qwen3.6-plus-high",
|
||||
name: "Qwen3.6 Plus (high effort)",
|
||||
@@ -104,7 +108,6 @@ 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 },
|
||||
{
|
||||
@@ -138,6 +141,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-minimal",
|
||||
@@ -148,6 +152,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-low",
|
||||
@@ -158,6 +163,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-medium",
|
||||
@@ -168,6 +174,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-high",
|
||||
@@ -178,6 +185,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-xhigh",
|
||||
@@ -188,6 +196,7 @@ 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 },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RegistryEntry } from "../../../shared.ts";
|
||||
import { OPENCODE_ZEN_GO_SHARED_MODELS } from "../../../shared.ts";
|
||||
|
||||
export const opencode_zenProvider: RegistryEntry = {
|
||||
id: "opencode-zen",
|
||||
@@ -15,6 +16,8 @@ export const opencode_zenProvider: RegistryEntry = {
|
||||
// from the live API response so new models work without a code deploy.
|
||||
passthroughModels: true,
|
||||
models: [
|
||||
...OPENCODE_ZEN_GO_SHARED_MODELS,
|
||||
|
||||
// ── Chat / Coding ──────────────────────────────────────────
|
||||
// #2900: big-pickle's upstream runs DeepSeek thinking mode — declare the
|
||||
// interleaved reasoning_content contract so follow-up/tool-use turns replay
|
||||
@@ -51,7 +54,25 @@ export const opencode_zenProvider: RegistryEntry = {
|
||||
{ id: "grok-4.6", name: "Grok 4.6" },
|
||||
|
||||
// ── Muse ───────────────────────────────────────────────────
|
||||
{ id: "muse-spark-1.2", name: "Muse Spark 1.2" },
|
||||
// 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",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-free",
|
||||
name: "Muse Spark 1.2 Contributor Free",
|
||||
supportsReasoning: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
|
||||
// ── DeepSeek ────────────────────────────────────────────────
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||
@@ -66,7 +87,7 @@ export const opencode_zenProvider: RegistryEntry = {
|
||||
|
||||
// ── Kimi / Moonshot ────────────────────────────────────────
|
||||
{ id: "kimi-k3", name: "Kimi K3" },
|
||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
|
||||
// kimi-k2.7-code declared identically on opencode-go — see OPENCODE_ZEN_GO_SHARED_MODELS.
|
||||
|
||||
// ── Qwen ───────────────────────────────────────────────────
|
||||
// Issue #2292: Qwen models return Claude-format SSE bodies even
|
||||
@@ -74,8 +95,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.
|
||||
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", targetFormat: "claude", supportsVision: false },
|
||||
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", targetFormat: "claude", supportsVision: false },
|
||||
// qwen3.5-plus / qwen3.6-plus declared identically on opencode-go — see
|
||||
// OPENCODE_ZEN_GO_SHARED_MODELS.
|
||||
|
||||
// ── Free Tier ──────────────────────────────────────────────
|
||||
// #6998 (2026-07-14): upstream free tier rotated — minimax-m2.5-free,
|
||||
|
||||
@@ -25,6 +25,7 @@ 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,
|
||||
@@ -719,6 +720,7 @@ export {
|
||||
GLM_TIMEOUT_MS,
|
||||
GLMT_TIMEOUT_MS,
|
||||
GLM_SHARED_MODELS,
|
||||
OPENCODE_ZEN_GO_SHARED_MODELS,
|
||||
MARITALK_DEFAULT_BASE_URL,
|
||||
CURSOR_REGISTRY_VERSION,
|
||||
getAntigravityProviderHeaders,
|
||||
|
||||
@@ -104,6 +104,12 @@ 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 {
|
||||
@@ -397,6 +403,29 @@ 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
|
||||
@@ -615,6 +644,7 @@ 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 =
|
||||
@@ -869,6 +899,9 @@ 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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,17 +30,45 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const;
|
||||
export const KEEPALIVE_PING = { type: 6 } as const;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export const ALLOWED_MESSAGE_TYPES = [
|
||||
"Chat",
|
||||
"Suggestion",
|
||||
"InternalSearchQuery",
|
||||
"Disengaged",
|
||||
"Progress",
|
||||
"EndOfRequest",
|
||||
"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",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -78,19 +106,26 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Individual / EDU option sets from a 2026-08-21 live capture — 34 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.
|
||||
*/
|
||||
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",
|
||||
@@ -98,6 +133,20 @@ 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. */
|
||||
@@ -433,12 +482,14 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
|
||||
}
|
||||
return {
|
||||
optionsSets: [...M365_DEFAULT_OPTION_SETS],
|
||||
// #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",
|
||||
// 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",
|
||||
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
|
||||
// Omitted entirely on the individual/EDU wire (see ChatInvocationOptions).
|
||||
disconnectBehavior: undefined,
|
||||
// 2026-08-21 capture — disconnectBehavior:"continue" is now present on the
|
||||
// individual/consumer wire too, not just enterprise (see ChatInvocationOptions).
|
||||
disconnectBehavior: "continue",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -467,16 +518,33 @@ export function resolveToneForModel(model: string | undefined): string | undefin
|
||||
|
||||
/**
|
||||
* Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated).
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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",
|
||||
@@ -487,17 +555,17 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
|
||||
? [...opts.allowedMessageTypes]
|
||||
: [...ALLOWED_MESSAGE_TYPES],
|
||||
clientCorrelationId: opts.clientCorrelationId ?? opts.traceId,
|
||||
clientInfo: {
|
||||
clientAppName: "Office",
|
||||
clientPlatform: "mcmcopilot-web",
|
||||
},
|
||||
clientInfo,
|
||||
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",
|
||||
@@ -510,22 +578,27 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
|
||||
requestId: opts.requestId,
|
||||
text: opts.text,
|
||||
},
|
||||
isSbsSupported: true,
|
||||
options: {},
|
||||
optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS],
|
||||
plugins: opts.plugins ?? [],
|
||||
// 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" }],
|
||||
...(opts.customInstructions ? { customInstructions: opts.customInstructions } : {}),
|
||||
productThreadType: "Office",
|
||||
renderReferencesBehindEOS: true,
|
||||
sessionId: opts.sessionId,
|
||||
sliceIds: [],
|
||||
source: "officeweb",
|
||||
streamingMode: "ConciseWithPadding",
|
||||
threadLevelGptId: {},
|
||||
tone: opts.tone ?? "magic",
|
||||
// 2026-08-21 capture (#11069): tone is now capitalized "Magic" on both tiers.
|
||||
tone: opts.tone ?? "Magic",
|
||||
toolChoice: opts.toolChoice ?? null,
|
||||
traceId: opts.traceId,
|
||||
// #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 } : {}),
|
||||
// 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -430,6 +430,7 @@ 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,
|
||||
|
||||
@@ -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://www.hailuo.ai";
|
||||
const BASE_URL = "https://chat.minimax.io";
|
||||
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";
|
||||
|
||||
@@ -471,6 +471,7 @@ 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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
|
||||
import { PROVIDERS } from "../config/constants.ts";
|
||||
import { getModelTargetFormat } from "../config/providerModels.ts";
|
||||
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
|
||||
import {
|
||||
injectReasoningContentForThinkingModel,
|
||||
isThinkingMessageModel,
|
||||
@@ -125,6 +125,24 @@ 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 {
|
||||
@@ -193,7 +211,10 @@ 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);
|
||||
}
|
||||
|
||||
@@ -202,7 +223,7 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
async execute(input: ExecuteInput) {
|
||||
this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai";
|
||||
this._requestFormat = resolveOpencodeTargetFormat(this.provider, input.model);
|
||||
|
||||
// #8681: Gate premium opencode models behind a usable API key.
|
||||
// When the connection is keyless (no apiKey, no accessToken) and the model
|
||||
|
||||
@@ -10,16 +10,24 @@ import {
|
||||
import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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 (await resolveMcpCallerApiKeyId().catch(() => undefined)) || "mcp";
|
||||
return "mcp";
|
||||
}
|
||||
|
||||
export const MemorySearchSchema = z.object({
|
||||
|
||||
@@ -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: (settings.autoRefreshProviderQuotaInterval ?? 180) * 1000,
|
||||
maxStateAgeMs: (Number(settings.autoRefreshProviderQuotaInterval) || 180) * 1000,
|
||||
});
|
||||
if (strictFilteredPool !== pool) pool = strictFilteredPool;
|
||||
|
||||
|
||||
@@ -128,6 +128,75 @@ 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),
|
||||
@@ -262,7 +331,27 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer {
|
||||
message.reasoning_content = joinedReasoning;
|
||||
}
|
||||
|
||||
const finalToolCalls = [...toolCalls.values()].sort((a, b) => a.index - b.index);
|
||||
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;
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (finalToolCalls.length > 0) {
|
||||
finishReason = "tool_calls";
|
||||
message.tool_calls = finalToolCalls;
|
||||
|
||||
@@ -131,12 +131,12 @@ function runNextBuild() {
|
||||
}
|
||||
|
||||
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
|
||||
// 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";
|
||||
// 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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,12 @@ if (!support.nodeCompatible) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Node.js ${support.nodeVersion} satisfies OmniRoute secure runtime policy (${support.supportedRange}).`
|
||||
);
|
||||
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}).`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,8 +83,10 @@ 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.
|
||||
const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK !== "0";
|
||||
// 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;
|
||||
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.
|
||||
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
} from "@/lib/combos/intelligentRouting";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
function getI18nOrFallback(t: any, key: string, fallback: string) {
|
||||
if (typeof t?.has === "function" && t.has(key)) return t(key);
|
||||
function getI18nOrFallback(
|
||||
t: any,
|
||||
key: string,
|
||||
fallback: string,
|
||||
values?: Record<string, unknown>
|
||||
) {
|
||||
if (typeof t?.has === "function" && t.has(key)) return t(key, values);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -94,10 +99,9 @@ export default function IntelligentComboPanel({
|
||||
const updatedCombo = await response.json();
|
||||
onComboUpdated?.(updatedCombo);
|
||||
notify.success(
|
||||
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.").replace(
|
||||
"{pack}",
|
||||
modePackId
|
||||
)
|
||||
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.", {
|
||||
pack: modePackId,
|
||||
}).replace("{pack}", modePackId)
|
||||
);
|
||||
} catch (error: any) {
|
||||
notify.error(error?.message || "Failed to update mode pack.");
|
||||
@@ -184,10 +188,9 @@ export default function IntelligentComboPanel({
|
||||
</div>
|
||||
{savingModePack && (
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
|
||||
"{pack}",
|
||||
savingModePack
|
||||
)}
|
||||
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…", {
|
||||
pack: savingModePack,
|
||||
}).replace("{pack}", savingModePack)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -533,9 +533,9 @@ function getStrategyBadgeClass(strategy) {
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
function getI18nOrFallback(t, key, fallback) {
|
||||
function getI18nOrFallback(t, key, fallback, values) {
|
||||
try {
|
||||
if (typeof t.has === "function" && t.has(key)) return t(key);
|
||||
if (typeof t.has === "function" && t.has(key)) return t(key, values);
|
||||
} catch {}
|
||||
return fallback;
|
||||
}
|
||||
@@ -1565,7 +1565,8 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"recommendationsUpdated",
|
||||
"Recommendations updated for {strategy}."
|
||||
"Recommendations updated for {strategy}.",
|
||||
{ strategy: strategyLabel }
|
||||
).replace("{strategy}", strategyLabel)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ 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";
|
||||
@@ -155,7 +156,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).
|
||||
// name-based upsert that would silently overwrite the first connection (#6499, #11033).
|
||||
setFormData((current) => ({
|
||||
...current,
|
||||
name: computeConnectionDefaultName(existingConnectionCount),
|
||||
@@ -209,13 +210,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,
|
||||
@@ -750,6 +751,12 @@ export default function AddApiKeyModal({
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{provider && (
|
||||
<HarImportButton
|
||||
provider={provider}
|
||||
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
|
||||
/>
|
||||
)}
|
||||
{!isNoAuthWebSessionCredential && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
@@ -757,6 +764,12 @@ 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}
|
||||
|
||||
@@ -49,6 +49,7 @@ 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";
|
||||
@@ -909,6 +910,12 @@ export default function EditConnectionModal({
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{provider && (
|
||||
<HarImportButton
|
||||
provider={provider}
|
||||
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
|
||||
/>
|
||||
)}
|
||||
{!isNoAuthWebSessionCredential && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
|
||||
@@ -4,7 +4,23 @@
|
||||
// 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(existingConnectionCount?: number): string {
|
||||
const count = existingConnectionCount ?? 0;
|
||||
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;
|
||||
return count <= 0 ? "main" : `main-${count + 1}`;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ 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,
|
||||
@@ -136,14 +138,25 @@ function tokensMatch(provided: string, expected: string): boolean {
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function authenticate(req: NextRequest): boolean {
|
||||
// If no API key is configured, allow all requests
|
||||
const configuredKey = process.env.OMNIROUTE_API_KEY;
|
||||
if (!configuredKey) return true;
|
||||
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;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
const token = authHeader.replace(/^Bearer\s+/i, "");
|
||||
return tokensMatch(token, configuredKey);
|
||||
const configuredKey = process.env.OMNIROUTE_API_KEY;
|
||||
if (configuredKey) {
|
||||
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
|
||||
}
|
||||
|
||||
// No API key required and none configured — allow (keyless local-first).
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============ JSON-RPC Helpers ============
|
||||
@@ -179,7 +192,7 @@ async function rejectIfA2ADisabled(id: string | number | null) {
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth check
|
||||
if (!authenticate(req)) {
|
||||
if (!(await authenticate(req))) {
|
||||
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
import {
|
||||
type CliAgentInfo,
|
||||
detectInstalledAgents,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { getAllRateLimitStatus } from "@omniroute/open-sse/services/rateLimitManager.ts";
|
||||
import {
|
||||
getStats as getSemaphoreStats,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { getCompressionAnalyticsSummary } from "@/lib/db/compressionAnalytics";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { issueDashboardCsrfToken } from "@/server/authz/csrf";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { cookies } from "next/headers";
|
||||
import { jwtVerify } from "jose";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { getBatch } from "@/lib/localDb";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { listBatches } from "@/lib/localDb";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
|
||||
1
src/app/api/cache/entries/route.ts
vendored
1
src/app/api/cache/entries/route.ts
vendored
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import {
|
||||
listSemanticCacheEntries,
|
||||
|
||||
1
src/app/api/cache/reasoning/route.ts
vendored
1
src/app/api/cache/reasoning/route.ts
vendored
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import {
|
||||
clearReasoningCacheAll,
|
||||
|
||||
1
src/app/api/cache/route.ts
vendored
1
src/app/api/cache/route.ts
vendored
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import {
|
||||
getCacheStats,
|
||||
clearCache,
|
||||
|
||||
1
src/app/api/cache/stats/route.ts
vendored
1
src/app/api/cache/stats/route.ts
vendored
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -38,7 +38,14 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const url = new URL(request.url);
|
||||
const scope: LeaderboardScope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
|
||||
const limit = Number(url.searchParams.get("limit") || 100);
|
||||
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 entries = await getTopN(scope, limit);
|
||||
|
||||
|
||||
@@ -18,10 +18,18 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const url = new URL(request.url);
|
||||
const scope = (url.searchParams.get("scope") || "global") as LeaderboardScope;
|
||||
const limit = Number(url.searchParams.get("limit") || 50);
|
||||
const rawLimit = url.searchParams.get("limit");
|
||||
const limit = rawLimit === null ? 50 : Number(rawLimit);
|
||||
const apiKeyId = url.searchParams.get("apiKeyId");
|
||||
|
||||
const entries = await getTopN(scope, Math.min(limit, 200));
|
||||
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);
|
||||
let myRank: number | null = null;
|
||||
let neighbors = null;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -20,10 +21,25 @@ import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null;
|
||||
const HEALTH_PAYLOAD_TTL_MS = 1000;
|
||||
|
||||
export async function GET() {
|
||||
// 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;
|
||||
const cachedNow = Date.now();
|
||||
if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) {
|
||||
return NextResponse.json(healthPayloadCache.payload);
|
||||
return NextResponse.json(
|
||||
fullView ? healthPayloadCache.payload : publicHealthView(healthPayloadCache.payload)
|
||||
);
|
||||
}
|
||||
|
||||
const readHealthValue = <T>(label: string, reader: () => T, fallback: T): T => {
|
||||
@@ -187,7 +203,7 @@ export async function GET() {
|
||||
});
|
||||
|
||||
healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS };
|
||||
return NextResponse.json(payload);
|
||||
return NextResponse.json(fullView ? payload : publicHealthView(payload));
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/monitoring/health error:", error);
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -24,6 +24,7 @@ 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";
|
||||
@@ -221,6 +222,16 @@ 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,
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
scanCliProxyAuthDir,
|
||||
@@ -23,9 +23,9 @@ function cliProxyConfigDir(): string {
|
||||
}
|
||||
|
||||
async function requireImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -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 { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
@@ -93,10 +93,11 @@ async function parseRequestBody(
|
||||
return { ok: true, resolved: resolved.resolved };
|
||||
}
|
||||
|
||||
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 });
|
||||
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 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -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 { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
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<NextResponse | null> {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
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 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
|
||||
|
||||
/**
|
||||
@@ -11,11 +11,9 @@ import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
// GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
|
||||
const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
// Try Cursor IDE first (has both accessToken and machineId)
|
||||
|
||||
@@ -6,15 +6,15 @@ import { isCloudEnabled } from "@/models";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { cursorImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { resolveProxyForProvider } from "@/models";
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import {
|
||||
createProviderConnection,
|
||||
getProviderConnections,
|
||||
@@ -31,11 +31,9 @@ import {
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
// GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
|
||||
const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (authError) return authError;
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { kiroImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity";
|
||||
@@ -38,9 +38,9 @@ export function buildKiroImportError(error: unknown): string {
|
||||
}
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// 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 upsertImportedKiroConnection(
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
extractLocalRaycastCredentials,
|
||||
isRaycastLocalExtractAvailable,
|
||||
} from "@/lib/oauth/services/raycastLocal";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { resolveProxyForProvider } from "@/models";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -11,14 +11,14 @@ import { createProviderConnection } from "@/models";
|
||||
import { RaycastService } from "@/lib/oauth/services/raycast";
|
||||
import { raycastImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { resolveProxyForProvider } from "@/models";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { traeImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* POST /api/oauth/trae/import
|
||||
@@ -22,9 +22,9 @@ import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
* region — optional, default "US-East"
|
||||
*/
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
isAnthropicCompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { providerModelMutationSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance/index";
|
||||
import {
|
||||
getProviderAuditTarget,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
getObsidianSyncStatus,
|
||||
@@ -21,10 +22,19 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const status = await getObsidianSyncStatus();
|
||||
// GHSA-62vw: the WebDAV password is reusable authentication material. Return
|
||||
// the plaintext only to a genuine management principal (dashboard session or
|
||||
// manage-scope key), never to an anonymous caller that reached this handler
|
||||
// through the requireLogin=false open mode. The dashboard's authenticated
|
||||
// reveal-password view is unaffected; anonymous callers get a set/unset flag.
|
||||
const hasManagement =
|
||||
(await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
|
||||
return NextResponse.json({
|
||||
webdavEnabled: status.webdavEnabled,
|
||||
webdavUsername: status.webdavEnabled ? status.webdavUsername : null,
|
||||
webdavPassword: status.webdavEnabled ? status.webdavPassword : null,
|
||||
webdavPassword:
|
||||
status.webdavEnabled && hasManagement ? status.webdavPassword : null,
|
||||
webdavPasswordSet: status.webdavEnabled && Boolean(status.webdavPassword),
|
||||
vaultPath: status.vaultPath,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { getProviderById } from "@/shared/constants/providers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getApiKeys } from "@/lib/db/apiKeys";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { getCallLogs } from "@/lib/usageDb";
|
||||
import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory";
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* returning combo metadata to API-key callers. Kept in a separate module so
|
||||
* the projection can be unit-tested without spinning up the Next.js route.
|
||||
*
|
||||
* #10968: the projection also reports `accountPinned` per model step — a boolean
|
||||
* derived from the stripped `connectionId`, so callers can distinguish a combo
|
||||
* that fails over between two accounts of one provider from a duplicated step.
|
||||
*
|
||||
* #3979: client-facing combo catalogs (the `/v1/combos`, VS Code and LobeHub /
|
||||
* OpenCode import surfaces) can opt into advertising the combo's resolved
|
||||
* capabilities (multimodal / reasoning / caching) so importing clients enable
|
||||
@@ -17,6 +21,19 @@ export interface PublicComboStep {
|
||||
model?: string;
|
||||
comboName?: string;
|
||||
providerId?: string;
|
||||
/**
|
||||
* #10968: whether this step pins one specific account of its provider.
|
||||
*
|
||||
* Two steps that pin different accounts of the same provider project to
|
||||
* identical `{kind, model, providerId}` objects, so a client cannot tell a
|
||||
* two-account failover from the same step listed twice. This says which it
|
||||
* is without exposing the `connectionId` the flag is derived from — not even
|
||||
* a prefix, per the issue.
|
||||
*
|
||||
* Set on every `model` step. Absent on `combo-ref`, which routes through
|
||||
* another combo and has no account of its own.
|
||||
*/
|
||||
accountPinned?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +83,10 @@ export function projectComboStep(step: Record<string, unknown>): PublicComboStep
|
||||
if (typeof step.providerId === "string" && step.providerId.length > 0) {
|
||||
out.providerId = step.providerId;
|
||||
}
|
||||
// Same shape test as providerId above. `cleanupComboConnectionRefs` drops the
|
||||
// key when the connection is deleted, so a step whose pinned account is gone
|
||||
// reports false rather than pointing at nothing.
|
||||
out.accountPinned = typeof step.connectionId === "string" && step.connectionId.length > 0;
|
||||
return out;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -68,7 +68,7 @@ async function postHandler(request, context) {
|
||||
|
||||
const response = await handleModeration({ body: { ...body, model }, credentials });
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
await clearRecoveredProviderState(credentials as Record<string, unknown>);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -15,22 +15,15 @@ import { encryptMetadata } from "@/lib/webhookDispatcher";
|
||||
import { isEncryptionEnabled } from "@/lib/db/encryption";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
|
||||
import { WEBHOOK_EVENT_VALUES } from "@/lib/webhooks/eventDescriptions";
|
||||
|
||||
const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const;
|
||||
const WEBHOOK_EVENT_VALUES = [
|
||||
"*",
|
||||
"request.completed",
|
||||
"request.failed",
|
||||
"provider.error",
|
||||
"provider.recovered",
|
||||
"quota.exceeded",
|
||||
"combo.switched",
|
||||
"test.ping",
|
||||
] as const;
|
||||
const WEBHOOK_EVENT_VALUES_WITH_WILDCARD = ["*", ...WEBHOOK_EVENT_VALUES] as const;
|
||||
|
||||
const updateWebhookSchema = z
|
||||
.object({
|
||||
url: z.string().min(1).max(2000).optional(),
|
||||
events: z.array(z.enum(WEBHOOK_EVENT_VALUES)).optional(),
|
||||
events: z.array(z.enum(WEBHOOK_EVENT_VALUES_WITH_WILDCARD)).optional(),
|
||||
secret: z.string().max(500).optional(),
|
||||
description: z.string().max(1000).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -14,12 +14,15 @@ import { encryptMetadata } from "@/lib/webhookDispatcher";
|
||||
import { isEncryptionEnabled } from "@/lib/db/encryption";
|
||||
import { parseAndValidateWebhookUrl } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
|
||||
import { WEBHOOK_EVENT_VALUES } from "@/lib/webhooks/eventDescriptions";
|
||||
|
||||
const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const;
|
||||
const WEBHOOK_EVENT_VALUES_WITH_WILDCARD = ["*", ...WEBHOOK_EVENT_VALUES] as const;
|
||||
|
||||
const createWebhookSchema = z
|
||||
.object({
|
||||
url: z.string().min(1).max(2000),
|
||||
events: z.array(z.string()).optional().default(["*"]),
|
||||
events: z.array(z.enum(WEBHOOK_EVENT_VALUES_WITH_WILDCARD)).optional().default(["*"]),
|
||||
secret: z.string().max(500).optional(),
|
||||
description: z.string().max(1000).optional().default(""),
|
||||
kind: z.enum(WEBHOOK_KINDS).optional().default("custom"),
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
* Kubernetes-style readiness alias of /healthz.
|
||||
* Same lifecycle phase, same 200/503 bodies. Not a liveness probe.
|
||||
*/
|
||||
export { dynamic, GET, HEAD } from "../healthz/route";
|
||||
export const dynamic = "force-dynamic";
|
||||
export { GET, HEAD } from "../healthz/route";
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "خطافات الويب",
|
||||
"description": "تسجيل، وعرض، واختبار، وإزالة نقاط نهاية webhook. تكوين اشتراكات الأحداث (request.completed، وprovider.error، وbudget.exceeded، إلخ) وإدارة محاولات التسليم."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "خادم MCP",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Vebhuklar",
|
||||
"description": "Vebhuk son nöqtələrini qeydiyyatdan keçirin, siyahıya alın, sınaqdan keçirin və silin. Hadisə abunəliklərini (request.completed, provider.error, budget.exceeded və s.) konfiqurasiya edin və çatdırılmanın təkrar cəhdlərini idarə edin."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "MCP Serveri",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Уебхукове",
|
||||
"description": "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "MCP сървър",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "ওয়েবহুক",
|
||||
"description": "ওয়েবহুক এন্ডপয়েন্ট রেজিস্টার, তালিকাভুক্ত, পরীক্ষা এবং অপসারণ করুন। ইভেন্ট সাবস্ক্রিপশন (request.completed, provider.error, budget.exceeded ইত্যাদি) কনফিগার করুন এবং ডেলিভারি রিট্রাই পরিচালনা করুন।"
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "MCP সার্ভার",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Webhooky",
|
||||
"description": "Registrujte, zobrazujte, testujte a odebírejte koncové body webhooků. Konfigurujte odběry událostí (request.completed, provider.error, budget.exceeded atd.) a spravujte opakování doručení."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "MCP server",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Webhooks",
|
||||
"description": "Registrer, list, test og fjern webhook-slutpunkter. Konfigurer begivenhedsabonnementer (request.completed, provider.error, budget.exceeded osv.) og administrer leveringsforsøg."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "MCP-server",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Webhooks",
|
||||
"description": "Webhook-Endpunkte registrieren, auflisten, testen und entfernen. Ereignis-Abonnements (request.completed, provider.error, budget.exceeded etc.) konfigurieren und Zustellungsversuche verwalten."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "MCP-Server",
|
||||
|
||||
@@ -6269,6 +6269,20 @@
|
||||
"webSessionGuideStep3": "Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.",
|
||||
"webSessionGuideStep3Manual": "Manual path: open the browser developer tools (F12 → Network), refresh the page, open an authenticated request, and copy the Cookie header value from Request Headers — omit the Cookie: prefix.",
|
||||
"webSessionGuideStep4": "Paste it here and check the connection. If it stops working, sign in again and replace it with a fresh value.",
|
||||
"harImportButtonLabel": "Import .har file",
|
||||
"harImportButtonBusy": "Importing…",
|
||||
"harImportButtonHint": "Export from DevTools Network tab after sending at least one chat message.",
|
||||
"harImportStatusValid": "Imported — valid for ~{minutes}m.",
|
||||
"harImportStatusExpiringSoon": "Imported — valid for only ~{minutes}m more.",
|
||||
"harImportStatusExpired": "Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.",
|
||||
"harImportStatusUnknownExpiry": "Imported. Couldn't read its expiry.",
|
||||
"harImportErrorNotJson": "That file isn't valid JSON — is it really a .har export?",
|
||||
"harImportErrorNoEntries": "This HAR has no network entries recorded.",
|
||||
"harImportErrorNoChathubUrl": "No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.",
|
||||
"harImportErrorUnparsableUrl": "Found the chat connection, but couldn't read its URL.",
|
||||
"harImportErrorMissingFields": "Found the chat connection, but the token was missing from it.",
|
||||
"harImportErrorReadFailed": "Couldn't read that file.",
|
||||
"harImportErrorUnknown": "Couldn't extract a credential from that HAR file.",
|
||||
"webSessionSecurityHint": "Treat this like a password: it may access your signed-in web account until it expires or is revoked.",
|
||||
"webNoAuthGuideTitle": "No credential required",
|
||||
"webNoAuthGuideBody": "{provider} does not need an API key or cookie. Save the connection to use its free web endpoint.",
|
||||
@@ -12207,7 +12221,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Webhooks",
|
||||
"description": "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries."
|
||||
"description": "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "MCP Server",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Webhooks",
|
||||
"description": "Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "MCP Server",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "وبهوکها",
|
||||
"description": "ثبت، فهرست کردن، آزمایش و حذف نقاط پایانی وبهوک. پیکربندی اشتراکهای رویداد (request.completed، provider.error، budget.exceeded و غیره) و مدیریت تلاشهای مجدد تحویل."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "سرور MCP",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Webhookit",
|
||||
"description": "Rekisteröi, listaa, testaa ja poista webhook-päätepisteitä. Määritä tapahtumatilaukset (request.completed, provider.error, budget.exceeded jne.) ja hallitse toimituksen uudelleenyrityksiä."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "MCP-palvelin",
|
||||
|
||||
@@ -12202,7 +12202,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Webhooks",
|
||||
"description": "Enregistrer, lister, tester et supprimer des points de terminaison de webhook. Configurer les abonnements aux événements (request.completed, provider.error, budget.exceeded, etc.) et gérer les tentatives de livraison."
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
},
|
||||
"omni-mcp": {
|
||||
"name": "Serveur MCP",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user