Compare commits

..

7 Commits

Author SHA1 Message Date
dependabot[bot]
cbb05f88c9 merge(release/v3.8.51): refresh onto 3b752f9d4c
Bring inherited Fast Quality Gates / ESLint / unit-test / docs-sync fixes from #11940/#11955/#11975.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-29 04:07:06 -03:00
Diego Rodrigues de Sa e Souza
3b752f9d4c chore(quality): type the 55 no-explicit-any sites frozen under #11924 (#11975)
Production (open-sse/utils/socksConnectorWithFamily.ts, 4 sites): every cast was
redundant — undici's buildConnector.BuildOptions already has `timeout?: number | null`,
socks' SocksClientOptions has `timeout?: number`, and Agent.Options' `connect` /
`connectTimeout` narrow to the connector's parameter types on their own. Behaviour
unchanged; check:open-sse-typecheck stays at the frozen 5.

Tests (51 sites): the socks-timeout mocks now carry the real types — the patched
SocksClient.createConnection is typed as the static it replaces, the fake
buildConnector returns buildConnector.connector, the proxy is a SocksProxy, the
dynamic import is typed as the module it loads; the e2e suite passes a SocksProxy and
Agent.Options and no longer casts undici's fetch init (its RequestInit already has
`dispatcher`); the isFree suites narrow getCustomModels()' JSON to a declared row
shape, feed deliberately-wrong values through `unknown`, and stop casting for
zod's safeParse, which takes unknown.

