mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 23:22:09 +03:00
Compare commits
53 Commits
fix/8864-u
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80d931ae2d | ||
|
|
56540f24c5 | ||
|
|
aa12873686 | ||
|
|
b2509bcd05 | ||
|
|
367ae2fb97 | ||
|
|
4220c810ee | ||
|
|
50fc0d7299 | ||
|
|
9a67185297 | ||
|
|
3157e8a7ad | ||
|
|
a51b8ba563 | ||
|
|
dae3a72e82 | ||
|
|
5631e91f0d | ||
|
|
1dd0173468 | ||
|
|
c89fc6ba2a | ||
|
|
84c9dfdd2c | ||
|
|
f3b190ba3e | ||
|
|
d021423af3 | ||
|
|
78b4082361 | ||
|
|
efc7134167 | ||
|
|
b44f22a949 | ||
|
|
02a078e95f | ||
|
|
d9b3ce266f | ||
|
|
da490a759f | ||
|
|
d91238b720 | ||
|
|
9629693a3c | ||
|
|
742ccb98a0 | ||
|
|
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/features/11104-operator-error-rules.md
Normal file
1
changelog.d/features/11104-operator-error-rules.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(providers):** let operators declare per-provider error rules through `settings.providerErrorRules` instead of patching the catalog — an operator-supplied rule for a provider is consulted before the built-in `providerRuleRegistry`, receives the raw error text, and has its declared scope/cooldown/reason actually honored end to end, for any provider (declaring the rule is the opt-in — no extra allowlist entry needed). Matches are plain case-insensitive substrings (never RegExp) and bounded to 50 rules to keep the hot path safe ([#11104](https://github.com/diegosouzapw/OmniRoute/pull/11104))
|
||||
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/11060-perplexity-filter.md
Normal file
1
changelog.d/fixes/11060-perplexity-filter.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): filter Perplexity model import to the Sonar family so Agent-API catalog ids stop surfacing as routable chat models (#11060)
|
||||
1
changelog.d/fixes/11095-termux-onnx.md
Normal file
1
changelog.d/fixes/11095-termux-onnx.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(install): make the ONNX dependency chain optional so Termux/Android installs succeed again (#11095)
|
||||
1
changelog.d/fixes/11101-reject-silent-validation.md
Normal file
1
changelog.d/fixes/11101-reject-silent-validation.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(providers):** Reject silent validation degradation on provider connection patch — unknown `rateLimitOverrides` keys (e.g. a typo'd `tpm`) and empty/non-numeric values now return `400` with the rejected key list instead of being silently dropped ([#11101](https://github.com/diegosouzapw/OmniRoute/pull/11101))
|
||||
1
changelog.d/fixes/11102-combo-suggestion-count.md
Normal file
1
changelog.d/fixes/11102-combo-suggestion-count.md
Normal file
@@ -0,0 +1 @@
|
||||
- **Autopilot suggestion counter:** the combo health autopilot summary now reports `suggestionCount` (the real number of suggested actions across all issues) instead of conflating it with link counts, while keeping `actionableCount` as a deprecated alias for backward compatibility. The `run_combo_test` action now links to the dashboard with the combo id (`/dashboard/combos?test=<comboId>`) rather than the read-only API route, so operators can actually trigger a test from the UI ([#11102](https://github.com/diegosouzapw/OmniRoute/pull/11102)).
|
||||
1
changelog.d/fixes/11103-persist-config-audit-log.md
Normal file
1
changelog.d/fixes/11103-persist-config-audit-log.md
Normal file
@@ -0,0 +1 @@
|
||||
- **Config audit persistence:** persist the configuration audit trail to SQLite (`config_audit_log`) instead of an in-memory buffer capped at 1000 volatile entries, and bound its growth with `cleanupConfigAudit()` driven by the `retention.configAudit` setting (default 30 days), wired into `runAutoCleanup` ([#11103](https://github.com/diegosouzapw/OmniRoute/pull/11103)).
|
||||
1
changelog.d/fixes/11109-stream-recovery-toolcall.md
Normal file
1
changelog.d/fixes/11109-stream-recovery-toolcall.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): resume mid-stream recovery after a _completed_ tool call — `finish_reason: "tool_calls"` is now tracked per-call instead of as a general terminal marker, so truncation of trailing prose after a fully-delivered tool call is recoverable while in-flight calls stay blocked ([#11109](https://github.com/diegosouzapw/OmniRoute/pull/11109))
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** `reasoning_effort` now learns the accepted values from a provider's own 400/422 response and clamps to the highest one instead of forwarding an unsupported `xhigh`/`max` (or a hardcoded `"high"` fallback) — fixes custom OpenAI-compatible connections and registered providers with no reasoning metadata ([#11116](https://github.com/diegosouzapw/OmniRoute/pull/11116)) — thanks @maxmad64bis
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(sse):** parallel `function_call` items in a Responses API stream (e.g. several tool calls dispatched in the same turn) now each get a stable, distinct `index`/`id` when translated to Chat Completions streaming deltas, instead of colliding on index 0 and tripping strict stream parsers with `Expected 'id' to be a string.` ([#11144](https://github.com/diegosouzapw/OmniRoute/pull/11144))
|
||||
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)).
|
||||
1
changelog.d/fixes/release-v3850-basereds-tests-i18n.md
Normal file
1
changelog.d/fixes/release-v3850-basereds-tests-i18n.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(i18n): complete Vietnamese translations for recently added UI strings (#9985)
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(quality): register `tests/unit/authz/oauth-autoimport-local-only.test.ts` in stryker `tap.testFiles` (residual of #11053)
|
||||
1
changelog.d/maintenance/vi-harimport-parity.md
Normal file
1
changelog.d/maintenance/vi-harimport-parity.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(i18n): translate the 14 `providers.harImport*` keys into Vietnamese (parity gap left by #11069)
|
||||
@@ -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,
|
||||
|
||||
@@ -448,14 +448,14 @@ classification rules pick the fallback `reason` and lock `scope`
|
||||
Classification rules only see full error **text** (needed to match body
|
||||
markers like `额度不足`) for providers listed in the `FULL_TEXT_RULE_PROVIDERS`
|
||||
allowlist in `providerErrorRules.ts` — currently only `"agentrouter"`. For
|
||||
every other provider, `checkFallbackError` hands `getProviderErrorRuleMatch`
|
||||
only the structured error (`{code, type}`), which is enough for
|
||||
header/status/code-based rules but blind to body-text markers. The helper
|
||||
`resolveRuleMatchBody()` performs this selection: full error text for
|
||||
allowlisted providers, the structured error otherwise. Adding a provider to
|
||||
`FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider opt-in — it exists so
|
||||
that the default path for every provider not on the list stays
|
||||
byte-for-byte unchanged.
|
||||
every other **built-in catalog** provider, `checkFallbackError` hands
|
||||
`getProviderErrorRuleMatch` only the structured error (`{code, type}`), which
|
||||
is enough for header/status/code-based rules but blind to body-text markers.
|
||||
The helper `resolveRuleMatchBody()` performs this selection: full error text
|
||||
for allowlisted providers, the structured error otherwise. Adding a
|
||||
**built-in** provider to `FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider
|
||||
opt-in — it exists so that the default path for every provider not on the
|
||||
list stays byte-for-byte unchanged.
|
||||
|
||||
A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in
|
||||
from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as
|
||||
@@ -466,6 +466,31 @@ honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota
|
||||
errors" above for what a `scope: "connection"` match actually does once a
|
||||
provider is on that allowlist.
|
||||
|
||||
**#11104 — operator-declared rules bypass both allowlists.** An operator can
|
||||
declare a per-provider rule at runtime via `settings.providerErrorRules`
|
||||
(`open-sse/config/providerErrorRules.ts::setOperatorProviderErrorRules`)
|
||||
without editing this file. Gating an operator rule behind
|
||||
`FULL_TEXT_RULE_PROVIDERS`/`HONORS_RULE_LOCK_SCOPE_PROVIDERS` — allowlists
|
||||
meant to protect the **default** behavior of built-in catalog rules — would
|
||||
make the settings mechanism inert for every provider except the ones already
|
||||
listed there, since declaring the rule is already the operator's explicit
|
||||
opt-in. `resolveRuleMatchBody()` and `honorsRuleLockScope()` both check
|
||||
`hasOperatorRuleForProvider()` first: a provider with an operator rule gets
|
||||
the raw error text and has its declared `scope` honored, regardless of
|
||||
whether it also appears in either allowlist.
|
||||
|
||||
**Known gap — `providerRuleRegistry` is never consulted for HTTP 400.**
|
||||
`checkFallbackError`'s `BAD_REQUEST` branch classifies status 400 entirely
|
||||
through its own pattern arrays (`MODEL_ACCESS_DENIED_PATTERNS`,
|
||||
`CONTEXT_OVERFLOW_PATTERNS`, etc. in `accountFallback.ts`) and returns before
|
||||
the `configuredRule`/`getProviderErrorRuleMatch` branch above it is reached.
|
||||
A built-in catalog rule (or an operator rule) with `status: 400` is
|
||||
syntactically valid but will never fire. No existing rule targets 400 today,
|
||||
so nothing in production is affected — but a future 400 rule needs this
|
||||
branch touched first, which is a larger change than adding a rule (it
|
||||
reclassifies 400 for every provider already relying on the pattern-array
|
||||
behavior) and is out of scope for a single-provider rule addition.
|
||||
|
||||
### Adding a new quota-misstating gateway
|
||||
|
||||
1. Register one rule array in `statusRestatementRegistry`
|
||||
|
||||
@@ -120,7 +120,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
|
||||
| `firecrawl` | caution | Cloud API ToS has no explicit personal-proxy prohibition found, but the open-source self-hosted version is AGPL-3.0 (re… |
|
||||
| `gemini` | caution | ToS explicitly states the free tier is for "developers building with Google AI models for professional or business purp… |
|
||||
| `groq` | caution | Services Agreement §6.3 prohibits reselling, sublicensing, or distributing API access; §3.2 bars reselling/leasing acco… |
|
||||
| `hackclub` | caution | Service is explicitly scoped to Hack Club teen members building projects/learning; no public ToS found explicitly permi… |
|
||||
| `huggingchat` | caution | Hugging Face ToS does not explicitly ban personal self-hosted proxies, but supplemental terms (referenced but not fully… |
|
||||
| `huggingface` | caution | ToS grants a limited license to access/use the service; the document does not explicitly permit or forbid a single-user… |
|
||||
| `hyperbolic` | caution | ToS grants API access "solely for your own personal or internal business purposes" and explicitly prohibits licensing, … |
|
||||
@@ -222,7 +221,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
|
||||
| `duckduckgo-web` | keyless | — | — | avoid | 6 |
|
||||
| `freemodel-dev` | keyless | — | — | unknown | 4 |
|
||||
| `friendliai` | keyless | — | — | avoid | 2 |
|
||||
| `hackclub` | keyless | — | — | caution | 3 |
|
||||
| `iflytek` | keyless | — | — | avoid | 1 |
|
||||
| `inference-net` | keyless | — | — | caution | 3 |
|
||||
| `liquid` | keyless | — | — | unknown | 1 |
|
||||
@@ -280,7 +278,6 @@ A 50-agent web-research pass (official docs + last-7-days news, adversarially ve
|
||||
- **`gitlawb`** — The shipped freeNote "Free tier available" is effectively stale. The original free MiMo access was removed in May 2026; the only remaining "free" option is a temporary promotional model (Nemotron 3 U…
|
||||
- **`gitlawb-gmi`** — Partially still accurate — free tier exists but is now narrowed to a single model (Nemotron 3 Ultra) after MiMo free access was revoked in late May 2026. The shipped note "Free tier available" unders…
|
||||
- **`groq`** — The shipped freeNote "30 RPM / 14.4K RPD" is accurate only for llama-3.1-8b-instant. Most other models (including llama-3.3-70b-versatile) have a much lower 1K RPD cap. The note omits model-specific …
|
||||
- **`hackclub`** — The "30+ models" count appears accurate and still matches. The core offering remains free for Hack Club members. No evidence of tightening — still "$0 ALWAYS FREE" per the homepage. The freeNote omit…
|
||||
- **`huggingchat`** — The shipped freeNote ("Free LLM chat — no subscription required. Rate limits apply.") is partially accurate but significantly understates the restrictions. The free tier now operates on a hard $0.10/…
|
||||
- **`huggingface`** — Significantly tightened. The shipped freeNote ("Free Inference API for thousands of models") implied unlimited/generous free access, but as of mid-2025 the free tier is capped at $0.10/month in recur…
|
||||
- **`hyperbolic`** — Our shipped freeNote says "$1-5 trial credits on signup" — the $1 trial credit portion is accurate, but the "$5" figure refers to the minimum deposit required to unlock GPU rental (not free credits g…
|
||||
|
||||
@@ -213,7 +213,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each
|
||||
| `glm-cn` | `glmcn` | GLM Coding (China) | API key | [link](https://open.bigmodel.cn) | — |
|
||||
| `glmt` | `glmt` | GLM Thinking | API key | [link](https://open.bigmodel.cn) | — |
|
||||
| `groq` | `groq` | Groq | API key | [link](https://groq.com) | Free tier: 30 RPM / 14.4K RPD — no credit card |
|
||||
| `hackclub` | `hc` | Hackclub AI | API key, aggregator | [link](https://ai.hackclub.com) | Sign in with your Hack Club account at ai.hackclub.com. |
|
||||
| `haiper` | `hp` | Haiper | API key, video | [link](https://haiper.ai) | Get API key at haiper.ai/haiper-api |
|
||||
| `hcnsec` | `hcnsec` | Huancheng Public API | API key | [link](https://api.hcnsec.cn) | Get API key at api.hcnsec.cn |
|
||||
| `helixmind` | `helixmind` | HelixMind | API key, aggregator | [link](https://helixmind.online) | Previously circulated 3 RPM/50 RPD and no-card claims were not confirmed during the 2026-08-02 audit; current quota and billing require account verification. |
|
||||
|
||||
@@ -171,6 +171,7 @@ export const HTTP_STATUS = {
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
NOT_ACCEPTABLE: 406,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
REQUEST_TIMEOUT: 408,
|
||||
GONE: 410,
|
||||
RATE_LIMITED: 429,
|
||||
@@ -263,11 +264,17 @@ export const PROVIDER_PROFILES = {
|
||||
circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS", 30000),
|
||||
// Provider-level circuit breaker (entire provider cooldown after repeated failures)
|
||||
providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD", 15), // Scaled for 500+ connections (was 5)
|
||||
providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS", 1800000), // 30min window (was 20min)
|
||||
providerFailureWindowMs: envInt(
|
||||
"OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS",
|
||||
1800000
|
||||
), // 30min window (was 20min)
|
||||
providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS", 600000), // 10min cooldown when threshold reached
|
||||
degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD", 7),
|
||||
maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER", 4),
|
||||
backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT", 3),
|
||||
backoffEscalationCount: envInt(
|
||||
"OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT",
|
||||
3
|
||||
),
|
||||
},
|
||||
// Local providers (localhost inference backends like Ollama, LM Studio, oMLX).
|
||||
// Not yet wired into getProviderProfile() — will be used when local provider_nodes
|
||||
|
||||
@@ -194,9 +194,6 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
|
||||
{ provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
|
||||
{ provider: "groq", modelId: "qwen/qwen3-32b", displayName: "Qwen3 32B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
|
||||
{ provider: "hackclub", modelId: "meta-llama/llama-3.3-70b-instruct", displayName: "Llama 3.3 70B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
|
||||
{ provider: "hackclub", modelId: "mistralai/mistral-7b-instruct", displayName: "Mistral 7B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
|
||||
{ provider: "hackclub", modelId: "deepseek-ai/deepseek-coder-33b", displayName: "DeepSeek Coder 33B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
|
||||
{ provider: "huggingchat", modelId: "baidu/ERNIE-4.5-VL-424B-A47B-Base-PT", displayName: "ERNIE 4.5 VL 424B A47B Base PT", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
|
||||
{ provider: "huggingchat", modelId: "CohereLabs/c4ai-command-r7b-12-2024", displayName: "Command R7B 12-2024", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
|
||||
{ provider: "huggingchat", modelId: "CohereLabs/command-a-reasoning-08-2025", displayName: "Command A Reasoning 08-2025", monthlyTokens: 500000, creditTokens: 0, freeType: "recurring-monthly", poolKey: "huggingchat", tos: "caution" },
|
||||
|
||||
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 },
|
||||
]);
|
||||
@@ -30,21 +30,63 @@ export type ProviderErrorRule = {
|
||||
export type ProviderErrorRuleMatch = {
|
||||
reason: ConfiguredErrorReason;
|
||||
/**
|
||||
* Intended lock scope. #10334: this field is CONSUMED end-to-end only for
|
||||
* providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS` (agentrouter-exclusive
|
||||
* today, gated by `honorsRuleLockScope()`) — for those, `checkFallbackError`
|
||||
* surfaces it as `ruleScope` on its return value for the persistence layer
|
||||
* to honor instead of re-deriving scope from `hasPerModelQuota()`. For
|
||||
* every other provider it remains INFORMATIONAL: `getProviderErrorRuleMatch`
|
||||
* callers still read only `reason`/`cooldownMs`, and the actual lock scope
|
||||
* is decided independently by each call site. Widening the allowlist is
|
||||
* tracked as a follow-up — see `docs/architecture/RESILIENCE_GUIDE.md` §7.
|
||||
* Intended lock scope. #10334: for a BUILT-IN catalog rule, this field is
|
||||
* CONSUMED end-to-end only for providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS`
|
||||
* (agentrouter-exclusive today, gated by `honorsRuleLockScope()`) — for those,
|
||||
* `checkFallbackError` surfaces it as `ruleScope` on its return value for the
|
||||
* persistence layer to honor instead of re-deriving scope from
|
||||
* `hasPerModelQuota()`. For every other built-in-rule provider it remains
|
||||
* INFORMATIONAL. #11104: an OPERATOR-declared rule (`OperatorProviderErrorRule`)
|
||||
* is exempt from this allowlist — `honorsRuleLockScope()` always returns true
|
||||
* when the provider has one, since the operator already opted in by declaring
|
||||
* the rule. Widening `HONORS_RULE_LOCK_SCOPE_PROVIDERS` itself (for a new
|
||||
* built-in catalog rule) is tracked as a follow-up — see
|
||||
* `docs/architecture/RESILIENCE_GUIDE.md` §7.
|
||||
*/
|
||||
scope: "model" | "provider" | "connection";
|
||||
/** Optional explicit cooldown; falls back to the existing per-reason defaults. */
|
||||
cooldownMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Operator-declared per-provider error rule (settings-driven).
|
||||
*
|
||||
* Mirrors the catalog `ProviderErrorRule` but is data-only so an operator can
|
||||
* add a scope/cooldown/reason override for a provider without editing this
|
||||
* file. `match` is a plain case-insensitive SUBSTRING of the error body — never
|
||||
* a RegExp — so an operator-supplied pattern can never introduce a ReDoS on the
|
||||
* error-classification hot path. Bounded to <= 50 rules total by the settings
|
||||
* schema. An operator rule is consulted BEFORE the built-in `providerRuleRegistry`
|
||||
* and wins on the first status+substring match for a provider.
|
||||
*/
|
||||
export type OperatorProviderErrorRule = {
|
||||
status: number;
|
||||
match: string;
|
||||
scope: "model" | "provider" | "connection";
|
||||
reason?: ConfiguredErrorReason;
|
||||
cooldownMs?: number;
|
||||
};
|
||||
|
||||
let operatorProviderErrorRules: Record<string, OperatorProviderErrorRule[]> = {};
|
||||
|
||||
/**
|
||||
* Inject operator-declared rules. Called from the runtime-settings applier
|
||||
* (`applyRuntimeSettings`) once at boot and on every settings update, with the
|
||||
* value validated by the settings schema. Pass `undefined`/empty/null to clear.
|
||||
* Provider keys are lowercased so lookups are case-insensitive.
|
||||
*/
|
||||
export function setOperatorProviderErrorRules(
|
||||
rules: Record<string, OperatorProviderErrorRule[]> | undefined | null
|
||||
): void {
|
||||
operatorProviderErrorRules = {};
|
||||
if (!rules) return;
|
||||
for (const [provider, list] of Object.entries(rules)) {
|
||||
if (Array.isArray(list) && list.length > 0) {
|
||||
operatorProviderErrorRules[provider.toLowerCase()] = list;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Opencode ───────────────────────────────────────────────────────────────────
|
||||
// Opencode Go uses an account-wide quota. The body usually says "rate limit
|
||||
// reached" but the presence of `x-ratelimit-remaining-requests: 0` is the
|
||||
@@ -272,11 +314,21 @@ export const providerRuleRegistry = new Map<string, ProviderErrorRule[]>([
|
||||
* FULL_TEXT_RULE_PROVIDERS: that set controls what body a rule matches against
|
||||
* (input), this one controls whether the matched scope changes caller behavior
|
||||
* (output). A provider could need one without the other.
|
||||
*
|
||||
* Providers with an operator-declared rule (`setOperatorProviderErrorRules`)
|
||||
* are honored too, without being added here: the allowlist exists to gate
|
||||
* BUILT-IN catalog rules, which change default behavior for every operator
|
||||
* running that provider — an operator rule is already an explicit, per-operator
|
||||
* opt-in, so gating it a second time behind this list would make the settings
|
||||
* mechanism (#11104) silently inert for every provider except the ones listed
|
||||
* below. See `hasOperatorRuleForProvider`.
|
||||
*/
|
||||
const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]);
|
||||
|
||||
export function honorsRuleLockScope(provider: string | null | undefined): boolean {
|
||||
return !!provider && HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(provider.toLowerCase());
|
||||
if (!provider) return false;
|
||||
const key = provider.toLowerCase();
|
||||
return HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(key) || hasOperatorRuleForProvider(key);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,28 +362,51 @@ export function egressBucketedLockProviders(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers whose rules match on the FULL upstream error text.
|
||||
* checkFallbackError's rule lookup normally passes only the structured
|
||||
* Providers whose BUILT-IN catalog rules match on the FULL upstream error
|
||||
* text. checkFallbackError's rule lookup normally passes only the structured
|
||||
* error ({code, type} — message stripped by the combo callers), which is
|
||||
* enough for header/status/code rules but blind to body-text markers like
|
||||
* agentrouter's "额度不足". Providers in this set get the raw error text as
|
||||
* the match body instead. EXCLUSIVE allowlist by owner decision (2026-08-13):
|
||||
* adding a provider here is an explicit opt-in — the default path for every
|
||||
* other provider must remain byte-for-byte unchanged.
|
||||
*
|
||||
* Operator-declared rules bypass this allowlist entirely (see
|
||||
* `hasOperatorRuleForProvider`): the operator's `match` is a literal substring
|
||||
* of the error body by construction, so a rule that never sees body text could
|
||||
* never match anything, defeating the point of declaring it.
|
||||
*/
|
||||
const FULL_TEXT_RULE_PROVIDERS = new Set(["agentrouter"]);
|
||||
|
||||
/**
|
||||
* True when an operator has declared at least one rule for this provider via
|
||||
* `settings.providerErrorRules` (injected through `setOperatorProviderErrorRules`).
|
||||
* Presence of the rule IS the opt-in — no separate allowlist to maintain, and
|
||||
* no widening decision needed as new operators configure new providers.
|
||||
*/
|
||||
export function hasOperatorRuleForProvider(provider: string | null | undefined): boolean {
|
||||
if (!provider) return false;
|
||||
const rules = operatorProviderErrorRules[provider.toLowerCase()];
|
||||
return !!rules && rules.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the body handed to getProviderErrorRuleMatch inside
|
||||
* checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS,
|
||||
* the structured error for everyone else.
|
||||
* checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS or any
|
||||
* provider with an operator-declared rule, the structured error for everyone
|
||||
* else.
|
||||
*/
|
||||
export function resolveRuleMatchBody(
|
||||
provider: string | null | undefined,
|
||||
structuredError: unknown,
|
||||
errorText: string | null | undefined
|
||||
): unknown {
|
||||
if (provider && FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) && errorText) {
|
||||
if (
|
||||
provider &&
|
||||
(FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) ||
|
||||
hasOperatorRuleForProvider(provider)) &&
|
||||
errorText
|
||||
) {
|
||||
return errorText;
|
||||
}
|
||||
return structuredError ?? null;
|
||||
@@ -346,10 +421,32 @@ export function getProviderErrorRuleMatch(
|
||||
provider: string | null | undefined,
|
||||
status: number,
|
||||
headers: Headers | Record<string, string> | null | undefined,
|
||||
body?: unknown
|
||||
body?: unknown,
|
||||
operatorRules?: Record<string, OperatorProviderErrorRule[]>
|
||||
): ProviderErrorRuleMatch | null {
|
||||
if (!provider) return null;
|
||||
const rules = providerRuleRegistry.get(provider.toLowerCase());
|
||||
const key = provider.toLowerCase();
|
||||
|
||||
// Operator-declared rules win first: an operator can override any catalog
|
||||
// rule for a provider without editing this file. `operatorRules` is the
|
||||
// injected source (tests / direct callers); when omitted we fall back to the
|
||||
// settings-backed cache populated by `setOperatorProviderErrorRules`.
|
||||
const opRules = (operatorRules ?? operatorProviderErrorRules)?.[key];
|
||||
if (opRules && opRules.length > 0) {
|
||||
const text = typeof body === "string" ? body : JSON.stringify(body ?? "");
|
||||
const lowered = text.toLowerCase();
|
||||
for (const r of opRules) {
|
||||
if (r.status === status && lowered.includes(r.match.toLowerCase())) {
|
||||
return {
|
||||
reason: r.reason ?? "quota_exhausted",
|
||||
scope: r.scope,
|
||||
cooldownMs: r.cooldownMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rules = providerRuleRegistry.get(key);
|
||||
if (!rules) return null;
|
||||
// Normalize headers: accept either a `Headers` object (from `fetch()`) or
|
||||
// a plain record. Provider rules access headers via plain object indexing.
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
} from "./providers/registry/alibaba/index.ts";
|
||||
export { REGISTRY } from "./providers/index.ts";
|
||||
import { REGISTRY } from "./providers/index.ts";
|
||||
import { isPrivateHost } from "@/shared/network/outboundUrlGuard";
|
||||
import {
|
||||
RegistryModel,
|
||||
REASONING_UNSUPPORTED,
|
||||
@@ -132,11 +133,8 @@ export function isLocalProvider(baseUrl?: string | null): boolean {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
const hostname = url.hostname;
|
||||
// Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening
|
||||
return (
|
||||
LOCAL_HOSTNAMES.has(hostname) ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname)
|
||||
);
|
||||
if (!hostname) return false;
|
||||
return LOCAL_HOSTNAMES.has(hostname) || isPrivateHost(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ import { togetherProvider } from "./registry/together/index.ts";
|
||||
import { cohereProvider } from "./registry/cohere/index.ts";
|
||||
import { cursorProvider, cursor_apiProvider } from "./registry/cursor/index.ts";
|
||||
import { volcengineProvider } from "./registry/volcengine/index.ts";
|
||||
import { hackclubProvider } from "./registry/hackclub/index.ts";
|
||||
import { freetheaiProvider } from "./registry/freetheai/index.ts";
|
||||
import { g4f_groqProvider } from "./registry/g4f-groq/index.ts";
|
||||
import { g4f_geminiProvider } from "./registry/g4f-gemini/index.ts";
|
||||
@@ -336,7 +335,6 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
cursor: cursorProvider,
|
||||
"cursor-api": cursor_apiProvider,
|
||||
volcengine: volcengineProvider,
|
||||
hackclub: hackclubProvider,
|
||||
freetheai: freetheaiProvider,
|
||||
"g4f-groq": g4f_groqProvider,
|
||||
"g4f-gemini": g4f_geminiProvider,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -27,7 +27,7 @@ export const clineProvider: RegistryEntry = {
|
||||
// the official free bucket and text-output models advertised as zero-cost.
|
||||
models: [
|
||||
{
|
||||
id: "zai/glm-5.2",
|
||||
id: "z-ai/glm-5.2",
|
||||
name: "GLM 5.2",
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { RegistryEntry } from "../../shared.ts";
|
||||
|
||||
export const hackclubProvider: RegistryEntry = {
|
||||
id: "hackclub",
|
||||
alias: "hc",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://ai.hackclub.com/proxy/v1/chat/completions",
|
||||
modelsUrl: "https://ai.hackclub.com/proxy/v1/models",
|
||||
authType: "optional",
|
||||
authHeader: "bearer",
|
||||
passthroughModels: true,
|
||||
defaultContextLength: 128000,
|
||||
models: [
|
||||
{ id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
||||
{ id: "mistralai/mistral-7b-instruct", name: "Mistral 7B" },
|
||||
{ id: "deepseek-ai/deepseek-coder-33b", name: "DeepSeek Coder 33B" },
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
@@ -23,9 +24,13 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
{ id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true },
|
||||
{ id: "glm-5.2-high", name: "GLM-5.2 (high effort)", supportsReasoning: true },
|
||||
{ id: "glm-5.2-max", name: "GLM-5.2 (max effort)", supportsReasoning: true },
|
||||
|
||||
...OPENCODE_ZEN_GO_SHARED_MODELS,
|
||||
// models[0] (glm-5.2) is the dashboard default (LlmChatCard/ProviderTestSlideOver take models[0]).
|
||||
|
||||
{ id: "glm-5.1", name: "GLM-5.1" },
|
||||
{ id: "glm-5", name: "GLM-5" },
|
||||
{ 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 +94,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 +110,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 +143,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-minimal",
|
||||
@@ -148,6 +154,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-low",
|
||||
@@ -158,6 +165,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-medium",
|
||||
@@ -168,6 +176,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-high",
|
||||
@@ -178,6 +187,7 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportsVision: true,
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-xhigh",
|
||||
@@ -188,6 +198,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",
|
||||
@@ -25,6 +26,10 @@ export const opencode_zenProvider: RegistryEntry = {
|
||||
supportsReasoning: true,
|
||||
interleavedField: "reasoning_content",
|
||||
},
|
||||
|
||||
...OPENCODE_ZEN_GO_SHARED_MODELS,
|
||||
// models[0] (big-pickle) is the dashboard default; SHARED spread kept after it.
|
||||
|
||||
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" },
|
||||
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra" },
|
||||
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna" },
|
||||
@@ -51,7 +56,27 @@ 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",
|
||||
},
|
||||
// Explicit wire-format overlay of the base opencode provider's muse-spark entry
|
||||
// (targetFormat: openai-responses). Keep in sync with base on catalog syncs.
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-free",
|
||||
name: "Muse Spark 1.2 Contributor Free",
|
||||
supportsReasoning: true,
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
|
||||
// ── DeepSeek ────────────────────────────────────────────────
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||
@@ -66,7 +91,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 +99,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,
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* perplexity-search reuses credentials from the "perplexity" chat provider.
|
||||
*/
|
||||
|
||||
import { isProviderBlockedByIdOrAlias } from "@/shared/utils/noAuthProviders";
|
||||
|
||||
export interface SearchProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -394,16 +396,18 @@ export function supportsSearchType(
|
||||
/**
|
||||
* Get all search providers as a flat list
|
||||
*/
|
||||
export function getAllSearchProviders(): Array<{
|
||||
export function getAllSearchProviders(blockedProviders: string[] = []): Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
searchTypes: string[];
|
||||
}> {
|
||||
return Object.values(SEARCH_PROVIDERS).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
searchTypes: p.searchTypes,
|
||||
}));
|
||||
return Object.values(SEARCH_PROVIDERS)
|
||||
.filter((p) => !p.disabled && !isProviderBlockedByIdOrAlias(p.id, blockedProviders))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
searchTypes: p.searchTypes,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
recordLearnedThinkingCap,
|
||||
parseThinkingBudgetMax,
|
||||
} from "../services/learnedThinkingCaps.ts";
|
||||
import {
|
||||
recordLearnedReasoningEffort,
|
||||
parseReasoningEffortEnum,
|
||||
} from "../services/learnedReasoningEffortCaps.ts";
|
||||
import {
|
||||
getParamFilterConfig,
|
||||
addParamToBlocklist,
|
||||
@@ -104,6 +108,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 +407,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 +648,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 =
|
||||
@@ -796,6 +830,9 @@ export class BaseExecutor {
|
||||
// loop. The learned cap is also recorded process-wide via
|
||||
// recordLearnedThinkingCap so future requests skip the 400 entirely.
|
||||
let thinkingBudgetClampedMax: number | null = null;
|
||||
// Set by the reasoning_effort 4xx clamp-and-retry below — guards the same
|
||||
// "fires at most once per URL" invariant as thinkingBudgetClampedMax above.
|
||||
let reasoningEffortClamped = false;
|
||||
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const requestCredentials = withForcedResponsesUpstream(
|
||||
@@ -869,6 +906,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) {
|
||||
@@ -1496,6 +1536,49 @@ export class BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// Reasoning-effort enum 4xx clamp-and-retry (any provider/model without a
|
||||
// declared reasoning_effort capability — custom OpenAI-compatible
|
||||
// connections, or a registered provider the registry hasn't caught up
|
||||
// with). Mirrors the thinking_budget clamp-and-retry above: parse the
|
||||
// upstream-advertised accepted values, record them process-wide (so
|
||||
// FUTURE requests clamp proactively via sanitizeReasoningEffortForProvider
|
||||
// → getLearnedReasoningEffort), clamp the live transformedBody by
|
||||
// re-running the sanitizer, and retry the same URL once.
|
||||
if (
|
||||
(response.status === HTTP_STATUS.BAD_REQUEST ||
|
||||
response.status === HTTP_STATUS.UNPROCESSABLE_ENTITY) &&
|
||||
!reasoningEffortClamped &&
|
||||
transformedBody &&
|
||||
typeof transformedBody === "object"
|
||||
) {
|
||||
const errText = await response
|
||||
.clone()
|
||||
.text()
|
||||
.catch(() => "");
|
||||
const acceptedValues = parseReasoningEffortEnum(errText);
|
||||
if (acceptedValues) {
|
||||
reasoningEffortClamped = true;
|
||||
const learned = recordLearnedReasoningEffort(this.provider, model, acceptedValues);
|
||||
if (learned) {
|
||||
transformedBody = sanitizeReasoningEffortForProvider(
|
||||
transformedBody,
|
||||
this.provider,
|
||||
model,
|
||||
log
|
||||
);
|
||||
let retryBody = JSON.stringify(transformedBody);
|
||||
if (usesClaudeCodeProtocol || this.provider === "claude") {
|
||||
retryBody = await signRequestBody(retryBody);
|
||||
}
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`Upstream ${response.status} rejected reasoning_effort on ${url} — clamped to ${learned} and retrying (learned for ${this.provider}/${model})`
|
||||
);
|
||||
response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generic reactive 400 field-downgrade; each field is stripped at most once.
|
||||
if (
|
||||
response.status === HTTP_STATUS.BAD_REQUEST &&
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
getProviderModel,
|
||||
getProviderModels,
|
||||
} from "../../config/providerModels.ts";
|
||||
import {
|
||||
getLearnedReasoningEffort,
|
||||
REASONING_EFFORT_ORDER,
|
||||
} from "../../services/learnedReasoningEffortCaps.ts";
|
||||
|
||||
/**
|
||||
* Sanitize reasoning_effort for providers that don't accept all values.
|
||||
@@ -338,10 +342,24 @@ export function sanitizeReasoningEffortForProvider(
|
||||
|
||||
const supportsXHigh = supportsXHighEffort(provider, modelStr);
|
||||
const supportsMax = supportsMaxEffortForProvider(provider, modelStr);
|
||||
// Highest value we've actually seen this provider+model accept in a real
|
||||
// upstream 4xx (learnedReasoningEffortCaps.ts) — takes priority over the
|
||||
// static registry (which defaults to "supports everything" when there's no
|
||||
// entry, e.g. custom OpenAI-compatible connections) and over the hardcoded
|
||||
// "high" fallback below (which isn't always valid either).
|
||||
const learnedCap = getLearnedReasoningEffort(provider, modelStr);
|
||||
const learnedRank = learnedCap ? REASONING_EFFORT_ORDER.indexOf(learnedCap) : -1;
|
||||
|
||||
// ── xhigh handling ──────────────────────────────────────────────────────
|
||||
// xhigh is OmniRoute-internal. Map it to the best effort the model accepts.
|
||||
if (effortStr === "xhigh") {
|
||||
if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("xhigh")) {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort xhigh → ${learnedCap} (learned)`
|
||||
);
|
||||
return writeEffortValue(b, learnedCap, c);
|
||||
}
|
||||
if (supportsXHigh) return body; // model accepts xhigh natively
|
||||
if (supportsMax) {
|
||||
log?.info?.(
|
||||
@@ -366,6 +384,13 @@ export function sanitizeReasoningEffortForProvider(
|
||||
// upstream, and if it 400s the user gets a clear signal. This prevents
|
||||
// new models from being unusable for weeks until they're whitelisted (#8057).
|
||||
if (effortStr === "max") {
|
||||
if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("max")) {
|
||||
log?.info?.(
|
||||
"REASONING_SANITIZE",
|
||||
`${provider}/${modelStr}: clamped reasoning_effort max → ${learnedCap} (learned)`
|
||||
);
|
||||
return writeEffortValue(b, learnedCap, c);
|
||||
}
|
||||
if (supportsMax) return body; // explicitly known to accept max
|
||||
|
||||
// A model that explicitly advertises its accepted tiers is safe to normalize.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -199,6 +199,8 @@ export async function handleRerank({
|
||||
return_documents,
|
||||
credentials,
|
||||
connectionId = null,
|
||||
apiKeyId = null,
|
||||
apiKeyName = null,
|
||||
}) {
|
||||
const startTime = Date.now();
|
||||
if (!model) return errorResponse(400, "model is required");
|
||||
@@ -267,10 +269,23 @@ export async function handleRerank({
|
||||
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
return errorResponse(
|
||||
res.status,
|
||||
errData.message || errData.error?.message || `Provider returned HTTP ${res.status}`
|
||||
);
|
||||
const errorMessage =
|
||||
errData.message || errData.error?.message || `Provider returned HTTP ${res.status}`;
|
||||
saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/rerank",
|
||||
status: res.status,
|
||||
model: `${providerId}/${modelId}`,
|
||||
provider: providerId,
|
||||
connectionId: connectionId || undefined,
|
||||
duration: Date.now() - startTime,
|
||||
requestBody,
|
||||
responseBody: errData,
|
||||
error: errorMessage,
|
||||
apiKeyId: apiKeyId || undefined,
|
||||
apiKeyName: apiKeyName || undefined,
|
||||
}).catch(() => {});
|
||||
return errorResponse(res.status, errorMessage);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
@@ -289,10 +304,13 @@ export async function handleRerank({
|
||||
status: 200,
|
||||
model: `${providerId}/${modelId}`,
|
||||
provider: providerId,
|
||||
connectionId: connectionId || undefined,
|
||||
duration: Date.now() - startTime,
|
||||
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
||||
responseBody: { results_count: Array.isArray(result?.results) ? result.results.length : 0 },
|
||||
connectionId,
|
||||
requestBody,
|
||||
responseBody: result,
|
||||
apiKeyId: apiKeyId || undefined,
|
||||
apiKeyName: apiKeyName || undefined,
|
||||
}).catch(() => {});
|
||||
|
||||
const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
honorsRuleLockScope,
|
||||
} from "../config/providerErrorRules.ts";
|
||||
import * as rot from "./rotationConfig.ts";
|
||||
import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts";
|
||||
import { getPassthroughProviders, getProviderCategory, isLocalProvider } from "../config/providerRegistry.ts";
|
||||
import {
|
||||
DEFAULT_RESILIENCE_SETTINGS,
|
||||
resolveResilienceSettings,
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
type FailureKind,
|
||||
} from "../../src/shared/utils/classify429";
|
||||
import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts";
|
||||
import { resolveProviderId } from "../../src/shared/constants/providers";
|
||||
import { resolveProviderId, isLocalProvider as isLocalProviderId, isSelfHostedChatProvider } from "../../src/shared/constants/providers";
|
||||
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
|
||||
import { getCodexModelScope } from "../config/codexQuotaScopes.ts";
|
||||
import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts";
|
||||
@@ -791,12 +791,14 @@ export function hasPerModelQuota(
|
||||
return connectionPassthroughModels;
|
||||
}
|
||||
if (!provider) return false;
|
||||
if (getCanonicalLockProvider(provider) === "antigravity") return true;
|
||||
if (getCanonicalLockProvider(provider) === "codex") return true;
|
||||
if (provider === "gemini" || provider === "github") return true;
|
||||
if (provider === "antigravity" || provider === "agy") return true;
|
||||
if (getPassthroughProviders().has(provider)) return true;
|
||||
if (isCompatibleProvider(provider)) return true;
|
||||
const canonicalId = resolveProviderId(provider);
|
||||
if (getCanonicalLockProvider(canonicalId) === "antigravity") return true;
|
||||
if (getCanonicalLockProvider(canonicalId) === "codex") return true;
|
||||
if (canonicalId === "gemini" || canonicalId === "github") return true;
|
||||
if (canonicalId === "antigravity" || canonicalId === "agy") return true;
|
||||
if (getPassthroughProviders().has(canonicalId)) return true;
|
||||
if (isCompatibleProvider(canonicalId)) return true;
|
||||
if (isLocalProviderId(canonicalId) || isSelfHostedChatProvider(canonicalId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
126
open-sse/services/learnedReasoningEffortCaps.ts
Normal file
126
open-sse/services/learnedReasoningEffortCaps.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Learned Reasoning-Effort Caps — reactive capability memory for providers/models
|
||||
* OmniRoute has no static registry entry for (custom OpenAI-compatible connections,
|
||||
* or any registered provider whose registry entry carries no reasoning metadata).
|
||||
*
|
||||
* Same shape as `learnedThinkingCaps.ts` (thinking_budget), generalized from a
|
||||
* numeric budget to an ordinal reasoning_effort scale: on a 4xx whose body
|
||||
* enumerates the accepted values, `base.ts`'s executor calls
|
||||
* `recordLearnedReasoningEffort`, which stores the highest recognized value in a
|
||||
* module-level Map keyed "provider:model" (lowercased). Subsequent requests for
|
||||
* the same provider+model read the cap via `getLearnedReasoningEffort` (consulted
|
||||
* by `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
|
||||
* so the 4xx→retry round-trip is paid at most once per process per provider+model.
|
||||
*
|
||||
* In-memory only (same operator-accepted tradeoff as the thinking-budget cache):
|
||||
* restart resets, the first request after a restart may re-learn at the cost of
|
||||
* one upstream 4xx.
|
||||
*/
|
||||
|
||||
export const REASONING_EFFORT_ORDER: readonly string[] = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
];
|
||||
|
||||
// key: `${provider}:${model}` lowercased → highest value known to be accepted.
|
||||
const learnedCaps = new Map<string, string>();
|
||||
|
||||
function buildKey(provider: string | null | undefined, model: string | null | undefined): string {
|
||||
const p = typeof provider === "string" ? provider.trim().toLowerCase() : "";
|
||||
const m = typeof model === "string" ? model.trim().toLowerCase() : "";
|
||||
if (!p || !m) return "";
|
||||
return `${p}:${m}`;
|
||||
}
|
||||
|
||||
function rankOf(value: string): number {
|
||||
return REASONING_EFFORT_ORDER.indexOf(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the learned cap for provider+model, or null when nothing has been
|
||||
* learned yet (no upstream 4xx recorded). Keyed case-insensitively.
|
||||
*/
|
||||
export function getLearnedReasoningEffort(
|
||||
provider: string | null | undefined,
|
||||
model: string | null | undefined
|
||||
): string | null {
|
||||
const key = buildKey(provider, model);
|
||||
if (!key) return null;
|
||||
return learnedCaps.get(key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that `acceptedValues` is the enum the upstream advertised for
|
||||
* provider+model, and store the highest recognized value as the learned cap.
|
||||
* Returns the stored value, or null when `acceptedValues` contained no token
|
||||
* from `REASONING_EFFORT_ORDER` (nothing usable to learn) or the key is unusable.
|
||||
*
|
||||
* Always monotonically decreases: if a cap already stored ranks lower than the
|
||||
* newly computed highest, the stored (lower) value wins and is returned
|
||||
* unchanged. This keeps a later, laxer-looking response (or a race between
|
||||
* concurrent requests) from ratcheting the cap back up.
|
||||
*/
|
||||
export function recordLearnedReasoningEffort(
|
||||
provider: string | null | undefined,
|
||||
model: string | null | undefined,
|
||||
acceptedValues: string[]
|
||||
): string | null {
|
||||
const key = buildKey(provider, model);
|
||||
if (!key) return null;
|
||||
|
||||
let best: string | null = null;
|
||||
let bestRank = -1;
|
||||
for (const raw of acceptedValues) {
|
||||
const rank = rankOf(raw);
|
||||
if (rank > bestRank) {
|
||||
bestRank = rank;
|
||||
best = raw;
|
||||
}
|
||||
}
|
||||
if (best === null) return null;
|
||||
|
||||
const existing = learnedCaps.get(key);
|
||||
if (existing !== undefined && rankOf(existing) <= bestRank) {
|
||||
return existing; // already learned an equal-or-lower cap; keep it
|
||||
}
|
||||
learnedCaps.set(key, best);
|
||||
return best;
|
||||
}
|
||||
|
||||
// Matches both prose shapes observed: OVH's `@ai-sdk/openai-compatible`
|
||||
// deserializer ("expected one of `a`, `b`") and a generic vendor prose form
|
||||
// ("Supported types are a, b, and c").
|
||||
const LIST_INTRO = /(?:expected one of|supported (?:types|values) are)[:\s]*([^.]+)/i;
|
||||
|
||||
/**
|
||||
* Extract the upstream-advertised accepted reasoning_effort values from a 4xx
|
||||
* error body. Returns only tokens present in REASONING_EFFORT_ORDER (unknown
|
||||
* tokens are dropped defensively) in the order they appeared, or null when the
|
||||
* text names no recognized enum member.
|
||||
*/
|
||||
export function parseReasoningEffortEnum(errText: unknown): string[] | null {
|
||||
if (typeof errText !== "string" || !errText) return null;
|
||||
const match = LIST_INTRO.exec(errText);
|
||||
if (!match) return null;
|
||||
const tokens = match[1]
|
||||
.split(/,|\band\b|&/i)
|
||||
.map((t) =>
|
||||
t
|
||||
.replace(/`/g, "")
|
||||
.replace(/\([^)]*\)/g, "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
.filter((t) => t.length > 0 && REASONING_EFFORT_ORDER.includes(t));
|
||||
return tokens.length > 0 ? tokens : null;
|
||||
}
|
||||
|
||||
/** Test-only: clear the learned-cap Map between tests. */
|
||||
export function __test_resetLearnedReasoningEffortCaps(): void {
|
||||
learnedCaps.clear();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { REGISTRY } from "../config/providerRegistry.ts";
|
||||
import type { ReasoningTransport } from "../config/providerRegistry.ts";
|
||||
import { isValidResponsesItemId } from "./responsesItemId.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -279,13 +280,27 @@ function sanitizeResponsesInput(
|
||||
if (!hasPlaintext && !hasOpaque && (!hasDisplaySummary(next) || stripOrphanedSummaries)) {
|
||||
continue;
|
||||
}
|
||||
if (!hasOpaque && typeof next.id === "string") delete next.id;
|
||||
// `id` is only worth keeping on an opaque item with a valid string value —
|
||||
// non-opaque items don't replay their id, and a malformed value (e.g. `null`,
|
||||
// observed on opencode/zen) must not survive either way (#11108).
|
||||
if (!hasOpaque || !isValidResponsesItemId(next.id)) delete next.id;
|
||||
// Some upstreams (e.g. opencode/zen) omit `summary` entirely on opaque
|
||||
// reasoning items instead of sending an empty array. Replaying that shape
|
||||
// verbatim trips strict Responses-API validators that require the field
|
||||
// to be present on every `input[]` item of type `reasoning` (#11108).
|
||||
// Plaintext-only items intentionally have no `summary` key and must stay
|
||||
// untouched.
|
||||
if (hasOpaque && next.summary === undefined) next.summary = [];
|
||||
filtered.push(next);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cloned = { ...record };
|
||||
if (typeof cloned.id === "string") delete cloned.id;
|
||||
// Strip `id` whenever present, valid or not: these items don't need a
|
||||
// replayed server id, and a malformed one (e.g. `null`, same opencode/zen
|
||||
// omission pattern as the reasoning branch above) must not survive either
|
||||
// (#11108).
|
||||
if (cloned.id !== undefined) delete cloned.id;
|
||||
filtered.push(cloned);
|
||||
}
|
||||
return filtered;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isValidResponsesItemId } from "./responsesItemId.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type SanitizeResponsesInputOptions = {
|
||||
dropInternalAssistantMessages?: boolean;
|
||||
@@ -40,7 +42,12 @@ function sanitizeFunctionName(name: string): string {
|
||||
}
|
||||
|
||||
function sanitizeInputItemId(record: JsonRecord): JsonRecord {
|
||||
if (typeof record.id !== "string") return record;
|
||||
if (record.id === undefined) return record;
|
||||
if (!isValidResponsesItemId(record.id)) {
|
||||
const next = { ...record };
|
||||
delete next.id;
|
||||
return next;
|
||||
}
|
||||
|
||||
const type = typeof record.type === "string" ? record.type : "";
|
||||
const expectedPrefix = SERVER_ITEM_ID_PREFIX_BY_TYPE[type];
|
||||
|
||||
7
open-sse/services/responsesItemId.ts
Normal file
7
open-sse/services/responsesItemId.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Shared by reasoningInputPolicy.ts and responsesInputSanitizer.ts: both strip a
|
||||
// Responses-API `input[]` item's `id` field when it isn't a valid string before
|
||||
// replay, so a malformed value (e.g. `null`, observed on opencode/zen) never
|
||||
// survives to trip a strict upstream with "Expected 'id' to be a string." (#11108).
|
||||
export function isValidResponsesItemId(id: unknown): id is string {
|
||||
return typeof id === "string";
|
||||
}
|
||||
@@ -185,7 +185,21 @@ export interface OpenAiSseScan {
|
||||
text: string;
|
||||
/** True if any `choices[].delta.tool_calls` appeared — NEVER continue those. */
|
||||
sawToolCall: boolean;
|
||||
/** True if a terminal marker (`[DONE]` or a non-null `finish_reason`) appeared. */
|
||||
/**
|
||||
* True only when `tool_calls` appeared in this scan AND its own
|
||||
* `finish_reason: "tool_calls"` has NOT also appeared in the same scan — i.e. the
|
||||
* call is still being streamed (arguments may be mid-flight). Once
|
||||
* `finish_reason: "tool_calls"` closes it, the call is complete, not in flight: the
|
||||
* client has the full arguments and a truncation past this point only drops
|
||||
* trailing prose, which continuation can safely recover.
|
||||
*/
|
||||
sawToolCallInFlight: boolean;
|
||||
/**
|
||||
* True if a terminal marker for the OVERALL stream appeared: `[DONE]`, or a
|
||||
* `finish_reason` other than `"tool_calls"`. A `finish_reason: "tool_calls"` ends
|
||||
* that one choice but is not terminal for continuation purposes — the model turn
|
||||
* (and the client-visible SSE) is still eligible to be resumed past it.
|
||||
*/
|
||||
terminal: boolean;
|
||||
/** True if at least one OpenAI-shaped `choices[].delta` was parsed (format gate). */
|
||||
parsedOpenAi: boolean;
|
||||
@@ -199,10 +213,11 @@ export interface OpenAiSseScan {
|
||||
export function scanOpenAiSseText(sse: string): OpenAiSseScan {
|
||||
let text = "";
|
||||
let sawToolCall = false;
|
||||
let toolCallFinished = false;
|
||||
let terminal = false;
|
||||
let parsedOpenAi = false;
|
||||
if (typeof sse !== "string" || sse.length === 0) {
|
||||
return { text, sawToolCall, terminal, parsedOpenAi };
|
||||
return { text, sawToolCall, sawToolCallInFlight: false, terminal, parsedOpenAi };
|
||||
}
|
||||
for (const line of sse.split("\n")) {
|
||||
const trimmed = line.trimStart();
|
||||
@@ -231,10 +246,17 @@ export function scanOpenAiSseText(sse: string): OpenAiSseScan {
|
||||
if (Array.isArray(toolCalls) && toolCalls.length > 0) sawToolCall = true;
|
||||
}
|
||||
const finishReason = (choice as { finish_reason?: unknown })?.finish_reason;
|
||||
if (finishReason != null) terminal = true;
|
||||
if (finishReason === "tool_calls") {
|
||||
// Ends this one choice, but the overall stream/turn stays continuable —
|
||||
// never counts as the general terminal marker (see OpenAiSseScan.terminal).
|
||||
toolCallFinished = true;
|
||||
} else if (finishReason != null) {
|
||||
terminal = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { text, sawToolCall, terminal, parsedOpenAi };
|
||||
const sawToolCallInFlight = sawToolCall && !toolCallFinished;
|
||||
return { text, sawToolCall, sawToolCallInFlight, terminal, parsedOpenAi };
|
||||
}
|
||||
|
||||
export interface ContinuableBody {
|
||||
@@ -369,7 +391,7 @@ export function createRecoverableStream(
|
||||
let emittedTail = ""; // raw SSE not yet scanned (awaiting an event boundary)
|
||||
let emittedText = ""; // assistant text already delivered to the client
|
||||
let emittedTerminal = false;
|
||||
let emittedToolCall = false;
|
||||
let emittedToolCallInFlight = false;
|
||||
let emittedParsedOpenAi = false;
|
||||
|
||||
// Enqueue to the client and, when continuation is enabled, fold the chunk into the
|
||||
@@ -388,7 +410,7 @@ export function createRecoverableStream(
|
||||
const scan = scanOpenAiSseText(complete);
|
||||
emittedText += scan.text;
|
||||
if (scan.terminal) emittedTerminal = true;
|
||||
if (scan.sawToolCall) emittedToolCall = true;
|
||||
if (scan.sawToolCallInFlight) emittedToolCallInFlight = true;
|
||||
if (scan.parsedOpenAi) emittedParsedOpenAi = true;
|
||||
};
|
||||
|
||||
@@ -402,7 +424,7 @@ export function createRecoverableStream(
|
||||
continueEnabled &&
|
||||
continuations < maxContinuations &&
|
||||
emittedParsedOpenAi &&
|
||||
!emittedToolCall &&
|
||||
!emittedToolCallInFlight &&
|
||||
!emittedTerminal &&
|
||||
emittedText.length > 0;
|
||||
|
||||
|
||||
@@ -201,6 +201,14 @@ export function openaiToOpenAIResponsesRequest(
|
||||
input.push({
|
||||
type: "reasoning",
|
||||
content: [{ type: "reasoning_text", text: reasoning }],
|
||||
// Strict Responses-API upstreams (e.g. opencode/zen) require `summary`
|
||||
// on every `input[]` item of type "reasoning", plaintext or opaque —
|
||||
// omitting it rejects the request with `input[N] missing required
|
||||
// field summary`. This item is always freshly built from a chat
|
||||
// client's plaintext reasoning, so there is no source summary to
|
||||
// preserve; default to an empty array like the replay sanitizer does
|
||||
// for opaque items in reasoningInputPolicy.ts (#11108).
|
||||
summary: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -866,21 +866,25 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
|
||||
function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
if (!chunk) {
|
||||
if (
|
||||
state.currentToolCallNeedsNormalization &&
|
||||
state.currentToolCallArgsBuffer &&
|
||||
state.currentToolCallName
|
||||
) {
|
||||
const toolSchema = state.toolSchemas?.get(state.currentToolCallName);
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(
|
||||
state.currentToolCallArgsBuffer,
|
||||
state.currentToolCallName,
|
||||
toolSchema
|
||||
);
|
||||
const argsStr =
|
||||
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {});
|
||||
state.currentToolCallArgsBuffer = "";
|
||||
state.currentToolCallNeedsNormalization = false;
|
||||
// Iterate every still-open call needing schema-aware normalization, not just a
|
||||
// single one — multiple parallel calls can each be pending here if the stream
|
||||
// ends before their output_item.done arrives.
|
||||
const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
|
||||
if (state.toolCallByCallId instanceof Map) {
|
||||
for (const entry of state.toolCallByCallId.values()) {
|
||||
if (entry.needsNormalization && entry.argsBuffer) {
|
||||
const toolSchema = state.toolSchemas?.get(entry.name);
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
|
||||
pendingNormalized.push({
|
||||
index: entry.index,
|
||||
argsStr: typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {}),
|
||||
});
|
||||
entry.argsBuffer = "";
|
||||
entry.needsNormalization = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pendingNormalized.length > 0) {
|
||||
state.finishReasonSent = true;
|
||||
state.finishReason = "tool_calls";
|
||||
const common = {
|
||||
@@ -889,24 +893,21 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
};
|
||||
return [
|
||||
{
|
||||
...common,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsStr } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...common,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
||||
},
|
||||
];
|
||||
const chunks: Record<string, unknown>[] = pendingNormalized.map(({ index, argsStr }) => ({
|
||||
...common,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { tool_calls: [{ index, function: { arguments: argsStr } }] },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}));
|
||||
chunks.push({
|
||||
...common,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
||||
});
|
||||
return chunks;
|
||||
}
|
||||
// Flush: send final chunk with finish_reason
|
||||
if (!state.finishReasonSent && state.started) {
|
||||
@@ -952,7 +953,23 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
state.chatId = `chatcmpl-${Date.now()}`;
|
||||
state.created = Math.floor(Date.now() / 1000);
|
||||
state.toolCallIndex = 0;
|
||||
// Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility —
|
||||
// that snapshot path mutates it directly and expects it to exist. In a turn with
|
||||
// multiple parallel calls this only ever reflects the LAST one opened/closed, so
|
||||
// it must never be used to identify a specific call — only as the "is at least
|
||||
// one tool call in flight this turn" signal computeFinishReason needs, which
|
||||
// toolCallIndex > 0 already covers on its own once any call has been added.
|
||||
state.currentToolCallId = null;
|
||||
// Per-call state keyed by call_id (replaces the old singular
|
||||
// currentToolCallId/ArgsBuffer/Name/NeedsNormalization/Deferred fields, which
|
||||
// assumed only one function_call could ever be in flight at a time).
|
||||
state.toolCallByCallId = new Map();
|
||||
// response.function_call_arguments.delta carries `item_id`/`output_index`, not
|
||||
// `call_id` — resolve either one back to the call_id key used by
|
||||
// toolCallByCallId (two independent reverse maps, since some upstreams omit
|
||||
// item_id on delta events but still send output_index).
|
||||
state.toolCallItemToCallId = new Map();
|
||||
state.toolCallOutputIndexToCallId = new Map();
|
||||
}
|
||||
|
||||
// Text content delta
|
||||
@@ -983,22 +1000,48 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
// Function call started
|
||||
if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
|
||||
const item = data.item;
|
||||
state.currentToolCallId = item.call_id || fallbackToolCallId();
|
||||
state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer
|
||||
state.currentToolCallDeferred = false;
|
||||
const callId = item.call_id || fallbackToolCallId();
|
||||
// Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility.
|
||||
state.currentToolCallId = callId;
|
||||
|
||||
const toolName = normalizeToolName(item.name);
|
||||
// Assign this call's index NOW, at .added, not at .done — two calls opened before
|
||||
// either closes (a genuine parallel dispatch) must never share an index. Deferred
|
||||
// (still-nameless) calls are the one exception: they don't claim an index until
|
||||
// .done resolves a real name, so a call that never gets one never burns a slot
|
||||
// another call could have used.
|
||||
let index: number | null = null;
|
||||
if (toolName) {
|
||||
index = state.toolCallIndex ?? 0;
|
||||
state.toolCallIndex = index + 1;
|
||||
}
|
||||
|
||||
if (!(state.toolCallByCallId instanceof Map)) state.toolCallByCallId = new Map();
|
||||
state.toolCallByCallId.set(callId, {
|
||||
index,
|
||||
name: toolName,
|
||||
argsBuffer: "",
|
||||
deferred: !toolName,
|
||||
needsNormalization: toolName === "Agent",
|
||||
});
|
||||
if (!(state.toolCallItemToCallId instanceof Map)) state.toolCallItemToCallId = new Map();
|
||||
if (item.id) state.toolCallItemToCallId.set(item.id, callId);
|
||||
// `output_index` is a top-level field on every Responses API streamed event
|
||||
// (response.output_item.added/.done AND function_call_arguments.delta alike) —
|
||||
// an identifier independent of item_id, for upstreams that omit item_id on delta
|
||||
// events.
|
||||
if (!(state.toolCallOutputIndexToCallId instanceof Map)) {
|
||||
state.toolCallOutputIndexToCallId = new Map();
|
||||
}
|
||||
if (data.output_index != null) state.toolCallOutputIndexToCallId.set(data.output_index, callId);
|
||||
|
||||
// Track this call_id so response.completed doesn't synthesize a duplicate
|
||||
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
|
||||
if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId);
|
||||
state.toolCallIdsSeen.add(callId);
|
||||
|
||||
const toolName = normalizeToolName(item.name);
|
||||
state.currentToolName = toolName; // track for schema lookup at done time
|
||||
state.currentToolCallName = toolName;
|
||||
state.currentToolCallNeedsNormalization = toolName === "Agent";
|
||||
if (!toolName) {
|
||||
// Some Responses providers briefly emit placeholder/empty tool names.
|
||||
// Defer emission until output_item.done in case the final name is populated there.
|
||||
state.currentToolCallDeferred = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1013,8 +1056,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: state.toolCallIndex,
|
||||
id: state.currentToolCallId,
|
||||
index,
|
||||
id: callId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
@@ -1037,11 +1080,26 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
const argsDelta = data.delta || "";
|
||||
if (!argsDelta) return null;
|
||||
|
||||
state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta;
|
||||
if (state.currentToolCallDeferred || state.currentToolCallNeedsNormalization) return null;
|
||||
// Resolve which in-flight call this delta belongs to. Try item_id first (the
|
||||
// field the Responses API documents for this event), then output_index (also a
|
||||
// top-level field on this event, and independent of item_id — covers upstreams
|
||||
// that omit item_id on delta events but still send output_index). Only once both
|
||||
// identifying fields are absent/unresolved do we fall back to guessing (the
|
||||
// single open call, or the most recently opened one as a last resort).
|
||||
const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null;
|
||||
let callId = data.item_id ? state.toolCallItemToCallId?.get(data.item_id) : undefined;
|
||||
if (!callId && data.output_index != null) {
|
||||
callId = state.toolCallOutputIndexToCallId?.get(data.output_index);
|
||||
}
|
||||
if (!callId && map) {
|
||||
callId = map.size === 1 ? [...map.keys()][0] : state.currentToolCallId;
|
||||
}
|
||||
const entry = callId ? map?.get(callId) : undefined;
|
||||
if (!entry) return null;
|
||||
|
||||
// #9168: buffer arguments until output_item.done for schema-aware null normalization
|
||||
// Previously emitted raw null values for optional enum fields (e.g. isolation: null).
|
||||
entry.argsBuffer = (entry.argsBuffer || "") + argsDelta;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1061,13 +1119,30 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
// carry the complete arguments only in output_item.done (no preceding delta events).
|
||||
if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
|
||||
const item = data.item;
|
||||
const buffered = state.currentToolCallArgsBuffer || "";
|
||||
const currentIndex = state.toolCallIndex; // capture before increment
|
||||
const callId = item.call_id || state.currentToolCallId || fallbackToolCallId();
|
||||
const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null;
|
||||
let callId = item.call_id;
|
||||
if (!callId && item.id) callId = state.toolCallItemToCallId?.get(item.id);
|
||||
if (!callId) callId = state.currentToolCallId || fallbackToolCallId();
|
||||
const trackedEntry = callId ? map?.get(callId) : undefined;
|
||||
// Some upstreams (e.g. Codex) send the complete payload only in output_item.done,
|
||||
// with no preceding output_item.added at all — there is no tracked entry to read an
|
||||
// index from.
|
||||
const entry = trackedEntry || { index: null, argsBuffer: "", deferred: false };
|
||||
|
||||
const buffered = entry.argsBuffer || "";
|
||||
const toolName = normalizeToolName(item.name);
|
||||
|
||||
// Claim (and advance) this call's index now if it wasn't assigned at .added — either
|
||||
// a deferred call whose name has just now resolved, or a Codex-style done-only
|
||||
// payload that never had an .added at all. A deferred call whose name is STILL empty
|
||||
// never claims an index (nothing was ever emitted for it either way).
|
||||
if (entry.index == null && toolName) {
|
||||
entry.index = state.toolCallIndex ?? 0;
|
||||
state.toolCallIndex = entry.index + 1;
|
||||
}
|
||||
const currentIndex = entry.index;
|
||||
const toolSchema = state.toolSchemas?.get(toolName);
|
||||
const shouldNormalizeArguments = toolName === "Agent";
|
||||
state.currentToolCallNeedsNormalization = shouldNormalizeArguments;
|
||||
|
||||
if (toolName && state.toolCalls instanceof Map) {
|
||||
const completedArguments =
|
||||
@@ -1077,6 +1152,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
toolName,
|
||||
toolSchema
|
||||
);
|
||||
// Keyed by index, not insertion order — readers that need call order for
|
||||
// parallel calls closed out of order should sort by this key rather than
|
||||
// relying on Map iteration order.
|
||||
state.toolCalls.set(currentIndex, {
|
||||
id: callId,
|
||||
index: currentIndex,
|
||||
@@ -1095,17 +1173,17 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set();
|
||||
if (callId) state.toolCallIdsSeen.add(callId);
|
||||
|
||||
if (state.currentToolCallDeferred) {
|
||||
state.currentToolCallDeferred = false;
|
||||
state.currentToolCallArgsBuffer = "";
|
||||
state.currentToolCallId = null;
|
||||
// This call is fully closed — remove it from the in-flight map (bounds the map
|
||||
// to genuinely in-flight calls, and keeps the single-open-call fallback in the
|
||||
// function_call_arguments.delta handler correct for whichever call opens next).
|
||||
if (map && callId) map.delete(callId);
|
||||
if (state.currentToolCallId === callId) state.currentToolCallId = null;
|
||||
|
||||
if (entry.deferred) {
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
state.toolCallIndex++;
|
||||
|
||||
const terminalArguments =
|
||||
typeof item.arguments === "string"
|
||||
? item.arguments.length > 0
|
||||
@@ -1148,12 +1226,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
};
|
||||
}
|
||||
|
||||
state.toolCallIndex++;
|
||||
state.currentToolCallArgsBuffer = ""; // reset for next tool call
|
||||
state.currentToolCallId = null;
|
||||
const needsNormalization = state.currentToolCallNeedsNormalization === true;
|
||||
state.currentToolCallNeedsNormalization = false;
|
||||
state.currentToolCallName = "";
|
||||
const needsNormalization = shouldNormalizeArguments;
|
||||
|
||||
// Nullable omission sentinels must be normalized before any argument bytes reach the client.
|
||||
// Other tool calls retain immediate argument streaming.
|
||||
|
||||
@@ -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,28 @@ 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;
|
||||
// Normalize tool_call indexes to contiguous 0-based (OpenAI contract).
|
||||
for (const tc of mergedToolCalls) {
|
||||
const splitArgs = splitConcatenatedToolCallArguments(tc.function.arguments);
|
||||
if (!splitArgs) {
|
||||
finalToolCalls.push({ ...tc, index: nextIndex++ });
|
||||
continue;
|
||||
}
|
||||
for (const [i, args] of splitArgs.entries()) {
|
||||
finalToolCalls.push({
|
||||
id: tc.id ? `${tc.id}_split${i}` : null,
|
||||
index: nextIndex++,
|
||||
type: tc.type,
|
||||
function: { name: tc.function.name, arguments: args },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (finalToolCalls.length > 0) {
|
||||
finishReason = "tool_calls";
|
||||
message.tool_calls = finalToolCalls;
|
||||
|
||||
72
package-lock.json
generated
72
package-lock.json
generated
@@ -18,7 +18,6 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@lobehub/icons": "^5.16.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -61,7 +60,6 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"omniglyph": "^1.4.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"open": "^11.0.1",
|
||||
"ora": "^9.4.1",
|
||||
"parse5": "^8.0.1",
|
||||
@@ -156,9 +154,11 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@atjsh/llmlingua-2": "3.0.0",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"better-sqlite3": "^13.0.2",
|
||||
"js-tiktoken": "^1.0.20",
|
||||
"keytar": "^7.9.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"sqlite-vec": "^0.1.9",
|
||||
"tls-client-node": "^0.2.0",
|
||||
"wreq-js": "^3.0.0"
|
||||
@@ -4510,6 +4510,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
|
||||
"integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -4518,13 +4519,15 @@
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz",
|
||||
"integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@huggingface/transformers": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz",
|
||||
"integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@huggingface/jinja": "^0.5.6",
|
||||
"@huggingface/tokenizers": "^0.1.3",
|
||||
@@ -9483,30 +9486,35 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
||||
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/base64": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
||||
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/codegen": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
|
||||
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
@@ -9516,24 +9524,28 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
||||
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
||||
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/pool": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
||||
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/utf8": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
|
||||
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
@@ -12737,6 +12749,7 @@
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
@@ -13998,6 +14011,7 @@
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
|
||||
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=14.0"
|
||||
}
|
||||
@@ -14971,7 +14985,8 @@
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
"integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/bottleneck": {
|
||||
"version": "2.19.5",
|
||||
@@ -17935,6 +17950,7 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0",
|
||||
@@ -17964,6 +17980,7 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-data-property": "^1.0.1",
|
||||
@@ -18064,7 +18081,8 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
|
||||
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
@@ -18908,7 +18926,8 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz",
|
||||
"integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/es6-promisify": {
|
||||
"version": "7.0.0",
|
||||
@@ -20400,7 +20419,8 @@
|
||||
"version": "25.9.23",
|
||||
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
|
||||
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.4.2",
|
||||
@@ -21226,6 +21246,7 @@
|
||||
"resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz",
|
||||
"integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"es6-error": "^4.1.1",
|
||||
@@ -21243,6 +21264,7 @@
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@@ -21291,6 +21313,7 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
"integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"define-properties": "^1.2.1",
|
||||
@@ -21645,7 +21668,8 @@
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
|
||||
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/hachure-fill": {
|
||||
"version": "0.5.2",
|
||||
@@ -21679,6 +21703,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.0"
|
||||
@@ -24790,7 +24815,8 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
@@ -26654,6 +26680,7 @@
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
"integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"escape-string-regexp": "^4.0.0"
|
||||
},
|
||||
@@ -29425,6 +29452,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -29632,7 +29660,8 @@
|
||||
"version": "1.24.3",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz",
|
||||
"integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/onnxruntime-node": {
|
||||
"version": "1.24.3",
|
||||
@@ -29640,6 +29669,7 @@
|
||||
"integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32",
|
||||
"darwin",
|
||||
@@ -29656,6 +29686,7 @@
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz",
|
||||
"integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"flatbuffers": "^25.1.24",
|
||||
"guid-typescript": "^1.0.9",
|
||||
@@ -29669,13 +29700,15 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
|
||||
"version": "1.24.0-dev.20251116-b39e144322",
|
||||
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz",
|
||||
"integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/open": {
|
||||
"version": "11.0.1",
|
||||
@@ -30906,7 +30939,8 @@
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
|
||||
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
@@ -31861,6 +31895,7 @@
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -31884,6 +31919,7 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
@@ -33280,6 +33316,7 @@
|
||||
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
|
||||
"integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"boolean": "^3.0.1",
|
||||
"detect-node": "^2.0.4",
|
||||
@@ -33655,7 +33692,8 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
||||
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
@@ -33688,6 +33726,7 @@
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz",
|
||||
"integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"type-fest": "^0.13.1"
|
||||
},
|
||||
@@ -33703,6 +33742,7 @@
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz",
|
||||
"integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -34474,7 +34514,8 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sql.js": {
|
||||
"version": "1.14.2",
|
||||
@@ -36187,6 +36228,7 @@
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-emoji-modifier-base": {
|
||||
|
||||
@@ -265,7 +265,6 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@lobehub/icons": "^5.16.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
@@ -308,7 +307,6 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"omniglyph": "^1.4.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"open": "^11.0.1",
|
||||
"ora": "^9.4.1",
|
||||
"parse5": "^8.0.1",
|
||||
@@ -343,9 +341,11 @@
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@atjsh/llmlingua-2": "3.0.0",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"better-sqlite3": "^13.0.2",
|
||||
"js-tiktoken": "^1.0.20",
|
||||
"keytar": "^7.9.0",
|
||||
"onnxruntime-node": "1.24.3",
|
||||
"sqlite-vec": "^0.1.9",
|
||||
"tls-client-node": "^0.2.0",
|
||||
"wreq-js": "^3.0.0"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
name: omni-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.
|
||||
---
|
||||
<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->
|
||||
|
||||
## Overview
|
||||
|
||||
Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries.
|
||||
Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries.
|
||||
|
||||
## Authentication
|
||||
|
||||
|
||||
@@ -291,7 +291,9 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) {
|
||||
icon="monitor_heart"
|
||||
label={t("comboHealthIssues")}
|
||||
value={report.summary.issueCount.toLocaleString()}
|
||||
subValue={t("comboHealthActionable", { count: report.summary.actionableCount })}
|
||||
subValue={t("comboHealthActionable", {
|
||||
count: report.summary.suggestionCount ?? report.summary.actionableCount ?? 0,
|
||||
})}
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="error"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user