mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
Compare commits
5 Commits
fix/sec-ad
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3caa59107e | ||
|
|
9469b9c79e | ||
|
|
4ac157b76d | ||
|
|
8f0d0a0d03 | ||
|
|
00dfdadf93 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,6 +2,18 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(sse): STRICT_ZERO_COST** — opt-in, off-by-default `freeAccessPolicy: "strict"` setting
|
||||
that hard-verifies every auto-combo candidate against live quota state and per-connection
|
||||
economic safety before it can be dispatched, going beyond `hidePaidModels`'s static catalog
|
||||
check. Adds curated `hardStopGuaranteed` metadata to `FREE_MODEL_BUDGETS`, a short-TTL quota
|
||||
cache reusing `getUsageForProvider()`, and a connection-safety guarantee: a candidate backed
|
||||
by multiple accounts has its `allowedConnectionIds` narrowed to exactly the connections
|
||||
independently verified `SAFE`, so dispatch can never use an unverified account. An
|
||||
`excludeTosAvoid` guard (default `false`) is available separately for contractual risk. See
|
||||
`docs/routing/STRICT_ZERO_COST.md`.
|
||||
|
||||
---
|
||||
|
||||
## [3.8.50] — TBD
|
||||
|
||||
1
changelog.d/fixes/command-code-effort-capabilities.md
Normal file
1
changelog.d/fixes/command-code-effort-capabilities.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(combo): resolve effort-suffixed command-code variants (e.g. `deepseek-v4-flash-max`) to their base model for capability lookups, so tool-bearing combo requests keep the declared priority order instead of reordering behind models with confirmed capabilities
|
||||
1
changelog.d/fixes/opencode-merge-provider-guard.md
Normal file
1
changelog.d/fixes/opencode-merge-provider-guard.md
Normal file
@@ -0,0 +1 @@
|
||||
- **OpenCode config merge:** stop `mergeOpenCodeConfig` splaying a malformed `provider` block into index keys. The root was already guarded against a non-object; the `provider` branch it spreads one level down was not, so an existing `"provider": ["a", "b"]` merged to `{"0": "a", "1": "b", …}`. Its sibling `mergeOpenCodeConfigText` already refuses the same input.
|
||||
146
docs/routing/STRICT_ZERO_COST.md
Normal file
146
docs/routing/STRICT_ZERO_COST.md
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: "STRICT_ZERO_COST"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-20
|
||||
---
|
||||
|
||||
# STRICT_ZERO_COST
|
||||
|
||||
> Opt-in, off by default (`settings.freeAccessPolicy !== "strict"` leaves every `auto/*`
|
||||
> candidate pool byte-identical). A stricter sibling of `hidePaidModels`
|
||||
> (`open-sse/services/autoCombo/paidModelFilter.ts`, #6512) for operators who need a hard
|
||||
> guarantee against ANY incremental monetary spend, not just "documented as free".
|
||||
|
||||
## Why this exists, and why `hidePaidModels` alone isn't enough
|
||||
|
||||
`hidePaidModels` answers "is this model classified free in `FREE_MODEL_BUDGETS` right now?" —
|
||||
a point-in-time catalog fact, checked via `isFreeModel()`/`providerHasFreeModels()`
|
||||
(`src/shared/utils/freeModels.ts`). It says nothing about two real risks:
|
||||
|
||||
1. A `recurring-*`/`one-time-initial` free tier's allowance can be **exhausted** — the catalog
|
||||
still lists the model as free, but the account behind it has no headroom left.
|
||||
2. Exceeding a free tier is not always a hard stop. Some providers document explicitly that no
|
||||
payment method can ever be attached ("no credit card required"); others don't say, and a
|
||||
handful bill automatically past the free allowance.
|
||||
|
||||
`hidePaidModels` cannot distinguish these — it was never meant to. STRICT_ZERO_COST adds exactly
|
||||
these two checks, evaluated per candidate, **before** category/tier ranking and **before**
|
||||
dispatch — never after a request has already gone out.
|
||||
|
||||
## Candidate classification
|
||||
|
||||
For every candidate in the pool (`open-sse/services/autoCombo/virtualFactory.ts::buildPreparedPool`,
|
||||
right after `filterPaidOnlyCandidates`):
|
||||
|
||||
1. **Not in `FREE_MODEL_BUDGETS` at all** → excluded. This covers genuinely paid models and any
|
||||
provider/model OmniRoute hasn't classified yet — new candidates start excluded, not included.
|
||||
2. **`freeType: "keyless"`** → passes immediately, **but only for a candidate that genuinely
|
||||
arrived via the no-auth path** (`connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID`,
|
||||
`open-sse/services/autoCombo/resilienceCandidateFilter.ts`). No credential exists for that
|
||||
candidate, so no request against it can ever be billed — no runtime check is needed or
|
||||
possible. The same catalogued `keyless` provider/model reached through a **real** DB
|
||||
connection (`connectionId` is an actual connection id, or the candidate carries
|
||||
`allowedConnectionIds`) does **not** get this shortcut — `keyless` metadata describes the
|
||||
no-auth path specifically, not the provider in general, and never authorizes a real,
|
||||
credentialed account. Such a candidate falls through to check 3 like any other, where it is
|
||||
excluded unless the catalog entry separately carries `hardStopGuaranteed: true` (real
|
||||
`keyless` entries never do — the shortcut was their only path to safety).
|
||||
3. **Any other `freeType`** (`recurring-daily`, `recurring-monthly`, `recurring-credit`,
|
||||
`recurring-uncapped`, `one-time-initial`, and any future type this module doesn't
|
||||
special-case) → passes only if **all** of the following hold:
|
||||
- `hardStopGuaranteed: true` is set on the catalog entry (`FreeModelBudget.hardStopGuaranteed`,
|
||||
`open-sse/config/freeModelCatalog.ts`) — a **curated, hand-set fact** about the provider's
|
||||
own published terms (e.g. an explicit "no credit card required" claim), never derived from
|
||||
`freeType` or from a live API response. Unset (`undefined`) and `false` are both treated as
|
||||
"not guaranteed".
|
||||
- A usage adapter exists for the provider in `USAGE_FETCHER_PROVIDERS`
|
||||
(`open-sse/services/usage.ts`) — the same registry that already backs the quota dashboard and
|
||||
`getUsageForProvider()`. No adapter → excluded, permanently, until one is added.
|
||||
- The live, cached `FreeAccessState` for **the specific connection actually being
|
||||
evaluated** is `status: "SAFE"`, was checked within
|
||||
`settings.autoRefreshProviderQuotaInterval` (default 180s — the existing setting, not a new
|
||||
number), and reports `remainingFreeAllowance` above a small safety margin.
|
||||
4. **`freeType: "discontinued"`** → always excluded.
|
||||
|
||||
## Connection safety (per-connection verification, never per-candidate)
|
||||
|
||||
A candidate in the auto-combo pool is not always tied to one connection. A "logical" candidate
|
||||
(`connectionId: null`) carries an `allowedConnectionIds` allowlist — one or more actual
|
||||
provider connections/accounts any of which could serve the request — and the account actually
|
||||
used is decided later, at dispatch time, by `open-sse/services/combo/autoStrategy.ts`
|
||||
(intersecting `allowedConnectionIds` against its own connection-selection logic, ~line 315-331).
|
||||
|
||||
STRICT_ZERO_COST verifies the free-access state of **each connection in that allowlist
|
||||
individually** (`evaluateCandidateConnections()` in `strictZeroCostFilter.ts`) and rewrites
|
||||
`allowedConnectionIds` down to exactly the subset that came back `SAFE` — never the full
|
||||
original list, and never a single arbitrarily-chosen member. Concretely:
|
||||
|
||||
- Account A `SAFE`, account B `UNKNOWN`/exhausted/billable → only A remains selectable.
|
||||
- All accounts `UNKNOWN` → the candidate is dropped entirely (empty safe set).
|
||||
- A single-connection candidate (`connectionId` set directly, no allowlist) that fails is
|
||||
dropped outright, never returned with an empty `allowedConnectionIds`.
|
||||
|
||||
Because `autoStrategy.ts` already enforces `allowedConnectionIds` as a hard allowlist before
|
||||
selecting a connection to dispatch to, rewriting it to the verified-SAFE subset is sufficient to
|
||||
guarantee the connection actually used at dispatch is always one this filter itself verified —
|
||||
never a different, unverified account on the same candidate. See
|
||||
`tests/unit/autoCombo/strict-zero-cost-connection-safety.test.ts` for the regression proof
|
||||
(keyless-bypass cases A/B/C, multi-account cases 1-5).
|
||||
|
||||
`discovered automatically`: a provider/model shipped tomorrow with the right metadata (in the
|
||||
catalog, with a usage adapter, `hardStopGuaranteed: true`) is usable the moment OmniRoute knows
|
||||
about it — no code change, no whitelist entry, nothing to edit in this module. One removed from
|
||||
the catalog disappears the same way. See
|
||||
`tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts` for the regression proof (via
|
||||
injectable fixtures, not by mutating the real catalog).
|
||||
|
||||
## Quota caching (`open-sse/services/autoCombo/freeAccessQuota.ts`)
|
||||
|
||||
Reuses `getUsageForProvider()` — no second quota system. A short, in-memory,
|
||||
process-lifetime cache sits in front of it (TTL equal to the default
|
||||
`autoRefreshProviderQuotaInterval`) so a Telegram-scale request rate never triggers a live
|
||||
billing-API call per candidate per request. Reads are synchronous: a cache miss returns
|
||||
`undefined` (→ excluded, fail-closed) and kicks off a background refresh for the _next_ read —
|
||||
nothing in the candidate-pool build path ever awaits a network call.
|
||||
|
||||
`invalidateFreeAccessState(provider, connectionId)` is called from
|
||||
`src/sse/services/auth.ts::markAccountUnavailable()` the moment a connection fails for any
|
||||
reason, so the very next pool build reads a clean cache miss instead of a stale `SAFE` entry —
|
||||
no waiting out the TTL after a 402/403/quota-exhausted response.
|
||||
|
||||
## ToS guard (independent of economic safety)
|
||||
|
||||
`excludeTosAvoid` (default `false`) drops any candidate whose curated `tos` verdict
|
||||
(`FreeModelBudget.tos`) is `"avoid"` — reuses the same field `hidePaidModels`'s sibling docs
|
||||
(`docs/reference/FREE_TIERS.md`) already populate. Deliberately separate from
|
||||
`freeAccessPolicy`: a candidate can be economically `SAFE` and still excluded here for
|
||||
contractual reasons, or left in when this guard is off even with `freeAccessPolicy: "strict"` on.
|
||||
|
||||
## What passes today
|
||||
|
||||
Run `npx tsx scripts/ad-hoc/dry-run-strict-zero-cost.ts` against a live instance's
|
||||
`GET /v1/auto-combo/{channel}/candidates` output for a real before/after — the script now reads
|
||||
each candidate's real `connectionId`, so it also proves the connection-safety fix live, not just
|
||||
in unit tests. As of 2026-08-20, only `freeType: "keyless"` candidates pass in practice (7 of 29
|
||||
live candidates on this instance: `opencode/big-pickle`, `opencode/deepseek-v4-flash-free`, and
|
||||
5 `felo-web` models — all confirmed arriving with the genuine no-auth `connectionId`, never a
|
||||
real connection) — no currently-catalogued `recurring-*` provider both has a usage adapter
|
||||
registered in `USAGE_FETCHER_PROVIDERS` **and** `hardStopGuaranteed: true` declared (e.g. `groq`
|
||||
has neither the adapter registered here nor is fetched offline in this dry run; `kiro` lacks
|
||||
`hardStopGuaranteed`). This is not a bug: it's the honest state of two independently-curated
|
||||
metadata sets that happen not to overlap yet, not a limitation of the filter itself.
|
||||
|
||||
With `excludeTosAvoid: true` added on top of the same live pool, the count drops from 7 to 0 —
|
||||
every one of the 7 surviving candidates is curated `tos: "avoid"` today (`felo-web`, `opencode`).
|
||||
This is a real, expected trade-off of turning the ToS guard on, not a bug: the guard is
|
||||
`false` by default for exactly this reason (see "ToS guard" above).
|
||||
|
||||
## Enabling
|
||||
|
||||
```json
|
||||
PUT /api/settings
|
||||
{ "freeAccessPolicy": "strict", "excludeTosAvoid": false }
|
||||
```
|
||||
|
||||
Both new settings default to their pre-feature values (`"off"` / `false`) — enabling neither
|
||||
changes any existing `auto/*` routing behavior.
|
||||
@@ -108,8 +108,9 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "bytez", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
|
||||
{ provider: "bytez", modelId: "mistralai/Mistral-7B-Instruct-v0.3", displayName: "mistralai/Mistral-7B-Instruct-v0.3", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
|
||||
{ provider: "bytez", modelId: "Qwen/Qwen2.5-72B-Instruct", displayName: "Qwen/Qwen2.5-72B-Instruct", monthlyTokens: 0, creditTokens: 1000000, freeType: "recurring-credit", poolKey: "bytez", tos: "ambiguous" },
|
||||
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution" },
|
||||
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution" },
|
||||
// hardStopGuaranteed: Cerebras pricing page states "Free Trial: 1M tokens/day... no credit card" (open-sse/services/../providers/apikey/inference-hosts.ts:74-84).
|
||||
{ provider: "cerebras", modelId: "zai-glm-4.7", displayName: "GLM 4.7", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
|
||||
{ provider: "cerebras", modelId: "gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 30000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "cerebras", tos: "caution", hardStopGuaranteed: true },
|
||||
// #8717: drop dead Workers AI ids (400/403/410). Keep Neurons/day budget on fp8-fast.
|
||||
{ provider: "cloudflare-ai", modelId: "@cf/mistral/mistral-7b-instruct-v0.2-lora", displayName: "Mistral 7B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },
|
||||
{ provider: "cloudflare-ai", modelId: "@cf/qwen/qwen2.5-coder-32b-instruct", displayName: "Qwen 2.5 Coder 32B (🆓)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "cloudflare-ai", tos: "caution" },
|
||||
@@ -187,11 +188,12 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "glm-cn", modelId: "glm-4.5-flash", displayName: "GLM-4.5-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" },
|
||||
{ provider: "glm-cn", modelId: "glm-4.7-flash", displayName: "GLM-4.7-Flash", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "zhipu-flash-free", tos: "ok" },
|
||||
{ provider: "glm-cn", modelId: "glm-signup-bonus", displayName: "Z.AI — 20M signup bonus", monthlyTokens: 0, creditTokens: 20000000, freeType: "one-time-initial", poolKey: "zhipu-signup", tos: "ok" },
|
||||
{ provider: "groq", modelId: "meta-llama/llama-4-scout-17b-16e-instruct", displayName: "Llama 4 Scout", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
|
||||
{ provider: "groq", modelId: "llama-3.3-70b-versatile", displayName: "Llama 3.3 70B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
|
||||
{ provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
|
||||
{ provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
|
||||
{ provider: "groq", modelId: "qwen/qwen3-32b", displayName: "Qwen3 32B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution" },
|
||||
// hardStopGuaranteed: Groq pricing page states "Free tier: 30 RPM / 14.4K RPD — no credit card" (open-sse/services/../providers/apikey/frontier-labs.ts:71-81).
|
||||
{ provider: "groq", modelId: "meta-llama/llama-4-scout-17b-16e-instruct", displayName: "Llama 4 Scout", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
|
||||
{ provider: "groq", modelId: "llama-3.3-70b-versatile", displayName: "Llama 3.3 70B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
|
||||
{ provider: "groq", modelId: "openai/gpt-oss-120b", displayName: "GPT-OSS 120B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
|
||||
{ provider: "groq", modelId: "openai/gpt-oss-20b", displayName: "GPT-OSS 20B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
|
||||
{ provider: "groq", modelId: "qwen/qwen3-32b", displayName: "Qwen3 32B", monthlyTokens: 15000000, creditTokens: 0, freeType: "recurring-daily", poolKey: "groq", tos: "caution", hardStopGuaranteed: true },
|
||||
{ provider: "hackclub", modelId: "meta-llama/llama-3.3-70b-instruct", displayName: "Llama 3.3 70B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
|
||||
{ provider: "hackclub", modelId: "mistralai/mistral-7b-instruct", displayName: "Mistral 7B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
|
||||
{ provider: "hackclub", modelId: "deepseek-ai/deepseek-coder-33b", displayName: "DeepSeek Coder 33B", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "hackclub", tos: "caution" },
|
||||
|
||||
@@ -26,6 +26,20 @@ export interface FreeModelBudget {
|
||||
* reports this per model as `mayTrainOnYourPrompts` on its public catalog.
|
||||
*/
|
||||
trainsOnPrompts?: boolean;
|
||||
/**
|
||||
* True only when the provider's own published terms document that exceeding
|
||||
* the free allowance is a hard stop (request refused / rate-limited) and NOT
|
||||
* automatic pay-as-you-go billing — e.g. an explicit "no credit card
|
||||
* required" claim on the provider's pricing page. This is a curated fact
|
||||
* about the upstream provider, not something derivable from `freeType` or
|
||||
* from any live API response, so it must be set by hand per entry with the
|
||||
* source of the claim in a comment. Leave unset (undefined) whenever this
|
||||
* isn't independently documented — `undefined` and `false` are both treated
|
||||
* as "not guaranteed" by `strictZeroCostFilter.ts`; never default to `true`
|
||||
* to grow the catalog. See STRICT_ZERO_COST in
|
||||
* `open-sse/services/autoCombo/strictZeroCostFilter.ts`.
|
||||
*/
|
||||
hardStopGuaranteed?: boolean;
|
||||
}
|
||||
|
||||
export interface FreeModelTotals {
|
||||
@@ -80,7 +94,7 @@ function fmt(n: number): string {
|
||||
function dedupedSum(
|
||||
models: FreeModelBudget[],
|
||||
pick: (m: FreeModelBudget) => number,
|
||||
include: (m: FreeModelBudget) => boolean,
|
||||
include: (m: FreeModelBudget) => boolean
|
||||
): number {
|
||||
const poolMax = new Map<string, number>();
|
||||
let loose = 0;
|
||||
@@ -100,30 +114,30 @@ export function computeFreeModelTotals(opts: { excludeTosAvoid?: boolean } = {})
|
||||
const steadyRecurringTokens = dedupedSum(
|
||||
models,
|
||||
(m) => m.monthlyTokens,
|
||||
(m) => RECURRING.has(m.freeType),
|
||||
(m) => RECURRING.has(m.freeType)
|
||||
);
|
||||
const recurringCredits = dedupedSum(
|
||||
models,
|
||||
(m) => m.creditTokens,
|
||||
(m) => m.freeType === "recurring-credit",
|
||||
(m) => m.freeType === "recurring-credit"
|
||||
);
|
||||
const oneTimeCredits = dedupedSum(
|
||||
models,
|
||||
(m) => m.creditTokens,
|
||||
(m) => m.freeType === "one-time-initial",
|
||||
(m) => m.freeType === "one-time-initial"
|
||||
);
|
||||
|
||||
const steadyWithRecurringCreditsTokens = steadyRecurringTokens + recurringCredits;
|
||||
const firstMonthRealisticTokens = steadyWithRecurringCreditsTokens + oneTimeCredits;
|
||||
|
||||
const poolCount = new Set(
|
||||
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey),
|
||||
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey)
|
||||
).size;
|
||||
|
||||
// Deposit-unlock boost: sum the FREE_TIER_BOOSTS whose pool still has a live
|
||||
// recurring model in the (optionally ToS-filtered) set.
|
||||
const livePools = new Set(
|
||||
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey),
|
||||
models.filter((m) => RECURRING.has(m.freeType) && m.poolKey).map((m) => m.poolKey)
|
||||
);
|
||||
const boostMonthlyTokens = Object.entries(FREE_TIER_BOOSTS)
|
||||
.filter(([pool]) => livePools.has(pool))
|
||||
|
||||
@@ -56,12 +56,21 @@ export function stripGroqUnsupportedFields<T extends Record<string, unknown>>(bo
|
||||
delete next.top_logprobs;
|
||||
if (Array.isArray(next.messages)) {
|
||||
next.messages = next.messages.map((m) => {
|
||||
if (m && typeof m === "object" && "name" in m) {
|
||||
const { name: _name, ...rest } = m as Record<string, unknown>;
|
||||
if (m && typeof m === "object") {
|
||||
const {
|
||||
name: _name,
|
||||
model: _model,
|
||||
messageId: _msgId,
|
||||
sender: _sender,
|
||||
...rest
|
||||
} = m as Record<string, unknown>;
|
||||
|
||||
return rest;
|
||||
}
|
||||
|
||||
return m;
|
||||
});
|
||||
}
|
||||
return next as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -104,12 +104,6 @@ import {
|
||||
import { applyPeerTraceHeader } from "@/shared/resilience/peerRouting";
|
||||
import { applyClineProtocolHeaders } from "@/shared/utils/clineAuth";
|
||||
import { isProbeContext } from "@/shared/utils/probeOrigin";
|
||||
import {
|
||||
parseAndValidatePublicUrl,
|
||||
parseAndValidateNonMetadataUrl,
|
||||
} from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { isLocalProvider, isSelfHostedChatProvider } from "@/shared/constants/providers";
|
||||
// Header helpers extracted to a pure leaf; re-exported for external importers
|
||||
// (executors + tests) that import them from "./base.ts".
|
||||
export {
|
||||
@@ -403,29 +397,6 @@ export class BaseExecutor {
|
||||
return fallback || this.config.baseUrl || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF guard for the runtime dispatch path (GHSA-4f49-hj64-448x). A persisted,
|
||||
* caller-supplied `providerSpecificData.baseUrl` reaches the fetch() calls
|
||||
* below, so a `manage`-scope actor (or, on a keyless install, an anonymous
|
||||
* one) could point a provider at loopback / internal / cloud-metadata hosts
|
||||
* and exfiltrate the stored upstream key. Mirror the provider VALIDATION
|
||||
* guard so runtime dispatch makes the same decision the validation layer
|
||||
* already makes: local / self-hosted providers are exempt (they legitimately
|
||||
* use private URLs, and the OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS opt-in still
|
||||
* applies through the guard), and for everything else `public-only` mode
|
||||
* blocks private + metadata while the default `block-metadata` mode blocks the
|
||||
* cloud-metadata IMDS pivot. Throws on a blocked URL.
|
||||
*/
|
||||
protected assertOutboundUrlAllowed(url: string): void {
|
||||
if (!url) return;
|
||||
if (isLocalProvider(this.provider) || isSelfHostedChatProvider(this.provider)) return;
|
||||
if (getProviderValidationGuard() === "public-only") {
|
||||
parseAndValidatePublicUrl(url);
|
||||
return;
|
||||
}
|
||||
parseAndValidateNonMetadataUrl(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternate protocol selected on this connection, if the provider declares one
|
||||
* that matches. Centralizes the registry lookup so every call-site resolves the
|
||||
@@ -644,7 +615,6 @@ export class BaseExecutor {
|
||||
async countTokens({ model, body, credentials, signal, log }: CountTokensInput) {
|
||||
const url = this.buildCountTokensUrl(model, credentials);
|
||||
if (!url) return null;
|
||||
this.assertOutboundUrlAllowed(url); // GHSA-4f49
|
||||
|
||||
const headers = this.buildHeaders(credentials, false);
|
||||
const requestBody =
|
||||
@@ -899,9 +869,6 @@ export class BaseExecutor {
|
||||
// Timeout only covers response start; stream stalls are handled downstream.
|
||||
const fetchStartTimeoutMs = this.getTimeoutMs();
|
||||
const fetchWithStartTimeout = async (requestUrl: string, requestOptions: RequestInit) => {
|
||||
// GHSA-4f49: guard here (not only next to the first buildUrl) so retries
|
||||
// and fallback URLs are validated too, before any bytes leave the host.
|
||||
this.assertOutboundUrlAllowed(requestUrl);
|
||||
const timeoutController = fetchStartTimeoutMs > 0 ? new AbortController() : null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
if (timeoutController) {
|
||||
|
||||
@@ -430,7 +430,6 @@ export class GlmExecutor extends DefaultExecutor {
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
this.assertOutboundUrlAllowed(url); // GHSA-4f49: glm has its own fetch path
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
||||
@@ -471,7 +471,6 @@ export class NlpCloudExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
try {
|
||||
this.assertOutboundUrlAllowed(url); // GHSA-4f49: nlpcloud has its own fetch path
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
|
||||
@@ -10,24 +10,16 @@ import {
|
||||
import { resolveMcpCallerApiKeyId } from "../mcpCallerIdentity.ts";
|
||||
|
||||
/**
|
||||
* Resolve the memory owner id for an MCP tool call.
|
||||
*
|
||||
* The authenticated caller's principal ALWAYS wins over a caller-supplied
|
||||
* `apiKeyId` — otherwise any MCP caller could read, write, or delete another
|
||||
* principal's memories by putting a different id in the tool arguments
|
||||
* (GHSA-cpv3-xr7r-xf8q, IDOR). The caller is resolved from the per-request HTTP
|
||||
* auth headers on SSE / Streamable HTTP transports, or from OMNIROUTE_API_KEY on
|
||||
* stdio. The explicit argument is only honored as a fallback when no caller can
|
||||
* be resolved (a bare local stdio process with no configured key — already
|
||||
* trusted), preserving the local-tooling flow. Keeps MCP-stored memories under
|
||||
* the same owner id that chat-context memory uses, so retrieval in the chat
|
||||
* pipeline finds entries written via MCP.
|
||||
* Resolve the memory owner id for an MCP tool call:
|
||||
* explicit arg wins, otherwise fall back to the authenticated caller's
|
||||
* principal id (HTTP auth headers on SSE/Streamable HTTP transports,
|
||||
* OMNIROUTE_API_KEY env var on stdio). Keeps MCP-stored memories under
|
||||
* the same owner id that chat-context memory uses, so retrieval in the
|
||||
* chat pipeline finds entries written via MCP.
|
||||
*/
|
||||
async function resolveMemoryOwnerId(explicit?: string): Promise<string> {
|
||||
const caller = await resolveMcpCallerApiKeyId().catch(() => undefined);
|
||||
if (caller) return caller;
|
||||
if (explicit && explicit.trim() !== "") return explicit.trim();
|
||||
return "mcp";
|
||||
return (await resolveMcpCallerApiKeyId().catch(() => undefined)) || "mcp";
|
||||
}
|
||||
|
||||
export const MemorySearchSchema = z.object({
|
||||
|
||||
209
open-sse/services/autoCombo/freeAccessQuota.ts
Normal file
209
open-sse/services/autoCombo/freeAccessQuota.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Live wiring for STRICT_ZERO_COST's quota-based branch.
|
||||
*
|
||||
* Reuses the existing `getUsageForProvider()` (`open-sse/services/usage.ts`)
|
||||
* instead of building a second quota system — this module only adds a short
|
||||
* TTL cache in front of it (so a Telegram-scale request rate never triggers a
|
||||
* live billing-API call per candidate per request) and an invalidation hook
|
||||
* for the resilience layer to call the moment a 402/403/quota-exhausted
|
||||
* response is observed (`accountFallback.ts`).
|
||||
*
|
||||
* The cache is intentionally synchronous to read: `resolveFreeAccessState()`
|
||||
* never awaits. A cache miss returns `undefined` (→ UNKNOWN → excluded,
|
||||
* fail-closed) and kicks off a background refresh for the *next* read —
|
||||
* nothing here can make `strictZeroCostFilter.ts`'s pool build block on a
|
||||
* network call.
|
||||
*/
|
||||
import {
|
||||
getUsageForProvider,
|
||||
USAGE_FETCHER_PROVIDERS,
|
||||
type UsageFetcherProvider,
|
||||
} from "./../usage.ts";
|
||||
import { getCachedProviderConnections } from "@/lib/db/readCache";
|
||||
import { defaultLogger as log } from "@omniroute/open-sse/utils/logger";
|
||||
import type { FreeAccessState } from "./strictZeroCostFilter";
|
||||
|
||||
const USAGE_FETCHER_PROVIDER_SET = new Set<string>(USAGE_FETCHER_PROVIDERS);
|
||||
|
||||
/** Default cache TTL, reused verbatim from the already-shipped
|
||||
* `settings.autoRefreshProviderQuotaInterval` (180s default,
|
||||
* `src/lib/db/settings.ts`) instead of inventing a new number. */
|
||||
const FALLBACK_TTL_MS = 180_000;
|
||||
|
||||
/** Cold-cache thundering-herd guard: after a process restart every candidate
|
||||
* in a pool build is a simultaneous cache miss, which without a cap would
|
||||
* fire one `getUsageForProvider()` call per distinct (provider, connection)
|
||||
* pair in the same tick. Capping concurrent background refreshes spreads
|
||||
* that burst out — a skipped refresh here just means this candidate stays
|
||||
* UNKNOWN (excluded, fail-closed) until a later pool build tries again, never
|
||||
* a correctness issue. */
|
||||
const MAX_CONCURRENT_REFRESHES = 4;
|
||||
|
||||
/** Entries older than this are pruned outright even if nothing ever triggers
|
||||
* a fresh refresh for that exact key again (e.g. the connection was deleted
|
||||
* and no candidate references it anymore, so a normal stale-triggered
|
||||
* refresh — which self-heals inside one TTL window — never fires). A sweep,
|
||||
* not a timer: piggybacks on `resolveFreeAccessState` calls so this module
|
||||
* never owns its own background interval. */
|
||||
const HARD_EVICTION_AGE_MS = FALLBACK_TTL_MS * 20; // 1 hour at the default TTL
|
||||
const SWEEP_EVERY_N_CALLS = 200;
|
||||
|
||||
interface CacheEntry {
|
||||
state: FreeAccessState;
|
||||
fetchedAtMs: number;
|
||||
}
|
||||
|
||||
// Keyed by `${provider}::${connectionId}` — module-level, process-lifetime
|
||||
// cache. Cleared per-entry by `invalidateFreeAccessState`, on a failed
|
||||
// refresh, or by the periodic sweep below; never wholesale.
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Set<string>();
|
||||
let resolveCallCount = 0;
|
||||
|
||||
function cacheKey(provider: string, connectionId: string): string {
|
||||
return `${provider}::${connectionId}`;
|
||||
}
|
||||
|
||||
// `getSettings()` is async (DB-backed); reading it synchronously here isn't
|
||||
// possible without changing `resolveFreeAccessState`'s synchronous contract.
|
||||
// Using the fallback unconditionally is equivalent in practice: it's the same
|
||||
// number as `settings.autoRefreshProviderQuotaInterval`'s own default
|
||||
// (`src/lib/db/settings.ts`), and `strictZeroCostFilter.ts`'s own
|
||||
// `maxStateAgeMs` (passed the real, live setting from `virtualFactory.ts`)
|
||||
// is the check that actually gates staleness for the STRICT_ZERO_COST
|
||||
// decision — this cache TTL only bounds how long a background refresh is
|
||||
// skipped, a looser, non-safety-critical concern.
|
||||
function ttlMs(): number {
|
||||
return FALLBACK_TTL_MS;
|
||||
}
|
||||
|
||||
/** Opportunistic sweep of very stale entries, run every N calls instead of on
|
||||
* a timer. Cheap (a single Map iteration) and only ever removes entries no
|
||||
* live candidate can plausibly still be waiting on. */
|
||||
function sweepIfDue(): void {
|
||||
resolveCallCount += 1;
|
||||
if (resolveCallCount % SWEEP_EVERY_N_CALLS !== 0) return;
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of cache) {
|
||||
if (now - entry.fetchedAtMs > HARD_EVICTION_AGE_MS) cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort, provider-agnostic extraction of "how much free allowance is
|
||||
* left" from whatever shape `getUsageForProvider()` returns for this
|
||||
* provider today. Adapters were written for a human-readable quota display,
|
||||
* not for this filter, so their payloads are heterogeneous; this function
|
||||
* recognizes the two shapes already used by other read paths in this
|
||||
* codebase (`quotas.*.remainingPercentage` / `.total`+`.remaining`, mirroring
|
||||
* `quota_omniroute.py`'s own parsing) and returns `null` — never a guess —
|
||||
* for anything else. `null` is treated as "not proven safe" by the filter.
|
||||
*/
|
||||
function extractRemainingAllowance(usage: unknown): number | null {
|
||||
if (!usage || typeof usage !== "object") return null;
|
||||
const quotas = (usage as Record<string, unknown>).quotas;
|
||||
if (!quotas || typeof quotas !== "object") return null;
|
||||
|
||||
let worstPercent: number | null = null;
|
||||
for (const raw of Object.values(quotas as Record<string, unknown>)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const q = raw as Record<string, unknown>;
|
||||
if (q.unlimited === true) continue;
|
||||
let pct: number | null =
|
||||
typeof q.remainingPercentage === "number" ? q.remainingPercentage : null;
|
||||
if (
|
||||
pct === null &&
|
||||
typeof q.total === "number" &&
|
||||
typeof q.remaining === "number" &&
|
||||
q.total > 0
|
||||
) {
|
||||
pct = (100 * q.remaining) / q.total;
|
||||
}
|
||||
if (pct === null) continue;
|
||||
worstPercent = worstPercent === null ? pct : Math.min(worstPercent, pct);
|
||||
}
|
||||
return worstPercent; // percentage points; the filter's threshold is compared against this unit
|
||||
}
|
||||
|
||||
async function refresh(provider: string, connectionId: string): Promise<void> {
|
||||
const key = cacheKey(provider, connectionId);
|
||||
if (inFlight.has(key)) return;
|
||||
if (inFlight.size >= MAX_CONCURRENT_REFRESHES) return; // thundering-herd guard — see const doc above
|
||||
inFlight.add(key);
|
||||
try {
|
||||
const connections = await getCachedProviderConnections();
|
||||
const connection = connections.find(
|
||||
(c): c is Record<string, unknown> =>
|
||||
!!c &&
|
||||
typeof c === "object" &&
|
||||
(c as Record<string, unknown>).id === connectionId &&
|
||||
(c as Record<string, unknown>).provider === provider
|
||||
);
|
||||
if (!connection) {
|
||||
cache.delete(key);
|
||||
return;
|
||||
}
|
||||
const usage = await getUsageForProvider(
|
||||
connection as unknown as Parameters<typeof getUsageForProvider>[0],
|
||||
{ forceRefresh: false }
|
||||
);
|
||||
const remaining = extractRemainingAllowance(usage);
|
||||
const state: FreeAccessState = {
|
||||
status: remaining === null ? "UNKNOWN" : remaining > 0 ? "SAFE" : "EXHAUSTED",
|
||||
remainingFreeAllowance: remaining,
|
||||
resetAt:
|
||||
usage &&
|
||||
typeof usage === "object" &&
|
||||
typeof (usage as Record<string, unknown>).resetAt === "string"
|
||||
? ((usage as Record<string, unknown>).resetAt as string)
|
||||
: null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
cache.set(key, { state, fetchedAtMs: Date.now() });
|
||||
} catch (err) {
|
||||
// A failed lookup must never leave a stale SAFE entry behind — drop it so
|
||||
// the next read is a clean cache miss (UNKNOWN), not a lucky reuse.
|
||||
cache.delete(key);
|
||||
log.warn("AUTO", "STRICT_ZERO_COST: usage refresh failed, treating as UNKNOWN", {
|
||||
provider,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous read for `strictZeroCostFilter.ts`. Returns `undefined` when
|
||||
* there's no usage adapter for this provider at all (a permanent UNKNOWN, no
|
||||
* point ever refreshing), or on a cold/stale cache — in both cases a
|
||||
* background refresh is kicked off (fire-and-forget, subject to the
|
||||
* concurrency cap above) so a *later* read can benefit, but this call itself
|
||||
* never blocks or throws.
|
||||
*/
|
||||
export function resolveFreeAccessState(
|
||||
provider: string,
|
||||
connectionId: string | undefined
|
||||
): FreeAccessState | undefined {
|
||||
sweepIfDue();
|
||||
if (!USAGE_FETCHER_PROVIDER_SET.has(provider as UsageFetcherProvider)) return undefined;
|
||||
if (!connectionId) return undefined;
|
||||
|
||||
const key = cacheKey(provider, connectionId);
|
||||
const entry = cache.get(key);
|
||||
const fresh = entry && Date.now() - entry.fetchedAtMs <= ttlMs();
|
||||
if (!fresh) {
|
||||
void refresh(provider, connectionId);
|
||||
}
|
||||
return fresh ? entry.state : undefined;
|
||||
}
|
||||
|
||||
/** Called by `accountFallback.ts` the moment a 402/403/quota-exhausted
|
||||
* response is classified for a connection — drops the cached entry
|
||||
* immediately instead of waiting out the TTL, so the very next candidate-pool
|
||||
* build reads a clean cache miss (UNKNOWN) rather than a stale SAFE. */
|
||||
export function invalidateFreeAccessState(provider: string, connectionId: string): void {
|
||||
cache.delete(cacheKey(provider, connectionId));
|
||||
}
|
||||
|
||||
export const __testing = { cache, extractRemainingAllowance, sweepIfDue };
|
||||
283
open-sse/services/autoCombo/strictZeroCostFilter.ts
Normal file
283
open-sse/services/autoCombo/strictZeroCostFilter.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* STRICT_ZERO_COST — an opt-in, stricter sibling of `hidePaidModels`
|
||||
* (`paidModelFilter.ts`) for operators who need a hard guarantee against ANY
|
||||
* incremental monetary spend, not just "documented as free".
|
||||
*
|
||||
* `hidePaidModels` answers "is this model classified free in FREE_MODEL_BUDGETS
|
||||
* right now?" — a point-in-time catalog fact. It says nothing about whether a
|
||||
* `recurring-*`/`one-time-initial` candidate's allowance has since been
|
||||
* consumed, and nothing about whether exceeding it is a hard stop or silent
|
||||
* pay-as-you-go billing. STRICT_ZERO_COST adds exactly those two checks,
|
||||
* before ranking, before dispatch — never after.
|
||||
*
|
||||
* Design, kept deliberately close to `filterPaidOnlyCandidates`'s own stated
|
||||
* goal: "a pure, dependency-light function so the filter is unit-testable in
|
||||
* isolation". The live quota lookup (`getUsageForProvider`, cached with a TTL)
|
||||
* lives in `freeAccessQuota.ts` and is injected here as a plain function —
|
||||
* this file never imports the DB or makes a network call itself.
|
||||
*
|
||||
* No provider or model name appears anywhere in this file. A candidate passes
|
||||
* or fails purely on the metadata it carries (`freeType`, `tos`,
|
||||
* `hardStopGuaranteed`) plus, for quota-based types, a `FreeAccessState`
|
||||
* resolved elsewhere. A future provider that ships correct metadata is
|
||||
* handled automatically; one that doesn't is excluded automatically — see
|
||||
* `docs/routing/STRICT_ZERO_COST.md`.
|
||||
*
|
||||
* ## Connection safety (fixed after code review, see `docs/routing/STRICT_ZERO_COST.md`)
|
||||
*
|
||||
* A candidate from `virtualFactory.ts`'s connection-based pool represents ONE
|
||||
* provider/model pair with a set of *eligible* connections
|
||||
* (`allowedConnectionIds`) — the actual connection used at dispatch is chosen
|
||||
* later (session stickiness/LKGP), not by this filter. Two invariants follow:
|
||||
*
|
||||
* 1. The `keyless` shortcut (no live check needed, because no credential
|
||||
* exists) is valid ONLY for candidates that genuinely came from the
|
||||
* no-auth path — identified by `connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID`
|
||||
* (`resilienceCandidateFilter.ts`). A `keyless`-catalogued model reached
|
||||
* through a real DB connection (the same provider also has a
|
||||
* credentialed connection) does NOT get the shortcut — it falls through
|
||||
* to the normal quota-based check like any other freeType, and is
|
||||
* excluded unless that specific connection independently proves SAFE.
|
||||
* 2. For a multi-account candidate (`connectionId: null`,
|
||||
* `allowedConnectionIds: [...]`), each connection is checked
|
||||
* INDIVIDUALLY. The returned candidate's `allowedConnectionIds` is
|
||||
* REWRITTEN to exactly the subset proven SAFE — never the full original
|
||||
* list. `autoStrategy.ts` (`open-sse/services/combo/autoStrategy.ts:315-331`)
|
||||
* already intersects further routing against `allowedConnectionIds`
|
||||
* before connection selection, so rewriting it here is enough to make
|
||||
* "verified this connection" and "dispatch used this connection" the
|
||||
* same set, by construction — no new enforcement point needed.
|
||||
*/
|
||||
import {
|
||||
FREE_MODEL_BUDGETS,
|
||||
type FreeModelBudget,
|
||||
} from "@omniroute/open-sse/config/freeModelCatalog.ts";
|
||||
import { SYNTHETIC_NOAUTH_CONNECTION_ID } from "./resilienceCandidateFilter";
|
||||
|
||||
/** Types whose allowance needs no runtime verification: no credential exists
|
||||
* for the candidate at all, so no request against it can ever be billed. */
|
||||
const KEYLESS_FREE_TYPES = new Set<FreeModelBudget["freeType"]>(["keyless"]);
|
||||
|
||||
export type FreeAccessStatus = "SAFE" | "EXHAUSTED" | "UNKNOWN";
|
||||
|
||||
/** Live-checked allowance state for one (provider, connection) pair. Resolved
|
||||
* and cached by `freeAccessQuota.ts`; passed in here as plain data so this
|
||||
* module stays free of DB/network dependencies. */
|
||||
export interface FreeAccessState {
|
||||
status: FreeAccessStatus;
|
||||
/** Remaining free allowance in the provider's own unit (tokens, requests, or
|
||||
* USD-equivalent) — whatever `getUsageForProvider()` reports. `null` when
|
||||
* the provider's usage payload doesn't expose a numeric remaining figure. */
|
||||
remainingFreeAllowance: number | null;
|
||||
/** When the allowance next resets, if the provider reports it. */
|
||||
resetAt: string | null;
|
||||
/** When this state was fetched (ISO 8601) — used to detect staleness. */
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/** A candidate as this module needs to see it — a structural subset of
|
||||
* `VirtualAutoComboCandidate` (`virtualFactory.ts`) so this file has no
|
||||
* dependency on that module's full type. */
|
||||
export interface StrictZeroCostCandidate {
|
||||
provider: string;
|
||||
model: string;
|
||||
connectionId: string | null;
|
||||
allowedConnectionIds?: string[];
|
||||
}
|
||||
|
||||
export interface StrictZeroCostOptions {
|
||||
/** Master switch — mirrors `hidePaidModels`'s own off-by-default shape. */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Resolves the live allowance state for ONE specific (provider, connection)
|
||||
* pair. Returns `undefined` when no usage capability exists for the
|
||||
* provider at all (no adapter registered in `USAGE_FETCHER_PROVIDERS`), or
|
||||
* when the cache has nothing fresh for this exact connection — both are a
|
||||
* meaningful, terminal UNKNOWN for that connection, not an error to retry.
|
||||
*
|
||||
* Synchronous by design: the caller (`virtualFactory.ts`) resolves and
|
||||
* caches state per candidate up front, once per pool build, so this filter
|
||||
* itself never awaits a network call and stays trivially testable.
|
||||
*/
|
||||
resolveFreeAccessState: (provider: string, connectionId: string) => FreeAccessState | undefined;
|
||||
/** Minimum remaining allowance (in the unit `resolveFreeAccessState` reports
|
||||
* — percentage points for the built-in `freeAccessQuota.ts` resolver) a
|
||||
* quota-based connection must exceed to pass. Must be >= 0; a fully-exhausted
|
||||
* account (`remainingFreeAllowance === 0`) fails at any non-negative
|
||||
* threshold via the strict `>` comparison below. */
|
||||
minRemainingAllowance: number;
|
||||
/** Maximum age, in ms, a `FreeAccessState.checkedAt` may have before it's
|
||||
* treated as stale (→ UNKNOWN, excluded). */
|
||||
maxStateAgeMs: number;
|
||||
/** `now` injection for deterministic tests; defaults to `Date.now`. */
|
||||
now?: () => number;
|
||||
/**
|
||||
* The free-model catalog to look candidates up against. Defaults to the
|
||||
* real, live `FREE_MODEL_BUDGETS` — overridable so tests can prove the
|
||||
* autodiscovery contract (a provider/model that appears in the catalog is
|
||||
* automatically considered; one that's removed automatically disappears)
|
||||
* with synthetic fixtures instead of mutating global state. Production
|
||||
* callers should never pass this. Threaded through by
|
||||
* `filterStrictZeroCostCandidates` (previously accepted but silently
|
||||
* ignored — fixed alongside the connection-safety review).
|
||||
*/
|
||||
catalog?: readonly FreeModelBudget[];
|
||||
}
|
||||
|
||||
export function findBudgetEntry(
|
||||
candidate: Pick<StrictZeroCostCandidate, "provider" | "model">,
|
||||
catalog: readonly FreeModelBudget[] = FREE_MODEL_BUDGETS
|
||||
): FreeModelBudget | undefined {
|
||||
return catalog.find((m) => m.provider === candidate.provider && m.modelId === candidate.model);
|
||||
}
|
||||
|
||||
function isConnectionStateSafe(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
resolveFreeAccessState: StrictZeroCostOptions["resolveFreeAccessState"],
|
||||
options: Pick<StrictZeroCostOptions, "minRemainingAllowance" | "maxStateAgeMs" | "now">
|
||||
): boolean {
|
||||
const state = resolveFreeAccessState(provider, connectionId);
|
||||
if (!state) return false; // no usage adapter for this provider, or lookup never ran/is stale
|
||||
if (state.status !== "SAFE") return false;
|
||||
|
||||
const now = (options.now ?? Date.now)();
|
||||
const checkedAtMs = Date.parse(state.checkedAt);
|
||||
if (!Number.isFinite(checkedAtMs) || now - checkedAtMs > options.maxStateAgeMs) return false;
|
||||
|
||||
if (state.remainingFreeAllowance === null) return false;
|
||||
// A negative threshold would let a negative/garbage reading pass; a caller
|
||||
// that genuinely wants "any allowance greater than zero" should pass 0.
|
||||
if (options.minRemainingAllowance < 0) return false;
|
||||
return state.remainingFreeAllowance > options.minRemainingAllowance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which of a candidate's connections satisfy STRICT_ZERO_COST. Pure —
|
||||
* `resolveFreeAccessState` is the only injected side-effecting dependency,
|
||||
* and it's a synchronous cache read (see `StrictZeroCostOptions` above).
|
||||
*
|
||||
* Returns the list of connection ids proven SAFE right now:
|
||||
* - `[SYNTHETIC_NOAUTH_CONNECTION_ID]` for a genuine no-auth candidate whose
|
||||
* catalog entry is `keyless` — no live check needed or possible.
|
||||
* - a (possibly empty) subset of the candidate's real connection id(s) for
|
||||
* every other case, each individually verified.
|
||||
* An empty array means the caller must exclude the candidate entirely.
|
||||
*/
|
||||
export function evaluateCandidateConnections(
|
||||
candidate: StrictZeroCostCandidate,
|
||||
budgetEntry: FreeModelBudget | undefined,
|
||||
resolveFreeAccessState: StrictZeroCostOptions["resolveFreeAccessState"],
|
||||
options: Pick<StrictZeroCostOptions, "minRemainingAllowance" | "maxStateAgeMs" | "now">
|
||||
): string[] {
|
||||
if (!budgetEntry) return []; // not in the catalog at all → paid, or genuinely unknown
|
||||
|
||||
const isGenuineNoAuthCandidate = candidate.connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID;
|
||||
if (KEYLESS_FREE_TYPES.has(budgetEntry.freeType)) {
|
||||
// The keyless shortcut is trustworthy ONLY when this specific candidate
|
||||
// instance actually has no credential behind it. A `keyless`-catalogued
|
||||
// model reached through a real DB connection (connectionId is a real id,
|
||||
// or the candidate carries allowedConnectionIds at all) must NOT take
|
||||
// this shortcut — it falls through to the quota-based check below like
|
||||
// any other freeType, and is excluded there unless hardStopGuaranteed is
|
||||
// also set for it (which the curated catalog does not do for keyless
|
||||
// entries today, so it will correctly exclude).
|
||||
if (isGenuineNoAuthCandidate) return [SYNTHETIC_NOAUTH_CONNECTION_ID];
|
||||
}
|
||||
if (budgetEntry.freeType === "discontinued") return [];
|
||||
if (isGenuineNoAuthCandidate) return []; // no-auth path but a non-keyless catalog entry: contradictory metadata, fail closed
|
||||
|
||||
// Every remaining freeType (recurring-*, one-time-initial, a keyless entry
|
||||
// reached via a real connection, and any future type this module doesn't
|
||||
// special-case) requires a documented hard stop before any live check even
|
||||
// runs — no point burning a quota lookup on a connection we could never
|
||||
// trust regardless of its answer.
|
||||
if (budgetEntry.hardStopGuaranteed !== true) return [];
|
||||
|
||||
const candidateConnectionIds = candidate.connectionId
|
||||
? [candidate.connectionId]
|
||||
: (candidate.allowedConnectionIds ?? []);
|
||||
|
||||
const safe: string[] = [];
|
||||
for (const connectionId of candidateConnectionIds) {
|
||||
if (connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID) continue; // never reachable here, defensive
|
||||
if (isConnectionStateSafe(candidate.provider, connectionId, resolveFreeAccessState, options)) {
|
||||
safe.push(connectionId);
|
||||
}
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pool-level filter, same off-by-default identity contract as
|
||||
* `filterPaidOnlyCandidates`. For a candidate that survives with a NARROWED
|
||||
* connection set (the multi-account case), the returned object has
|
||||
* `allowedConnectionIds` rewritten to exactly the SAFE subset — dispatch can
|
||||
* then never select a connection this filter didn't verify, because
|
||||
* `autoStrategy.ts` already enforces `allowedConnectionIds` as a hard
|
||||
* allowlist downstream (see the module docstring above).
|
||||
*/
|
||||
export function filterStrictZeroCostCandidates<T extends StrictZeroCostCandidate>(
|
||||
pool: T[],
|
||||
options: StrictZeroCostOptions
|
||||
): T[] {
|
||||
if (!options.enabled) return pool;
|
||||
|
||||
const kept: T[] = [];
|
||||
let changed = false;
|
||||
for (const candidate of pool) {
|
||||
const budgetEntry = findBudgetEntry(candidate, options.catalog);
|
||||
const safeConnectionIds = evaluateCandidateConnections(
|
||||
candidate,
|
||||
budgetEntry,
|
||||
options.resolveFreeAccessState,
|
||||
options
|
||||
);
|
||||
if (safeConnectionIds.length === 0) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isGenuineNoAuthCandidate = candidate.connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID;
|
||||
const isSingleConnectionCandidate = candidate.connectionId !== null;
|
||||
if (isGenuineNoAuthCandidate || isSingleConnectionCandidate) {
|
||||
// Nothing to narrow — either the no-auth sentinel, or a candidate that
|
||||
// already pointed at exactly one connection which proved safe.
|
||||
kept.push(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multi-account candidate: only rewrite if the safe subset is actually
|
||||
// narrower than what was there before, to preserve the same
|
||||
// identity-when-nothing-changed contract as `filterPaidOnlyCandidates`.
|
||||
const original = candidate.allowedConnectionIds ?? [];
|
||||
const isSameSet =
|
||||
original.length === safeConnectionIds.length &&
|
||||
safeConnectionIds.every((id) => original.includes(id));
|
||||
if (isSameSet) {
|
||||
kept.push(candidate);
|
||||
} else {
|
||||
changed = true;
|
||||
kept.push({ ...candidate, allowedConnectionIds: safeConnectionIds });
|
||||
}
|
||||
}
|
||||
return changed ? kept : pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate, optional ToS guard — kept independent from economic safety on
|
||||
* purpose (Marco's requirement): a model can be economically SAFE and still
|
||||
* excluded here for ToS reasons, or left in when this guard is off even if
|
||||
* STRICT_ZERO_COST is on. Reuses the same curated `tos` field, no new data.
|
||||
*/
|
||||
export function filterTosAvoidCandidates<T extends StrictZeroCostCandidate>(
|
||||
pool: T[],
|
||||
excludeTosAvoid: boolean,
|
||||
catalog?: readonly FreeModelBudget[]
|
||||
): T[] {
|
||||
if (!excludeTosAvoid) return pool;
|
||||
return pool.filter((candidate) => {
|
||||
const budgetEntry = findBudgetEntry(candidate, catalog);
|
||||
return budgetEntry?.tos !== "avoid";
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
|
||||
import { getHiddenModelsByProvider } from "@/models";
|
||||
import { getSyncedAvailableModelsByConnection, getCustomModels } from "@/lib/db/models";
|
||||
import { filterPaidOnlyCandidates } from "./paidModelFilter";
|
||||
import { filterStrictZeroCostCandidates, filterTosAvoidCandidates } from "./strictZeroCostFilter";
|
||||
import { resolveFreeAccessState } from "./freeAccessQuota";
|
||||
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
|
||||
import { resolveProviderAlias } from "../model.ts";
|
||||
import { filterExcludedCandidates } from "./candidateOverrides";
|
||||
@@ -590,6 +592,29 @@ export async function prepareVirtualAutoComboInputs(
|
||||
// exclude paid-only backends from EVERY `auto/*` candidate pool.
|
||||
const paidFilteredPool = filterPaidOnlyCandidates(pool, settings.hidePaidModels === true);
|
||||
if (paidFilteredPool !== pool) pool = paidFilteredPool;
|
||||
|
||||
// STRICT_ZERO_COST: opt-in, off by default (`settings.freeAccessPolicy !== "strict"`
|
||||
// leaves `pool` byte-identical, same contract as `hidePaidModels`). See
|
||||
// `strictZeroCostFilter.ts` for why this is stricter than `hidePaidModels` alone —
|
||||
// including the connection-safety invariant it enforces per-connection, not just
|
||||
// per-candidate: `resolveFreeAccessState` here is a raw pass-through of the real
|
||||
// per-(provider,connectionId) resolver; the filter itself decides which connection(s)
|
||||
// on each candidate to check and rewrites `allowedConnectionIds` to the SAFE subset.
|
||||
const strictFilteredPool = filterStrictZeroCostCandidates(pool, {
|
||||
enabled: settings.freeAccessPolicy === "strict",
|
||||
resolveFreeAccessState,
|
||||
// 1 percentage point of headroom, not 0: `freeAccessQuota.ts` reports
|
||||
// remaining allowance as a percentage, and a raw ">0" comparison would
|
||||
// let a reading of e.g. 0.3% (rounding noise, not real headroom) pass.
|
||||
minRemainingAllowance: 1,
|
||||
maxStateAgeMs: (settings.autoRefreshProviderQuotaInterval ?? 180) * 1000,
|
||||
});
|
||||
if (strictFilteredPool !== pool) pool = strictFilteredPool;
|
||||
|
||||
// Separate, optional ToS guard — independent of economic safety on purpose.
|
||||
const tosFilteredPool = filterTosAvoidCandidates(pool, settings.excludeTosAvoid === true);
|
||||
if (tosFilteredPool !== pool) pool = tosFilteredPool;
|
||||
|
||||
return pool;
|
||||
};
|
||||
|
||||
|
||||
@@ -589,10 +589,7 @@ function resolveSilentCloseOutcome(input: {
|
||||
if (!input.bytesWereForwarded) return null;
|
||||
|
||||
if (!input.clientTerminalSeen) {
|
||||
if (
|
||||
input.clientResponseFormat === FORMATS.CLAUDE &&
|
||||
input.contentWatcher.sawContent()
|
||||
) {
|
||||
if (input.clientResponseFormat === FORMATS.CLAUDE && input.contentWatcher.sawContent()) {
|
||||
// #7699 — upstream dropped after content reached the client on a Claude
|
||||
// stream. Keep the partial response: emit a clean max_tokens completion
|
||||
// instead of an error frame so Anthropic SDK / Claude Code don't report
|
||||
@@ -611,6 +608,16 @@ function resolveSilentCloseOutcome(input: {
|
||||
if (input.clientResponseFormat === FORMATS.OPENAI && input.contentWatcher.sawContent()) {
|
||||
return { kind: "error", reason: "Upstream stream ended without a terminal marker" };
|
||||
}
|
||||
// Responses-format clients (Codex CLI and other /v1/responses consumers):
|
||||
// a healthy OpenAI Responses stream ALWAYS terminates with an explicit
|
||||
// `response.completed` event — it is the format's only terminal marker and
|
||||
// carries the final status/usage. Content forwarded without it is an
|
||||
// upstream drop, the same class as #10443 for chat completions; surface a
|
||||
// synthetic response.failed instead of a silent close so clients report
|
||||
// the break instead of waiting on a completion event that never comes.
|
||||
if (isResponsesClientFormat(input.clientResponseFormat) && input.contentWatcher.sawContent()) {
|
||||
return { kind: "error", reason: "Upstream stream ended without a terminal marker" };
|
||||
}
|
||||
}
|
||||
|
||||
const watcher = input.contentWatcher;
|
||||
|
||||
105
scripts/ad-hoc/dry-run-strict-zero-cost.ts
Normal file
105
scripts/ad-hoc/dry-run-strict-zero-cost.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Ad-hoc, one-shot dry run of STRICT_ZERO_COST against the real candidate
|
||||
* pools currently served by this OmniRoute instance (fetched via the
|
||||
* existing read-only `GET /v1/auto-combo/{channel}/candidates` endpoint —
|
||||
* no changes made, no billable calls). Not wired into any test suite.
|
||||
*
|
||||
* Simulates the filter offline: no live usage-quota state is available
|
||||
* (that adapter only runs inside the deployed container), so
|
||||
* `resolveFreeAccessState` always returns `undefined` here — meaning any
|
||||
* quota-based candidate is reported UNKNOWN unless it lacks even a usage
|
||||
* adapter, in which case it's reported UNKNOWN for that reason instead. This
|
||||
* intentionally shows the current, honest ceiling of what's usable today.
|
||||
*
|
||||
* Uses each candidate's REAL `connectionId` from the live endpoint (rather
|
||||
* than assuming) to also exercise the post-code-review connection-safety
|
||||
* check: a `keyless`-catalogued model whose live `connectionId` is NOT the
|
||||
* no-auth sentinel is correctly reported as excluded here too.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
evaluateCandidateConnections,
|
||||
findBudgetEntry,
|
||||
} from "../../open-sse/services/autoCombo/strictZeroCostFilter.ts";
|
||||
import { SYNTHETIC_NOAUTH_CONNECTION_ID } from "../../open-sse/services/autoCombo/resilienceCandidateFilter.ts";
|
||||
import { USAGE_FETCHER_PROVIDERS } from "../../open-sse/services/usage.ts";
|
||||
|
||||
const usageProviders = new Set<string>(USAGE_FETCHER_PROVIDERS);
|
||||
const OPTIONS = { minRemainingAllowance: 1, maxStateAgeMs: 180_000 };
|
||||
|
||||
interface Candidate {
|
||||
provider: string;
|
||||
model: string;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
function loadCandidates(path: string): Candidate[] {
|
||||
const raw = JSON.parse(readFileSync(path, "utf8"));
|
||||
const list = Array.isArray(raw) ? raw : raw.candidates;
|
||||
// The candidates endpoint's `model` field is the FULL "<providerOrAlias>/<modelId>"
|
||||
// string (`modelStr` — the leading segment is sometimes the provider id,
|
||||
// e.g. "groq/...", sometimes its short alias, e.g. "oc/..." for opencode);
|
||||
// FREE_MODEL_BUDGETS.modelId is always bare. Strip exactly the first "/"
|
||||
// segment (whichever form it is) so e.g. "groq/meta-llama/llama-4-scout..."
|
||||
// becomes "meta-llama/llama-4-scout..." and "oc/big-pickle" becomes
|
||||
// "big-pickle", matching the catalog's modelId either way.
|
||||
return list.map((c: { provider: string; model: string; connectionId?: string }) => {
|
||||
const slash = c.model.indexOf("/");
|
||||
return {
|
||||
provider: c.provider,
|
||||
model: slash === -1 ? c.model : c.model.slice(slash + 1),
|
||||
connectionId: c.connectionId ?? SYNTHETIC_NOAUTH_CONNECTION_ID,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function run(label: string, path: string): void {
|
||||
const candidates = loadCandidates(path);
|
||||
console.log(`\n=== ${label} — ${candidates.length} candidati live ===`);
|
||||
|
||||
const kept: Candidate[] = [];
|
||||
const excluded: { candidate: Candidate; reason: string }[] = [];
|
||||
|
||||
for (const c of candidates) {
|
||||
const entry = findBudgetEntry(c);
|
||||
if (!entry) {
|
||||
excluded.push({ candidate: c, reason: "non presente nel catalogo free curato" });
|
||||
continue;
|
||||
}
|
||||
const isNoAuthConnection = c.connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID;
|
||||
if (entry.freeType === "keyless") {
|
||||
const safe = evaluateCandidateConnections(c, entry, () => undefined, OPTIONS);
|
||||
if (safe.length > 0) {
|
||||
kept.push(c);
|
||||
} else if (!isNoAuthConnection) {
|
||||
excluded.push({
|
||||
candidate: c,
|
||||
reason:
|
||||
"keyless nel catalogo ma raggiunto tramite una connessione DB reale (non il sentinel noauth) — shortcut non applicato, richiederebbe hardStopGuaranteed",
|
||||
});
|
||||
} else {
|
||||
excluded.push({ candidate: c, reason: "keyless ma valutazione fallita (inatteso)" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const hasAdapter = usageProviders.has(entry.provider);
|
||||
const reason = !hasAdapter
|
||||
? `nessun usage adapter per '${entry.provider}' in USAGE_FETCHER_PROVIDERS`
|
||||
: entry.hardStopGuaranteed !== true
|
||||
? "hardStopGuaranteed non dichiarato per questo modello"
|
||||
: "nessuno stato quota live disponibile in questo dry-run offline (richiederebbe il container reale)";
|
||||
excluded.push({ candidate: c, reason });
|
||||
}
|
||||
|
||||
console.log(`PRIMA (STRICT_ZERO_COST off): ${candidates.length} candidati`);
|
||||
console.log(`DOPO (STRICT_ZERO_COST on): ${kept.length} candidati sopravvissuti`);
|
||||
console.log("Sopravvissuti:");
|
||||
for (const c of kept) console.log(` OK ${c.provider}/${c.model}`);
|
||||
console.log("Esclusi (motivo):");
|
||||
for (const { candidate: c, reason } of excluded) {
|
||||
console.log(` EXCL ${c.provider}/${c.model} — ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
run("auto/coding:free", process.argv[2] ?? "/tmp/dryrun_coding_free.json");
|
||||
run("auto/best-free", process.argv[3] ?? "/tmp/dryrun_best-free.json");
|
||||
@@ -17,8 +17,6 @@ import { logRoutingDecision } from "@/lib/a2a/routingLogger";
|
||||
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
|
||||
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
|
||||
import { getSettings } from "@/lib/db/settings";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
|
||||
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============
|
||||
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
|
||||
@@ -138,25 +136,14 @@ function tokensMatch(provided: string, expected: string): boolean {
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
async function authenticate(req: NextRequest): Promise<boolean> {
|
||||
// /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
|
||||
// pipeline enforces for /v1 never ran here — the route accepted every caller
|
||||
// whenever OMNIROUTE_API_KEY was unset, which is the shipped default
|
||||
// (GHSA-v54m-6rm3-p565). Apply the same posture directly: when a client key is
|
||||
// required, demand a valid OmniRoute key; otherwise honor the legacy explicit
|
||||
// A2A key; otherwise stay keyless (the same local-first default as /v1).
|
||||
const apiKey = extractApiKey(req);
|
||||
if (isRequireApiKeyEnabled()) {
|
||||
return apiKey ? await isValidApiKey(apiKey) : false;
|
||||
}
|
||||
|
||||
function authenticate(req: NextRequest): boolean {
|
||||
// If no API key is configured, allow all requests
|
||||
const configuredKey = process.env.OMNIROUTE_API_KEY;
|
||||
if (configuredKey) {
|
||||
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
|
||||
}
|
||||
if (!configuredKey) return true;
|
||||
|
||||
// No API key required and none configured — allow (keyless local-first).
|
||||
return true;
|
||||
const authHeader = req.headers.get("authorization") || "";
|
||||
const token = authHeader.replace(/^Bearer\s+/i, "");
|
||||
return tokensMatch(token, configuredKey);
|
||||
}
|
||||
|
||||
// ============ JSON-RPC Helpers ============
|
||||
@@ -192,7 +179,7 @@ async function rejectIfA2ADisabled(id: string | number | null) {
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth check
|
||||
if (!(await authenticate(req))) {
|
||||
if (!authenticate(req)) {
|
||||
return jsonRpcError(null, -32600, "Unauthorized: missing or invalid API key");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { readRunningBuildSha } from "@/lib/monitoring/buildSha";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/health — System health overview
|
||||
@@ -21,25 +20,10 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
let healthPayloadCache: { payload: unknown; expiresAt: number } | null = null;
|
||||
const HEALTH_PAYLOAD_TTL_MS = 1000;
|
||||
|
||||
// GHSA-mvf8-qc78-5mxm: the full health payload fingerprints the host (version,
|
||||
// node version, pid, memory, provider config). An anonymous caller — the common
|
||||
// case on a keyless install, and what a liveness/load-balancer probe needs — gets
|
||||
// only the liveness verdict; the detail is reserved for a management principal.
|
||||
function publicHealthView(payload: unknown): Record<string, unknown> {
|
||||
const p = (payload ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
status: p.status ?? "unknown",
|
||||
...(p.setupComplete !== undefined ? { setupComplete: p.setupComplete } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const fullView = (await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
|
||||
export async function GET() {
|
||||
const cachedNow = Date.now();
|
||||
if (healthPayloadCache && cachedNow <= healthPayloadCache.expiresAt) {
|
||||
return NextResponse.json(
|
||||
fullView ? healthPayloadCache.payload : publicHealthView(healthPayloadCache.payload)
|
||||
);
|
||||
return NextResponse.json(healthPayloadCache.payload);
|
||||
}
|
||||
|
||||
const readHealthValue = <T>(label: string, reader: () => T, fallback: T): T => {
|
||||
@@ -203,7 +187,7 @@ export async function GET(request: Request) {
|
||||
});
|
||||
|
||||
healthPayloadCache = { payload, expiresAt: Date.now() + HEALTH_PAYLOAD_TTL_MS };
|
||||
return NextResponse.json(fullView ? payload : publicHealthView(payload));
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/monitoring/health error:", error);
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
|
||||
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { startLocalServer } from "@/lib/oauth/utils/server";
|
||||
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
@@ -222,16 +221,6 @@ export async function GET(
|
||||
(requestDeviceCode as any)(provider, null, providerOverrideConfig)
|
||||
);
|
||||
} else if ((provider === "kiro" || provider === "amazon-q") && startUrl) {
|
||||
// GHSA-7x63: `region` is interpolated into the AWS OIDC endpoint URLs
|
||||
// below, which requestDeviceCode() then fetches. Validate it against the
|
||||
// canonical AWS region shape before it can steer the outbound host to an
|
||||
// attacker-chosen target (userinfo/fragment tricks → SSRF / metadata).
|
||||
if (!AWS_REGION_PATTERN.test(region)) {
|
||||
return NextResponse.json(
|
||||
{ error: "region must be a valid AWS region (e.g. us-east-1)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const providerOverrideConfig = {
|
||||
...providerData.config,
|
||||
startUrl,
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
scanCliProxyAuthDir,
|
||||
@@ -23,9 +23,9 @@ function cliProxyConfigDir(): string {
|
||||
}
|
||||
|
||||
async function requireImportAuth(request: Request) {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { extractCodexAccountInfo } from "@/lib/oauth/services/codexImport";
|
||||
import { parseCodexSessionJson } from "@/lib/oauth/utils/codexSessionImport";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
@@ -93,11 +93,10 @@ async function parseRequestBody(
|
||||
return { ok: true, resolved: resolved.resolved };
|
||||
}
|
||||
|
||||
async function requireAuth(request: Request): Promise<Response | null> {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action.
|
||||
// Require management scope (or a dashboard session) rather than accepting any
|
||||
// valid client key, which the PUBLIC /api/oauth/ classification otherwise allows.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
async function requireAuth(request: Request): Promise<NextResponse | null> {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json(buildErrorBody(401, "Unauthorized"), { status: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { normalizeCodexImportRecord, flattenCodexImportPayload } from "@/lib/oauth/services/codexImport";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import { refreshCodexToken, isUnrecoverableRefreshError } from "@omniroute/open-sse/services/tokenRefresh.ts";
|
||||
|
||||
@@ -82,10 +82,10 @@ const bodySchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
async function requireAuth(request: Request): Promise<Response | null> {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
async function requireAuth(request: Request): Promise<NextResponse | null> {
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
|
||||
|
||||
/**
|
||||
@@ -11,9 +11,11 @@ import { tryAgentAuth, tryIdeAuth } from "@/lib/cursor/tokenExtractor";
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key (finding #258-4).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
|
||||
const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (authError) return authError;
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Try Cursor IDE first (has both accessToken and machineId)
|
||||
|
||||
@@ -6,15 +6,15 @@ import { isCloudEnabled } from "@/models";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { cursorImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { resolveProxyForProvider } from "@/models";
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import {
|
||||
createProviderConnection,
|
||||
getProviderConnections,
|
||||
@@ -31,9 +31,11 @@ import {
|
||||
* 🔒 Auth-guarded: requires JWT cookie or Bearer API key.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// GHSA-mg76 / GHSA-gxv4: reading/importing host credentials is a management action.
|
||||
const authError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (authError) return authError;
|
||||
if (await isAuthRequired(request)) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const targetProvider = searchParams.get("targetProvider") === "amazon-q" ? "amazon-q" : "kiro";
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { kiroImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { findKiroConnectionByIdentity } from "@/lib/oauth/kiroConnectionIdentity";
|
||||
@@ -38,9 +38,9 @@ export function buildKiroImportError(error: unknown): string {
|
||||
}
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
async function upsertImportedKiroConnection(
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
extractLocalRaycastCredentials,
|
||||
isRaycastLocalExtractAvailable,
|
||||
} from "@/lib/oauth/services/raycastLocal";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { resolveProxyForProvider } from "@/models";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
||||
@@ -11,14 +11,14 @@ import { createProviderConnection } from "@/models";
|
||||
import { RaycastService } from "@/lib/oauth/services/raycast";
|
||||
import { raycastImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { resolveProxyForProvider } from "@/models";
|
||||
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { createProviderConnection } from "@/models";
|
||||
import { traeImportSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
/**
|
||||
* POST /api/oauth/trae/import
|
||||
@@ -22,9 +22,9 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
* region — optional, default "US-East"
|
||||
*/
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
// require management scope (or a dashboard session), not any valid client key.
|
||||
return requireManagementAuth(request, { invalidApiKeyStatus: 401 });
|
||||
if (!(await isAuthRequired(request))) return null;
|
||||
if (await isAuthenticated(request)) return null;
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
import {
|
||||
getObsidianSyncStatus,
|
||||
@@ -22,19 +21,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const status = await getObsidianSyncStatus();
|
||||
// GHSA-62vw: the WebDAV password is reusable authentication material. Return
|
||||
// the plaintext only to a genuine management principal (dashboard session or
|
||||
// manage-scope key), never to an anonymous caller that reached this handler
|
||||
// through the requireLogin=false open mode. The dashboard's authenticated
|
||||
// reveal-password view is unaffected; anonymous callers get a set/unset flag.
|
||||
const hasManagement =
|
||||
(await requireManagementAuth(request, { alwaysRequireAuth: true })) === null;
|
||||
return NextResponse.json({
|
||||
webdavEnabled: status.webdavEnabled,
|
||||
webdavUsername: status.webdavEnabled ? status.webdavUsername : null,
|
||||
webdavPassword:
|
||||
status.webdavEnabled && hasManagement ? status.webdavPassword : null,
|
||||
webdavPasswordSet: status.webdavEnabled && Boolean(status.webdavPassword),
|
||||
webdavPassword: status.webdavEnabled ? status.webdavPassword : null,
|
||||
vaultPath: status.vaultPath,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -238,6 +238,11 @@ export async function getSettings() {
|
||||
// (`:free` suffix, zero-price pricing, or FREE_MODEL_BUDGETS membership). Default
|
||||
// false preserves prior behaviour; opt-in only.
|
||||
hidePaidModels: false,
|
||||
// Opt-in, default off: same shape as hidePaidModels above, but requires a
|
||||
// live hard-stop-guaranteed quota check for non-keyless free candidates.
|
||||
// See open-sse/services/autoCombo/strictZeroCostFilter.ts.
|
||||
freeAccessPolicy: "off",
|
||||
excludeTosAvoid: false,
|
||||
// #9418: Opt-in filter that hides auto/* virtual combos from the /v1/models catalog.
|
||||
// User-defined combos are unaffected; routing still works for hidden ids sent explicitly.
|
||||
hideAutoCombos: false,
|
||||
|
||||
@@ -254,6 +254,39 @@ function leafModelId(modelId: string | null | undefined): string | null {
|
||||
return leaf && leaf !== modelId ? leaf : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effort suffixes the catalog synthesizes as `<base>-<tier>` variant ids from a
|
||||
* base model's `supportedThinkingEfforts` (mirrors REGISTERED_EFFORT_SUFFIXES
|
||||
* in open-sse/utils/registeredEffortVariants.ts, plus `minimal` for muse).
|
||||
*/
|
||||
const EFFORT_VARIANT_SUFFIXES = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Strip a trailing effort-tier suffix off a model id (e.g.
|
||||
* `deepseek-v4-flash-max` → `deepseek-v4-flash`). Longest token first so
|
||||
* `xhigh` is matched before `high`. Returns null when no known suffix matches
|
||||
* or the id would be left empty.
|
||||
*/
|
||||
function stripKnownEffortSuffix(modelId: string): string | null {
|
||||
const normalized = String(modelId || "").trim();
|
||||
if (!normalized) return null;
|
||||
for (const suffix of EFFORT_VARIANT_SUFFIXES) {
|
||||
const token = `-${suffix}`;
|
||||
if (normalized.length > token.length && normalized.endsWith(token)) {
|
||||
return normalized.slice(0, -token.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStaticSpec(modelId: string | null, rawModel: string | null): ModelSpec | undefined {
|
||||
if (modelId) {
|
||||
const byCanonical = getModelSpec(modelId);
|
||||
@@ -704,15 +737,38 @@ export function getResolvedModelCapabilities(
|
||||
// persisted override never feeds back into the comparison that (re)writes it.
|
||||
const usePersistedOverrides = options?.persistedOverrides !== false;
|
||||
const resolved = resolveCapabilityInput(input);
|
||||
const spec = getStaticSpec(resolved.model, resolved.rawModel);
|
||||
const registryModel = getRegistryModel(resolved.provider, resolved.model);
|
||||
const synced = getSyncedCapabilityForResolved(
|
||||
let spec = getStaticSpec(resolved.model, resolved.rawModel);
|
||||
let registryModel = getRegistryModel(resolved.provider, resolved.model);
|
||||
let synced = getSyncedCapabilityForResolved(
|
||||
resolved.provider,
|
||||
resolved.model,
|
||||
resolved.rawModel,
|
||||
snapshot
|
||||
);
|
||||
|
||||
// Effort-suffixed variants (e.g. command-code `deepseek-v4-flash-max`,
|
||||
// `meta/muse-spark-1.2-contributor-xhigh`) are synthesized in the catalog
|
||||
// from the base model's `supportedThinkingEfforts`; they have no registry
|
||||
// row, synced row, or static spec of their own. Without a base-model
|
||||
// fallback the variant resolves with NULL tool/vision/context capabilities,
|
||||
// so a tool-bearing combo request treats the target as incompatible and
|
||||
// silently reorders it behind models with confirmed capabilities. Resolve
|
||||
// the variant's capabilities from its base model when every direct source
|
||||
// misses.
|
||||
if (!spec && !registryModel && !synced && resolved.provider && resolved.model) {
|
||||
const baseModelId = stripKnownEffortSuffix(resolved.model);
|
||||
if (baseModelId && baseModelId !== resolved.model) {
|
||||
spec = getStaticSpec(baseModelId, resolved.rawModel);
|
||||
registryModel = getRegistryModel(resolved.provider, baseModelId);
|
||||
synced = getSyncedCapabilityForResolved(
|
||||
resolved.provider,
|
||||
baseModelId,
|
||||
resolved.rawModel,
|
||||
snapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const modalitiesInput = parseModalities(synced?.modalities_input);
|
||||
const modalitiesOutput = parseModalities(synced?.modalities_output);
|
||||
const lookupKey =
|
||||
|
||||
@@ -7,12 +7,7 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
import { getServiceRow, updateServiceField, setToolStatus } from "@/lib/db/versionManager";
|
||||
import { RingBuffer } from "./ringBuffer";
|
||||
import { HealthChecker } from "./healthCheck";
|
||||
import {
|
||||
decidePreSpawn,
|
||||
isAdoptExistingEnabled,
|
||||
probeBeforeSpawn,
|
||||
resolvePortPid,
|
||||
} from "./portProbe";
|
||||
import { decidePreSpawn, probeBeforeSpawn, resolvePortPid } from "./portProbe";
|
||||
import type { ServiceConfig, ServiceState, ServiceStatus, LogLine, HealthState } from "./types";
|
||||
|
||||
const CRASH_FAST_THRESHOLD_MS = 5_000;
|
||||
@@ -116,7 +111,7 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
// Opt-in per ServiceConfig so the default spawn path is unchanged.
|
||||
if (this.config.probeBeforeSpawn) {
|
||||
const probe = await probeBeforeSpawn(this.config.healthUrl(), this.config.port);
|
||||
const decision = decidePreSpawn(probe, this.config.port, isAdoptExistingEnabled());
|
||||
const decision = decidePreSpawn(probe, this.config.port);
|
||||
|
||||
if (decision.action === "adopt") {
|
||||
// Something healthy already serves this port. We didn't spawn it,
|
||||
|
||||
@@ -37,30 +37,11 @@ const PID_RESOLVE_TIMEOUT_MS = 2_000;
|
||||
*
|
||||
* Pure — no I/O — so it can be exhaustively unit-tested.
|
||||
*/
|
||||
export function decidePreSpawn(
|
||||
probe: PreSpawnProbe,
|
||||
port: number,
|
||||
allowAdopt = false
|
||||
): PreSpawnDecision {
|
||||
export function decidePreSpawn(probe: PreSpawnProbe, port: number): PreSpawnDecision {
|
||||
// A healthy instance is already serving on the port — adopt it rather than
|
||||
// spawn a duplicate that would immediately die with EADDRINUSE.
|
||||
if (probe.healthy) {
|
||||
// A 2xx on the health path does NOT prove the listener is our service: a
|
||||
// local process can squat the port, answer 200, and get adopted — receiving
|
||||
// the injected service API key and script execution inside the dashboard
|
||||
// origin (GHSA-wg9p-6m2g-4v27). Adopt an already-healthy listener only when
|
||||
// the operator explicitly opts in; otherwise surface the same actionable
|
||||
// error we already use for a held-but-unhealthy port instead of silently
|
||||
// trusting the listener.
|
||||
if (allowAdopt) {
|
||||
return { action: "adopt" };
|
||||
}
|
||||
return {
|
||||
action: "error",
|
||||
message:
|
||||
`Port ${port} is already serving a healthy response, but adopting an ` +
|
||||
`existing listener is disabled by default (a 2xx cannot prove the listener ` +
|
||||
`is this service). Set OMNIROUTE_ADOPT_EXISTING_SERVICE=1 to allow adoption, ` +
|
||||
`or stop the process holding the port and start the service again.`,
|
||||
};
|
||||
return { action: "adopt" };
|
||||
}
|
||||
// Port is held but nothing healthy answers: an orphaned or unrelated process
|
||||
// is squatting on it. Surface a clear, actionable error instead of letting
|
||||
@@ -78,17 +59,6 @@ export function decidePreSpawn(
|
||||
return { action: "spawn" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the operator opted in to adopting an already-healthy listener on a
|
||||
* service port. Off by default (GHSA-wg9p-6m2g-4v27): a squatter can answer a
|
||||
* 2xx, so auto-adoption is only safe when the operator knows the listener is
|
||||
* genuinely their (externally-managed) instance.
|
||||
*/
|
||||
export function isAdoptExistingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const v = env.OMNIROUTE_ADOPT_EXISTING_SERVICE;
|
||||
return v === "1" || v === "true";
|
||||
}
|
||||
|
||||
/** TCP connect check: resolves true when something accepts a connection. */
|
||||
function isPortInUse(port: number, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
|
||||
@@ -56,8 +56,6 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
"/api/jobs", // JobRegistry control (enable/disable/run-now) + run history - runtime job administration, loopback-only (Hard Rules #15 + #17)
|
||||
"/api/jobs/", // sub-paths: /api/jobs/:id/{runs,enable,disable,run-now} (the bare `/api/jobs` above matches the list route; this matches children)
|
||||
"/api/oauth/cursor/auto-import", // spawns execFile("which", argv-array-of-one-arg "cursor") to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable. Note: this comment intentionally avoids a literal closing square bracket character — check-openapi-security-tiers.mjs's naive regex parser for this array stops at the first one it finds, silently truncating its view of every entry after this one.
|
||||
"/api/oauth/kiro/auto-import", // reads host-local Kiro credential files (homedir kiro-cli data) — must reach the loopback-only gate, not the PUBLIC /api/oauth/ prefix (GHSA-wgwc-crjm-pmwv, GHSA-gxv4-955v-v6cm). Excluded from PUBLIC in publicApiRoutes.ts.
|
||||
"/api/oauth/raycast/auto-import", // reads host-local Raycast credential files — same loopback-only rationale as the kiro and cursor auto-import routes above.
|
||||
"/api/skills/collect/", // Skill Collector CLI detection: GET .../detect probes getCliRuntimeStatus() per CLI_TOOL_IDS entry, which spawns a child process to check each tool — RCE-via-tunnel surface (Hard Rules #15 + #17, PR #6294 review).
|
||||
"/api/discovery/", // Discovery tool (opt-in provider scanner): the scan route makes outbound probes to provider endpoints (SSRF-adjacent) and the whole surface is an admin research tool — strict-loopback only, no manage-scope bypass (NOT in LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES). See _tasks/features-v3.8.42/gaps/DISCOVERY_TOOL_DESIGN.md.
|
||||
VNC_ROUTE_PREFIX, // #7892: /api/vnc-session/* spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — RCE-via-tunnel surface (Hard Rules #15 + #17), same CVE class (GHSA-fhh6-4qxv-rpqj).
|
||||
|
||||
@@ -144,26 +144,6 @@ export function getCorsStatus(): CorsStatus {
|
||||
* compression middleware only appends it conditionally, so shared caches can't
|
||||
* otherwise reliably tell compressed vs uncompressed variants apart.
|
||||
*/
|
||||
function requestCarriesTokenOrPreflight(request: Request): boolean {
|
||||
// Preflight (OPTIONS) never carries the Authorization / x-api-key header, so it
|
||||
// must be allowed through — the actual request that follows is re-evaluated by
|
||||
// this same check and only gets the permissive Origin if it presents a token.
|
||||
if (request.method === "OPTIONS") return true;
|
||||
if (
|
||||
request.headers.get("authorization") ||
|
||||
request.headers.get("x-api-key") ||
|
||||
request.headers.get("x-goog-api-key")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// A dashboard session cookie is a credential too (#5242 browser/Electron
|
||||
// clients). auth_token is HttpOnly + SameSite, so a cross-site attacker page
|
||||
// cannot get it auto-attached — only a truly credential-less request (the
|
||||
// GHSA-7px7 anonymous case on a keyless install) falls through to fail-closed.
|
||||
const cookie = request.headers.get("cookie");
|
||||
return Boolean(cookie && /(?:^|;\s*)auth_token=/.test(cookie));
|
||||
}
|
||||
|
||||
export function applyCorsHeaders(
|
||||
response: Response,
|
||||
request: Request,
|
||||
@@ -171,15 +151,7 @@ export function applyCorsHeaders(
|
||||
): void {
|
||||
const requestOrigin = request.headers.get("origin");
|
||||
let allowed = resolveAllowedOrigin(requestOrigin);
|
||||
if (allowed === null && relaxForTokenAuth && requestCarriesTokenOrPreflight(request)) {
|
||||
// GHSA-7px7-29v2-m97p: the permissive Origin echo is only safe on the
|
||||
// assumption that these routes are token-authenticated (browsers never
|
||||
// auto-attach Authorization/x-api-key). On a keyless install that assumption
|
||||
// breaks — an anonymous cross-origin page would be echoed its own Origin and
|
||||
// could read the response. Only relax for a request that actually carries a
|
||||
// credential, plus CORS preflights (OPTIONS never carries the header — the
|
||||
// real request that follows is re-checked), so authenticated browser/Electron
|
||||
// clients (#5242) keep working while credential-less cross-origin reads do not.
|
||||
if (allowed === null && relaxForTokenAuth) {
|
||||
allowed = requestOrigin && requestOrigin.length > 0 ? requestOrigin : "*";
|
||||
}
|
||||
if (allowed !== null) {
|
||||
|
||||
@@ -71,26 +71,7 @@ function isPublicCloudApiRoute(pathname: string, method: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// OAuth "auto-import" routes read host-local credential files (Cursor / Kiro /
|
||||
// Raycast tokens). The broad `/api/oauth/` PUBLIC prefix would classify them
|
||||
// PUBLIC, which skips the LOCAL_ONLY tier entirely (GHSA-wgwc-crjm-pmwv) and
|
||||
// exposes the host credential to a remote caller (GHSA-gxv4-955v-v6cm). Exclude
|
||||
// them so they fall through to MANAGEMENT and reach the loopback-only gate.
|
||||
const LOCAL_ONLY_OAUTH_IMPORT_ROUTES = [
|
||||
"/api/oauth/cursor/auto-import",
|
||||
"/api/oauth/kiro/auto-import",
|
||||
"/api/oauth/raycast/auto-import",
|
||||
];
|
||||
|
||||
export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
|
||||
if (
|
||||
LOCAL_ONLY_OAUTH_IMPORT_ROUTES.some(
|
||||
(route) => pathname === route || pathname.startsWith(`${route}/`)
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPublicCloudApiRoute(pathname, method)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -91,11 +91,23 @@ export const mergeOpenCodeConfig = (
|
||||
? existingConfig
|
||||
: {};
|
||||
|
||||
// Same guard as the root above, one level down. Spreading a non-object here
|
||||
// does not throw, it splays the value into index keys: an existing
|
||||
// `"provider": ["a", "b"]` merged to `{"0": "a", "1": "b", omniroute: ... }`
|
||||
// and a string was exploded one character per key. mergeOpenCodeConfigText
|
||||
// refuses the same input outright, so the two disagreed on what to do with a
|
||||
// malformed config.
|
||||
const existingProvider = (safeConfig as Record<string, unknown>).provider;
|
||||
const safeProvider =
|
||||
existingProvider && typeof existingProvider === "object" && !Array.isArray(existingProvider)
|
||||
? (existingProvider as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
...safeConfig,
|
||||
$schema: safeConfig.$schema || "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
...((safeConfig as any).provider || {}),
|
||||
...safeProvider,
|
||||
omniroute: buildOpenCodeProviderConfig(input),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -645,37 +645,13 @@ async function validateRateLimitAndThrottle(context: PolicyContext): Promise<Res
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `x-api-key` / `x-goog-api-key` (no anthropic-version, no claude
|
||||
* user-agent) is accepted by the CLIENT_API auth layer (clientApi.ts
|
||||
* `extractBearer`) but ignored by the Issue-#2225-gated `extractApiKey()` used
|
||||
* for policy resolution — so a genuine key sent that way passed auth while
|
||||
* skipping its own allowedModels / budget / rate-limit policy
|
||||
* (GHSA-2phc-xp22-9f56). Resolve those headers here so the policy layer sees the
|
||||
* same key auth accepted. Bearer, URL-token and anthropic-gated paths are already
|
||||
* covered by `extractApiKey()`; unknown keys still fail open downstream, so this
|
||||
* only tightens enforcement for real keys.
|
||||
*/
|
||||
function extractUngatedClientApiKey(request: Request): string | null {
|
||||
const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key");
|
||||
if (xApiKey && xApiKey.trim()) return xApiKey.trim();
|
||||
const xGoog = request.headers.get("x-goog-api-key") ?? request.headers.get("X-Goog-Api-Key");
|
||||
if (xGoog && xGoog.trim()) return xGoog.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function enforceApiKeyPolicy(
|
||||
request: Request,
|
||||
modelStr: string | null
|
||||
): Promise<ApiKeyPolicyResult> {
|
||||
// A real bearer key wins; then a bare x-api-key/x-goog-api-key that auth
|
||||
// accepted but extractApiKey() gates out; otherwise an authenticated dashboard
|
||||
// playground may test a specific key's policy by id (resolved server-side,
|
||||
// secret never sent).
|
||||
const apiKey =
|
||||
extractApiKey(request) ||
|
||||
extractUngatedClientApiKey(request) ||
|
||||
(await resolvePlaygroundTestKey(request));
|
||||
// A real bearer key wins; otherwise an authenticated dashboard playground may
|
||||
// test a specific key's policy by id (resolved server-side, secret never sent).
|
||||
const apiKey = extractApiKey(request) || (await resolvePlaygroundTestKey(request));
|
||||
|
||||
// No API key = local/session mode, skip policy checks
|
||||
if (!apiKey) {
|
||||
|
||||
@@ -128,6 +128,16 @@ export const updateSettingsSchema = z.object({
|
||||
blockedProviders: z.array(z.string().max(100)).optional(),
|
||||
noAuthFallbackDisabledProviders: z.array(z.string().max(100)).optional(),
|
||||
hidePaidModels: z.boolean().optional(),
|
||||
// STRICT_ZERO_COST (opt-in, default "off"): stricter than hidePaidModels — a
|
||||
// candidate must be keyless (no credential exists, so no request against it
|
||||
// can ever be billed) OR pass a live, fresh, hard-stop-guaranteed quota
|
||||
// check, per candidate, before ranking/dispatch. See
|
||||
// open-sse/services/autoCombo/strictZeroCostFilter.ts.
|
||||
freeAccessPolicy: z.enum(["off", "strict"]).optional(),
|
||||
// Separate from freeAccessPolicy on purpose: excludes candidates whose
|
||||
// curated `tos` verdict is "avoid" (proxy/self-hosted use conflicts with the
|
||||
// provider's own terms) — a contractual concern, not an economic one.
|
||||
excludeTosAvoid: z.boolean().optional(),
|
||||
hideHealthCheckLogs: z.boolean().optional(),
|
||||
hideEndpointCloudflaredTunnel: z.boolean().optional(),
|
||||
hideEndpointTailscaleFunnel: z.boolean().optional(),
|
||||
|
||||
@@ -2453,6 +2453,18 @@ export async function markAccountUnavailable(
|
||||
try {
|
||||
await currentMutex;
|
||||
|
||||
// STRICT_ZERO_COST: this connection just failed (whatever the reason) —
|
||||
// drop any cached "SAFE" free-allowance reading for it immediately rather
|
||||
// than waiting out the TTL, so the very next candidate-pool build reads a
|
||||
// clean cache miss (UNKNOWN → excluded) instead of a stale SAFE. Cheap,
|
||||
// idempotent, and correct to over-invalidate on non-quota failures too —
|
||||
// worst case is one extra background refresh.
|
||||
if (provider) {
|
||||
const { invalidateFreeAccessState } =
|
||||
await import("@omniroute/open-sse/services/autoCombo/freeAccessQuota.ts");
|
||||
invalidateFreeAccessState(provider, connectionId);
|
||||
}
|
||||
|
||||
const resourceBypass = getResource404Bypass(status, errorText, connectionId, log);
|
||||
if (resourceBypass) return resourceBypass;
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* GHSA-v54m-6rm3-p565 — /a2a sits outside the authz proxy matcher, so it never
|
||||
* saw the REQUIRE_API_KEY posture and accepted every caller when OMNIROUTE_API_KEY
|
||||
* was unset (the default). authenticate() now honors REQUIRE_API_KEY directly.
|
||||
*/
|
||||
|
||||
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(), "omni-a2a-require-key-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "a2a-require-key-secret";
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const route = await import("../../src/app/a2a/route.ts");
|
||||
|
||||
const ORIGINAL_REQUIRE = process.env.REQUIRE_API_KEY;
|
||||
const ORIGINAL_A2A_KEY = process.env.OMNIROUTE_API_KEY;
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL_REQUIRE === undefined) delete process.env.REQUIRE_API_KEY;
|
||||
else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE;
|
||||
if (ORIGINAL_A2A_KEY === undefined) delete process.env.OMNIROUTE_API_KEY;
|
||||
else process.env.OMNIROUTE_API_KEY = ORIGINAL_A2A_KEY;
|
||||
});
|
||||
|
||||
function post(key?: string) {
|
||||
return route.POST(
|
||||
new Request("http://localhost/a2a", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(key ? { authorization: `Bearer ${key}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "message/send", params: {} }),
|
||||
}) as never
|
||||
);
|
||||
}
|
||||
|
||||
async function isUnauthorized(res: Response) {
|
||||
const body = (await res.clone().json()) as { error?: { code?: number } };
|
||||
return body.error?.code === -32600;
|
||||
}
|
||||
|
||||
test("REQUIRE_API_KEY=true rejects an unkeyed /a2a call (GHSA-v54m)", async () => {
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
process.env.REQUIRE_API_KEY = "true";
|
||||
assert.equal(await isUnauthorized(await post()), true, "no key must be rejected");
|
||||
|
||||
const key = await apiKeysDb.createApiKey("a2a-client", "machine-a2a", []);
|
||||
assert.equal(
|
||||
await isUnauthorized(await post(key.key)),
|
||||
false,
|
||||
"a valid key must clear the /a2a auth gate"
|
||||
);
|
||||
});
|
||||
|
||||
test("keyless local-first default still allows /a2a (posture preserved)", async () => {
|
||||
delete process.env.REQUIRE_API_KEY;
|
||||
delete process.env.OMNIROUTE_API_KEY;
|
||||
assert.equal(await isUnauthorized(await post()), false, "keyless default must not 401");
|
||||
});
|
||||
@@ -96,17 +96,6 @@ function makeAnthropicPolicyRequest(apiKey) {
|
||||
});
|
||||
}
|
||||
|
||||
// A bare `x-api-key` with NO anthropic-version header and no claude user-agent:
|
||||
// the CLIENT_API auth layer accepts it, but the gated extractApiKey() used by
|
||||
// the policy layer used to ignore it, so the key's per-key policy was skipped
|
||||
// entirely (GHSA-2phc-xp22-9f56).
|
||||
function makeBareXApiKeyPolicyRequest(apiKey) {
|
||||
return new Request("http://localhost/v1/responses", {
|
||||
method: "POST",
|
||||
headers: apiKey ? { "x-api-key": apiKey } : {},
|
||||
});
|
||||
}
|
||||
|
||||
async function readErrorMessage(response) {
|
||||
const body = (await response.json()) as { error?: { message?: unknown } };
|
||||
return typeof body.error?.message === "string" ? body.error.message : "";
|
||||
@@ -468,29 +457,6 @@ test("enforceApiKeyPolicy rejects disabled keys and blocked schedules", async ()
|
||||
assert.match(await readErrorMessage(blocked.rejection), /Access denied outside allowed hours/);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy enforces allowedModels for a bare x-api-key (GHSA-2phc-xp22-9f56)", async () => {
|
||||
const restrictedKey = await createKeyWithPolicy({
|
||||
allowedModels: ["openai/gpt-4.1"],
|
||||
});
|
||||
const policy = await loadPolicy("bare-x-api-key");
|
||||
|
||||
// Disallowed model via a bare x-api-key must be rejected, exactly as it is for
|
||||
// a Bearer token — the header used to carry the key must not weaken the policy.
|
||||
const disallowed = await policy.enforceApiKeyPolicy(
|
||||
makeBareXApiKeyPolicyRequest(restrictedKey.key),
|
||||
"anthropic/claude-3-7-sonnet"
|
||||
);
|
||||
assert.equal(disallowed.rejection.status, 403);
|
||||
assert.match(await readErrorMessage(disallowed.rejection), /not allowed/);
|
||||
|
||||
// The allowed model still passes through the same header.
|
||||
const allowed = await policy.enforceApiKeyPolicy(
|
||||
makeBareXApiKeyPolicyRequest(restrictedKey.key),
|
||||
"openai/gpt-4.1"
|
||||
);
|
||||
assert.equal(allowed.rejection, null);
|
||||
});
|
||||
|
||||
test("enforceApiKeyPolicy rejects disallowed models and exhausted budgets", async () => {
|
||||
const restrictedKey = await createKeyWithPolicy({
|
||||
allowedModels: ["openai/gpt-4.1"],
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isPublicApiRoute } from "../../../src/shared/constants/publicApiRoutes.ts";
|
||||
import { classifyRoute } from "../../../src/server/authz/classify.ts";
|
||||
import { isLocalOnlyPath } from "../../../src/server/authz/routeGuard.ts";
|
||||
|
||||
// GHSA-wgwc-crjm-pmwv / GHSA-gxv4-955v-v6cm — the OAuth auto-import routes read
|
||||
// host-local credential files. They must NOT be PUBLIC (which skips the LOCAL_ONLY
|
||||
// tier); they must classify MANAGEMENT and be loopback-gated.
|
||||
|
||||
const AUTO_IMPORT = [
|
||||
"/api/oauth/cursor/auto-import",
|
||||
"/api/oauth/kiro/auto-import",
|
||||
"/api/oauth/raycast/auto-import",
|
||||
];
|
||||
|
||||
test("OAuth auto-import routes are excluded from PUBLIC classification", () => {
|
||||
for (const p of AUTO_IMPORT) {
|
||||
assert.equal(isPublicApiRoute(p), false, `${p} must not be PUBLIC`);
|
||||
assert.equal(classifyRoute(p, "GET").routeClass, "MANAGEMENT", `${p} must classify MANAGEMENT`);
|
||||
}
|
||||
});
|
||||
|
||||
test("OAuth auto-import routes are LOCAL_ONLY (loopback-gated)", () => {
|
||||
for (const p of AUTO_IMPORT) {
|
||||
assert.equal(isLocalOnlyPath(p), true, `${p} must be LOCAL_ONLY`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the rest of /api/oauth/ (callbacks, browser flows) stays PUBLIC", () => {
|
||||
assert.equal(isPublicApiRoute("/api/oauth/cursor/callback"), true);
|
||||
assert.equal(isPublicApiRoute("/api/oauth/codex/authorize"), true);
|
||||
// A sibling that merely shares the prefix must not be swept in.
|
||||
assert.equal(isPublicApiRoute("/api/oauth/cursor/auto-import-status"), true);
|
||||
});
|
||||
192
tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts
Normal file
192
tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* STRICT_ZERO_COST — autodiscovery contract (Fase 5-8 of the design audit):
|
||||
* the filter must never hardcode a provider/model allowlist. It reads
|
||||
* whatever `catalog` (FREE_MODEL_BUDGETS-shaped) it's given, so a new SAFE
|
||||
* entry becomes usable the moment it appears, and a removed one disappears
|
||||
* the moment it's gone — no code change, no KITT change.
|
||||
*
|
||||
* Uses `evaluateCandidateConnections`'s injectable `catalog` argument (added
|
||||
* for exactly this test) with synthetic fixtures instead of mutating the
|
||||
* real, shared `FREE_MODEL_BUDGETS` — that constant is imported by
|
||||
* production code and other test files; mutating it at runtime would be a
|
||||
* global side effect this file has no business causing.
|
||||
*/
|
||||
import { test } from "vitest";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
evaluateCandidateConnections,
|
||||
findBudgetEntry,
|
||||
type FreeAccessState,
|
||||
type StrictZeroCostCandidate,
|
||||
} from "../../../open-sse/services/autoCombo/strictZeroCostFilter.ts";
|
||||
import { SYNTHETIC_NOAUTH_CONNECTION_ID } from "../../../open-sse/services/autoCombo/resilienceCandidateFilter.ts";
|
||||
import type { FreeModelBudget } from "../../../open-sse/config/freeModelCatalog.ts";
|
||||
|
||||
const NOW = "2026-08-20T00:00:00.000Z";
|
||||
const OPTIONS = { minRemainingAllowance: 1, maxStateAgeMs: 180_000, now: () => Date.parse(NOW) };
|
||||
|
||||
function safeState(): FreeAccessState {
|
||||
return { status: "SAFE", remainingFreeAllowance: 40, resetAt: null, checkedAt: NOW };
|
||||
}
|
||||
|
||||
// `evaluateCandidateConnections` has exactly one source of truth for "is this
|
||||
// candidate documented as free": the `budgetEntry` its caller looked up via
|
||||
// `findBudgetEntry(candidate, catalog)`. These provider ids are otherwise
|
||||
// arbitrary — the fixtures below prove the behavior is driven entirely by
|
||||
// catalog membership, not by any hardcoded provider/model name.
|
||||
const KEYLESS_PROVIDER = "felo-web";
|
||||
const QUOTA_PROVIDER = "groq";
|
||||
const REAL_CONN = "conn-1";
|
||||
|
||||
function keylessEntry(modelId: string): FreeModelBudget {
|
||||
return {
|
||||
provider: KEYLESS_PROVIDER,
|
||||
modelId,
|
||||
displayName: modelId,
|
||||
monthlyTokens: 0,
|
||||
creditTokens: 0,
|
||||
freeType: "keyless",
|
||||
poolKey: null,
|
||||
tos: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
function quotaEntry(modelId: string, hardStopGuaranteed = true): FreeModelBudget {
|
||||
return {
|
||||
provider: QUOTA_PROVIDER,
|
||||
modelId,
|
||||
displayName: modelId,
|
||||
monthlyTokens: 1_000_000,
|
||||
creditTokens: 0,
|
||||
freeType: "recurring-daily",
|
||||
poolKey: null,
|
||||
tos: "ok",
|
||||
hardStopGuaranteed,
|
||||
};
|
||||
}
|
||||
|
||||
// 14/18. New provider/model appears in the catalog → automatically a candidate.
|
||||
test("a brand-new keyless model appearing in the catalog is usable the instant it appears", () => {
|
||||
const modelId = "synthetic-new-model-14";
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: KEYLESS_PROVIDER,
|
||||
model: modelId,
|
||||
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
|
||||
};
|
||||
const catalogBefore: FreeModelBudget[] = []; // model does not exist yet
|
||||
const catalogAfter: FreeModelBudget[] = [keylessEntry(modelId)]; // "OmniRoute" just added it
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(
|
||||
candidate,
|
||||
findBudgetEntry(candidate, catalogBefore),
|
||||
() => undefined,
|
||||
OPTIONS
|
||||
),
|
||||
[],
|
||||
"must be excluded before the catalog knows about it"
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(
|
||||
candidate,
|
||||
findBudgetEntry(candidate, catalogAfter),
|
||||
() => undefined,
|
||||
OPTIONS
|
||||
),
|
||||
[SYNTHETIC_NOAUTH_CONNECTION_ID],
|
||||
"must pass automatically the moment the SAME code sees it in the catalog — no code change made between these two calls"
|
||||
);
|
||||
});
|
||||
|
||||
// 15/19. Provider/model removed from the catalog → automatically disappears.
|
||||
test("a model removed from the catalog stops being usable, with no code change", () => {
|
||||
const modelId = "synthetic-removed-model-15";
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: KEYLESS_PROVIDER,
|
||||
model: modelId,
|
||||
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
|
||||
};
|
||||
const catalogBefore: FreeModelBudget[] = [keylessEntry(modelId)];
|
||||
const catalogAfter: FreeModelBudget[] = []; // "OmniRoute" dropped the promo
|
||||
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(
|
||||
candidate,
|
||||
findBudgetEntry(candidate, catalogBefore),
|
||||
() => undefined,
|
||||
OPTIONS
|
||||
),
|
||||
[SYNTHETIC_NOAUTH_CONNECTION_ID]
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(
|
||||
candidate,
|
||||
findBudgetEntry(candidate, catalogAfter),
|
||||
() => undefined,
|
||||
OPTIONS
|
||||
),
|
||||
[],
|
||||
"must be excluded automatically once gone — no whitelist entry to delete anywhere"
|
||||
);
|
||||
});
|
||||
|
||||
// Case 2 of Fase 8: a quota-based model ships WITH a usage adapter and
|
||||
// hardStopGuaranteed=true and a SAFE live state — passes automatically.
|
||||
test("a new quota-based model with hardStopGuaranteed + SAFE state is usable automatically", () => {
|
||||
const modelId = "synthetic-quota-model-safe";
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: QUOTA_PROVIDER,
|
||||
model: modelId,
|
||||
connectionId: REAL_CONN,
|
||||
};
|
||||
const catalog: FreeModelBudget[] = [quotaEntry(modelId, true)];
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(
|
||||
candidate,
|
||||
findBudgetEntry(candidate, catalog),
|
||||
() => safeState(),
|
||||
OPTIONS
|
||||
),
|
||||
[REAL_CONN]
|
||||
);
|
||||
});
|
||||
|
||||
// Case 3 of Fase 8: metadata incomplete (no hardStopGuaranteed) → UNKNOWN → excluded automatically.
|
||||
test("a new quota-based model WITHOUT hardStopGuaranteed is excluded automatically, even with a SAFE state", () => {
|
||||
const modelId = "synthetic-quota-model-unguaranteed";
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: QUOTA_PROVIDER,
|
||||
model: modelId,
|
||||
connectionId: REAL_CONN,
|
||||
};
|
||||
const entry = quotaEntry(modelId);
|
||||
delete entry.hardStopGuaranteed; // simulate metadata that was never set, not defaulted to true
|
||||
const catalog: FreeModelBudget[] = [entry];
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(
|
||||
candidate,
|
||||
findBudgetEntry(candidate, catalog),
|
||||
() => safeState(),
|
||||
OPTIONS
|
||||
),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// Production never overrides `catalog` — `findBudgetEntry(candidate)` with no
|
||||
// second argument must read the real, live FREE_MODEL_BUDGETS, so an entirely
|
||||
// invented provider id (one no real OmniRoute release has ever catalogued) is
|
||||
// excluded automatically, with zero whitelist to edit, when the default is used.
|
||||
test("an entirely unknown provider id is excluded automatically when the default (real) catalog is used", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "totally-unheard-of-provider-xyz",
|
||||
model: "whatever",
|
||||
connectionId: REAL_CONN,
|
||||
};
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(candidate, findBudgetEntry(candidate), () => undefined, OPTIONS),
|
||||
[],
|
||||
"findBudgetEntry() with no catalog override reads the real FREE_MODEL_BUDGETS — nothing to add or remove for this provider to stay excluded"
|
||||
);
|
||||
});
|
||||
303
tests/unit/autoCombo/strict-zero-cost-connection-safety.test.ts
Normal file
303
tests/unit/autoCombo/strict-zero-cost-connection-safety.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* STRICT_ZERO_COST — connection-safety regression guards for the two
|
||||
* blockers found in code review of commit 5d4f881:
|
||||
*
|
||||
* BLOCKER 1 (keyless bypass): the `keyless` shortcut must apply ONLY to a
|
||||
* candidate that genuinely came from the no-auth path
|
||||
* (`connectionId === SYNTHETIC_NOAUTH_CONNECTION_ID`), never to a
|
||||
* `keyless`-catalogued model reached through a real DB connection.
|
||||
*
|
||||
* BLOCKER 2 (multi-account mismatch): a multi-account candidate's
|
||||
* `allowedConnectionIds` must be rewritten to exactly the SAFE subset, so
|
||||
* whatever `autoStrategy.ts` selects at dispatch time can never be a
|
||||
* connection this filter didn't itself verify.
|
||||
*
|
||||
* Both are pure-function tests against `evaluateCandidateConnections()` /
|
||||
* `filterStrictZeroCostCandidates()` — no DB, no network.
|
||||
*/
|
||||
import { test } from "vitest";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
evaluateCandidateConnections,
|
||||
filterStrictZeroCostCandidates,
|
||||
type FreeAccessState,
|
||||
type StrictZeroCostCandidate,
|
||||
} from "../../../open-sse/services/autoCombo/strictZeroCostFilter.ts";
|
||||
import { SYNTHETIC_NOAUTH_CONNECTION_ID } from "../../../open-sse/services/autoCombo/resilienceCandidateFilter.ts";
|
||||
import type { FreeModelBudget } from "../../../open-sse/config/freeModelCatalog.ts";
|
||||
|
||||
const NOW = "2026-08-20T00:00:00.000Z";
|
||||
const OPTIONS = { minRemainingAllowance: 1, maxStateAgeMs: 180_000, now: () => Date.parse(NOW) };
|
||||
|
||||
function safeState(overrides: Partial<FreeAccessState> = {}): FreeAccessState {
|
||||
return {
|
||||
status: "SAFE",
|
||||
remainingFreeAllowance: 40,
|
||||
resetAt: null,
|
||||
checkedAt: NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// A synthetic keyless budget entry — hardStopGuaranteed deliberately absent,
|
||||
// matching the real catalog (keyless entries never set it; the shortcut is
|
||||
// what makes them safe, not this field).
|
||||
function keylessEntry(): FreeModelBudget {
|
||||
return {
|
||||
provider: "kp",
|
||||
modelId: "kp-model",
|
||||
displayName: "kp-model",
|
||||
monthlyTokens: 0,
|
||||
creditTokens: 0,
|
||||
freeType: "keyless",
|
||||
poolKey: null,
|
||||
tos: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
function quotaEntry(hardStopGuaranteed = true): FreeModelBudget {
|
||||
return {
|
||||
provider: "qp",
|
||||
modelId: "qp-model",
|
||||
displayName: "qp-model",
|
||||
monthlyTokens: 1_000_000,
|
||||
creditTokens: 0,
|
||||
freeType: "recurring-daily",
|
||||
poolKey: null,
|
||||
tos: "ok",
|
||||
hardStopGuaranteed,
|
||||
};
|
||||
}
|
||||
|
||||
const REAL_CONN = "real-connection-42";
|
||||
|
||||
// ─── BLOCKER 1 — keyless bypass ─────────────────────────────────────────
|
||||
|
||||
// A. keyless + SYNTHETIC_NOAUTH_CONNECTION_ID → PASS
|
||||
test("A: keyless + SYNTHETIC_NOAUTH_CONNECTION_ID passes with zero live checks", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "kp",
|
||||
model: "kp-model",
|
||||
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
|
||||
};
|
||||
const resolveFreeAccessState = () => {
|
||||
throw new Error("must never be called for a genuine no-auth candidate");
|
||||
};
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(candidate, keylessEntry(), resolveFreeAccessState, OPTIONS),
|
||||
[SYNTHETIC_NOAUTH_CONNECTION_ID]
|
||||
);
|
||||
});
|
||||
|
||||
// B. same provider/model, catalogued keyless, but reached via a real DB
|
||||
// connectionId → must NOT take the keyless shortcut.
|
||||
test("B: keyless-catalogued model reached via a real connectionId does NOT get the keyless shortcut", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "kp",
|
||||
model: "kp-model",
|
||||
connectionId: REAL_CONN, // NOT the sentinel — a genuine DB connection
|
||||
};
|
||||
let called = false;
|
||||
const resolveFreeAccessState = () => {
|
||||
called = true;
|
||||
return safeState(); // even if the connection WOULD report SAFE quota...
|
||||
};
|
||||
const result = evaluateCandidateConnections(
|
||||
candidate,
|
||||
keylessEntry(),
|
||||
resolveFreeAccessState,
|
||||
OPTIONS
|
||||
);
|
||||
// ...the entry has no hardStopGuaranteed (real keyless catalog rows never
|
||||
// set it — the shortcut was their only path to safety), so falling through
|
||||
// to the quota branch must still exclude it.
|
||||
assert.deepEqual(
|
||||
result,
|
||||
[],
|
||||
"must exclude — keyless metadata alone cannot authorize a real connection"
|
||||
);
|
||||
assert.equal(
|
||||
called,
|
||||
false,
|
||||
"must not even bother resolving live state once hardStopGuaranteed is absent"
|
||||
);
|
||||
});
|
||||
|
||||
// C. a connection that later gains a credential still can't exploit the
|
||||
// keyless classification — same code path as B, demonstrated with a
|
||||
// multi-account (allowedConnectionIds) shape to also prove the "logical
|
||||
// candidate" branch, not just the single-connectionId branch, is covered.
|
||||
test("C: a keyless-catalogued model behind allowedConnectionIds (credentialed accounts) is never shortcut-eligible", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "kp",
|
||||
model: "kp-model",
|
||||
connectionId: null,
|
||||
allowedConnectionIds: ["acct-with-credential-1", "acct-with-credential-2"],
|
||||
};
|
||||
const resolveFreeAccessState = () => safeState(); // both accounts report SAFE quota
|
||||
const result = evaluateCandidateConnections(
|
||||
candidate,
|
||||
keylessEntry(),
|
||||
resolveFreeAccessState,
|
||||
OPTIONS
|
||||
);
|
||||
assert.deepEqual(
|
||||
result,
|
||||
[],
|
||||
"keyless metadata must never authorize any real, credentialed account, regardless of what its quota says"
|
||||
);
|
||||
});
|
||||
|
||||
// Sanity: a non-keyless entry reached via the no-auth sentinel is contradictory
|
||||
// metadata (shouldn't happen in practice) and must fail closed, not silently
|
||||
// fall through to a quota check that can never resolve a state for "noauth".
|
||||
test("sanity: non-keyless freeType via the no-auth sentinel connection fails closed", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "qp",
|
||||
model: "qp-model",
|
||||
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
|
||||
};
|
||||
const result = evaluateCandidateConnections(
|
||||
candidate,
|
||||
quotaEntry(true),
|
||||
() => safeState(),
|
||||
OPTIONS
|
||||
);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
// ─── BLOCKER 2 — multi-account connection mismatch ─────────────────────
|
||||
|
||||
// 1. account A SAFE, B UNKNOWN → dispatch can use ONLY A.
|
||||
test("1: A SAFE, B UNKNOWN -> safe connection set is exactly [A]", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "qp",
|
||||
model: "qp-model",
|
||||
connectionId: null,
|
||||
allowedConnectionIds: ["A", "B"],
|
||||
};
|
||||
const resolve = (_provider: string, connectionId: string) =>
|
||||
connectionId === "A" ? safeState() : undefined; // B: no state at all == UNKNOWN
|
||||
assert.deepEqual(evaluateCandidateConnections(candidate, quotaEntry(true), resolve, OPTIONS), [
|
||||
"A",
|
||||
]);
|
||||
});
|
||||
|
||||
// 2. A EXHAUSTED, B SAFE → SOLO B.
|
||||
test("2: A EXHAUSTED, B SAFE -> safe connection set is exactly [B]", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "qp",
|
||||
model: "qp-model",
|
||||
connectionId: null,
|
||||
allowedConnectionIds: ["A", "B"],
|
||||
};
|
||||
const resolve = (_provider: string, connectionId: string) =>
|
||||
connectionId === "A"
|
||||
? safeState({ status: "EXHAUSTED", remainingFreeAllowance: 0 })
|
||||
: safeState();
|
||||
assert.deepEqual(evaluateCandidateConnections(candidate, quotaEntry(true), resolve, OPTIONS), [
|
||||
"B",
|
||||
]);
|
||||
});
|
||||
|
||||
// 3. A SAFE, B billable/unverifiable (UNKNOWN) → B not selectable.
|
||||
test("3: A SAFE, B UNKNOWN (billable/unverifiable) -> B never enters the safe set", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "qp",
|
||||
model: "qp-model",
|
||||
connectionId: null,
|
||||
allowedConnectionIds: ["A", "B"],
|
||||
};
|
||||
const resolve = (_provider: string, connectionId: string) =>
|
||||
connectionId === "A" ? safeState() : undefined;
|
||||
const safe = evaluateCandidateConnections(candidate, quotaEntry(true), resolve, OPTIONS);
|
||||
assert.equal(safe.includes("B"), false);
|
||||
assert.deepEqual(safe, ["A"]);
|
||||
});
|
||||
|
||||
// 4. tutte UNKNOWN → candidato escluso.
|
||||
test("4: all connections UNKNOWN -> candidate fully excluded (empty safe set)", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "qp",
|
||||
model: "qp-model",
|
||||
connectionId: null,
|
||||
allowedConnectionIds: ["A", "B", "C"],
|
||||
};
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(candidate, quotaEntry(true), () => undefined, OPTIONS),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// 5. account selezionato al dispatch è uno di quelli verificati SAFE — proven
|
||||
// at the pool-filter level: the returned candidate's `allowedConnectionIds`
|
||||
// is rewritten to exactly the safe subset, which is the same field
|
||||
// `autoStrategy.ts` (open-sse/services/combo/autoStrategy.ts:315-331)
|
||||
// already intersects against before connection selection — so whatever it
|
||||
// picks is provably a member of this set, by construction.
|
||||
test("5: filterStrictZeroCostCandidates rewrites allowedConnectionIds to exactly the verified-SAFE subset", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "qp",
|
||||
model: "qp-model",
|
||||
connectionId: null,
|
||||
allowedConnectionIds: ["A", "B", "C"],
|
||||
};
|
||||
const resolve = (_provider: string, connectionId: string) =>
|
||||
connectionId === "B" ? safeState() : undefined; // only B is SAFE
|
||||
const result = filterStrictZeroCostCandidates([candidate], {
|
||||
enabled: true,
|
||||
resolveFreeAccessState: resolve,
|
||||
catalog: [quotaEntry(true)],
|
||||
...OPTIONS,
|
||||
});
|
||||
assert.equal(result.length, 1);
|
||||
assert.deepEqual(
|
||||
result[0].allowedConnectionIds,
|
||||
["B"],
|
||||
"dispatch (via autoStrategy.ts's own allowedConnectionIds intersection) can only ever select B — the one connection this filter actually verified"
|
||||
);
|
||||
assert.equal(
|
||||
result[0].allowedConnectionIds?.includes("A"),
|
||||
false,
|
||||
"A (UNKNOWN) must never remain selectable"
|
||||
);
|
||||
assert.equal(
|
||||
result[0].allowedConnectionIds?.includes("C"),
|
||||
false,
|
||||
"C (UNKNOWN) must never remain selectable"
|
||||
);
|
||||
});
|
||||
|
||||
test("multi-account candidate with an unchanged safe set is returned as the SAME reference (identity contract)", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "qp",
|
||||
model: "qp-model",
|
||||
connectionId: null,
|
||||
allowedConnectionIds: ["A"],
|
||||
};
|
||||
const pool = [candidate];
|
||||
const result = filterStrictZeroCostCandidates(pool, {
|
||||
enabled: true,
|
||||
resolveFreeAccessState: () => safeState(),
|
||||
catalog: [quotaEntry(true)],
|
||||
...OPTIONS,
|
||||
});
|
||||
assert.equal(result, pool, "nothing was narrowed, so the pool array reference must be preserved");
|
||||
assert.equal(result[0], candidate, "the candidate object itself must be preserved, not cloned");
|
||||
});
|
||||
|
||||
test("single-connection candidate that fails is dropped, never returned with an empty allowedConnectionIds", () => {
|
||||
const candidate: StrictZeroCostCandidate = {
|
||||
provider: "qp",
|
||||
model: "qp-model",
|
||||
connectionId: REAL_CONN,
|
||||
};
|
||||
const result = filterStrictZeroCostCandidates([candidate], {
|
||||
enabled: true,
|
||||
resolveFreeAccessState: () => undefined,
|
||||
catalog: [quotaEntry(true)],
|
||||
...OPTIONS,
|
||||
});
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
248
tests/unit/autoCombo/strict-zero-cost-filter.test.ts
Normal file
248
tests/unit/autoCombo/strict-zero-cost-filter.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* STRICT_ZERO_COST — regression guard for `strictZeroCostFilter.ts`, wired
|
||||
* into `open-sse/services/autoCombo/virtualFactory.ts::buildPreparedPool`
|
||||
* right after `filterPaidOnlyCandidates` (#6512). Mirrors the style/shape of
|
||||
* `tests/unit/autoCombo/paid-model-filter-6512.test.ts`.
|
||||
*
|
||||
* Pure, dependency-light by design: `resolveFreeAccessState` is injected as a
|
||||
* plain function, so no DB/network mocking is needed anywhere in this file.
|
||||
*
|
||||
* Connection-safety-specific cases (the keyless bypass and multi-account
|
||||
* bugs found in code review) live in their own file,
|
||||
* `strict-zero-cost-connection-safety.test.ts` — this file covers the
|
||||
* single-connection / no-connection-ambiguity cases only.
|
||||
*/
|
||||
import { test } from "vitest";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
evaluateCandidateConnections,
|
||||
filterStrictZeroCostCandidates,
|
||||
filterTosAvoidCandidates,
|
||||
type FreeAccessState,
|
||||
} from "../../../open-sse/services/autoCombo/strictZeroCostFilter.ts";
|
||||
import { SYNTHETIC_NOAUTH_CONNECTION_ID } from "../../../open-sse/services/autoCombo/resilienceCandidateFilter.ts";
|
||||
import { FREE_MODEL_BUDGETS } from "../../../open-sse/config/freeModelCatalog.ts";
|
||||
|
||||
const NOW = "2026-08-20T00:00:00.000Z";
|
||||
const nowMs = () => Date.parse(NOW);
|
||||
|
||||
function freshState(overrides: Partial<FreeAccessState> = {}): FreeAccessState {
|
||||
return {
|
||||
status: "SAFE",
|
||||
remainingFreeAllowance: 50,
|
||||
resetAt: null,
|
||||
checkedAt: NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const BASE_OPTIONS = { minRemainingAllowance: 1, maxStateAgeMs: 180_000, now: nowMs };
|
||||
|
||||
const REAL_CONN = "conn-real-1";
|
||||
|
||||
// A real keyless entry from the catalog (felo-web, all models keyless/tos=avoid),
|
||||
// as a genuine no-auth candidate (the only shape that legitimately gets the shortcut).
|
||||
const KEYLESS = {
|
||||
provider: "felo-web",
|
||||
model: "felo-chat",
|
||||
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
|
||||
};
|
||||
// A real quota-based entry with hardStopGuaranteed: true (added by this feature),
|
||||
// as a concrete single-connection candidate.
|
||||
const QUOTA_SAFE = { provider: "groq", model: "llama-3.3-70b-versatile", connectionId: REAL_CONN };
|
||||
// A real quota-based entry WITHOUT hardStopGuaranteed (agentrouter: one-time-initial,
|
||||
// no usage adapter, no documented "no credit card" claim — must never pass).
|
||||
const QUOTA_UNGUARANTEED = {
|
||||
provider: "agentrouter",
|
||||
model: "claude-opus-5",
|
||||
connectionId: REAL_CONN,
|
||||
};
|
||||
// Not in FREE_MODEL_BUDGETS at all.
|
||||
const UNKNOWN_MODEL = {
|
||||
provider: "groq",
|
||||
model: "definitely-not-cataloged",
|
||||
connectionId: REAL_CONN,
|
||||
};
|
||||
// A provider with no free models documented anywhere (mirrors paid-model-filter-6512's own PAID fixture).
|
||||
const PAID = { provider: "openai", model: "gpt-4o", connectionId: REAL_CONN };
|
||||
|
||||
test("sanity: fixtures exist in the real catalog with the metadata these tests assume", () => {
|
||||
const groqEntry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile"
|
||||
);
|
||||
assert.equal(groqEntry?.hardStopGuaranteed, true, "groq must carry hardStopGuaranteed: true");
|
||||
const arEntry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "agentrouter" && m.modelId === "claude-opus-5"
|
||||
);
|
||||
assert.notEqual(
|
||||
arEntry?.hardStopGuaranteed,
|
||||
true,
|
||||
"agentrouter must NOT carry hardStopGuaranteed: true (no documented hard-stop guarantee)"
|
||||
);
|
||||
const feloEntry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "felo-web" && m.modelId === "felo-chat"
|
||||
);
|
||||
assert.equal(feloEntry?.freeType, "keyless");
|
||||
assert.equal(feloEntry?.tos, "avoid", "felo-web must be tos=avoid for the ToS-guard tests below");
|
||||
});
|
||||
|
||||
// 1. keyless SAFE (genuine no-auth candidate) → PASS
|
||||
test("keyless candidate from the genuine no-auth path passes with no state at all", () => {
|
||||
const entry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "felo-web" && m.modelId === "felo-chat"
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(KEYLESS, entry, () => undefined, BASE_OPTIONS),
|
||||
[SYNTHETIC_NOAUTH_CONNECTION_ID]
|
||||
);
|
||||
});
|
||||
|
||||
// 2. paid → EXCLUDE
|
||||
test("paid candidate (no free-model documentation at all) is excluded", () => {
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(PAID, undefined, () => undefined, BASE_OPTIONS),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// 3 & 4. unknown model / unknown provider → EXCLUDE
|
||||
test("model absent from the free catalog is excluded even under a known provider", () => {
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(UNKNOWN_MODEL, undefined, () => undefined, BASE_OPTIONS),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// 5. quota SAFE + fresh + hardStop → PASS
|
||||
test("quota-based candidate with hardStopGuaranteed, fresh SAFE state above threshold passes", () => {
|
||||
const entry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile"
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(
|
||||
QUOTA_SAFE,
|
||||
entry,
|
||||
() => freshState({ remainingFreeAllowance: 40 }),
|
||||
BASE_OPTIONS
|
||||
),
|
||||
[REAL_CONN]
|
||||
);
|
||||
});
|
||||
|
||||
// 6. quota exhausted → EXCLUDE
|
||||
test("EXHAUSTED status excludes even with a fresh checkedAt", () => {
|
||||
const entry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile"
|
||||
);
|
||||
const state = freshState({ status: "EXHAUSTED", remainingFreeAllowance: 0 });
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(QUOTA_SAFE, entry, () => state, BASE_OPTIONS),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// 7. usage adapter absent (no state resolvable) → EXCLUDE
|
||||
test("quota-based candidate with no resolvable state is excluded, not assumed safe", () => {
|
||||
const entry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile"
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(QUOTA_SAFE, entry, () => undefined, BASE_OPTIONS),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// 8. usage API error → EXCLUDE (modeled as UNKNOWN status; freeAccessQuota.ts
|
||||
// deletes the cache entry on a failed refresh, which resolves to `undefined`
|
||||
// here — same assertion as #7, the important contract is "never falls back to SAFE").
|
||||
test("UNKNOWN status excludes", () => {
|
||||
const entry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile"
|
||||
);
|
||||
const state = freshState({ status: "UNKNOWN", remainingFreeAllowance: null });
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(QUOTA_SAFE, entry, () => state, BASE_OPTIONS),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// 9. usage state stale → EXCLUDE
|
||||
test("stale checkedAt excludes even when status is SAFE", () => {
|
||||
const entry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "groq" && m.modelId === "llama-3.3-70b-versatile"
|
||||
);
|
||||
const stale = freshState({ checkedAt: "2026-08-19T00:00:00.000Z" }); // >24h before NOW
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(QUOTA_SAFE, entry, () => stale, BASE_OPTIONS),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// 10 & 11. hardStopGuaranteed undefined / false → EXCLUDE
|
||||
test("hardStopGuaranteed undefined excludes even with a perfect SAFE state", () => {
|
||||
const entry = FREE_MODEL_BUDGETS.find(
|
||||
(m) => m.provider === "agentrouter" && m.modelId === "claude-opus-5"
|
||||
);
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(QUOTA_UNGUARANTEED, entry, () => freshState(), BASE_OPTIONS),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test("hardStopGuaranteed explicitly false excludes", () => {
|
||||
const entry = {
|
||||
...FREE_MODEL_BUDGETS[0],
|
||||
hardStopGuaranteed: false,
|
||||
freeType: "recurring-monthly" as const,
|
||||
};
|
||||
assert.deepEqual(
|
||||
evaluateCandidateConnections(
|
||||
{ provider: entry.provider, model: entry.modelId, connectionId: REAL_CONN },
|
||||
entry,
|
||||
() => freshState(),
|
||||
BASE_OPTIONS
|
||||
),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
// 12 & 13. ToS guard, independent of economic evaluation
|
||||
test("tos=avoid + excludeTosAvoid=true excludes a keyless-safe candidate", () => {
|
||||
const result = filterTosAvoidCandidates([KEYLESS], true);
|
||||
assert.deepEqual(result, [], "felo-web (tos=avoid) must be dropped when the guard is on");
|
||||
});
|
||||
|
||||
test("tos=avoid + excludeTosAvoid=false leaves normal economic evaluation untouched", () => {
|
||||
const result = filterTosAvoidCandidates([KEYLESS, PAID], false);
|
||||
assert.deepEqual(result, [KEYLESS, PAID], "guard off must be a pure identity pass-through");
|
||||
});
|
||||
|
||||
// Pool-level filter: default-off regression guard, mirrors paid-model-filter-6512's own first test.
|
||||
test("freeAccessPolicy off (default) returns the pool UNCHANGED (identity, regression guard)", () => {
|
||||
const pool = [KEYLESS, QUOTA_SAFE, PAID];
|
||||
const result = filterStrictZeroCostCandidates(pool, {
|
||||
enabled: false,
|
||||
resolveFreeAccessState: () => undefined,
|
||||
...BASE_OPTIONS,
|
||||
});
|
||||
assert.equal(result, pool, "must return the exact same array reference when opt-in is off");
|
||||
});
|
||||
|
||||
test("strict pool filter keeps only keyless(no-auth) + guaranteed-quota-SAFE candidates", () => {
|
||||
const pool = [KEYLESS, QUOTA_SAFE, QUOTA_UNGUARANTEED, PAID, UNKNOWN_MODEL];
|
||||
const result = filterStrictZeroCostCandidates(pool, {
|
||||
enabled: true,
|
||||
resolveFreeAccessState: (provider) =>
|
||||
provider === "groq" ? freshState({ remainingFreeAllowance: 40 }) : undefined,
|
||||
...BASE_OPTIONS,
|
||||
});
|
||||
assert.deepEqual(result, [KEYLESS, QUOTA_SAFE]);
|
||||
});
|
||||
|
||||
// Autodiscovery cases (14-19) are in
|
||||
// tests/unit/autoCombo/strict-zero-cost-autodiscovery.test.ts, which fabricates
|
||||
// synthetic FREE_MODEL_BUDGETS-shaped fixtures rather than real provider ids —
|
||||
// see that file for why a fixture-based test is the correct tool here.
|
||||
// Blocker 1 (keyless bypass) and Blocker 2 (multi-account) cases are in
|
||||
// tests/unit/autoCombo/strict-zero-cost-connection-safety.test.ts.
|
||||
@@ -1,38 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
|
||||
|
||||
// GHSA-4f49-hj64-448x — a persisted, caller-supplied providerSpecificData.baseUrl
|
||||
// reaches fetch() on the runtime dispatch path with no SSRF guard. BaseExecutor
|
||||
// now mirrors the provider VALIDATION guard before every upstream fetch. In the
|
||||
// shipped default (block-metadata) mode the cloud-metadata IMDS pivot is blocked
|
||||
// for non-local providers, public upstreams pass, and local / self-hosted
|
||||
// providers (vLLM, LM Studio, Ollama, …) stay exempt so loopback/LAN keeps working.
|
||||
|
||||
function guardOf(provider: string) {
|
||||
const exec = new DefaultExecutor(provider) as unknown as {
|
||||
assertOutboundUrlAllowed(url: string): void;
|
||||
};
|
||||
return (url: string) => exec.assertOutboundUrlAllowed(url);
|
||||
}
|
||||
|
||||
test("BaseExecutor blocks cloud-metadata for a non-local provider (GHSA-4f49-hj64-448x)", () => {
|
||||
const guard = guardOf("openai");
|
||||
assert.throws(() => guard("http://169.254.169.254/latest/meta-data/iam/security-credentials/"));
|
||||
// IPv4-mapped IPv6 spelling of the same address (folded out by #10843).
|
||||
assert.throws(() => guard("http://[::ffff:169.254.169.254]/latest/meta-data/"));
|
||||
});
|
||||
|
||||
test("BaseExecutor allows a public upstream URL for a non-local provider", () => {
|
||||
const guard = guardOf("openai");
|
||||
assert.doesNotThrow(() => guard("https://api.openai.com/v1/chat/completions"));
|
||||
});
|
||||
|
||||
test("BaseExecutor exempts local / self-hosted providers from the outbound guard", () => {
|
||||
assert.doesNotThrow(() => guardOf("ollama-local")("http://127.0.0.1:11434/v1/chat/completions"));
|
||||
assert.doesNotThrow(() => guardOf("lm-studio")("http://192.168.1.50:1234/v1/chat/completions"));
|
||||
});
|
||||
|
||||
test("BaseExecutor guard is a no-op for an empty URL", () => {
|
||||
assert.doesNotThrow(() => guardOf("openai")(""));
|
||||
});
|
||||
@@ -74,3 +74,53 @@ test("MiniMax M3 via command-code keeps existing vision capability (no regressio
|
||||
const caps = getResolvedModelCapabilities("command-code/MiniMaxAI/MiniMax-M3");
|
||||
assert.equal(caps.supportsVision, true);
|
||||
});
|
||||
|
||||
test("command-code effort-suffixed variants resolve capabilities from their base model", async () => {
|
||||
// Effort variants (e.g. `-max`, `-xhigh`) are synthesized from the base's
|
||||
// supportedThinkingEfforts and have no registry/synced row of their own.
|
||||
// Without the base-model fallback they resolve NULL tool/vision/context,
|
||||
// which makes a tool-bearing combo request drop them behind confirmed
|
||||
// targets (observed: orchestrator tried opencode-go/mimo-v2.5-max at
|
||||
// position 2 while command-code deepseek sat unused at its declared
|
||||
// priority position 2).
|
||||
//
|
||||
// Seed the models.dev capability store (the source getResolvedModelCapabilities
|
||||
// reads for tool/vision/context) with the base models, then verify the
|
||||
// effort-suffixed variants inherit those capabilities via the base fallback.
|
||||
const modelsDevSync = await import("../../src/lib/modelsDevSync.ts");
|
||||
modelsDevSync.saveModelsDevCapabilities({
|
||||
"command-code": {
|
||||
"deepseek/deepseek-v4-flash": {
|
||||
tool_call: true,
|
||||
reasoning: true,
|
||||
attachment: false,
|
||||
limit_context: 1000000,
|
||||
limit_input: 1000000,
|
||||
limit_output: 131072,
|
||||
},
|
||||
"meta/muse-spark-1.2-contributor": {
|
||||
tool_call: true,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
limit_context: 1048576,
|
||||
limit_input: 1048576,
|
||||
limit_output: 1048576,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cases: Array<[string, boolean]> = [
|
||||
["command-code/deepseek/deepseek-v4-flash-max", false], // text-only base
|
||||
["command-code/deepseek/deepseek-v4-flash-high", false],
|
||||
["command-code/meta/muse-spark-1.2-contributor-xhigh", true], // vision base
|
||||
["command-code/meta/muse-spark-1.2-contributor-high", true],
|
||||
];
|
||||
for (const [modelId, vision] of cases) {
|
||||
const caps = getResolvedModelCapabilities(modelId);
|
||||
assert.equal(caps.provider, "command-code", `${modelId} provider`);
|
||||
assert.equal(caps.supportsTools, true, `${modelId} must inherit tool support from base`);
|
||||
assert.equal(caps.supportsVision, vision, `${modelId} must inherit vision from base`);
|
||||
assert.equal(typeof caps.contextWindow, "number", `${modelId} must inherit a context window`);
|
||||
assert.equal(caps.supportsThinking, true, `${modelId} must inherit reasoning from base`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -129,12 +129,12 @@ describe("cors/origins.applyCorsHeaders", () => {
|
||||
assert.match(res.headers.get("Vary") || "", /Origin/);
|
||||
});
|
||||
|
||||
it("CLIENT_API: echoes arbitrary Origin (+Vary) for a token-carrying request (relaxForTokenAuth)", () => {
|
||||
it("CLIENT_API: echoes arbitrary Origin (+Vary) when no allowlist matches (relaxForTokenAuth)", () => {
|
||||
// Token-authenticated /v1/* surface (issue #5242): no allowlist, arbitrary
|
||||
// origin → echo it back so browser/Electron renderers can read the body.
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { Origin: "http://localhost", Authorization: "Bearer omr_test_key" },
|
||||
headers: { Origin: "http://localhost" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "http://localhost");
|
||||
@@ -143,40 +143,14 @@ describe("cors/origins.applyCorsHeaders", () => {
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
it("CLIENT_API: returns '*' when no Origin header is present for a token-carrying request", () => {
|
||||
it("CLIENT_API: returns '*' when no Origin header is present (relaxForTokenAuth)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { "x-api-key": "omr_test_key" },
|
||||
});
|
||||
const req = new Request("https://server.example.com/api/v1/models");
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*");
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Credentials"), null);
|
||||
});
|
||||
|
||||
it("CLIENT_API: does NOT echo the Origin for a credential-less cross-origin request (GHSA-7px7)", () => {
|
||||
// A keyless install serves /v1 anonymously; echoing the Origin to a
|
||||
// credential-less cross-origin page would let any visited page drive the
|
||||
// gateway. Only token-carrying requests get the permissive echo.
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { Origin: "https://evil.example" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), null);
|
||||
});
|
||||
|
||||
it("CLIENT_API: a CORS preflight (OPTIONS) is still allowed through (relaxForTokenAuth)", () => {
|
||||
// Preflight never carries the auth header; blocking it would break the
|
||||
// credentialed request that follows, so OPTIONS keeps the permissive echo.
|
||||
const res = new Response(null, { status: 204 });
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: "http://localhost" },
|
||||
});
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "http://localhost");
|
||||
});
|
||||
|
||||
it("MANAGEMENT: stays fail-closed for arbitrary Origin with no allowlist (relax off)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
const req = new Request("https://server.example.com/api/keys", {
|
||||
@@ -239,11 +213,7 @@ describe("cors/origins.applyCorsHeaders", () => {
|
||||
|
||||
it("CLIENT_API: appends Vary: Accept-Encoding even without an Origin header (#6737)", () => {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
// Token-carrying request (post-GHSA-7px7 the permissive echo requires a
|
||||
// credential); this test's point is the Vary: Accept-Encoding stamp.
|
||||
const req = new Request("https://server.example.com/api/v1/models", {
|
||||
headers: { "x-api-key": "omr_test_key" },
|
||||
});
|
||||
const req = new Request("https://server.example.com/api/v1/models");
|
||||
applyCorsHeaders(res, req, true);
|
||||
assert.equal(res.headers.get("Access-Control-Allow-Origin"), "*");
|
||||
assert.match(res.headers.get("Vary") || "", /Accept-Encoding/);
|
||||
|
||||
@@ -187,37 +187,3 @@ test("omniroute_memory_search: hardcoded fallback config has retrievalStrategy=e
|
||||
"fallback from catch path must use retrievalStrategy=exact"
|
||||
);
|
||||
});
|
||||
|
||||
// ── IDOR: the authenticated caller's principal must win over a caller-supplied
|
||||
// apiKeyId (GHSA-cpv3-xr7r-xf8q). With a resolvable caller (here: OMNIROUTE_API_KEY
|
||||
// on the stdio path → "env-key"), omniroute_memory_add must store under the
|
||||
// caller, NOT under the arbitrary apiKeyId in the tool arguments.
|
||||
test("omniroute_memory_add: caller principal wins over a spoofed apiKeyId (GHSA-cpv3)", async () => {
|
||||
const db = core.getDbInstance();
|
||||
const prevEnvKey = process.env.OMNIROUTE_API_KEY;
|
||||
process.env.OMNIROUTE_API_KEY = "test-mcp-caller-key";
|
||||
try {
|
||||
const { memoryTools } = await import("../../open-sse/mcp-server/tools/memoryTools.ts");
|
||||
const result = await memoryTools.omniroute_memory_add.handler({
|
||||
apiKeyId: "victim-b",
|
||||
type: "factual",
|
||||
key: "idor-k1",
|
||||
content: "owned-by-caller",
|
||||
});
|
||||
assert.equal(result.success, true, "add must succeed");
|
||||
|
||||
const rows = db
|
||||
.prepare("SELECT api_key_id FROM memories WHERE key = 'idor-k1'")
|
||||
.all() as Array<{ api_key_id: string }>;
|
||||
assert.equal(rows.length, 1, "exactly one memory row expected");
|
||||
assert.equal(
|
||||
rows[0].api_key_id,
|
||||
"env-key",
|
||||
"memory must be stored under the resolved caller (env-key), not the spoofed apiKeyId"
|
||||
);
|
||||
assert.notEqual(rows[0].api_key_id, "victim-b", "must NOT store under the caller-supplied id");
|
||||
} finally {
|
||||
if (prevEnvKey === undefined) delete process.env.OMNIROUTE_API_KEY;
|
||||
else process.env.OMNIROUTE_API_KEY = prevEnvKey;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* GHSA-mvf8-qc78-5mxm — GET /api/monitoring/health returned host-fingerprinting
|
||||
* detail (version, node version, pid, memory, provider config) to anonymous
|
||||
* callers. It now serves only the liveness verdict to non-management callers.
|
||||
*/
|
||||
|
||||
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";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-health-view-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const route = await import("../../src/app/api/monitoring/health/route.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("anonymous health GET is reduced to liveness only (GHSA-mvf8)", async () => {
|
||||
const res = await route.GET(new Request("http://localhost/api/monitoring/health") as never);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.ok("status" in body, "liveness status must be present for probes");
|
||||
// No host fingerprinting for an anonymous caller.
|
||||
const keys = Object.keys(body);
|
||||
const allowed = new Set(["status", "setupComplete"]);
|
||||
for (const k of keys) {
|
||||
assert.ok(allowed.has(k), `anonymous health view leaked field: ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("management session sees the full health payload", async () => {
|
||||
const sessionReq = (await makeManagementSessionRequest(
|
||||
"http://localhost/api/monitoring/health"
|
||||
)) as unknown as NextRequest;
|
||||
const res = await route.GET(sessionReq as never);
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
assert.ok(
|
||||
Object.keys(body).length > 2,
|
||||
"a management caller must still receive the detailed payload"
|
||||
);
|
||||
});
|
||||
@@ -70,17 +70,9 @@ describe("#6205 A — embed panel root no longer 404s", () => {
|
||||
// ─── SUB-BUG B: pre-spawn port/health decision ───────────────────────────────
|
||||
|
||||
describe("#6205 B — pre-spawn port probe avoids raw EADDRINUSE", () => {
|
||||
it("adopts a healthy existing instance when adoption is opted in (no spawn)", () => {
|
||||
const decision = decidePreSpawn({ healthy: true, portInUse: true }, 20130, true);
|
||||
assert.equal(decision.action, "adopt");
|
||||
});
|
||||
|
||||
it("does NOT adopt a healthy listener by default — a 2xx cannot prove identity (GHSA-wg9p-6m2g-4v27)", () => {
|
||||
it("adopts a healthy existing instance (no spawn)", () => {
|
||||
const decision = decidePreSpawn({ healthy: true, portInUse: true }, 20130);
|
||||
assert.equal(decision.action, "error");
|
||||
assert.match(decision.message, /adopt/i);
|
||||
assert.match(decision.message, /OMNIROUTE_ADOPT_EXISTING_SERVICE/);
|
||||
assert.ok(!decision.message.includes("at /"), "must not leak a stack trace");
|
||||
assert.equal(decision.action, "adopt");
|
||||
});
|
||||
|
||||
it("returns a clear error object (not a throw) when the port is held but unhealthy", () => {
|
||||
@@ -100,10 +92,9 @@ describe("#6205 B — pre-spawn port probe avoids raw EADDRINUSE", () => {
|
||||
assert.equal(decision.action, "spawn");
|
||||
});
|
||||
|
||||
it("adopts a healthy instance (opted in) even if the TCP probe missed it", () => {
|
||||
// With adoption opted in, health is authoritative: a 2xx means a real
|
||||
// instance is serving even when the TCP connect probe raced and missed it.
|
||||
const decision = decidePreSpawn({ healthy: true, portInUse: false }, 20130, true);
|
||||
it("adopts a healthy instance even if the TCP probe missed it", () => {
|
||||
// Health is authoritative: a 2xx means a real instance is serving.
|
||||
const decision = decidePreSpawn({ healthy: true, portInUse: false }, 20130);
|
||||
assert.equal(decision.action, "adopt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* GHSA-7x63-xvp5-w2jc — the kiro / amazon-q device-code action interpolates a
|
||||
* caller-supplied `region` into the AWS OIDC endpoint URLs that requestDeviceCode()
|
||||
* fetches. An attacker-shaped region (userinfo / fragment) re-points the outbound
|
||||
* host (SSRF → cloud metadata). The route must reject a non-canonical region with
|
||||
* a 400 before any outbound fetch.
|
||||
*/
|
||||
|
||||
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";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-oauth-region-ssrf-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const route = await import("../../src/app/api/oauth/[provider]/[action]/route.ts");
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function deviceCode(provider: string, region: string) {
|
||||
const url =
|
||||
`http://localhost/api/oauth/${provider}/device-code` +
|
||||
`?startUrl=${encodeURIComponent("https://d-1234567890.awsapps.com/start")}` +
|
||||
`®ion=${encodeURIComponent(region)}`;
|
||||
return route.GET(new Request(url) as unknown as NextRequest, {
|
||||
params: Promise.resolve({ provider, action: "device-code" }),
|
||||
});
|
||||
}
|
||||
|
||||
test("kiro device-code rejects a non-canonical region before any outbound fetch (GHSA-7x63)", async () => {
|
||||
for (const bad of [
|
||||
"evil.com",
|
||||
"169.254.169.254",
|
||||
"us-east-1@169.254.169.254",
|
||||
"us-east-1#.amazonaws.com@evil.com",
|
||||
"us-east-1/../..",
|
||||
"US-EAST-1", // uppercase is not the canonical shape
|
||||
]) {
|
||||
const res = await deviceCode("kiro", bad);
|
||||
assert.equal(res.status, 400, `region "${bad}" must be rejected with 400`);
|
||||
}
|
||||
});
|
||||
|
||||
test("amazon-q device-code also validates region", async () => {
|
||||
const res = await deviceCode("amazon-q", "evil.com:1@169.254.169.254");
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* GHSA-mg76-rhpx-gvw3 / GHSA-gxv4-955v-v6cm — OAuth import / auto-import routes
|
||||
* create or read provider credentials. They were guarded only by isAuthenticated(),
|
||||
* which (because /api/oauth/ is PUBLIC-classified) accepts ANY valid client API key.
|
||||
* They must now require MANAGEMENT scope.
|
||||
*/
|
||||
|
||||
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(), "omni-oauth-import-manage-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "oauth-import-manage-secret";
|
||||
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const codexImportToken = await import("../../src/app/api/oauth/codex/import-token/route.ts");
|
||||
const cursorAutoImport = await import("../../src/app/api/oauth/cursor/auto-import/route.ts");
|
||||
|
||||
test.before(async () => {
|
||||
process.env.JWT_SECRET = "oauth-import-manage-jwt";
|
||||
process.env.INITIAL_PASSWORD = "oauth-import-manage-pass";
|
||||
await settingsDb.updateSettings({ requireLogin: true });
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
});
|
||||
|
||||
function post(route: { POST: (r: Request) => Promise<Response> }, key?: string) {
|
||||
return route.POST(
|
||||
new Request("http://localhost/api/oauth/codex/import-token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(key ? { authorization: `Bearer ${key}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ accessToken: "x", name: "poc" }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function get(route: { GET: (r: Request) => Promise<Response> }, key?: string) {
|
||||
return route.GET(
|
||||
new Request("http://localhost/api/oauth/cursor/auto-import", {
|
||||
headers: key ? { authorization: `Bearer ${key}` } : {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
test("codex/import-token: non-manage key → 403, no key → 401, manage key passes the auth gate (GHSA-mg76)", async () => {
|
||||
const nonManage = await apiKeysDb.createApiKey("client", "machine-client", []);
|
||||
const manage = await apiKeysDb.createApiKey("admin", "machine-admin", ["manage"]);
|
||||
|
||||
assert.equal((await post(codexImportToken, nonManage.key)).status, 403, "non-manage key rejected");
|
||||
assert.equal((await post(codexImportToken)).status, 401, "no credential rejected");
|
||||
|
||||
const withManage = await post(codexImportToken, manage.key);
|
||||
assert.notEqual(withManage.status, 401, "manage key must clear the auth gate");
|
||||
assert.notEqual(withManage.status, 403, "manage key must clear the auth gate");
|
||||
});
|
||||
|
||||
test("cursor/auto-import: a non-manage key cannot read the host's Cursor token (GHSA-gxv4)", async () => {
|
||||
const nonManage = await apiKeysDb.createApiKey("client2", "machine-client2", []);
|
||||
assert.equal((await get(cursorAutoImport, nonManage.key)).status, 403, "non-manage key rejected");
|
||||
assert.equal((await get(cursorAutoImport)).status, 401, "no credential rejected");
|
||||
});
|
||||
@@ -114,46 +114,7 @@ test("POST with a valid temp dir → returns { username, password }, GET shows e
|
||||
const getBody = (await getRes.json()) as Record<string, unknown>;
|
||||
assert.equal(getBody.webdavEnabled, true);
|
||||
assert.ok(typeof getBody.webdavUsername === "string" && (getBody.webdavUsername as string).length > 0);
|
||||
// Anonymous GET (this request carries no management credential): the plaintext
|
||||
// password is masked (GHSA-62vw), but the set/unset flag still reflects state.
|
||||
assert.equal(getBody.webdavPassword, null);
|
||||
assert.equal(getBody.webdavPasswordSet, true);
|
||||
} finally {
|
||||
fs.rmSync(vaultDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("GET masks the WebDAV password for anonymous callers but reveals it to a management session (GHSA-62vw)", async () => {
|
||||
const vaultDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-vault-62vw-"));
|
||||
try {
|
||||
// Enable WebDAV so there is a stored password to leak.
|
||||
const enableRes = await route.POST(
|
||||
makeRequest("http://localhost/api/settings/obsidian/webdav", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ vaultPath: vaultDir }),
|
||||
})
|
||||
);
|
||||
assert.equal(enableRes.status, 200);
|
||||
|
||||
// Anonymous (open-mode) caller: password masked, flag still set.
|
||||
const anonBody = (await (await route.GET(
|
||||
makeRequest("http://localhost/api/settings/obsidian/webdav")
|
||||
)).json()) as Record<string, unknown>;
|
||||
assert.equal(anonBody.webdavEnabled, true);
|
||||
assert.equal(anonBody.webdavPassword, null, "anonymous caller must not receive the plaintext password");
|
||||
assert.equal(anonBody.webdavPasswordSet, true);
|
||||
|
||||
// Genuine management session: the operator's reveal-password view still works.
|
||||
const sessionReq = (await makeManagementSessionRequest(
|
||||
"http://localhost/api/settings/obsidian/webdav"
|
||||
)) as unknown as NextRequest;
|
||||
const sessionBody = (await (await route.GET(sessionReq)).json()) as Record<string, unknown>;
|
||||
assert.ok(
|
||||
typeof sessionBody.webdavPassword === "string" &&
|
||||
(sessionBody.webdavPassword as string).length > 0,
|
||||
"a management session must still receive the plaintext password"
|
||||
);
|
||||
assert.ok(typeof getBody.webdavPassword === "string" && (getBody.webdavPassword as string).length > 0);
|
||||
} finally {
|
||||
fs.rmSync(vaultDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
68
tests/unit/opencode-merge-provider-guard.test.ts
Normal file
68
tests/unit/opencode-merge-provider-guard.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
// `mergeOpenCodeConfig` guarded the root against a non-object but not the
|
||||
// `provider` branch it spreads one level down. Spreading a non-object does not
|
||||
// throw, it splays the value into index keys, so an existing config with a
|
||||
// malformed `provider` was rewritten into nonsense instead of being rejected:
|
||||
//
|
||||
// provider: ["a", "b"] -> { "0": "a", "1": "b", omniroute: {...} }
|
||||
// provider: "oops" -> { "0": "o", "1": "o", "2": "p", "3": "s", ... }
|
||||
//
|
||||
// Its sibling `mergeOpenCodeConfigText` throws on the same input
|
||||
// ("Can not add index to parent of type array"), so the two disagreed on what a
|
||||
// malformed config means.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { mergeOpenCodeConfig, mergeOpenCodeConfigText } =
|
||||
await import("../../src/shared/services/opencodeConfig.ts");
|
||||
|
||||
const INPUT = {
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk_test_opencode",
|
||||
model: "claude-sonnet-4-5-thinking",
|
||||
};
|
||||
|
||||
for (const [label, provider] of [
|
||||
["an array", ["a", "b"]],
|
||||
["a string", "oops"],
|
||||
["a number", 7],
|
||||
["null", null],
|
||||
] as const) {
|
||||
test(`mergeOpenCodeConfig drops a provider block that is ${label}`, () => {
|
||||
const merged = mergeOpenCodeConfig({ provider } as never, INPUT);
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(merged.provider),
|
||||
["omniroute"],
|
||||
`a provider block that is ${label} must not be splayed into index keys`
|
||||
);
|
||||
assert.equal(merged.provider.omniroute.options.baseURL, "http://localhost:20128/v1");
|
||||
});
|
||||
}
|
||||
|
||||
test("mergeOpenCodeConfig still preserves sibling providers", () => {
|
||||
// The guard must only reject non-objects, never a legitimate provider map.
|
||||
const merged = mergeOpenCodeConfig(
|
||||
{ provider: { custom: { name: "Custom Provider" }, other: { name: "Other" } } } as never,
|
||||
INPUT
|
||||
);
|
||||
|
||||
assert.deepEqual(Object.keys(merged.provider).sort(), ["custom", "omniroute", "other"]);
|
||||
assert.equal(merged.provider.custom.name, "Custom Provider");
|
||||
});
|
||||
|
||||
test("mergeOpenCodeConfig still guards the root itself", () => {
|
||||
for (const existing of [["a"], "oops", 7, null, undefined]) {
|
||||
const merged = mergeOpenCodeConfig(existing as never, INPUT);
|
||||
assert.deepEqual(Object.keys(merged.provider), ["omniroute"]);
|
||||
assert.equal(merged.$schema, "https://opencode.ai/config.json");
|
||||
}
|
||||
});
|
||||
|
||||
test("mergeOpenCodeConfigText keeps refusing the same malformed input", () => {
|
||||
// Pinning the sibling's behaviour: the object variant now drops the bad
|
||||
// block, the text variant still refuses to touch the file. Both are safe;
|
||||
// neither corrupts.
|
||||
for (const text of ['{"provider": ["a","b"]}', '{"provider": "oops"}']) {
|
||||
assert.throws(() => mergeOpenCodeConfigText(text, INPUT));
|
||||
}
|
||||
});
|
||||
@@ -53,3 +53,27 @@ test("stripGroqUnsupportedFields is immutable (does not mutate input)", () => {
|
||||
assert.equal(input.messages[0].name, "bob");
|
||||
assert.equal(input.logprobs, true);
|
||||
});
|
||||
|
||||
test("stripGroqUnsupportedFields drops unsupported messages[].model and other metadata while keeping role and content", () => {
|
||||
const out = stripGroqUnsupportedFields({
|
||||
messages: [
|
||||
{ role: "user", content: "hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "hello!",
|
||||
model: "groq/openai/gpt-oss-20b",
|
||||
messageId: "msg_123",
|
||||
sender: "assistant",
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(out.messages.length, 2);
|
||||
assert.equal(out.messages[0].role, "user");
|
||||
assert.equal(out.messages[0].content, "hello");
|
||||
assert.equal(out.messages[1].role, "assistant");
|
||||
assert.equal(out.messages[1].content, "hello!");
|
||||
assert.equal("model" in out.messages[1], false);
|
||||
assert.equal("messageId" in out.messages[1], false);
|
||||
assert.equal("sender" in out.messages[1], false);
|
||||
});
|
||||
|
||||
|
||||
116
tests/unit/silent-sse-close-responses-no-terminal.test.ts
Normal file
116
tests/unit/silent-sse-close-responses-no-terminal.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Regression test — silent SSE close on OpenAI Responses-format clients.
|
||||
*
|
||||
* Companion to #10443 (OpenAI chat completions) and #7699 (Claude). A healthy
|
||||
* OpenAI Responses stream ALWAYS terminates with an explicit
|
||||
* `response.completed` event; it is the format's only terminal marker and
|
||||
* carries the final status/usage. When the upstream forwards content deltas
|
||||
* and then closes without that event, Responses-format clients (Codex CLI and
|
||||
* other /v1/responses consumers) previously received a silent mid-stream
|
||||
* close — indistinguishable from a healthy end, so the client waits on a
|
||||
* completion event that never arrives. The close must surface a synthetic
|
||||
* `response.failed` instead, keeping everything already forwarded.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { createDisconnectAwareStream, createStreamController } =
|
||||
await import("../../open-sse/utils/streamHandler.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
function createNoopAbortWritableStream(): { getWriter: () => { abort: () => Promise<void> } } {
|
||||
return { getWriter: () => ({ abort: () => Promise.resolve() }) };
|
||||
}
|
||||
|
||||
async function drainStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = stream.getReader();
|
||||
const parts: Uint8Array[] = [];
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
parts.push(value);
|
||||
}
|
||||
return new TextDecoder().decode(
|
||||
parts.reduce((acc, c) => {
|
||||
const merged = new Uint8Array(acc.length + c.length);
|
||||
merged.set(acc, 0);
|
||||
merged.set(c, acc.length);
|
||||
return merged;
|
||||
}, new Uint8Array(0))
|
||||
);
|
||||
}
|
||||
|
||||
async function runClientStream(
|
||||
upstreamChunks: string[],
|
||||
clientResponseFormat: string | null
|
||||
): Promise<string> {
|
||||
const upstream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of upstreamChunks) controller.enqueue(encoder.encode(chunk));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const transform = new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
const transformedBody = upstream.pipeThrough(transform);
|
||||
|
||||
const sc = createStreamController({
|
||||
provider: "opencode-go",
|
||||
model: "muse-spark-1.2-contributor",
|
||||
clientResponseFormat,
|
||||
});
|
||||
|
||||
const wrapped = createDisconnectAwareStream(
|
||||
{ readable: transformedBody, writable: createNoopAbortWritableStream() },
|
||||
sc
|
||||
);
|
||||
|
||||
return drainStream(wrapped);
|
||||
}
|
||||
|
||||
test("Responses format: content then bare close emits response.failed, not a silent close", async () => {
|
||||
const text = await runClientStream(
|
||||
[
|
||||
'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"partial"}\n\n',
|
||||
],
|
||||
FORMATS.OPENAI_RESPONSES
|
||||
);
|
||||
|
||||
// The forwarded content survives...
|
||||
assert.match(text, /partial/);
|
||||
// ...and the close must be flagged with the format's failure terminal.
|
||||
assert.match(text, /response\.failed/);
|
||||
assert.match(text, /Upstream stream ended without a terminal marker/);
|
||||
});
|
||||
|
||||
test("Responses format: response.completed counts as terminal, no synthetic failure", async () => {
|
||||
const text = await runClientStream(
|
||||
[
|
||||
'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"done"}\n\n',
|
||||
'event: response.completed\ndata: {"type":"response.completed","response":{"status":"completed"}}\n\n',
|
||||
],
|
||||
FORMATS.OPENAI_RESPONSES
|
||||
);
|
||||
|
||||
assert.match(text, /done/);
|
||||
assert.match(text, /response\.completed/);
|
||||
assert.doesNotMatch(text, /response\.failed/);
|
||||
assert.doesNotMatch(text, /Upstream stream ended without a terminal marker/);
|
||||
});
|
||||
|
||||
test("Responses format: OPENAI_RESPONSE alias gets the same bare-close verdict", async () => {
|
||||
const text = await runClientStream(
|
||||
[
|
||||
'event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"partial"}\n\n',
|
||||
],
|
||||
FORMATS.OPENAI_RESPONSE
|
||||
);
|
||||
|
||||
assert.match(text, /partial/);
|
||||
assert.match(text, /response\.failed/);
|
||||
});
|
||||
Reference in New Issue
Block a user