The six files' suppression entries are removed: 1238 → 1232 files, 5487 → 5432
suppressed. ESLint without the suppressions file reports 0 problems on all six;
with it, no stale entry is left. The five suites pass (4, 2, 5, 4, 4).
2026-08-29 03:06:50 -03:00
Diego Rodrigues de Sa e Souza
757cc3bb9d fix(release): let the Electron workflow start again — grant actions:read to the npm leg (release/v3.8.51 twin of #11973) (#11974)
Same three changes as #11973 on main, applied to this branch's newer copy of the
workflow so the v3.8.51 tag does not repeat v3.8.50's zero-asset release:
publish-npm grants actions:read (the called publish job requests it — a caller that
grants less is refused at startup and the release job dies with it), a publish_npm
dispatch input gates the npm leg, and web-build/build/release check out the tag
named by the dispatch. actionlint clean; the five workflow-pinning suites pass.
2026-08-29 02:42:13 -03:00
Diego Rodrigues de Sa e Souza
60bbf0f8f0 fix(ci): stop a stalled Codecov upload from cancelling the Coverage job and the main run (#11972)
The job has timeout-minutes: 20; the c8 merge across 8 shards takes ~10 min and the
Codecov upload (declared informational) then hung for the rest of the budget on two
consecutive main runs (33207760653, 33215115341) — GitHub cancels the step, the job
ends cancelled, and the run's conclusion turns cancelled although every blocking job
was green. The upload step now has its own 5-minute ceiling and continue-on-error;
the job budget is 30 min. check-workflows suite 32/32; zizmor ratchet unchanged.
2026-08-29 02:10:25 -03:00
Diego Rodrigues de Sa e Souza
3d4f3e4960 test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966) (#11968)
* test(infra): retry recursive temp-dir removal instead of failing a shard on ENOTEMPTY (#11966)

Two shards on release/v3.8.51 went red in one day with the same signature —
"ENOTEMPTY, Directory not empty: /tmp/omniroute-<test>-XXXXXX" — from
combo-same-provider-cascade (Unit Tests fast-path 4/4, on a PR that touches only
.github/) and auth-policy-embeddings-webfetch-7785 (the 20k-test TIA step). Both pass
alone and on re-run: the cleanup races something still writing into the directory
(SQLite WAL/-shm checkpoint, a worker, the backup) and under a loaded hosted runner
the window opens. 1154 test files do their own cleanup with
fs.rmSync(dir, { recursive: true, force: true }); 57 already asked for retries.

One-shot codemod (scripts/ad-hoc/codemod-rm-maxretries.mjs, kept for the record):
every rm / rmSync / rmdirSync option object with `recursive: true` and no
`maxRetries` gains `maxRetries: 5, retryDelay: 100` — Node itself then retries
ENOTEMPTY/EBUSY/EPERM for up to ~0.5 s before giving up. 2243 call sites in 1292
files under tests/, the shared tests/_setup/isolateDataDir.ts exit hook included.
Only the option object changes: no call site, assertion or import is touched.

Validation: prettier and ESLint (with the frozen suppressions) clean on all 1292
files; a random 20-file sample runs green (quota-redis-store hangs identically on
the untouched tree — it needs a Redis on localhost, an environment matter). The
four unit shards on this PR are the full run.

* fix(quality): let check-forgotten-sibling-tests read a 1,000-file diff

The gate shells out to `git diff` through execFileSync with Node's default 1 MB
maxBuffer; the 1,292-file codemod in this PR is the first diff large enough to
overflow it, and the gate died with `spawnSync git ENOBUFS` before comparing
anything. 64 MB is far above any real PR and costs nothing when unused.
2026-08-29 01:17:40 -03:00
Diego Rodrigues de Sa e Souza
751710616a fix(ci): run the build-bearing nightly jobs on the box's light pool (#11965) (#11967)
Four nightly jobs run a backend-only `next build` on ubuntu-latest (7 GB):
Schemathesis, promptfoo injection guard, garak probes and the axe a11y suite
(self-building webServer). On release/v3.8.51 three of them died with the hosted
VM shutdown signature and nobody saw it — nightlies have no audience — and the
fourth passes by a margin of minutes. They now target [self-hosted, omni-light]
(hosted fallback when USE_VPS_RUNNER is off), a new two-listener label on the .113
box for jobs that need ~6 GB, not the 14-16 GB of a full build; they run once a day
in the 04:00-06:00 UTC window, when the box is idle.

Fleet reshaped the same day and documented in docs/ops/RUNNER_BOX.md: 4 active
OmniRoute listeners (omniroute-113-5/-6 omni-build, omniroute-113/-2 omni-light),
omniroute-113-3/-4/-7/-8 disabled (systemctl enable --now brings one back), janitor
ceiling MAX_ACTIVE_RUNNERS=4. The remaining headroom limit is the VM's 31 GB of RAM
(2 heavy + 2 light ≈ 42 GB peak, inside the 16 GB swap); more RAM on the Proxmox VM
is the lever that turns the label ceilings into 3 heavy + 2 light.

check:workflows --ratchet unchanged (194/194); check-workflows and
backend-only-smoke-workflows suites pass; docs-sync PASS.
2026-08-28 23:26:52 -03:00
dependabot[bot]
1ecd83c6af chore(deps): bump github/codeql-action from 4.37.7 to 4.37.8
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.7 to 4.37.8.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4.37.7...v4.37.8)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 18:26:32 +00:00
18 changed files with 222 additions and 106 deletions

View File

@@ -979,7 +979,11 @@ jobs:
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
timeout-minutes: 20
# 30, not 20 (2026-08-29): the informational Codecov upload below hung for the rest of
# the budget on two consecutive main runs (33207760653, 33215115341); the job ended
# `cancelled` and dragged the whole run's conclusion to `cancelled` although every
# blocking job was green. The upload step now has its own ceiling; this is headroom.
timeout-minutes: 30
needs: test-unit
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
env:
@@ -1058,6 +1062,10 @@ jobs:
# (if-no-files-found: warn) — Sonar consumes the same file.
- name: Upload coverage to Codecov (informational)
if: always()
# Informational means informational: its own ceiling and continue-on-error, so a
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
timeout-minutes: 5
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage/lcov.info

View File

@@ -499,7 +499,7 @@ jobs:
- name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
uses: github/codeql-action/upload-sarif@v4.37.7
uses: github/codeql-action/upload-sarif@v4.37.8
with:
sarif_file: trivy-results.sarif
category: trivy-image

View File

@@ -10,6 +10,11 @@ on:
description: "Release version (e.g., v1.6.8)"
required: true
type: string
publish_npm:
description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)"
required: false
default: true
type: boolean
# Least-privilege default: read-only at the top level; each job grants the writes it
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
@@ -76,6 +81,9 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
# tag push this resolves to the same commit.
ref: ${{ needs.validate.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
with:
@@ -161,6 +169,9 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
# tag push this resolves to the same commit.
ref: ${{ needs.validate.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
with:
@@ -347,6 +358,8 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
# Source archives + SBOM come from the tag being released, not the dispatching branch.
ref: ${{ needs.validate.outputs.version }}
# `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL
# ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their
@@ -462,11 +475,20 @@ jobs:
publish-npm:
name: Publish to npm
needs: [validate, release]
# A re-dispatch that only re-attaches desktop assets must not publish the npm package again.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
permissions:
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
# A reusable workflow's job cannot request more permission than the caller grants,
# so a `read` here makes GitHub reject the run at startup (startup_failure).
#
# `actions: read` for the same reason: the called `publish` job downloads the next-build
# artefact and requests it. v3.8.50 (run 33005490476) died at startup with "The nested
# job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'" — and
# because `release` lives in this same workflow, the tag shipped with ZERO assets. Keep
# this block a superset of every job's permissions in npm-publish.yml.
actions: read
contents: write
id-token: write # npm provenance (forwarded to the reusable workflow)
packages: write # publish to npm.pkg.github.com

View File

@@ -10,7 +10,10 @@ permissions:
jobs:
promptfoo-guard:
name: promptfoo — injection guard (block mode, no secret)
runs-on: ubuntu-latest
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v7
with:
@@ -46,7 +49,10 @@ jobs:
garak:
name: garak probes (skip without provider secret)
runs-on: ubuntu-latest
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
# NOTE: the `secrets` context is NOT available in a job-level `if:` — referencing
# it there makes GitHub reject the file on push (startup_failure on every push).
# Map the secret into a job-level env and gate each step on a presence check, so

View File

@@ -78,7 +78,10 @@ jobs:
a11y:
name: A11y axe (nightly, freeze-and-alert)
runs-on: ubuntu-latest
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,

View File

@@ -10,7 +10,10 @@ permissions:
jobs:
schemathesis:
name: Schemathesis — OpenAPI contract fuzz (advisory)
runs-on: ubuntu-latest
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v7

View File

@@ -0,0 +1 @@
- Electron release workflow: the `publish-npm` job now grants `actions: read` to the reusable `npm-publish.yml` it calls (its `publish` job requests it), which is what made GitHub refuse the whole v3.8.50 run at startup and ship the release with zero desktop assets; a `workflow_dispatch` now builds the requested tag instead of the dispatching branch and can skip the npm leg (`publish_npm=false`) when only re-attaching assets

View File

@@ -0,0 +1 @@
- Type the 55 `no-explicit-any` sites that had been frozen under #11924 — four redundant casts in `socksConnectorWithFamily.ts` (undici/socks types already accept them) and the mocks/fixtures of the socks-timeout and isFree suites — and drop their suppression entries; the ESLint ratchet shrinks from 5487 to 5432 (Closes #11924)

View File

@@ -0,0 +1 @@
- Move the four nightly jobs that build the backend (`nightly-schemathesis`, `nightly-llm-security` promptfoo + garak, `nightly-resilience` axe-a11y) off the hosted 7 GB runner — where they died on `release/v3.8.51` unseen — onto the box's new `omni-light` pool (two listeners), and document the reshaped fleet (4 active OmniRoute listeners: 2 `omni-build` + 2 `omni-light`, janitor ceiling 4) (Closes #11965)

View File

@@ -0,0 +1 @@
- `Coverage` job on `ci.yml`: the informational Codecov upload gets its own 5-minute ceiling and `continue-on-error`, and the job budget grows from 20 to 30 minutes (the 8-shard c8 merge alone takes ~10) — a stalled upload no longer ends the job `cancelled` and drags a fully green `main` run's conclusion down with it

View File

@@ -837,11 +837,6 @@
"count": 5
}
},
"open-sse/utils/socksConnectorWithFamily.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 4
}
},
"open-sse/utils/stream.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
@@ -4952,11 +4947,6 @@
"count": 20
}
},
"tests/unit/free-models-isfree.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"tests/unit/functional-gateway-mirrors-append.test.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -5243,11 +5233,6 @@
"count": 3
}
},
"tests/unit/models-db-isfree.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 18
}
},
"tests/unit/modelsDevSync-extended.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -5547,11 +5532,6 @@
"count": 4
}
},
"tests/unit/providerModelMutationSchema-isfree.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
}
},
"tests/unit/providers-route-managed-catalog.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 4
@@ -6021,16 +6001,6 @@
"count": 7
}
},
"tests/unit/socks-connect-timeout-e2e.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 11
}
},
"tests/unit/socks-connect-timeout.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 18
}
},
"tests/unit/spend-batch-writer.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -6552,4 +6522,4 @@
"count": 2
}
}
}
}

