Merge branch 'release/v3.8.49' into feat/trunk-flaky-uploads

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-14 22:45:31 -03:00
committed by GitHub
82 changed files with 1757 additions and 123 deletions

View File

@@ -505,7 +505,9 @@ NEXT_PUBLIC_CLOUD_URL=
#OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
# OpenCode Go has no public quota API — this has no default and stays
# unset unless you explicitly opt in to a self-hosted/mirrored endpoint:
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings

View File

@@ -62,7 +62,7 @@ jobs:
docs-gates:
name: Docs Gates (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -82,7 +82,7 @@ jobs:
name: Fast Quality Gates
needs: changes
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
@@ -210,7 +210,7 @@ jobs:
fast-vitest:
name: Vitest (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
env:
@@ -231,7 +231,7 @@ jobs:
fast-unit:
name: Unit Tests fast-path (${{ matrix.shard }}/4)
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
@@ -277,7 +277,7 @@ jobs:
lint-guard:
name: No new ESLint warnings
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
steps:
@@ -316,7 +316,7 @@ jobs:
merge-integrity:
name: Merge integrity (changelog + generated skills)
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
env:

View File

@@ -0,0 +1 @@
- fix(providers): preserve relayAuth for vercel/deno/cloudflare relay proxies referenced by-id from the no-auth-provider Proxy Pool dropdown (#5716)

View File

@@ -0,0 +1 @@
- fix(sse): sanitize non-Latin1 characters before embedding combo diagnostics in HTTP headers, preventing a ByteString crash on quality-check failure (#6612)

View File

@@ -0,0 +1 @@
- fix(providers): reject chat-completions requests for cloud-agent-only providers like jules instead of silently mis-routing them to OpenAI's endpoint (#6699)

View File

@@ -0,0 +1 @@
- **fix(db):** cap the sql.js OOM-during-probe path in `getDbInstance()` at 3 attempts with a terminal diagnostic — previously only the generic-corruption probe-failure path had a cycle-breaker (#6632), so a persistently OOMing `storage.sqlite` probe re-threw the identical error forever on every call from every background poller, hanging the app with "Internal Server Error" and no self-recovery (#6835).

View File

@@ -0,0 +1 @@
- fix(providers): DuckDuckGo AI Chat executor propagates the real upstream status (429 rate limit with `Retry-After`) instead of misclassifying VQD-token acquisition failures as a hardcoded 503 (#6996)

View File

@@ -0,0 +1 @@
- fix(providers): refresh OpenCode (`oc`) free-tier model catalog — 6 delisted IDs replaced with the 4 currently-live free models (#6998)

View File

@@ -0,0 +1 @@
- fix(compression): wire the adaptive context-budget "dial" (`contextBudget`) into the settings schema and DB so it can actually be persisted via `PUT /api/settings/compression`, instead of being silently rejected (#7005)

View File

@@ -0,0 +1 @@
- fix(usage): stop opencode-go quota lookup from defaulting to an unrelated Z.AI endpoint (#7022)

View File

@@ -0,0 +1 @@
- fix(build): extend the Turbopack `ignoreIssue` suppression to `open-sse/services/compression/**`, matching the `getModuleDir()` dynamic-path fs pattern already suppressed for `src/lib/agentSkills/**` in #6582, eliminating the remaining 610 "Overly broad patterns" warnings (#7051)

View File

@@ -0,0 +1 @@
- fix(providers): web-cookie connection-test/cookie-validation probe (zai-web and every other registry-entry web-cookie provider) now honors the configured HTTP/SOCKS proxy — the `/models` probe routed through `directHttpsRequest`'s hardcoded native-fetch bypass, silently skipping proxy resolution even though the executor's actual chat traffic already respected it (#7058)

View File

@@ -0,0 +1 @@
- fix(resilience): recognize Ollama Cloud's 5-hour session usage-limit 429 as quota-exhausted instead of a generic rate limit (#7071)

View File

@@ -0,0 +1 @@
- fix(dashboard): restore mobile single-column fallback on the Provider Quota page card grid, fixing clipped labels/buttons on phone-width viewports (#7072)

View File

@@ -0,0 +1 @@
- fix(dashboard): include proxyId when testing a saved registry proxy so SOCKS5/auth credentials are loaded (#7080)

View File

@@ -0,0 +1 @@
- **fix(sse):** claude-web now surfaces the real upstream error body for non-SSE 400/403/429/500 responses instead of reporting "no response body" — the streaming client was discarding the already-captured temp-file bytes and reading the native binding's empty in-memory body field instead (#7134).

View File

@@ -0,0 +1 @@
- fix(db): honor combo-level proxy assignments from the registry when resolving a connection's proxy (#7149)

View File

@@ -0,0 +1 @@
- fix(dashboard): wire modelAliases fetch into HermesAgentToolCard so OpenRouter and other passthrough providers appear in the Hermes Agent role picker (#7151)

View File

@@ -0,0 +1 @@
- fix(dashboard): filter hidden custom models out of the legacy combo model picker (#7156)

View File

@@ -0,0 +1 @@
- fix(dashboard): Agent Bridge DNS toggle now sends POST (was PUT), fixing HTTP 405 on Start/Stop DNS (#7157)

View File

@@ -0,0 +1 @@
- fix(dashboard): implement missing `handleToggleSource` callback on the Free Pool tab so the page no longer crashes with a `ReferenceError` (#7161)

View File

@@ -0,0 +1 @@
- fix(sse): stop duplicating text in Gemini Web streamed responses (#7163)

View File

@@ -0,0 +1 @@
- **CI**: quality.yml draft guards now also match Mergify speculative merge-queue PRs (`mergify/merge-queue/*` heads are drafts by design) — without this every queued batch failed its anchor check in 2s and dequeued

View File

@@ -278,7 +278,8 @@ Every compressed request includes stats in the server logs:
| Phase 1 | Off, Lite | ✅ Shipped |
| Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped |
| Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped |
| Phase 4 | Output Styles, SLM-tier Ultra, adaptive context-budget, eval harness | ✅ Shipped |
| Phase 4 | Output Styles, SLM-tier Ultra, eval harness | ✅ Shipped |
| Phase 4C | Adaptive context-budget ("dial") — compute engine + API (`contextBudget` on `PUT /api/settings/compression`) | ✅ Shipped (API-configurable; dashboard controls not yet built, #7005) |
---

View File

@@ -272,7 +272,7 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱
| `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 |
| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 |
| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode (zen/go) 配额查询端点。可覆盖为中继/测试固定件。 |
| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。可覆盖为中继/测试固定件。 |
| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(未设置)_ | `open-sse/services/opencodeOllamaUsage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。OpenCode Go 没有公开的配额 API因此没有默认值除非运维人员显式设置该变量选择接入自建/镜像端点,否则不会发起网络请求。 |
| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | 配置了 workspace ID 和 auth Cookie 时用于配额抓取的 OpenCode Go Dashboard 基础 URL。可覆盖为中继/测试固定件。 |
| `OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go workspace ID。配置多个账户时推荐使用每个连接的 Dashboard 字段。 |
| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID 环境变量的备选名,在较短的别名之前使用。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 |

View File

@@ -280,7 +280,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OMNIROUTE_PUBLIC_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public origin used to compose async callback URLs. Lowest-priority fallback for kie.ai callbacks; also used as a generic public URL for other relays. |
| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | CrofAI quota lookup endpoint used by the Usage page. Override for relays / test fixtures. |
| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | OpenCode (zen/go) quota lookup endpoint used by the Usage page. Override for relays / test fixtures. |
| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. Override for relays / test fixtures. |
| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(unset)_ | `open-sse/services/opencodeOllamaUsage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. OpenCode Go has no public quota API, so this has no default and the network call is skipped unless the operator opts in to a self-hosted/mirrored endpoint. |
| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | OpenCode Go dashboard base URL used for quota scraping when a workspace ID and auth cookie are configured. Override for relays / test fixtures. |
| `OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID used for dashboard quota scraping. Prefer the per-connection Dashboard field when multiple accounts are configured. |
| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go workspace ID env var used before the shorter alias. Prefer the per-connection Dashboard field when multiple accounts are configured. |

View File

@@ -128,11 +128,25 @@ const nextConfig = {
// expected diagnostic — suppress it here rather than fight the analyzer,
// mirroring the isNextIntlExtractorDynamicImportWarning precedent below
// for the webpack path. (#6582)
// open-sse/services/compression/ruleLoader.ts and
// .../engines/rtk/filterLoader.ts both define an identical
// getModuleDir() helper that walks up directories via
// path.resolve(anchor) + fs.existsSync(...) in a loop with a
// non-literal argument — the same dynamic-path fs access pattern as
// the agentSkills case above, but not covered by that narrower
// allowlist glob, so the "Overly broad patterns..." warning kept
// firing (610 times, once per entry point transitively importing the
// compression module). Same known-benign, bounded fs access;
// suppressed here rather than fought. (#7051, follow-up to #6582)
ignoreIssue: [
{
path: "**/src/lib/agentSkills/**",
description: /Overly broad patterns can lead to build performance issues/,
},
{
path: "**/open-sse/services/compression/**",
description: /Overly broad patterns can lead to build performance issues/,
},
],
},
output: "standalone",

View File

@@ -23,29 +23,15 @@ export const opencodeProvider: RegistryEntry = {
interleavedField: "reasoning_content",
},
{ id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportsReasoning: true },
// #3110: MiniMax M3 free tier via OpenCode
// #3328: MiniMax M3 is multimodal (verified: describes base64 images via the
// opencode upstream) — flag it so vision requests aren't gated/stripped.
{
id: "minimax-m3-free",
name: "MiniMax M3 Free",
contextLength: 1048576,
supportsVision: true,
},
{ id: "minimax-m2.5-free", name: "MiniMax M2.5 Free", contextLength: 204800 },
{ id: "ling-2.6-1t-free", name: "Ling 2.6 Free", contextLength: 262000 },
{
id: "trinity-large-preview-free",
name: "Trinity Large Preview Free",
contextLength: 131000,
},
{ id: "nemotron-3-super-free", name: "Nemotron 3 Super Free", contextLength: 1000000 },
{
id: "qwen3.6-plus-free",
name: "Qwen3.6 Plus Free",
targetFormat: "claude",
supportsVision: false,
contextLength: 200000,
},
// #6998: 2026-07-14 refresh — the upstream free tier rotated its lineup;
// minimax-m3-free, minimax-m2.5-free, ling-2.6-1t-free,
// trinity-large-preview-free, nemotron-3-super-free and qwen3.6-plus-free
// were delisted (401 "Model X is not supported") and replaced by the 4
// entries below, confirmed live against
// https://opencode.ai/zen/v1/chat/completions.
{ id: "mimo-v2.5-free", name: "MiMo V2.5 Free", contextLength: 131000 },
{ id: "hy3-free", name: "HY3 Free", contextLength: 131000 },
{ id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", contextLength: 1000000 },
{ id: "north-mini-code-free", name: "North Mini Code Free", contextLength: 131000 },
],
};

View File

@@ -64,11 +64,18 @@ function shouldUseBrowserBacked(): boolean {
interface DuckDuckGoVqdHeaders {
vqd4: string | null;
vqdHash1: string | null;
// #6996: the real upstream HTTP status of the VQD-acquisition attempt (null when
// no request was made / a network error was thrown). Lets execute() distinguish a
// retryable 429 rate-limit from a genuine 5xx instead of collapsing both to 503.
status: number | null;
retryAfter: string | null;
}
interface DuckDuckGoAuthHeaders {
vqd4: string | null;
vqdHash1: string | null;
status: number | null;
retryAfter: string | null;
}
interface DuckDuckGoModelCapabilities {
@@ -369,10 +376,13 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
const isStreaming = stream !== false;
const upstreamHeaders = upstreamExtraHeaders || {};
const errorResponse = (status: number, message: string): Response =>
const errorResponse = (status: number, message: string, retryAfter?: string | null): Response =>
new Response(JSON.stringify({ error: { message } }), {
status,
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...(retryAfter ? { "Retry-After": retryAfter } : {}),
},
});
if (messages.length === 0) {
@@ -468,6 +478,19 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
const vqdHeaders = await this.acquireAuthHeaders(mergedSignal);
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
clearTimeout(timeout);
// #6996: surface the real upstream status instead of a hardcoded 503 so a
// 429 rate-limit gets a connection-cooldown, not a whole-provider circuit
// breaker trip (see CLAUDE.md "Provider Circuit Breaker" — only
// 408/500/502/503/504 should trip it, not 429). Any other non-2xx status
// (403 anti-bot challenge, genuine 5xx, or a thrown network error where
// status is null) keeps the existing 503 fallback.
if (vqdHeaders.status === 429) {
return errorResponse(
429,
"Failed to acquire VQD token: upstream rate limited",
vqdHeaders.retryAfter
);
}
return errorResponse(503, "Failed to acquire VQD token");
}
@@ -555,16 +578,25 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
});
this.rememberResponseCookies(resp);
if (!resp.ok) return { vqd4: null, vqdHash1: null };
if (!resp.ok) {
return {
vqd4: null,
vqdHash1: null,
status: resp.status,
retryAfter: resp.headers.get("Retry-After"),
};
}
return {
vqd4: resp.headers.get("x-vqd-4"),
vqdHash1: resp.headers.get("x-vqd-hash-1"),
status: resp.status,
retryAfter: null,
};
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
return { vqd4: null, vqdHash1: null };
return { vqd4: null, vqdHash1: null, status: null, retryAfter: null };
}
}
@@ -576,6 +608,8 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
return {
vqd4: null,
vqdHash1: await solveDuckDuckGoChallenge(challenge, FAKE_HEADERS["User-Agent"]),
status: null,
retryAfter: null,
};
} catch (error) {
void error;
@@ -588,6 +622,8 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
return {
vqd4: headers.vqd4,
vqdHash1: await solveDuckDuckGoChallenge(headers.vqdHash1, FAKE_HEADERS["User-Agent"]),
status: headers.status,
retryAfter: headers.retryAfter,
};
} catch (error) {
void error;

View File

@@ -112,12 +112,15 @@ function parseCookies(raw: string): Array<{ name: string; value: string }> {
* [["wrb.fr", null, "<JSON string>"]]
*
* The JSON string contains nested array: inner[4][0][1] = ["text chunks"].
* We concatenate text from every wrb.fr line because Gemini can split one
* assistant answer across multiple StreamGenerate chunks.
* Each wrb.fr line is a CUMULATIVE snapshot of the whole answer generated so
* far (not an independent delta), so we keep only the text from the LAST
* frame that yields non-empty text instead of concatenating every frame —
* concatenating would reproduce the same growing text with each snapshot
* (see #7163).
*/
export function parseStreamResponse(raw: string): string {
const lines = raw.split("\n");
const textChunks: string[] = [];
let lastText = "";
for (const rawLine of lines) {
const line = rawLine.trim();
@@ -133,12 +136,12 @@ export function parseStreamResponse(raw: string): string {
const responseArray = inner?.[4]?.[0]?.[1];
if (!Array.isArray(responseArray)) continue;
const text = responseArray.filter((c: unknown) => typeof c === "string").join("");
if (text) textChunks.push(text);
if (text) lastText = text;
} catch {
// Skip unparseable lines
}
}
return textChunks.join("");
return lastText;
}
function readCredentialString(value: unknown): string {

View File

@@ -170,8 +170,26 @@ const executors = {
const defaultCache = new Map();
// #6699 — providers that exist ONLY as Cloud Agent task-API entries
// (CLOUD_AGENT_PROVIDERS / staticModels "Available Models" catalog) and have no
// chat-completions REGISTRY entry anywhere in open-sse/. Without this guard,
// getExecutor() silently falls through to DefaultExecutor's
// `PROVIDERS[provider] || PROVIDERS.openai` fallback, sending the user's real
// provider key to OpenAI's endpoint (mislabeled as coming from the provider the
// user actually selected). Starting with just "jules" (the reported case);
// "devin" and "codex-cloud" share the same structural gap and are left for a
// follow-up once their own chat-routing behavior is confirmed.
const CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS = new Set(["jules"]);
export function getExecutor(provider) {
if (executors[provider]) return executors[provider];
if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) {
const err = new Error(
`Provider "${provider}" is a cloud-agent provider and does not support direct chat completions; use the Cloud Agents task API instead.`
);
(err as Error & { status?: number }).status = 400;
throw err;
}
if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider));
return defaultCache.get(provider);
}

View File

@@ -90,6 +90,7 @@ export interface AccountProxyConfig {
port: number;
username?: string;
password?: string;
relayAuth?: string;
} | null;
}

View File

@@ -21,6 +21,7 @@ export interface OpencodeAccountProxyConfig {
port: number;
username?: string;
password?: string;
relayAuth?: string;
} | null;
}

View File

@@ -41,6 +41,7 @@ import {
isSubscriptionQuotaText,
buildSubscriptionQuotaFallback,
buildWeeklyQuotaFallback,
buildSessionQuotaFallback,
} from "./quotaTextCooldowns.ts";
import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts";
@@ -1454,6 +1455,12 @@ export function checkFallbackError(
}
const weeklyResult = buildWeeklyQuotaFallback(errorStr);
if (weeklyResult) return weeklyResult;
// Issue #7071 (session usage cap) is the same sibling gap as #3709 above —
// runs UNCONDITIONALLY for the same reason: apikey-category providers
// like ollama-cloud are excluded from the oauth-only shouldUseQuotaSignal
// gate.
const sessionResult = buildSessionQuotaFallback(errorStr);
if (sessionResult) return sessionResult;
const quotaResetHintMs = parseRetryFromErrorText(errorStr);
if (

View File

@@ -344,7 +344,19 @@ function toHeaders(raw: Record<string, string[]>): Headers {
// to a file path, terminating when the upstream sends `streamOutputEOFSymbol`.
// We tail the file from a worker and surface the bytes as a ReadableStream.
async function tlsFetchStreaming(
// Cap for the bounded fallback read of a non-SSE error body straight from the
// streaming temp file (mirrors the 2048-byte cap executors/claude-web.ts
// already applies when reading error bodies) — avoids buffering an unbounded
// error page into memory. See #7134.
const MAX_ERROR_BODY_BYTES = 16 * 1024;
/**
* Exported for tests (issue #7134): allows injecting a fake `client` so the
* non-SSE error-body fallback path can be exercised without
* `--experimental-test-module-mocks`, matching the DI pattern already used
* by `__setTlsFetchOverrideForTesting` for the outer `tlsFetchClaude`.
*/
export async function tlsFetchStreaming(
client: { request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike> },
url: string,
requestOptions: Record<string, unknown>,
@@ -417,11 +429,22 @@ async function tlsFetchStreaming(
const r = await requestPromise.catch(
(e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike
);
// tls-client-node's `streamOutputPath` mode writes the response body to
// the temp file chunk-by-chunk and does NOT also populate the resolved
// response's in-memory `body` field (confirmed against
// node_modules/tls-client-node/dist/response.js) — so for every non-SSE,
// non-2xx claude-web response (400/403/429/500 with a real JSON/HTML
// error), `r.body` is empty even though the real bytes are sitting in
// `path` (we just peeked them above). Prefer `r.body` when it IS
// populated (some native-client modes do fill it in); otherwise fall
// back to a bounded read of the temp file so the real upstream error
// detail reaches the caller instead of being silently discarded. #7134
const text = r.body || (await readFirstBytes(path, MAX_ERROR_BODY_BYTES).catch(() => ""));
await cleanupTempPath(path);
return {
status: r.status,
headers: toHeaders(r.headers),
text: r.body,
text,
body: null,
};
}

View File

@@ -13,8 +13,13 @@ type UsageQuota = {
currency?: string;
};
const OPENCODE_GO_QUOTA_URL =
process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit";
// OpenCode Go does not expose a public quota API. There is no working
// opencode.ai endpoint to default to (see #7022) — the quota-by-API-key path
// below is opt-in only and activates exclusively when the operator sets
// OMNIROUTE_OPENCODE_GO_QUOTA_URL explicitly. Never hardcode a third-party
// host here (a previous default silently sent the user's API key to an
// unrelated Z.AI endpoint).
const OPENCODE_GO_QUOTA_URL = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL?.trim() || "";
const OPENCODE_GO_DASHBOARD_BASE_URL =
process.env.OMNIROUTE_OPENCODE_GO_DASHBOARD_URL ?? "https://opencode.ai/workspace";
const OPENCODE_GO_QUOTA_TOTALS = { session: 12, weekly: 30, mcp_monthly: 60 } as const;
@@ -335,6 +340,15 @@ export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?:
};
}
if (!OPENCODE_GO_QUOTA_URL) {
return {
message:
"OpenCode Go does not expose a public quota API. " +
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping, " +
"or set OMNIROUTE_OPENCODE_GO_QUOTA_URL to opt in to an explicit quota endpoint.",
};
}
try {
const res = await fetch(OPENCODE_GO_QUOTA_URL, {
headers: {
@@ -348,7 +362,8 @@ export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?:
if (res.status === 401 || res.status === 403) {
return {
message:
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
"OpenCode Go API key is valid for chat/models but cannot read quota from the configured " +
"OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " +
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.",
};
}
@@ -374,7 +389,8 @@ export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?:
) {
return {
message:
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
"OpenCode Go API key is valid for chat/models but cannot read quota from the configured " +
"OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " +
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.",
};
}

View File

@@ -103,3 +103,37 @@ export function buildWeeklyQuotaFallback(errorStr: string): QuotaTextFallback |
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}
// ─── Issue #7071 — Ollama Cloud 5-hour SESSION usage cap ───────────────────
//
// Ollama Cloud also enforces a rolling 5-hour "session" usage cap, sibling to
// the weekly cap above (#3709/#6638). On cap the upstream returns 429 with a
// body like "you (<account>) have reached your session usage limit". Same
// root cause as the weekly gap: neither the generic subscription-quota-text
// classifier nor the weekly one recognize "session" wording, so the account
// fell through to the generic 429 backoff and got retried within the same
// 5-hour window instead of cooling down for it — combo/LKGP routing cycled
// back to the "exhausted" account instead of advancing to the next one.
//
// Patterns are scoped to "session ... usage limit" / "session limit reached"
// / "reached your session ... usage limit" phrasing (not a bare "session"
// match) so unrelated "session expired"/"session token invalid" auth errors
// from other providers are not misclassified as quota-exhausted.
const SESSION_QUOTA_COOLDOWN_MS = 5 * 60 * 60 * 1000; // 5 hours
export function isSessionUsageLimitText(lower: string): boolean {
return (
lower.includes("session usage limit") ||
lower.includes("session limit reached") ||
(lower.includes("reached your session") && lower.includes("usage limit"))
);
}
export function buildSessionQuotaFallback(errorStr: string): QuotaTextFallback | null {
if (!isSessionUsageLimitText(errorStr.toLowerCase())) return null;
return {
shouldFallback: true,
cooldownMs: SESSION_QUOTA_COOLDOWN_MS,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}

View File

@@ -145,6 +145,22 @@ function clampDiagStr(v: unknown, max = 128): string {
return typeof v === "string" ? v.slice(0, max).replace(/[\r\n]+/g, " ") : "";
}
/**
* HTTP header values must be Latin1/ByteString (undici throws a TypeError
* otherwise — see #6612). Replace any codepoint outside the Latin1 range
* (0-255) with "?" so header construction never throws. Only used for the
* literal header value; the JSON body keeps the original, unsanitized
* readable text via `sanitizeComboDiagnostics`.
*/
function toHeaderSafeAscii(v: string): string {
let out = "";
for (let i = 0; i < v.length; i++) {
const code = v.charCodeAt(i);
out += code > 255 ? "?" : v[i];
}
return out;
}
/**
* Whitelist projection — guarantees only id/reason string primitives + integer
* counts can escape, regardless of what the caller assembled. This is the secret
@@ -186,10 +202,12 @@ export function errorResponseWithComboDiagnostics(
if (opts.code) body.error.code = opts.code;
if (opts.type) body.error.type = opts.type;
body.diagnostics = safe;
const excludedHeader = safe.excluded
.map((e) => `${e.provider}${e.model ? `/${e.model}` : ""}:${e.reason}`)
.join(",")
.slice(0, 900);
const excludedHeader = toHeaderSafeAscii(
safe.excluded
.map((e) => `${e.provider}${e.model ? `/${e.model}` : ""}:${e.reason}`)
.join(",")
.slice(0, 900)
);
return new Response(JSON.stringify(body), {
status: statusCode,
headers: {
@@ -197,7 +215,7 @@ export function errorResponseWithComboDiagnostics(
"x-omniroute-combo-pool-size": String(safe.poolSize),
"x-omniroute-combo-attempted": String(safe.attempted),
"x-omniroute-combo-excluded": excludedHeader,
"x-omniroute-combo-terminal-reason": safe.terminalReason.slice(0, 200),
"x-omniroute-combo-terminal-reason": toHeaderSafeAscii(safe.terminalReason.slice(0, 200)),
},
});
}

View File

@@ -47,6 +47,10 @@ export default function HermesAgentToolCard({
const [previewYaml, setPreviewYaml] = useState<string | null>(null);
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [firstSetupAt, setFirstSetupAt] = useState<string | null>(null);
// Model aliases drive the passthrough provider groups (OpenRouter, Requesty,
// DGrid, AgentRouter, Charm Hyper, ...) in ModelSelectModal — without them,
// those providers never surface in the Hermes Agent role picker (#7151).
const [modelAliases, setModelAliases] = useState({});
// Track whether we have already seeded from batchStatus on this expand
const seededFromBatchRef = useRef(false);
@@ -109,8 +113,19 @@ export default function HermesAgentToolCard({
});
}
loadCurrentConfig();
fetchModelAliases();
}, [isExpanded, batchStatus, loadCurrentConfig]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.warn("Error fetching model aliases:", error);
}
};
const setRoleSelection = (roleId: string, model: string, provider = "OmniRoute") => {
setSelections((prev) => ({ ...prev, [roleId]: { model, provider } }));
};
@@ -522,6 +537,7 @@ export default function HermesAgentToolCard({
showCombos={true}
activeProviders={activeProviders}
alwaysIncludeProviders={HERMES_AGENT_ZERO_CONFIG_PROVIDERS}
modelAliases={modelAliases}
/>
</Card>
);

View File

@@ -14,6 +14,7 @@ import Toggle from "@/shared/components/Toggle";
import Tooltip from "@/shared/components/Tooltip";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { FieldLabelWithHelp, WeightTotalBar } from "./parts";
import { useComboProxyAssignments } from "./useComboProxyAssignments";
import { ResponseValidationEditor, type ResponseValidationValue } from "./ResponseValidationEditor";
import ReasoningTokenBufferToggle from "./ReasoningTokenBufferToggle";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
@@ -681,6 +682,7 @@ export default function CombosPage() {
const notify = useNotificationStore();
const [proxyTargetCombo, setProxyTargetCombo] = useState(null);
const [proxyConfig, setProxyConfig] = useState(null);
const { comboProxyAssignedIds, fetchComboProxyAssignments } = useComboProxyAssignments();
const [providerNodes, setProviderNodes] = useState([]);
const [showUsageGuide, setShowUsageGuide] = useState(true);
const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState("");
@@ -1210,7 +1212,7 @@ export default function CombosPage() {
onTest={() => handleTestCombo(combo)}
testing={testingCombo === combo.name}
onProxy={() => setProxyTargetCombo(combo)}
hasProxy={!!proxyConfig?.combos?.[combo.id]}
hasProxy={comboProxyAssignedIds.has(combo.id) || !!proxyConfig?.combos?.[combo.id]}
onToggle={() => handleToggleCombo(combo)}
dragDisabled={savingComboOrder || activeFilter !== "all" || combos.length < 2}
isDragged={comboDragIndex === index}
@@ -1260,7 +1262,7 @@ export default function CombosPage() {
{proxyTargetCombo && (
<ProxyConfigModal
isOpen={!!proxyTargetCombo}
onClose={() => setProxyTargetCombo(null)}
onClose={() => (setProxyTargetCombo(null), fetchComboProxyAssignments())}
level="combo"
levelId={proxyTargetCombo.id}
levelLabel={proxyTargetCombo.name}

View File

@@ -0,0 +1,33 @@
import { useCallback, useEffect, useState } from "react";
// #7149: the Combo "Set Proxy" modal writes through the modern proxy_assignments
// registry (scope="combo"), not the legacy /api/settings/proxy `combos` map — the
// dashboard's "has a proxy" indicator must read from the same registry the modal
// actually writes to, or it stays stale/gray even after a successful save.
export function parseComboProxyAssignmentIds(data: unknown): string[] {
const items = (data as { items?: unknown })?.items;
if (!Array.isArray(items)) return [];
return items
.filter(
(entry): entry is { scopeId: string; proxyId: string } =>
!!(entry as { scopeId?: unknown })?.scopeId && !!(entry as { proxyId?: unknown })?.proxyId
)
.map((entry) => entry.scopeId);
}
export function useComboProxyAssignments() {
const [comboProxyAssignedIds, setComboProxyAssignedIds] = useState<Set<string>>(new Set());
const fetchComboProxyAssignments = useCallback(() => {
fetch("/api/settings/proxies/assignments?scope=combo")
.then((r) => (r.ok ? r.json() : null))
.then((data) => setComboProxyAssignedIds(new Set(parseComboProxyAssignmentIds(data))))
.catch(() => {});
}, []);
useEffect(() => {
fetchComboProxyAssignments();
}, [fetchComboProxyAssignments]);
return { comboProxyAssignedIds, fetchComboProxyAssignments };
}

View File

@@ -139,6 +139,16 @@ export default function FreePoolTab() {
});
};
const handleToggleSource = (source: SourceId) => {
setDisabledSources((prev) => {
const next = new Set(prev);
if (next.has(source)) next.delete(source);
else next.add(source);
saveDisabledSources(next);
return next;
});
};
const handleToggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);

View File

@@ -147,7 +147,7 @@ export default function AgentBridgePageClient({
setActionError(null);
try {
const res = await fetch(`/api/tools/agent-bridge/agents/${agentId}/dns`, {
method: "PUT",
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});

View File

@@ -56,7 +56,7 @@ export default function QuotaCardGrid({
({conns.length} account{conns.length !== 1 ? "s" : ""})
</span>
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-3">
{conns.map((conn) => (
<QuotaCard
key={conn.id}

View File

@@ -28,6 +28,8 @@ import {
type RtkConfig,
type UltraConfig,
} from "@omniroute/open-sse/services/compression/types.ts";
import { DEFAULT_CONTEXT_BUDGET } from "@omniroute/open-sse/services/compression/adaptiveCompression/types.ts";
import { normalizeContextBudgetConfig } from "./compressionContextBudget";
import {
isPreserveSystemPromptMode,
normalizePreserveSystemPromptMode,
@@ -550,6 +552,7 @@ export async function getCompressionSettings(): Promise<CompressionConfig> {
stackedPipeline: normalizeStackedPipeline(undefined),
aggressive: normalizeAggressiveConfig(undefined),
ultra: normalizeUltraConfig(undefined),
contextBudget: normalizeContextBudgetConfig(undefined),
contextEditing: { ...DEFAULT_CONTEXT_EDITING_CONFIG },
engines: {},
activeComboId: null,
@@ -652,6 +655,9 @@ export async function getCompressionSettings(): Promise<CompressionConfig> {
case "ultraConfig":
config.ultra = normalizeUltraConfig(parsed);
break;
case "contextBudget":
config.contextBudget = normalizeContextBudgetConfig(parsed);
break;
case "contextEditing":
config.contextEditing = normalizeContextEditingConfig(parsed);
break;

View File

@@ -0,0 +1,86 @@
// Adaptive context-budget "dial" (#7005) DB normalizer, extracted out of compression.ts to keep
// that file under the file-size cap. The compute engine (computeTarget.ts / ladder.ts /
// resolveAdaptivePlan.ts) shipped in PR #4716 but this normalizer never existed, so the
// `contextBudget` setting could never be persisted. Mirrors normalizeUltraConfig/
// normalizeAggressiveConfig in compression.ts.
import {
DEFAULT_CONTEXT_BUDGET,
type ContextBudgetConfig,
type ContextBudgetMode,
type ContextBudgetPolicy,
type LadderStage,
} from "@omniroute/open-sse/services/compression/adaptiveCompression/types.ts";
type JsonRecord = Record<string, unknown>;
function toRecord(value: unknown): JsonRecord {
return value && typeof value === "object" ? (value as JsonRecord) : {};
}
function boundedInt(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.floor(value)));
}
function boundedNumber(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, value));
}
const CONTEXT_BUDGET_MODES = new Set<ContextBudgetMode>(["floor", "replace-autotrigger", "off"]);
const CONTEXT_BUDGET_POLICIES = new Set<ContextBudgetPolicy>([
"reserve-output",
"percentage",
"absolute",
]);
function normalizeLadderOverride(value: unknown): LadderStage[] | undefined {
if (!Array.isArray(value)) return undefined;
const out: LadderStage[] = [];
for (const raw of value) {
const record = toRecord(raw);
if (typeof record.engine !== "string" || !record.engine.trim()) continue;
out.push({
engine: record.engine,
...(typeof record.intensity === "string" ? { intensity: record.intensity } : {}),
});
}
return out.length > 0 ? out : undefined;
}
export function normalizeContextBudgetConfig(value: unknown): ContextBudgetConfig {
const record = toRecord(value);
const ladderOverride = normalizeLadderOverride(record.ladderOverride);
return {
...DEFAULT_CONTEXT_BUDGET,
mode:
typeof record.mode === "string" && CONTEXT_BUDGET_MODES.has(record.mode as ContextBudgetMode)
? (record.mode as ContextBudgetMode)
: DEFAULT_CONTEXT_BUDGET.mode,
policy:
typeof record.policy === "string" &&
CONTEXT_BUDGET_POLICIES.has(record.policy as ContextBudgetPolicy)
? (record.policy as ContextBudgetPolicy)
: DEFAULT_CONTEXT_BUDGET.policy,
outputReserve: boundedInt(
record.outputReserve,
DEFAULT_CONTEXT_BUDGET.outputReserve,
0,
Number.MAX_SAFE_INTEGER
),
safetyMargin: boundedInt(
record.safetyMargin,
DEFAULT_CONTEXT_BUDGET.safetyMargin,
0,
Number.MAX_SAFE_INTEGER
),
pct: boundedNumber(record.pct, DEFAULT_CONTEXT_BUDGET.pct, 0, 1),
absoluteBudget: boundedInt(
record.absoluteBudget,
DEFAULT_CONTEXT_BUDGET.absoluteBudget,
0,
Number.MAX_SAFE_INTEGER
),
...(ladderOverride ? { ladderOverride } : {}),
};
}

View File

@@ -466,6 +466,14 @@ declare global {
// Next.js HMR re-evaluations so concurrent subsystems all see the same
// count and we abort with a clear error instead of looping forever.
var __omnirouteDbProbeRestoreCount: number | undefined;
// Cycle-breaker counter for the OOM-during-probe path (#6835). Unlike the
// generic corruption path above, an OOM probe failure never renames the
// file away (intentional — the DB may be perfectly fine, just too large
// for the current heap), so the restore-count cap above is structurally
// unreachable here. Without an independent cap, every background poller
// (BATCH, HealthCheck, ProviderLimitsSync, ModelSync) re-throws the same
// OOM error forever with no terminal diagnostic.
var __omnirouteDbOomFailureCount: number | undefined;
}
function getDb(): SqliteDatabase | null {
@@ -1076,6 +1084,22 @@ export function getDbInstance(): SqliteDatabase {
// immediately gives the user a clear "increase --max-old-space-size"
// signal instead of silently renaming a perfectly good DB.
if (/out of memory|allocation failure|Array buffer allocation failed|allocation failed/i.test(message)) {
// Cycle-breaker (#6835): the OOM path never renames the file away,
// so it never trips the generic probe-failed/restore cap above. Cap
// it independently after 3 consecutive OOM failures (same threshold
// as the generic path) so repeated polling doesn't hang forever with
// no actionable terminal diagnostic.
if (
(globalThis.__omnirouteDbOomFailureCount =
(globalThis.__omnirouteDbOomFailureCount || 0) + 1) > 3
) {
throw new Error(
`[DB] Aborting startup: persistent out-of-memory probing ${sqliteFile} after 3 attempts. ` +
`Increase the V8 heap with NODE_OPTIONS=--max-old-space-size=4096 (or higher) — the ` +
`current heap is insufficient for this database — and restart, or shrink/restore the ` +
`database from a backup. Original error: ${message}`
);
}
throw new Error(
`[DB] Out of memory while probing ${sqliteFile}. ` +
`The bundled sql.js driver loads the entire file into WASM memory; ` +

View File

@@ -541,33 +541,46 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?:
}
}
// Step 7: Legacy combo-level (only if proxy_enabled)
if (connectionProxyEnabled && config.combos && Object.keys(config.combos).length > 0) {
// Step 7: Combo-level (only if proxy_enabled). For every combo whose model
// list references this connection's provider, check the modern registry
// (proxy_assignments, scope='combo') first — this is the assignment the
// dashboard's Combo "Set Proxy" modal actually writes to (#7149, where the
// registry write path and this read path had diverged, leaving combo-level
// proxy assignment completely inert). Fall back to the legacy in-memory
// combos map for any pre-existing legacy data.
if (connectionProvider && connectionProxyEnabled) {
const combos = db.prepare("SELECT id, data FROM combos").all();
for (const comboRow of combos) {
const comboRecord = toRecord(comboRow);
const comboId = typeof comboRecord.id === "string" ? comboRecord.id : null;
if (comboId && config.combos[comboId]) {
try {
const comboRaw = typeof comboRecord.data === "string" ? comboRecord.data : null;
if (!comboRaw) continue;
const combo = toRecord(JSON.parse(comboRaw));
const comboModels = Array.isArray(combo.models) ? combo.models : [];
const usesProvider = comboModels.some(
(entry) => getComboModelProvider(entry) === connectionProvider
);
if (usesProvider) {
const result = {
proxy: withFamilyDefault(config.combos[comboId]),
level: "combo",
levelId: comboId,
};
cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result);
return result;
}
} catch {
// Ignore malformed combo records during proxy resolution.
if (!comboId) continue;
try {
const comboRaw = typeof comboRecord.data === "string" ? comboRecord.data : null;
if (!comboRaw) continue;
const combo = toRecord(JSON.parse(comboRaw));
const comboModels = Array.isArray(combo.models) ? combo.models : [];
const usesProvider = comboModels.some(
(entry) => getComboModelProvider(entry) === connectionProvider
);
if (!usesProvider) continue;
const registryCombo = await resolveProxyForScopeFromRegistry("combo", comboId);
if (registryCombo?.proxy) {
cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, registryCombo);
return registryCombo;
}
if (config.combos?.[comboId]) {
const result = {
proxy: withFamilyDefault(config.combos[comboId]),
level: "combo",
levelId: comboId,
};
cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result);
return result;
}
} catch {
// Ignore malformed combo records during proxy resolution.
}
}
}

View File

@@ -90,9 +90,9 @@ export {
reorderCombos,
deleteCombo,
} from "./db/combos";
export * from "./db/compressionCacheStats";
export * from "./db/compressionCombos";
export * from "./db/compressionContextBudget";
export * from "./db/compressionRunTelemetry";
export * from "./db/modelContextOverrides";

View File

@@ -155,7 +155,7 @@ export async function validateWebCookieProvider({
const baseUrl = normalizeBaseUrl(entry.baseUrl || "");
const testUrl = `${baseUrl}/models`;
const res = await directHttpsRequest(
const res = await validationRead(
testUrl,
{
method: "GET",
@@ -164,7 +164,7 @@ export async function validateWebCookieProvider({
Cookie: cookie,
},
},
10_000
isLocalProvider(provider)
);
if (res.status === 401 || res.status === 403) {

View File

@@ -232,8 +232,13 @@ export default function ModelSelectModal({
const isCustomProvider =
isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId);
// Get user-added custom models for this provider (if any)
const providerCustomModels = customModels[providerId] || [];
// Get user-added custom models for this provider (if any), excluding
// any explicitly hidden by the operator (#7156 — the legacy picker
// must respect the same isHidden flag the Precision Builder and
// /v1/models catalog already honor).
const providerCustomModels = (customModels[providerId] || []).filter(
(cm) => !cm.isHidden
);
if (providerInfo.passthroughModels) {
// Passthrough aliases are stored prefixed by the canonical providerId

View File

@@ -463,6 +463,7 @@ export default function ProxyConfigModal({
username?: string;
password?: string;
} | null = null;
let testProxyId: string | null = null;
if (mode === "saved") {
if (!selectedProxyId) {
@@ -481,6 +482,7 @@ export default function ProxyConfigModal({
host: found.host || "",
port: String(found.port || 8080),
};
testProxyId = selectedProxyId;
} else {
if (!String(host || "").trim()) {
setTesting(false);
@@ -498,7 +500,7 @@ export default function ProxyConfigModal({
const res = await fetch("/api/settings/proxy/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxy }),
body: JSON.stringify(testProxyId ? { proxy, proxyId: testProxyId } : { proxy }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {

View File

@@ -266,6 +266,32 @@ export const engineToggleSchema = z.object({
level: z.string().optional(),
});
export const contextBudgetModeSchema = z.enum(["floor", "replace-autotrigger", "off"]);
export const contextBudgetPolicySchema = z.enum(["reserve-output", "percentage", "absolute"]);
export const contextBudgetLadderStageSchema = z
.object({
engine: z.string().trim().min(1),
intensity: z.string().optional(),
})
.strict();
// Adaptive context-budget "dial" (#7005): the compute engine shipped in PR #4716 but was
// never wired to this update schema, so any PUT containing `contextBudget` was rejected
// with 400. Mirrors ContextBudgetConfig (open-sse/services/compression/adaptiveCompression/
// types.ts) and the `.strict()` pattern used by ultraConfigSchema/aggressiveConfigSchema.
export const contextBudgetConfigSchema = z
.object({
mode: contextBudgetModeSchema.optional(),
policy: contextBudgetPolicySchema.optional(),
outputReserve: z.number().int().min(0).optional(),
safetyMargin: z.number().int().min(0).optional(),
pct: z.number().min(0).max(1).optional(),
absoluteBudget: z.number().int().min(0).optional(),
ladderOverride: z.array(contextBudgetLadderStageSchema).optional(),
})
.strict();
export const compressionSettingsUpdateSchema = z
.object({
enabled: z.boolean().optional(),
@@ -286,6 +312,7 @@ export const compressionSettingsUpdateSchema = z
languageConfig: languageConfigSchema.optional(),
aggressive: aggressiveConfigSchema.optional(),
ultra: ultraConfigSchema.optional(),
contextBudget: contextBudgetConfigSchema.optional(),
contextEditing: contextEditingConfigSchema.optional(),
engines: z.record(z.string(), engineToggleSchema).optional(),
enginesExplicit: z.boolean().optional(),

View File

@@ -1,4 +1,5 @@
import { getProxyById } from "@/lib/db/proxies";
import { isRelayProxyType, extractRelayAuth } from "@/lib/db/proxies/mappers";
/**
* #5217 (Gap 1) — Per-account proxy resolution for no-auth providers
@@ -29,6 +30,7 @@ export interface ResolvedAccountProxy {
port: number;
username?: string;
password?: string;
relayAuth?: string;
}
export interface AccountProxyEntry {
@@ -43,6 +45,7 @@ interface ProxyRegistryRecordLike {
port?: number | string;
username?: string | null;
password?: string | null;
notes?: string | null;
}
/** Async lookup of a proxy registry record by id (null when absent). */
@@ -53,12 +56,17 @@ function normalizeRecord(rec: ProxyRegistryRecordLike | Partial<ResolvedAccountP
if (!host) return null;
const username = typeof rec.username === "string" ? rec.username : "";
const password = typeof rec.password === "string" ? rec.password : "";
const type = typeof rec.type === "string" && rec.type ? rec.type : "socks5";
const relayAuth = isRelayProxyType(type)
? extractRelayAuth((rec as ProxyRegistryRecordLike).notes)
: undefined;
const resolved: ResolvedAccountProxy = {
type: typeof rec.type === "string" && rec.type ? rec.type : "socks5",
type,
host,
port: Number(rec.port) || 0,
...(username ? { username } : {}),
...(password ? { password } : {}),
...(relayAuth ? { relayAuth } : {}),
};
return resolved;
}

View File

@@ -182,6 +182,7 @@
"tests/unit/issue-6343-v0-web-alias-collision.test.ts",
"tests/unit/issue-6638-ollama-quota.test.ts",
"tests/unit/issue-6686-quota-preflight-coverage.test.ts",
"tests/unit/issue-7071-ollama-session-quota.test.ts",
"tests/unit/livews-forward-backoff-4604.test.ts",
"tests/unit/management-auth-hardening.test.ts",
"tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts",

View File

@@ -0,0 +1,24 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const clientPath = path.resolve(
__dirname,
"../../src/app/(dashboard)/dashboard/tools/agent-bridge/AgentBridgePageClient.tsx"
);
const source = readFileSync(clientPath, "utf8");
test("#7157: dns toggle fetch call uses method POST (route.ts only exports POST)", () => {
const dnsCallMatch = source.match(
/\/api\/tools\/agent-bridge\/agents\/\$\{agentId\}\/dns`,\s*\{\s*method:\s*"([A-Z]+)"/
);
assert.ok(dnsCallMatch, "expected to find the dns fetch call in AgentBridgePageClient.tsx");
assert.equal(
dnsCallMatch?.[1],
"POST",
"dns fetch call must use method: 'POST' to match the route.ts export (issue #7157)"
);
});

View File

@@ -79,3 +79,41 @@ test("combo diagnostics: secret containment — non-whitelisted fields never sur
assert.ok(!serialized.includes("accessToken"), "no accessToken KEY survives");
assert.ok(!serialized.includes("token"), "no token KEY survives");
});
test("combo diagnostics: terminalReason with a non-Latin1 char (em dash) must not crash Response construction (#6612)", () => {
const terminalReason = "reasoning consumed 5/5 tokens — no content output";
assert.doesNotThrow(() => {
const res = errorResponseWithComboDiagnostics(
502,
`Upstream response failed quality validation: ${terminalReason}`,
{
poolSize: 4,
attempted: 1,
excluded: [{ provider: "deepseek", model: "deepseek-v4-flash-free", reason: "quality — bad" }],
attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }],
terminalReason,
}
);
assert.equal(res.status, 502);
});
});
test("combo diagnostics: JSON body keeps the original non-Latin1 text even though headers are ASCII-sanitized (#6612)", async () => {
const terminalReason = "reasoning consumed 5/5 tokens — no content output";
const res = errorResponseWithComboDiagnostics(
502,
`Upstream response failed quality validation: ${terminalReason}`,
{
poolSize: 1,
attempted: 1,
excluded: [],
attemptOrder: [{ provider: "deepseek", model: "deepseek-v4-flash-free" }],
terminalReason,
}
);
// Header value must be a valid Latin1 ByteString — em dash (U+2014) replaced.
assert.equal(res.headers.get("x-omniroute-combo-terminal-reason"), terminalReason.replace("—", "?"));
const body = await res.json();
// JSON body keeps the original, readable (unsanitized) em dash.
assert.equal(body.diagnostics.terminalReason, terminalReason);
});

View File

@@ -0,0 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import { parseComboProxyAssignmentIds } from "../../src/app/(dashboard)/dashboard/combos/useComboProxyAssignments.ts";
test("#7149: parseComboProxyAssignmentIds extracts scopeIds from valid combo assignments", () => {
const data = {
items: [
{ scopeId: "combo-1", proxyId: "proxy-1", scope: "combo" },
{ scopeId: "combo-2", proxyId: "proxy-2", scope: "combo" },
],
};
assert.deepEqual(parseComboProxyAssignmentIds(data), ["combo-1", "combo-2"]);
});
test("#7149: parseComboProxyAssignmentIds drops entries missing scopeId or proxyId", () => {
const data = {
items: [
{ scopeId: "combo-1", proxyId: "proxy-1" },
{ scopeId: "combo-2", proxyId: null },
{ scopeId: null, proxyId: "proxy-3" },
{},
],
};
assert.deepEqual(parseComboProxyAssignmentIds(data), ["combo-1"]);
});
test("#7149: parseComboProxyAssignmentIds returns [] for missing/malformed items", () => {
assert.deepEqual(parseComboProxyAssignmentIds(null), []);
assert.deepEqual(parseComboProxyAssignmentIds(undefined), []);
assert.deepEqual(parseComboProxyAssignmentIds({}), []);
assert.deepEqual(parseComboProxyAssignmentIds({ items: "not-an-array" }), []);
});

View File

@@ -0,0 +1,87 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-proxy-7149-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret";
const core = await import("../../src/lib/db/core.ts");
const proxiesDb = await import("../../src/lib/db/proxies.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
type ProxyResolutionLike = {
proxy?: { host?: string } | null;
level?: string;
levelId?: string | null;
} | null;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#7149: a proxy assigned to a Combo via the dashboard (registry scope='combo') is honored when resolving the proxy for a request routed through that combo", async () => {
await resetStorage();
const comboProxy = await proxiesDb.createProxy({
name: "Combo-Assigned Proxy",
type: "http",
host: "10.20.30.40",
port: 8888,
});
assert.ok(comboProxy?.id);
const combo = await combosDb.createCombo({
name: "diy_deepseek-v4-flash",
strategy: "round-robin",
models: ["openai/gpt-4"],
});
const comboRecord = combo as Record<string, unknown>;
assert.ok(comboRecord?.id);
const comboId = comboRecord.id as string;
const assignment = await proxiesDb.assignProxyToScope("combo", comboId, comboProxy!.id);
assert.ok(assignment, "assignProxyToScope('combo', ...) should persist the assignment");
const directRegistryLookup = (await proxiesDb.resolveProxyForScopeFromRegistry(
"combo",
comboId
)) as ProxyResolutionLike;
assert.ok(
directRegistryLookup?.proxy,
"the registry must be able to answer a direct combo-scope lookup"
);
assert.equal(directRegistryLookup?.proxy?.host, "10.20.30.40");
const connection = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
apiKey: "sk-test-1234",
name: "openai-account-1",
});
const connectionRecord = connection as Record<string, unknown> | null;
const connectionId = connectionRecord?.id as string;
assert.ok(connectionId, "test setup requires a real connection id");
const resolved = (await settingsDb.resolveProxyForConnection(
connectionId
)) as ProxyResolutionLike;
assert.equal(
resolved?.level,
"combo",
`expected the combo-assigned proxy to be resolved (level="combo"), got level="${resolved?.level}" — the registry-based combo proxy assignment is never consulted by resolveProxyForConnection()`
);
assert.equal(resolved?.proxy?.host, "10.20.30.40");
});

View File

@@ -0,0 +1,84 @@
// Regression test for #7005 — adaptive context-budget dial not configurable.
//
// The compute engine for the adaptive context-budget ("dial") shipped in PR #4716
// (Phase 4C), but it was never wired to persistence or the API: the PUT schema
// rejected any `contextBudget` payload (strict schema, no such key) and the DB-backed
// GET path never surfaced a `contextBudget` field. This test proves both halves of
// the wiring: the Zod schema accepts a `contextBudget` write, and the DB read/write
// path round-trips it.
import { describe, it, beforeEach, afterEach, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-adaptive-context-budget-db-")
);
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../../src/lib/db/core.ts");
const { getCompressionSettings, updateCompressionSettings } = await import(
"../../../src/lib/db/compression.ts"
);
const { compressionSettingsUpdateSchema } = await import(
"../../../src/shared/validation/compressionConfigSchemas.ts"
);
const { DEFAULT_CONTEXT_BUDGET } = await import(
"../../../open-sse/services/compression/adaptiveCompression/types.ts"
);
beforeEach(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
afterEach(() => {
core.resetDbInstance();
});
after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
});
describe("bug #7005: adaptive context-budget dial is configurable", () => {
it("compressionSettingsUpdateSchema accepts a contextBudget write", () => {
const result = compressionSettingsUpdateSchema.safeParse({
contextBudget: {
mode: "floor",
policy: "percentage",
outputReserve: 2048,
safetyMargin: 512,
pct: 0.75,
absoluteBudget: 0,
},
});
assert.equal(result.success, true, JSON.stringify("error" in result ? result.error : null));
});
it("getCompressionSettings() defaults contextBudget to DEFAULT_CONTEXT_BUDGET when absent", async () => {
const settings = await getCompressionSettings();
assert.deepEqual(settings.contextBudget, DEFAULT_CONTEXT_BUDGET);
});
it("updateCompressionSettings() persists a partial contextBudget merge", async () => {
await updateCompressionSettings({
contextBudget: { ...DEFAULT_CONTEXT_BUDGET, mode: "floor", policy: "absolute", absoluteBudget: 8000 },
});
const settings = await getCompressionSettings();
assert.equal(settings.contextBudget?.mode, "floor");
assert.equal(settings.contextBudget?.policy, "absolute");
assert.equal(settings.contextBudget?.absoluteBudget, 8000);
// Untouched fields keep their defaults (this is a JSON-column replace like ultra/aggressive,
// not a deep merge — the caller sends the full object, mirroring the existing pattern).
assert.equal(settings.contextBudget?.outputReserve, DEFAULT_CONTEXT_BUDGET.outputReserve);
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-6996-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const { DuckDuckGoWebExecutor, STATUS_URL } = await import(
"../../open-sse/executors/duckduckgo-web.ts"
);
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
const executeInputBase = {
model: "gpt-4o-mini",
body: {
model: "gpt-4o-mini",
messages: [{ role: "user", content: "hi" }],
stream: false,
},
stream: false,
credentials: {},
};
describe("#6996 DuckDuckGo VQD 429 misclassification", () => {
let originalFetch: typeof fetch;
before(() => {
originalFetch = globalThis.fetch;
});
after(() => {
globalThis.fetch = originalFetch;
resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
it("propagates upstream 429 instead of masking it as a generic 503", async () => {
// Set the mock AFTER the module import so it wins over
// open-sse/utils/proxyFetch.ts's own module-load-time
// `globalThis.fetch = patchedFetch` side effect.
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : (input as URL | Request).toString();
if (url === STATUS_URL) {
return new Response("", {
status: 429,
headers: { "Retry-After": "30" },
});
}
if (url.includes("/duckchat/v1/chat")) {
throw new Error("unexpected chat POST reached without a VQD token");
}
return new Response("<html></html>", { status: 200 });
}) as typeof fetch;
const executor = new DuckDuckGoWebExecutor();
const response = await executor.execute(executeInputBase);
const httpResponse =
response instanceof Response
? response
: (response as { response: Response }).response;
const bodyText = await httpResponse.text();
assert.equal(
httpResponse.status,
429,
`expected the executor to surface DuckDuckGo's real 429 rate-limit status, got ${httpResponse.status} (body: ${bodyText})`
);
});
it("still returns 503 fallback for a genuine 5xx status on the VQD endpoint", async () => {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : (input as URL | Request).toString();
if (url === STATUS_URL) {
return new Response("", { status: 500 });
}
if (url.includes("/duckchat/v1/chat")) {
throw new Error("unexpected chat POST reached without a VQD token");
}
return new Response("<html></html>", { status: 200 });
}) as typeof fetch;
const executor = new DuckDuckGoWebExecutor();
const response = await executor.execute(executeInputBase);
const httpResponse =
response instanceof Response
? response
: (response as { response: Response }).response;
const bodyText = await httpResponse.text();
assert.equal(
httpResponse.status,
503,
`expected the executor to keep the 503 fallback for a genuine upstream 5xx, got ${httpResponse.status} (body: ${bodyText})`
);
});
});

View File

@@ -91,13 +91,13 @@ afterEach(() => {
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("FreePoolTab source toggles", () => {
it("renders a toggle group with exactly 3 buttons", async () => {
it("renders a toggle group with exactly 4 buttons", async () => {
const el = renderTab();
await waitForCondition(() => el.querySelector("[role='group']") !== null);
const bar = el.querySelector("[role='group']")!;
expect(bar).toBeTruthy();
const buttons = bar.querySelectorAll("button");
expect(buttons.length).toBe(3);
expect(buttons.length).toBe(4);
});
it("all toggles start enabled (aria-pressed=true)", async () => {
@@ -160,7 +160,7 @@ describe("FreePoolTab source toggles", () => {
expect(stored).toContain("1proxy");
});
it("button labels are 1proxy, Proxifly, IPLocate", async () => {
it("button labels are 1proxy, Proxifly, IPLocate, Webshare", async () => {
const el = renderTab();
await waitForCondition(() => el.querySelector("[role='group']") !== null);
const texts = Array.from(el.querySelector("[role='group']")!.querySelectorAll("button")).map(
@@ -169,6 +169,7 @@ describe("FreePoolTab source toggles", () => {
expect(texts).toContain("1proxy");
expect(texts).toContain("Proxifly");
expect(texts).toContain("IPLocate");
expect(texts).toContain("Webshare");
});
});

View File

@@ -266,15 +266,20 @@ test("#2832: GeminiWebExecutor catch block sanitizes Playwright launch errors (i
// ─── StreamGenerate parsing ─────────────────────────────────────────────────
test("parseStreamResponse concatenates Gemini Web text from multiple wrb.fr chunks", () => {
test("parseStreamResponse keeps only the final cumulative StreamGenerate snapshot (no duplication) — regression for #7163", () => {
const makeChunk = (text: string) => {
const inner = new Array(80).fill(null);
inner[4] = [[null, [text]]];
return `[["wrb.fr", null, ${JSON.stringify(JSON.stringify(inner))}]]`;
};
const raw = `)]}'\n10\n${makeChunk("First ")}\n5\n${makeChunk("chunk")}`;
assert.equal(parseStreamResponse(raw), "First chunk");
// Gemini's StreamGenerate frames are CUMULATIVE snapshots: each later frame
// repeats the full answer generated so far, not just the new characters.
const frame1 = "Hello!";
const frame2 = "Hello! How can I";
const frame3 = "Hello! How can I help you out today?";
const raw = `)]}'\n10\n${makeChunk(frame1)}\n5\n${makeChunk(frame2)}\n5\n${makeChunk(frame3)}`;
assert.equal(parseStreamResponse(raw), frame3);
});
test("parseStreamResponse ignores wrb.fr lines whose first entry is not an array", () => {

View File

@@ -0,0 +1,103 @@
/**
* Issue #7071 — Ollama Cloud's 5-hour "session" usage-limit 429 is never
* recognized as quota-exhausted. The upstream returns a body like:
* "you (<account>) have reached your session usage limit"
*
* This exactly mirrors the already-fixed "weekly usage limit" gap (#3709,
* #6638): ollama-cloud is an apikey-category provider (not oauth), so the
* oauth-only `shouldUseQuotaSignal` gate in checkFallbackError skips the
* generic subscription-quota-text branch (#2321) for its 429s. Without a
* dedicated, ungated session check the account fell through to the generic
* 429 backoff (~3s, capped low) and got retried within the same 5-hour
* session window instead of cooling down for the session's duration —
* combo/LKGP routing cycled back to the "exhausted" account instead of
* advancing to the next one.
*
* This test proves: (1) the session-usage-limit text is classified as
* QUOTA_EXHAUSTED with a cooldown far longer than the generic backoff cap,
* for BOTH apikey and oauth provider categories, and (2) unrelated
* session-expired/auth wording and the sibling weekly-quota text are
* unaffected.
*/
import test from "node:test";
import assert from "node:assert/strict";
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
const { isSessionUsageLimitText, buildSessionQuotaFallback, isWeeklyUsageLimitText } =
await import("../../open-sse/services/quotaTextCooldowns.ts");
const { RateLimitReason, BACKOFF_CONFIG } = await import("../../open-sse/config/constants.ts");
const { BACKOFF_CONFIG: ERROR_BACKOFF_CONFIG } = await import("../../open-sse/config/errorConfig.ts");
const SESSION_BODY = "you (acme-corp) have reached your session usage limit";
const SESSION_COOLDOWN_MS = 5 * 60 * 60 * 1000; // 5 hours
test("#7071 sanity: weekly text IS recognized (already fixed by #3709/#6638)", () => {
assert.equal(isWeeklyUsageLimitText("you (acme-corp) have reached your weekly usage limit"), true);
});
test("#7071 isSessionUsageLimitText matches the ollama-cloud 429 body", () => {
assert.equal(isSessionUsageLimitText(SESSION_BODY.toLowerCase()), true);
assert.equal(isSessionUsageLimitText("session limit reached, try later"), true);
assert.equal(isSessionUsageLimitText("rate_limit_exceeded: too many requests"), false);
// Must not false-positive on unrelated "session expired" auth errors.
assert.equal(isSessionUsageLimitText("your session has expired, please log in again"), false);
assert.equal(isSessionUsageLimitText("session token invalid"), false);
});
test("#7071 buildSessionQuotaFallback returns a 5h QUOTA_EXHAUSTED cooldown, far above the generic backoff cap", () => {
const result = buildSessionQuotaFallback(SESSION_BODY);
assert.ok(result, "expected a non-null fallback for session-usage-limit text");
assert.equal(result!.reason, RateLimitReason.QUOTA_EXHAUSTED);
assert.equal(result!.cooldownMs, SESSION_COOLDOWN_MS);
assert.ok(result!.cooldownMs > (ERROR_BACKOFF_CONFIG.max ?? BACKOFF_CONFIG.max));
});
test("#7071 buildSessionQuotaFallback returns null for unrelated error text", () => {
assert.equal(buildSessionQuotaFallback("rate_limit_exceeded: too many requests"), null);
assert.equal(buildSessionQuotaFallback("your session has expired, please log in again"), null);
});
test("#7071 BUG: checkFallbackError misclassifies ollama-cloud session-quota 429 as generic RATE_LIMIT_EXCEEDED instead of QUOTA_EXHAUSTED", () => {
const out = checkFallbackError(
429,
SESSION_BODY,
0, // backoffLevel
null, // model
"ollama-cloud", // provider (apikey category)
null, // headers
null, // profileOverride
null // structuredError
);
assert.equal(out.shouldFallback, true);
assert.equal(
out.reason,
RateLimitReason.QUOTA_EXHAUSTED,
`expected QUOTA_EXHAUSTED for session-usage-limit text, got reason=${out.reason} cooldownMs=${out.cooldownMs}`
);
assert.equal(out.cooldownMs, SESSION_COOLDOWN_MS);
});
test("#7071 checkFallbackError: oauth-category provider with session-limit text also gets the long cooldown", () => {
const out = checkFallbackError(429, SESSION_BODY, 0, null, "claude", null, null, null);
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
assert.equal(out.cooldownMs, SESSION_COOLDOWN_MS);
});
test("#7071 checkFallbackError: ollama-cloud generic rate-limit body is unaffected (no false positive)", () => {
const out = checkFallbackError(
429,
"rate_limit_exceeded: too many requests",
0,
null,
"ollama-cloud",
null,
null,
null
);
assert.equal(out.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
assert.ok(
out.cooldownMs <= 2 * 60 * 1000,
"generic rate limit text must keep the normal short backoff, not the 5h session cooldown"
);
});

View File

@@ -29,13 +29,11 @@ describe("MiniMax M3 model registration (#3110)", () => {
assert.equal(m3.contextLength, 1_048_576);
});
it("opencode provider has minimax-m3-free with 1M context", () => {
it("opencode provider does NOT list minimax-m3-free (#6998 — delisted upstream, 401)", () => {
const entry = REGISTRY.opencode;
assert.ok(entry, "opencode registry entry must exist");
const m3 = entry.models.find((m) => m.id === "minimax-m3-free");
assert.ok(m3, "minimax-m3-free must be in opencode models");
assert.equal(m3.name, "MiniMax M3 Free");
assert.equal(m3.contextLength, 1_048_576);
assert.equal(m3, undefined, "minimax-m3-free was delisted from OpenCode Zen's free tier (#6998)");
});
it("opencode-go provider has minimax-m3 with Claude targetFormat", () => {

View File

@@ -288,6 +288,30 @@ test("turbopack.ignoreIssue suppresses the agentSkills over-bundling warning (#6
assert.match(String(agentSkillsRule.description), /Overly broad patterns/);
});
test("turbopack.ignoreIssue suppresses the compression module over-bundling warning (#7051)", async () => {
// open-sse/services/compression/ruleLoader.ts and
// .../engines/rtk/filterLoader.ts both define an identical getModuleDir()
// helper that walks up directories via path.resolve(anchor) +
// fs.existsSync(...) in a loop with a non-literal argument — the same
// class of dynamic-path fs access that #6582 suppressed for
// src/lib/agentSkills/**, but that narrow allowlist glob didn't cover this
// module, so the warning kept firing (610 times) for every entry point
// transitively importing the compression module. This guards the config
// shape so the suppression rule isn't silently dropped in a future edit.
const { default: nextConfig } = await loadNextConfig("ignore-issue-compression");
const rules = nextConfig.turbopack?.ignoreIssue;
assert.ok(Array.isArray(rules), "expected turbopack.ignoreIssue to be an array");
const compressionRule = rules.find((rule) =>
String(rule.path).includes("open-sse/services/compression")
);
assert.ok(
compressionRule,
"expected an ignoreIssue rule targeting open-sse/services/compression/**"
);
assert.match(String(compressionRule.description), /Overly broad patterns/);
});
test("optimizePackageImports excludes the internal @omniroute/open-sse workspace (build-OOM guard)", async () => {
// Regression guard: adding the internal `@omniroute/open-sse` workspace to
// optimizePackageImports makes Next.js resolve its entire barrel at build

View File

@@ -0,0 +1,49 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { resolveAccountProxies } from "../../src/sse/services/noAuthProxyResolution.ts";
test("resolveAccountProxies preserves relayAuth for relay-type (vercel/deno/cloudflare) pool proxies", async () => {
const fakeVercelProxyRow = {
id: "proxy-1",
type: "vercel",
host: "my-relay-abc123.vercel.app",
port: 443,
username: null,
password: null,
notes: JSON.stringify({ relayAuth: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }),
};
const resolved = await resolveAccountProxies(
[{ fingerprint: "acct-1", proxyId: "proxy-1" }],
async (id) => (id === "proxy-1" ? fakeVercelProxyRow : null)
);
const proxy = resolved[0].proxy as unknown as { type?: string; relayAuth?: string };
assert.equal(proxy?.type, "vercel");
assert.equal(
proxy?.relayAuth,
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"relayAuth must survive resolveAccountProxies() for relay-type (vercel/deno/cloudflare) proxies"
);
});
test("resolveAccountProxies leaves relayAuth absent for plain non-relay (socks5/http) pool proxies", async () => {
const fakeSocksProxyRow = {
id: "proxy-2",
type: "socks5",
host: "1.2.3.4",
port: 1080,
username: "u",
password: "p",
notes: JSON.stringify({ relayAuth: "should-not-leak-onto-non-relay-types" }),
};
const resolved = await resolveAccountProxies(
[{ fingerprint: "acct-2", proxyId: "proxy-2" }],
async (id) => (id === "proxy-2" ? fakeSocksProxyRow : null)
);
const proxy = resolved[0].proxy as unknown as { type?: string; relayAuth?: string };
assert.equal(proxy?.type, "socks5");
assert.equal(proxy?.relayAuth, undefined);
});

View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
const { opencodeProvider } = await import(
"../../open-sse/config/providers/registry/opencode/index.ts"
);
function modelIds(): string[] {
return (opencodeProvider.models ?? []).map((m) => m.id);
}
const DELISTED_FREE_MODELS = [
"minimax-m3-free",
"minimax-m2.5-free",
"ling-2.6-1t-free",
"trinity-large-preview-free",
"nemotron-3-super-free",
"qwen3.6-plus-free",
];
const LIVE_FREE_MODELS_MISSING_FROM_CATALOG = [
"mimo-v2.5-free",
"hy3-free",
"nemotron-3-ultra-free",
"north-mini-code-free",
];
test("issue #6998: oc registry does not advertise delisted free-tier models", () => {
const ids = modelIds();
for (const delisted of DELISTED_FREE_MODELS) {
assert.ok(!ids.includes(delisted), `oc registry still advertises delisted upstream model "${delisted}"`);
}
});
test("issue #6998: oc registry advertises the current live free-tier models", () => {
const ids = modelIds();
for (const live of LIVE_FREE_MODELS_MISSING_FROM_CATALOG) {
assert.ok(ids.includes(live), `oc registry is missing live upstream free-tier model "${live}"`);
}
});

View File

@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { getOpenCodeGoUsage } from "../../open-sse/services/opencodeOllamaUsage.ts";
test("getOpenCodeGoUsage does not send the user's OpenCode Go API key to api.z.ai by default", async () => {
const originalFetch = globalThis.fetch;
const originalEnv = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL;
delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL;
let calledHost: string | null = null;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input.toString();
calledHost = new URL(url).host;
throw new Error(`unexpected outbound fetch to ${url}`);
}) as typeof fetch;
try {
const result = await getOpenCodeGoUsage("sk-fake-opencode-go-key", undefined);
assert.notStrictEqual(calledHost, "api.z.ai");
assert.strictEqual(calledHost, null);
assert.ok(
typeof result.message === "string" && result.message.length > 0,
"expected a descriptive message when no quota URL is configured"
);
} finally {
globalThis.fetch = originalFetch;
if (originalEnv === undefined) delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL;
else process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = originalEnv;
}
});

View File

@@ -1,9 +1,25 @@
import test from "node:test";
import test, { after } from "node:test";
import assert from "node:assert/strict";
// The OpenCode Go quota-by-API-key path is opt-in only (see #7022 — there is no
// working default quota endpoint, so OMNIROUTE_OPENCODE_GO_QUOTA_URL must be set
// explicitly by the operator). The module reads this env var once at import time,
// so it has to be set BEFORE the dynamic import below for the opt-in tests in this
// file (which simulate an operator who configured the URL) to exercise the fetch path.
const ORIGINAL_OPENCODE_GO_QUOTA_URL = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL;
process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
const usage = await import("../../open-sse/services/usage.ts");
const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
after(() => {
if (ORIGINAL_OPENCODE_GO_QUOTA_URL === undefined) {
delete process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL;
} else {
process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL = ORIGINAL_OPENCODE_GO_QUOTA_URL;
}
});
test("USAGE_SUPPORTED_PROVIDERS includes opencode-go", () => {
assert.ok(
(USAGE_SUPPORTED_PROVIDERS as string[]).includes("opencode-go"),
@@ -298,7 +314,8 @@ test("getUsageForProvider returns message for invalid OpenCode Go API keys", asy
})) as { message: string };
assert.equal(
result.message,
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
"OpenCode Go API key is valid for chat/models but cannot read quota from the configured " +
"OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " +
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping."
);
} finally {
@@ -342,7 +359,8 @@ test("getUsageForProvider returns message when OpenCode Go quota API returns 200
})) as { message: string };
assert.equal(
result.message,
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
"OpenCode Go API key is valid for chat/models but cannot read quota from the configured " +
"OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " +
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping."
);
} finally {

View File

@@ -0,0 +1,40 @@
// Probe for issue #6699 -- "Google Jules provider validation rejects a valid API key".
//
// A second reporter (MohammadMD1383) supplied screenshots showing that OmniRoute, when
// actually routing a chat-completion request for a saved "jules" connection, sends the
// request to https://api.openai.com/v1/chat/completions and surfaces OpenAI's own
// "Incorrect API key provided ... platform.openai.com" error -- even though the provider
// is displayed as JULES with target "jules/jules". This probe proves the executor-level
// root cause directly: getExecutor("jules") has no specialized executor and no REGISTRY
// entry, so DefaultExecutor's constructor silently falls back to PROVIDERS.openai,
// making buildUrl() return OpenAI's endpoint for a provider the user believes is Jules.
import test from "node:test";
import assert from "node:assert/strict";
import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts";
test("#6699: jules has no specialized executor (falls through to DefaultExecutor)", () => {
assert.equal(hasSpecializedExecutor("jules"), false);
});
test("#6699: a chat-completion request routed to provider 'jules' must not silently hit OpenAI's endpoint", () => {
// Desired behavior: the Jules provider (a cloud-agent, registered only in
// CLOUD_AGENT_PROVIDERS/staticModels, never in the chat REGISTRY) must not silently
// resolve to OpenAI's chat/completions endpoint when routed through the normal
// chat-completions executor path. getExecutor() now throws a clear, sanitized error
// for this narrow set of chat-unsupported cloud-agent providers instead of falling
// through to DefaultExecutor's `PROVIDERS.openai` fallback (which produced the
// "Incorrect API key provided ... platform.openai.com" error the reporter saw for a
// genuine Jules key). Before the fix, getExecutor("jules") returned a working
// executor whose buildUrl() resolved to OpenAI's endpoint -- this assertion FAILS on
// unfixed release/v3.8.49 code because no error is thrown at all.
assert.throws(
() => getExecutor("jules"),
(err) => {
assert.match(err.message, /cloud-agent provider/i);
assert.match(err.message, /does not support direct chat completions/i);
assert.equal(err.status, 400);
return true;
},
"provider 'jules' must raise a clear error instead of silently inheriting OpenAI's base URL/config"
);
});

View File

@@ -0,0 +1,28 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
test("getDbInstance() caps the probe-failed/restore cycle at 3 attempts (#6835)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-6835-"));
process.env.DATA_DIR = tmpDir;
const sqliteFile = path.join(tmpDir, "storage.sqlite");
const backupFile = `${sqliteFile}.probe-failed-1000000000000`;
fs.writeFileSync(backupFile, Buffer.from("not a real sqlite file, always fails to open"));
const core = await import("../../src/lib/db/core.ts");
const errors: string[] = [];
for (let i = 0; i < 6; i++) {
try {
core.getDbInstance();
errors.push("(no error)");
break;
} catch (err: unknown) {
errors.push(err instanceof Error ? err.message : String(err));
}
}
const abortIndex = errors.findIndex((e) => e.includes("Aborting startup"));
assert.notEqual(abortIndex, -1, "Expected the cap to trip; got: " + errors.join(" | "));
assert.ok(abortIndex <= 4, "Expected cap by call #4; took until #" + abortIndex);
fs.rmSync(tmpDir, { recursive: true, force: true });
});

View File

@@ -0,0 +1,58 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
test("getDbInstance() eventually caps a persistently-OOMing sql.js probe (#6835)", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-6835-oom-"));
process.env.DATA_DIR = tmpDir;
const sqliteFile = path.join(tmpDir, "storage.sqlite");
fs.mkdirSync(sqliteFile); // forces better-sqlite3/node:sqlite to fail synchronously (EISDIR-style)
await import("../../src/lib/db/adapters/driverFactory.ts");
const core = await import("../../src/lib/db/core.ts");
const fakeAdapter = {
driver: "sql.js" as const,
open: true,
name: sqliteFile,
prepare() {
throw new Error("out of memory");
},
exec() {
throw new Error("out of memory");
},
pragma() {
throw new Error("out of memory");
},
transaction<T>(fn: (...a: unknown[]) => T) {
return fn;
},
immediate() {},
async backup() {},
checkpoint() {},
close() {},
raw: null,
};
(
globalThis as unknown as { __omnirouteSqlJsAdapters: Map<string, unknown> }
).__omnirouteSqlJsAdapters = new Map([[sqliteFile, fakeAdapter]]);
const errors: string[] = [];
for (let i = 0; i < 8; i++) {
try {
core.getDbInstance();
errors.push("(no error)");
break;
} catch (err: unknown) {
errors.push(err instanceof Error ? err.message : String(err));
}
}
const anyAborted = errors.some((e) => e.includes("Aborting startup"));
assert.ok(
anyAborted,
"Expected getDbInstance() to eventually give up with a terminal " +
"'Aborting startup'-style diagnostic after repeated OOM probe failures, the same way it " +
"already does for generic corruption (#6632). Instead every call re-threw an identical, " +
"uncapped OOM error:\n" + errors.map((e, i) => ` [${i}] ${e}`).join("\n")
);
fs.rmSync(tmpDir, { recursive: true, force: true });
});

View File

@@ -0,0 +1,102 @@
import test from "node:test";
import assert from "node:assert/strict";
import { writeFile } from "node:fs/promises";
// Issue #7134 — claude-web reported "Claude Web API error (400) with no
// response body" even when Claude's upstream DID send a real JSON error body.
//
// Root cause: tlsFetchStreaming() streams the upstream response to a temp
// file via tls-client-node's `streamOutputPath` mode. For a non-SSE,
// non-2xx response, the native binding resolves with an EMPTY in-memory
// `body` field (it only populates `body` for its non-streaming mode) even
// though the real error bytes were already written to the temp file and
// even peeked (`looksLikeSse`) to decide the response wasn't SSE. The old
// code read the empty `r.body` instead of the file it just peeked, throwing
// away the real upstream error detail.
//
// This test injects a fake `client` (matching the `{ request }` shape
// tlsFetchStreaming already accepts for DI) that reproduces the exact
// tls-client-node contract under `streamOutputPath`: write bytes to the file,
// resolve with an empty `body`. No `--experimental-test-module-mocks` flag
// needed — this exercises the real, unmodified `tlsFetchStreaming` via
// dependency injection instead of module-mocking `tls-client-node`.
const { tlsFetchStreaming } = await import("../../open-sse/services/claudeTlsClient.ts");
const REAL_CLAUDE_ERROR_BODY = JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message: "This conversation UUID does not exist or you do not have access to it.",
},
});
function makeFakeClient(status: number, bodyOnFile: string) {
return {
request: async (_url: string, opts: Record<string, unknown>) => {
const streamOutputPath = opts.streamOutputPath as string;
await writeFile(streamOutputPath, bodyOnFile);
return {
status,
headers: {},
// tls-client-node does not populate `body` for streamed requests —
// this is the exact defect condition.
body: "",
cookies: {},
text: async () => "",
json: async () => ({}),
bytes: async () => new Uint8Array(),
};
},
};
}
test("issue #7134: tlsFetchStreaming surfaces the real error body for a non-SSE 400 under stream:true", async () => {
const client = makeFakeClient(400, REAL_CLAUDE_ERROR_BODY);
const result = await tlsFetchStreaming(
client,
"https://claude.ai/api/organizations/x/chat_conversations/y/completion",
{ method: "POST" },
"[DONE]",
null,
5_000
);
assert.equal(result.status, 400);
assert.equal(result.body, null);
assert.ok(
result.text && result.text.includes("does not exist or you do not have access to it"),
`expected the real Claude error body to be surfaced, got: ${JSON.stringify(result.text)}`
);
});
test("issue #7134: tlsFetchStreaming still uses r.body when the native client DOES populate it", async () => {
const client = {
request: async (_url: string, opts: Record<string, unknown>) => {
const streamOutputPath = opts.streamOutputPath as string;
await writeFile(streamOutputPath, "{}");
return {
status: 403,
headers: {},
body: "populated body from native client",
cookies: {},
text: async () => "",
json: async () => ({}),
bytes: async () => new Uint8Array(),
};
},
};
const result = await tlsFetchStreaming(
client,
"https://claude.ai/api/organizations/x/chat_conversations/y/completion",
{ method: "POST" },
"[DONE]",
null,
5_000
);
assert.equal(result.status, 403);
assert.equal(result.text, "populated body from native client");
});

View File

@@ -63,16 +63,17 @@ test("#2822 opencode-go/qwen3.6-plus deve ter supportsVision !== true", () => {
);
});
// #3328 — o oposto do #2822: MiniMax M3 (opencode) É multimodal (verificado
// empiricamente: descreve imagens base64 via o upstream opencode). Deve ter
// supportsVision: true para não ser barrado/strippado em requests com imagem.
test("#3328 opencode/minimax-m3-free deve ter supportsVision: true", () => {
// #3328 — o oposto do #2822: MiniMax M3 (opencode) era multimodal (verificado
// empiricamente: descrevia imagens base64 via o upstream opencode). #6998:
// minimax-m3-free foi deslistado do free tier da OpenCode Zen (401 "not
// supported") em 2026-07-14 e removido do catálogo estático — este teste
// agora confirma a remoção.
test("#6998 opencode/minimax-m3-free não deve mais estar registrado (deslistado upstream)", () => {
const model = getModel("opencode", "minimax-m3-free");
assert.ok(model, "minimax-m3-free deve estar registrado em opencode");
assert.strictEqual(
model.supportsVision,
true,
"opencode/minimax-m3-free é multimodal — supportsVision deve ser true"
assert.equal(
model,
undefined,
"opencode/minimax-m3-free foi deslistado do free tier da OpenCode Zen (#6998)"
);
});

View File

@@ -1,15 +1,18 @@
import test from "node:test";
import assert from "node:assert/strict";
// The validator probes the provider's /models endpoint via safeOutboundFetch
// fetchWithTimeout, which binds globalThis.fetch at MODULE LOAD time. The mock MUST be
// installed BEFORE importing validation.ts — a late reassignment (inside a test) is
// ignored and the validator hits the real network instead. (That made the 401/403
// assertions pass only by coincidence — live chatgpt.com returns 401/403 — while the
// 200 case failed.) A mutable `nextResponse` lets each test vary the probe result, and
// `fetchCalls` proves the mocked probe ran rather than the live network.
// The validator probes the provider's /models endpoint via validationRead → safeOutboundFetch
// fetchWithTimeout, which reads `globalThis.fetch` dynamically at CALL time (#7058 — routed
// through the proxy-aware patched fetch instead of a bypassing directHttpsRequest). Importing
// validation.ts pulls in the proxy-patch module, which installs its own `globalThis.fetch`
// exactly once at import time — so the mock must be (re)installed AFTER the import, not before,
// or the patch silently clobbers it. A mutable `nextResponse` lets each test vary the probe
// result, and `fetchCalls` proves the mocked probe ran rather than the live network.
let nextResponse: { status: number; body: string } = { status: 200, body: "{}" };
let fetchCalls = 0;
const { validateWebCookieProvider } = await import("../../src/lib/providers/validation.ts");
globalThis.fetch = (async () => {
fetchCalls++;
return new Response(nextResponse.body, {
@@ -18,8 +21,6 @@ globalThis.fetch = (async () => {
});
}) as typeof fetch;
const { validateWebCookieProvider } = await import("../../src/lib/providers/validation.ts");
function mockFetch(status: number, body: string) {
nextResponse = { status, body };
fetchCalls = 0;

View File

@@ -6,8 +6,11 @@
// the shipped JSX structure and grouping logic directly:
// 1. Grouping still produces one header per distinct provider with the
// correct account count ("N account(s)").
// 2. The per-group card grid starts multi-column (`grid-cols-2`), not
// single-column, so cards fill horizontal space sooner.
// 2. The per-group card grid keeps a single-column mobile fallback
// (`grid-cols-1`) below the `sm:` breakpoint (restored by #7072 after
// PR #6815 dropped it and clipped labels on phone-width viewports),
// while still going multi-column (`sm:grid-cols-2`) from `sm:` up so
// cards fill horizontal space sooner on larger screens.
// 3. Provider groups themselves flow into multiple columns on wide screens
// (`columns-*`) instead of an unconditional vertical `flex flex-col`
// stack.
@@ -104,14 +107,14 @@ test("QuotaCardGrid (#3520) — outer container flows groups into multiple colum
assert.notEqual(outerClassName, "flex flex-col gap-6");
});
test("QuotaCardGrid (#3520) — per-group card grid starts multi-column (grid-cols-2), not single-column", () => {
test("QuotaCardGrid (#3520/#7072) — per-group card grid keeps a mobile grid-cols-1 fallback and goes multi-column from sm: up", () => {
const classNames = extractDivClassNames(COMPONENT_PATH);
const cardGridClassName = classNames.find(
(c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c)
);
assert.ok(cardGridClassName, "expected to find the per-group card grid's className");
assert.match(cardGridClassName!, /\bgrid-cols-2\b/);
assert.doesNotMatch(cardGridClassName!, /\bgrid-cols-1\b/);
assert.match(cardGridClassName!, /\bgrid-cols-1\b/);
assert.match(cardGridClassName!, /\bsm:grid-cols-2\b/);
});
test("QuotaCardGrid (#3520) — early-returns null when there are no connections", () => {

View File

@@ -0,0 +1,79 @@
// #7072 — Provider Quota page card grid clipped on mobile.
//
// PR #6815 changed QuotaCardGrid.tsx's per-group card grid from
// `grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4` to
// `grid-cols-2 md:grid-cols-3 xl:grid-cols-4`, dropping the mobile (<768px)
// single-column fallback that every other card-grid in the dashboard still
// has (ProviderQuotaWidget.tsx, EvalsTab.tsx, MediaPageClient.tsx,
// SystemStorageTab.tsx). Forcing 2 columns even on phone widths squeezes
// each QuotaCard, and since QuotaCard's outer Card uses `overflow-hidden`,
// the overflowing button/label text is clipped instead of wrapping.
//
// This regression guard parses QuotaCardGrid.tsx's JSX via the TypeScript
// compiler API and asserts the per-group card grid's className restores an
// unprefixed `grid-cols-1` mobile fallback, while keeping the #6815 density
// gains (grid-cols-2 at `sm:` and up).
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
const COMPONENT_PATH = path.resolve(
import.meta.dirname,
"../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx"
);
function extractDivClassNames(sourcePath: string): string[] {
const sourceText = fs.readFileSync(sourcePath, "utf8");
const sourceFile = ts.createSourceFile(
sourcePath,
sourceText,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX
);
const classNames: string[] = [];
function visit(node: ts.Node) {
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
const tagName = node.tagName.getText(sourceFile);
if (tagName === "div") {
for (const attr of node.attributes.properties) {
if (ts.isJsxAttribute(attr) && attr.name.getText(sourceFile) === "className") {
const init = attr.initializer;
if (init && ts.isStringLiteral(init)) {
classNames.push(init.text);
} else if (
init &&
ts.isJsxExpression(init) &&
init.expression &&
ts.isStringLiteral(init.expression)
) {
classNames.push(init.expression.text);
}
}
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return classNames;
}
test("QuotaCardGrid (#7072) — per-group card grid keeps a single-column mobile fallback", () => {
const classNames = extractDivClassNames(COMPONENT_PATH);
const cardGridClassName = classNames.find((c) => /\bgrid\b/.test(c) && /grid-cols-/.test(c));
assert.ok(cardGridClassName, "expected to find the per-group card grid's className");
const tokens = cardGridClassName!.split(/\s+/);
const unprefixedGridCols = tokens.find((t) => /^grid-cols-\d+$/.test(t));
assert.equal(
unprefixedGridCols,
"grid-cols-1",
`expected unprefixed grid-cols-1 (mobile fallback), got className="${cardGridClassName}"`
);
assert.match(cardGridClassName!, /\bsm:grid-cols-2\b/, "expected sm:grid-cols-2 to be preserved");
});

View File

@@ -389,3 +389,66 @@ describe("ProxyConfigModal custom registry saves", () => {
).toBe(false);
});
});
describe("ProxyConfigModal test connection (saved proxy)", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
fetchCalls = [];
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("includes proxyId when testing a saved SOCKS5 registry proxy so the server can load its stored credentials", async () => {
installFetchMock((url, init) => {
const method = String(init?.method || "GET").toUpperCase();
if (method === "GET" && url === "/api/settings/proxies") {
return {
body: {
items: [
{
id: "socks5-1",
name: "Geonode SOCKS5",
type: "socks5",
host: "proxy.geonode.io",
port: 12000,
username: "***",
password: "***",
source: "manual",
},
],
total: 1,
socks5Enabled: true,
},
};
}
if (url.startsWith("/api/settings/proxies/assignments?") && url.includes("scope=provider")) {
return {
body: { items: [{ proxyId: "socks5-1", scope: "provider", scopeId: "claude" }], total: 1 },
};
}
if (method === "POST" && url === "/api/settings/proxy/test") {
return { body: { success: true, publicIp: "1.2.3.4", latencyMs: 500 } };
}
return defaultProxyConfigResponses(url) || { status: 404, body: {} };
});
const { container } = await renderProxyConfigModal();
await clickButton(container, "testConnection");
await waitForCall((call) => call.method === "POST" && call.url === "/api/settings/proxy/test");
const testCall = fetchCalls.find(
(call) => call.method === "POST" && call.url === "/api/settings/proxy/test"
);
expect(testCall).toBeTruthy();
expect(testCall?.body?.proxyId).toBe("socks5-1");
}, 20000);
});

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
// Regression probe for issue #7151: OpenRouter (and every other
// `passthroughModels` provider — requesty, dgrid, agentrouter, charm-hyper,
// etc.) never appears in the Hermes Agent role model picker.
//
// Root cause: <ModelSelectModal> derives a passthrough provider's model list
// from the `modelAliases` prop (ModelSelectModal.tsx groupedModels →
// buildPassthroughAliasModels(modelAliases, providerId)). When `modelAliases`
// is `{}` (the component default), that helper returns `[]` and the provider
// group is skipped entirely — see modelSelectModalHelpers.ts. Every sibling
// CLI tool card (Codex, Claude, Cline, Kilo, Droid, OpenClaw, Antigravity)
// fetches `/api/models/alias` and passes the result through, but
// HermesAgentToolCard never does, so OpenRouter's managed-available-model
// aliases (synced automatically after the connection is tested — see
// syncManagedAvailableModelAliases in src/lib/providerModels/managedAvailableModels.ts)
// are invisible to it.
const __dirname = dirname(fileURLToPath(import.meta.url));
const CARD_PATH = resolve(
__dirname,
"../../../src/app/(dashboard)/dashboard/cli-code/components/HermesAgentToolCard.tsx"
);
describe("HermesAgentToolCard model alias wiring (#7151)", () => {
const source = readFileSync(CARD_PATH, "utf8");
it("declares modelAliases state", () => {
expect(source).toMatch(/const \[modelAliases, setModelAliases\] = useState\(\{\}\)/);
});
it("fetches /api/models/alias when expanded", () => {
expect(source).toContain('fetch("/api/models/alias")');
});
it("passes modelAliases prop to ModelSelectModal", () => {
// Regression guard: this prop is what unlocks passthrough provider groups
// (OpenRouter, Requesty, DGrid, AgentRouter, Charm Hyper, ...) in the
// Hermes Agent role picker. Without it, OpenRouter is silently absent
// from the "Select" modal for every role (Default, Delegation, ...).
expect(source).toMatch(/modelAliases=\{modelAliases\}/);
});
});

View File

@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ModelSelectModal from "@/shared/components/ModelSelectModal";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
const roots: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
async function render(props: React.ComponentProps<typeof ModelSelectModal>): Promise<HTMLDivElement> {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
await act(async () => {
root.render(<ModelSelectModal {...props} />);
});
roots.push({ root, el });
return el;
}
beforeEach(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/combos")) return new Response(JSON.stringify({ combos: [] }), { status: 200 });
if (url.includes("/api/provider-nodes")) return new Response(JSON.stringify({ nodes: [] }), { status: 200 });
if (url.includes("/api/provider-models")) {
return new Response(
JSON.stringify({
models: {
requesty: [
{ id: "visible-model-1", name: "Visible Model", source: "imported" },
{ id: "hidden-model-1", name: "Hidden Model", source: "imported", isHidden: true },
],
},
modelCompatOverrides: [],
}),
{ status: 200 }
);
}
return new Response(JSON.stringify({}), { status: 200 });
})
);
});
afterEach(() => {
for (const { root, el } of roots.splice(0)) { act(() => root.unmount()); el.remove(); }
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("ModelSelectModal hidden-model filtering (#7156)", () => {
it("does not list a custom model explicitly flagged isHidden:true", async () => {
const el = await render({
isOpen: true, onClose: vi.fn(), onSelect: vi.fn(),
activeProviders: [{ provider: "requesty", id: "conn-1" }],
modelAliases: {}, title: "Add model to combo",
});
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
expect(el.textContent).toContain("Visible Model");
expect(el.textContent).not.toContain("Hidden Model");
});
});

View File

@@ -0,0 +1,98 @@
// Regression test for #7058 — zai-web (and every other entry-bearing web-cookie
// provider) never honored a configured HTTP/SOCKS proxy during connection-test /
// cookie validation.
//
// Root cause: validateWebCookieProvider() probed `${baseUrl}/models` via
// directHttpsRequest(), which hardcodes `bypassProxyPatch: true` — forcing
// safeOutboundFetch to use the pre-patch native fetch and skip proxy-context/
// env-var resolution entirely. That bypass was introduced in #3226 as a narrow,
// documented exception for a single NVIDIA NIM workaround
// (see tests/unit/proxy-bypass-scope-guard-3226.test.ts) but validateWebCookieProvider
// adopted it as its default transport from inception (#4023), silently extending the
// bypass to every web-cookie provider with a registry entry (zai-web among them).
//
// This test proves the cookie-validation probe reaches a local forward proxy
// (via a real CONNECT tunnel — the same mechanism undici uses for both HTTP and
// HTTPS targets) when one is configured via HTTP_PROXY, exactly like the
// specialty web-cookie validators (chatgpt-web, grok-web, ...) already do via
// validationRead/validationWrite.
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import net from "node:net";
const { validateWebCookieProvider } = await import("../../src/lib/providers/validation.ts");
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
const { clearDispatcherCache } = await import("../../open-sse/utils/proxyDispatcher.ts");
const zaiWebEntry = REGISTRY["zai-web"] as { baseUrl?: string } | undefined;
const ORIGINAL_BASE_URL = zaiWebEntry?.baseUrl;
const ORIGINAL_HTTP_PROXY = process.env.HTTP_PROXY;
test.after(() => {
if (zaiWebEntry && ORIGINAL_BASE_URL !== undefined) {
zaiWebEntry.baseUrl = ORIGINAL_BASE_URL;
}
if (ORIGINAL_HTTP_PROXY === undefined) {
delete process.env.HTTP_PROXY;
} else {
process.env.HTTP_PROXY = ORIGINAL_HTTP_PROXY;
}
clearDispatcherCache();
});
test("zai-web cookie validation routes through the configured HTTP_PROXY (#7058)", async () => {
assert.ok(zaiWebEntry, "zai-web must have a providerRegistry entry for this test to be meaningful");
// Stand-in for chat.z.ai's /models probe target.
const target = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "application/json" });
res.end("{}");
});
await new Promise<void>((resolve) => target.listen(0, () => resolve()));
const targetPort = (target.address() as net.AddressInfo).port;
// Minimal forward proxy that only speaks CONNECT (like a real corporate proxy) and
// always tunnels to the local target above, regardless of the requested host — this
// lets the "upstream" host be a non-resolvable placeholder without any real DNS
// dependency, while still proving the request actually reached the proxy.
let sawConnect = false;
const proxy = http.createServer((_req, res) => {
res.writeHead(501);
res.end("CONNECT only");
});
proxy.on("connect", (_req, socket) => {
sawConnect = true;
const upstream = net.connect(targetPort, "127.0.0.1", () => {
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
upstream.pipe(socket);
socket.pipe(upstream);
});
upstream.on("error", () => socket.destroy());
socket.on("error", () => upstream.destroy());
});
await new Promise<void>((resolve) => proxy.listen(0, () => resolve()));
const proxyPort = (proxy.address() as net.AddressInfo).port;
// A non-local-looking hostname: isLocalAddress()/resolveProxyForRequest() force a
// direct connection for any 127.*/localhost/LAN target, which would defeat this test.
zaiWebEntry!.baseUrl = "http://zai-web-validation-probe-7058.invalid";
process.env.HTTP_PROXY = `http://127.0.0.1:${proxyPort}`;
clearDispatcherCache();
try {
const result = await validateWebCookieProvider({ provider: "zai-web", apiKey: "token=fake" });
assert.equal(
sawConnect,
true,
"BUG #7058: zai-web cookie validation never reached the configured HTTP_PROXY " +
"(bypassProxyPatch:true unconditionally uses the native, unpatched fetch)"
);
assert.equal(result.valid, true, `expected a valid session, got ${JSON.stringify(result)}`);
} finally {
target.close();
proxy.close();
clearDispatcherCache();
}
});