View File

@@ -7,13 +7,13 @@ title: Self-Hosted Runner Box Operations
The self-hosted pool (`self-hosted, omni-release` on all eight runners; `omni-build` on two) runs on the **.113** box.
Measured 2026-08-28 (v3.8.50 postmortem, Parte III):
| resource | value | what it means for scheduling |
| --------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| RAM / CPU | **31 GB / 32 cores** (was 16 GB when this doc was first written) | one `next-build` peaks at **~14 GB** → 2 concurrent heavy builds saturate the box, 3 take it down (2026-08-28 06:42Z: load 56, two jobs lost) |
| swap | 15 GB | it swapped its way through the v3.8.50 publish; pressure shows in `/proc/pressure/memory` |
| `/tmp` | **12 GB tmpfs = RAM** | anything parked there is memory; leftovers are swept after 3 h |
| disk | 188 GB | `_work` checkouts of 8 runners reach ~70 GB with no cap |
| runners | **10 listeners**: 8 OmniRoute + OmniHeuris + OmniMind | all share the memory above |
| resource | value | what it means for scheduling |
| --------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| RAM / CPU | **31 GB / 32 cores** (was 16 GB when this doc was first written) | one `next-build` peaks at **~14 GB** → 2 concurrent heavy builds saturate the box, 3 take it down (2026-08-28 06:42Z: load 56, two jobs lost) |
| swap | 15 GB | it swapped its way through the v3.8.50 publish; pressure shows in `/proc/pressure/memory` |
| `/tmp` | **12 GB tmpfs = RAM** | anything parked there is memory; leftovers are swept after 3 h |
| disk | 188 GB | `_work` checkouts of 8 runners reach ~70 GB with no cap |
| runners | **6 listeners**: 4 OmniRoute (2 `omni-build` + 2 `omni-light`) + OmniHeuris + OmniMind | all share the memory above; `omniroute-113-3/-4/-7/-8` are disabled (`systemctl enable --now` brings one back) |
## Install the janitor (one-time, on the box)
@@ -21,7 +21,7 @@ Measured 2026-08-28 (v3.8.50 postmortem, Parte III):
scp scripts/ops/runner-janitor.sh root@192.168.0.113:/opt/omniroute-ops/runner-janitor.sh
ssh root@192.168.0.113 'chmod +x /opt/omniroute-ops/runner-janitor.sh; apt-get install -y lsof'
# cron (root): every 30 min, log to /var/log/runner-janitor.log
*/30 * * * * MAX_ACTIVE_RUNNERS=8 /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
*/30 * * * * MAX_ACTIVE_RUNNERS=4 /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
```
`lsof` is required: the janitor proves a path is idle with one snapshot of open
@@ -57,6 +57,15 @@ a time, only when idle**, with the idle check and the restart in the same comman
queues a third build instead of the kernel killing one. Pair with the `heavy-build-*`
concurrency lanes in `ci.yml`. To add capacity, label another runner — never raise
the count past what 31 GB holds (one next-build ≈ 1416 GB).
- **Light pool: `omni-light` (2026-08-29, #11965).** `omniroute-113` and `omniroute-113-2` carry
`omni-light` for jobs that need a backend-only `next build` (~56 GB) but not a full one: the
nightly Schemathesis, promptfoo, garak and axe-a11y jobs. They ran on the hosted 7 GB runner and
died on `release/v3.8.51` with nobody watching. Worst case on the box is 2 heavy + 2 light ≈
30 + 12 GB — over 31 GB of RAM, inside the 16 GB of swap; the real fix for headroom is more RAM
on the Proxmox VM (`tomni-proxmox-113`), which turns the label ceilings into 3 heavy + 2 light.
- **Fewer listeners on purpose.** Four OmniRoute units were disabled on 2026-08-29 — with only
`ci.yml` `Build` and the nightlies using the box, 8 listeners were idle and each extra one is a
potential 14 GB tenant. The janitor ceiling is 4 (`MAX_ACTIVE_RUNNERS=4` in cron).
- **Never clean `/tmp` or `_work` by hand while any runner is busy.** A
check-then-delete with a gap between the two is how a live Build job lost its
`_work` on 2026-08-27. The janitor does the check and the removal in one step;

View File

@@ -49,13 +49,15 @@ export function socksConnectorWithFamily(
const isDisabled = connectTimeout === 0;
// SOCKS lib: 0 throws (isValidTimeoutValue: value>0) and undefined → DEFAULT_TIMEOUT 30s;
// undici: 0 disables (core/util.js: if (!opts.timeout) return noop), undefined → 10s. Divergence intentional.
const handshakeTimeout = isDisabled ? undefined : (connectTimeout ?? resolveSocksHandshakeTimeoutMs());
const handshakeTimeout = isDisabled
? undefined
: (connectTimeout ?? resolveSocksHandshakeTimeoutMs());
const tlsTimeout = connectTimeout;
// Sequential budget: both phases bounded by the same connectTimeout → wall-time up to 60s for https
// (vs 30s direct). Shared-deadline alternative rejected as unjustified complexity.
const build = _buildConnectorForTest ?? buildConnector;
const undiciConnect = build(
tlsTimeout !== undefined ? ({ ...tlsOpts, timeout: tlsTimeout } as any) : tlsOpts
tlsTimeout !== undefined ? { ...tlsOpts, timeout: tlsTimeout } : tlsOpts
);
const socketOptions = buildSocksFamilySocketOptions(family);
return async (options, callback) => {
@@ -69,7 +71,7 @@ export function socksConnectorWithFamily(
const r = await SocksClient.createConnection({
command: "connect",
proxy,
timeout: handshakeTimeout as any,
timeout: handshakeTimeout,
destination: { host: hostname, port: resolvePort(protocol, port) },
existing_socket: httpSocket as never,
socket_options: socketOptions as never,
@@ -97,6 +99,6 @@ export function createSocksDispatcherWithFamily(
};
return new Agent({
...rest,
connect: socksConnectorWithFamily(proxy, family, connect as any, connectTimeout as any),
connect: socksConnectorWithFamily(proxy, family, connect, connectTimeout),
});
}

View File

@@ -9,8 +9,13 @@ describe("isFreeModel isFree opt-in", () => {
assert.equal(isFreeModel("local", { id: "my-model", isFree: true }), true);
});
it("isFree:false/null/undefined/1/'true' → not free (strict ===true)", () => {
for (const v of [false, null, undefined, 1, "true" as any]) {
assert.equal(isFreeModel("any", { id: "x", isFree: v as any }), false, `isFree=${String(v)} should be false`);
const junk: unknown[] = [false, null, undefined, 1, "true"];
for (const v of junk) {
assert.equal(
isFreeModel("any", { id: "x", isFree: v as boolean }),
false,
`isFree=${String(v)} should be false`
);
}
});
it("providerHasFreeModels unchanged by custom isFree", () => {

View File

@@ -11,6 +11,8 @@ import { rmSync, mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
type CustomRow = { id: string; isFree?: boolean };
describe("custom isFree tri-state (DB)", () => {
let dir: string;
let prevDataDir: string | undefined;
@@ -44,8 +46,8 @@ describe("custom isFree tri-state (DB)", () => {
undefined,
true
);
const rows: any = await getCustomModels("p");
const r = rows.find((x: any) => x.id === "m1");
const rows = (await getCustomModels("p")) as CustomRow[];
const r = rows.find((x) => x.id === "m1");
assert.equal(r.isFree, true);
await addCustomModel(
"p",
@@ -60,8 +62,8 @@ describe("custom isFree tri-state (DB)", () => {
undefined,
undefined
);
const rows2: any = await getCustomModels("p");
const r2 = rows2.find((x: any) => x.id === "m2");
const rows2 = (await getCustomModels("p")) as CustomRow[];
const r2 = rows2.find((x) => x.id === "m2");
assert.equal(r2.isFree, undefined);
});
@@ -79,9 +81,9 @@ describe("custom isFree tri-state (DB)", () => {
undefined,
true
);
await updateCustomModel("p", "m", { isFree: null } as any);
const rows: any = await getCustomModels("p");
const r = rows.find((x: any) => x.id === "m");
await updateCustomModel("p", "m", { isFree: null });
const rows = (await getCustomModels("p")) as CustomRow[];
const r = rows.find((x) => x.id === "m");
assert.equal(r.isFree, undefined);
});
@@ -99,13 +101,13 @@ describe("custom isFree tri-state (DB)", () => {
undefined,
undefined
);
await updateCustomModel("p", "m", { isFree: true } as any);
let rows: any = await getCustomModels("p");
assert.equal(rows.find((x: any) => x.id === "m").isFree, true);
await updateCustomModel("p", "m", { isFree: true });
let rows = (await getCustomModels("p")) as CustomRow[];
assert.equal(rows.find((x) => x.id === "m").isFree, true);
// tri-state helper treats false as Boolean(false) → stored as false (falsy free), but only true is free per isFree guard
await updateCustomModel("p", "m", { isFree: false } as any);
await updateCustomModel("p", "m", { isFree: false });
rows = await getCustomModels("p");
assert.equal(rows.find((x: any) => x.id === "m").isFree, false);
assert.equal(rows.find((x) => x.id === "m").isFree, false);
});
it("replaceCustomModels preserves isFree (new wins else prev)", async () => {
@@ -138,15 +140,15 @@ describe("custom isFree tri-state (DB)", () => {
// replace with new truth for override, omit for keep (prev should win)
await replaceCustomModels("p", [
{ id: "keep", name: "keep" },
{ id: "override", name: "override", isFree: true } as any,
{ id: "override", name: "override", isFree: true },
]);
const rows: any = await getCustomModels("p");
const rows = (await getCustomModels("p")) as CustomRow[];
assert.equal(
rows.find((x: any) => x.id === "keep").isFree,
rows.find((x) => x.id === "keep").isFree,
true,
"prev isFree preserved when new omits"
);
assert.equal(rows.find((x: any) => x.id === "override").isFree, true, "new isFree wins");
assert.equal(rows.find((x) => x.id === "override").isFree, true, "new isFree wins");
});
it("allowEmpty:false intact (no destructive clear)", async () => {
@@ -163,8 +165,8 @@ describe("custom isFree tri-state (DB)", () => {
undefined,
true
);
const before: any = await getCustomModels("p");
const after: any = await replaceCustomModels("p", [], { allowEmpty: false });
const before = (await getCustomModels("p")) as CustomRow[];
const after = await replaceCustomModels("p", [], { allowEmpty: false });
assert.equal(after.length, before.length);
});
});

View File

@@ -4,17 +4,35 @@ import { providerModelMutationSchema } from "../../src/shared/validation/schemas
describe("providerModelMutationSchema isFree", () => {
it("isFree:true accepted", () => {
assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: true }).success, true);
assert.equal(
providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: true }).success,
true
);
});
it("old payload without isFree still valid", () => {
assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m" }).success, true);
assert.equal(
providerModelMutationSchema.safeParse({ provider: "p", modelId: "m" }).success,
true
);
});
it("rejects isFree:0 and isFree:\"yes\"", () => {
assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: 0 as any }).success, false);
assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: "yes" as any }).success, false);
it('rejects isFree:0 and isFree:"yes"', () => {
assert.equal(
providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: 0 }).success,
false
);
assert.equal(
providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: "yes" }).success,
false
);
});
it("nullable true/false/null accepted", () => {
assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: null }).success, true);
assert.equal(providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: false }).success, true);
assert.equal(
providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: null }).success,
true
);
assert.equal(
providerModelMutationSchema.safeParse({ provider: "p", modelId: "m", isFree: false }).success,
true
);
});
});

View File

@@ -1,11 +1,14 @@
import { describe, it, afterEach } from "node:test";
import assert from "node:assert/strict";
import net from "node:net";
import { fetch } from "undici";
import { fetch, type Agent } from "undici";
import type { SocksProxy } from "socks";
import { createSocksDispatcherWithFamily } from "../../open-sse/utils/socksConnectorWithFamily.ts";
import { clearDispatcherCache } from "../../open-sse/utils/proxyDispatcher.ts";
async function startFakeSocks(opts: { stallAfterGrant: boolean }): Promise<{ port: number; close: () => Promise<void> }> {
async function startFakeSocks(opts: {
stallAfterGrant: boolean;
}): Promise<{ port: number; close: () => Promise<void> }> {
return new Promise((resolve) => {
const serverSockets = new Set<net.Socket>();
const server = net.createServer((socket) => {
@@ -44,22 +47,43 @@ describe("stub SOCKS e2e", () => {
it("pre-grant stall (SOCKS timer) \u2192 error < 1000ms", async () => {
const { port, close } = await startFakeSocks({ stallAfterGrant: false });
const dispatcher = createSocksDispatcherWithFamily({ host: "127.0.0.1", port, type: 5 } as any, 4 as any, { connectTimeout: 300, connect: {} } as any);
const proxy: SocksProxy = { host: "127.0.0.1", port, type: 5 };
const dispatcher = createSocksDispatcherWithFamily(proxy, 4, {
connectTimeout: 300,
connect: {},
} as Agent.Options);
const t0 = Date.now();
await assert.rejects(() => fetch("https://example.invalid/", { dispatcher } as any));
assert.ok(Date.now() - t0 < 1000, `pre-grant stall must error < 1000ms, took ${Date.now() - t0}ms`);
await assert.rejects(() => fetch("https://example.invalid/", { dispatcher }));
assert.ok(
Date.now() - t0 < 1000,
`pre-grant stall must error < 1000ms, took ${Date.now() - t0}ms`
);
await close();
});
it("post-grant stall (TLS timer, https:// only) \u2192 error < 1500ms", async () => {
const { port, close } = await startFakeSocks({ stallAfterGrant: true });
const dispatcher = createSocksDispatcherWithFamily({ host: "127.0.0.1", port, type: 5 } as any, 4 as any, { connectTimeout: 300, connect: {} } as any);
const proxy: SocksProxy = { host: "127.0.0.1", port, type: 5 };
const dispatcher = createSocksDispatcherWithFamily(proxy, 4, {
connectTimeout: 300,
connect: {},
} as Agent.Options);
const t0 = Date.now();
let caught: any = null;
await assert.rejects(async () => { try { await fetch("https://example.invalid/", { dispatcher } as any); } catch (e) { caught = e; throw e; } });
const err: any = caught;
let caught: unknown = null;
await assert.rejects(async () => {
try {
await fetch("https://example.invalid/", { dispatcher });
} catch (e) {
caught = e;
throw e;
}
});
const err = caught as { message?: string } | null;
// The ~1000ms wall time is connectTimeout 300ms + undici immediate/queue overhead, not the 10000ms default.
assert.ok(Date.now() - t0 < 1500, `post-grant stall must error < 1500ms, took ${Date.now() - t0}ms (err: ${String((err as any)?.message ?? err).slice(0, 120)})`);
assert.ok(
Date.now() - t0 < 1500,
`post-grant stall must error < 1500ms, took ${Date.now() - t0}ms (err: ${String(err?.message ?? err).slice(0, 120)})`
);
await close();
});
});

View File

@@ -1,6 +1,8 @@
import { describe, it, afterEach, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { SocksClient } from "socks";
import type net from "node:net";
import { SocksClient, type SocksClientOptions, type SocksProxy } from "socks";
import type { buildConnector } from "undici";
// Lightweight oracle — node:test, no vi.mock.
// We patch SocksClient.createConnection (writable) and inject a fake
@@ -8,59 +10,79 @@ import { SocksClient } from "socks";
// mutating the read-only undici module.
describe("socks connectTimeout forwarder", () => {
let capturedTimeout: any = undefined;
let capturedTlsTimeout: any = undefined;
let capturedTimeout: number | undefined = undefined;
let capturedTlsTimeout: number | null | undefined = undefined;
let capturedTlsUndefined = false;
let origCreateConnection: any;
let origCreateConnection: typeof SocksClient.createConnection;
beforeEach(() => {
origCreateConnection = SocksClient.createConnection;
capturedTimeout = undefined;
capturedTlsTimeout = undefined;
capturedTlsUndefined = false;
(SocksClient as any).createConnection = async (opts: any) => {
SocksClient.createConnection = (async (opts: SocksClientOptions) => {
capturedTimeout = opts?.timeout;
return { socket: { setNoDelay: () => ({ setNoDelay: () => {} }) } } as any;
};
return { socket: { setNoDelay: () => ({ setNoDelay: () => {} }) } } as unknown as Awaited<
ReturnType<typeof origCreateConnection>
>;
}) as typeof SocksClient.createConnection;
});
afterEach(() => {
(SocksClient as any).createConnection = origCreateConnection;
SocksClient.createConnection = origCreateConnection;
capturedTimeout = undefined;
capturedTlsTimeout = undefined;
capturedTlsUndefined = false;
});
function fakeBuildConnector(opts: any = {}) {
function fakeBuildConnector(opts: buildConnector.BuildOptions = {}): buildConnector.connector {
if (opts && typeof opts.timeout !== "undefined") capturedTlsTimeout = opts.timeout;
else capturedTlsUndefined = true;
return (_options: any, cb: any) => cb(null, { setNoDelay: () => ({}) } as any);
return (_options, cb) => cb(null, { setNoDelay: () => ({}) } as unknown as net.Socket);
}
async function driveConnector(args: {
family: 4 | 6 | null;
tlsOpts?: any;
tlsOpts?: buildConnector.BuildOptions;
connectTimeout?: number;
protocol?: string;
hostname?: string;
port?: string;
}) {
const mod: any = await import(`../../open-sse/utils/socksConnectorWithFamily.ts?t=${Date.now()}-${Math.random()}`);
const proxy = { host: "1.2.3.4", port: 1080, type: 5 } as any;
const mod = (await import(
`../../open-sse/utils/socksConnectorWithFamily.ts?t=${Date.now()}-${Math.random()}`
)) as typeof import("../../open-sse/utils/socksConnectorWithFamily.ts");
const proxy: SocksProxy = { host: "1.2.3.4", port: 1080, type: 5 };
const tlsOpts = args.tlsOpts ?? {};
const connectTimeout = args.connectTimeout;
const connector: any = mod.socksConnectorWithFamily(proxy, args.family, tlsOpts, connectTimeout, fakeBuildConnector as any);
const connector = mod.socksConnectorWithFamily(
proxy,
args.family,
tlsOpts,
connectTimeout,
fakeBuildConnector
);
await new Promise<void>((resolve, reject) =>
connector(
{ protocol: args.protocol ?? "https:", hostname: args.hostname ?? "example.com", port: args.port ?? "443" } as any,
(err: any) => (err ? reject(err) : resolve())
{
protocol: args.protocol ?? "https:",
hostname: args.hostname ?? "example.com",
port: args.port ?? "443",
},
(err) => (err ? reject(err) : resolve())
)
);
return { capturedTimeout, capturedTlsTimeout, capturedTlsUndefined, mod, connector };
}
it("U1: Agent.connectTimeout → SocksClient.timeout + TLS timeout", async () => {
const { capturedTimeout: t, capturedTlsTimeout: tls } = await driveConnector({ family: 4, tlsOpts: {}, connectTimeout: 5000, protocol: "https:", port: "443" });
const { capturedTimeout: t, capturedTlsTimeout: tls } = await driveConnector({
family: 4,
tlsOpts: {},
connectTimeout: 5000,
protocol: "https:",
port: "443",
});
assert.equal(t, 5000);
assert.equal(tls, 5000);
});
@@ -69,7 +91,13 @@ describe("socks connectTimeout forwarder", () => {
const prev = process.env.SOCKS_HANDSHAKE_TIMEOUT_MS;
process.env.SOCKS_HANDSHAKE_TIMEOUT_MS = "7777";
try {
const { capturedTimeout: t, capturedTlsUndefined: tlsUndef } = await driveConnector({ family: 6, tlsOpts: {}, connectTimeout: undefined, protocol: "https:", port: "443" });
const { capturedTimeout: t, capturedTlsUndefined: tlsUndef } = await driveConnector({
family: 6,
tlsOpts: {},
connectTimeout: undefined,
protocol: "https:",
port: "443",
});
assert.equal(t, 7777);
assert.equal(tlsUndef, true);
} finally {
@@ -79,12 +107,24 @@ describe("socks connectTimeout forwarder", () => {
});
it("U3: http (no TLS) still bounds SocksClient", async () => {
const { capturedTimeout: t } = await driveConnector({ family: null, tlsOpts: {}, connectTimeout: 5000, protocol: "http:", port: "80" });
const { capturedTimeout: t } = await driveConnector({
family: null,
tlsOpts: {},
connectTimeout: 5000,
protocol: "http:",
port: "80",
});
assert.equal(t, 5000);
});
it("U4: connectTimeout=0 → SocksClient undefined (SOCKS defaults to 30s) + TLS timeout 0 (disabled)", async () => {
const { capturedTimeout: t, capturedTlsTimeout: tls } = await driveConnector({ family: 4, tlsOpts: {}, connectTimeout: 0, protocol: "https:", port: "443" });
const { capturedTimeout: t, capturedTlsTimeout: tls } = await driveConnector({
family: 4,
tlsOpts: {},
connectTimeout: 0,
protocol: "https:",
port: "443",
});
assert.equal(t, undefined);
assert.equal(tls, 0);
});