mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-29 10:32:11 +03:00
Compare commits
7 Commits
fix/tests-
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbb05f88c9 | ||
|
|
3b752f9d4c | ||
|
|
757cc3bb9d | ||
|
|
60bbf0f8f0 | ||
|
|
3d4f3e4960 | ||
|
|
751710616a | ||
|
|
1ecd83c6af |
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -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
|
||||
|
||||
22
.github/workflows/electron-release.yml
vendored
22
.github/workflows/electron-release.yml
vendored
@@ -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
|
||||
|
||||
10
.github/workflows/nightly-llm-security.yml
vendored
10
.github/workflows/nightly-llm-security.yml
vendored
@@ -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
|
||||
|
||||
5
.github/workflows/nightly-resilience.yml
vendored
5
.github/workflows/nightly-resilience.yml
vendored
@@ -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,
|
||||
|
||||
5
.github/workflows/nightly-schemathesis.yml
vendored
5
.github/workflows/nightly-schemathesis.yml
vendored
@@ -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
|
||||
|
||||
1
changelog.d/fixes/v3850-electron-release-assets.md
Normal file
1
changelog.d/fixes/v3850-electron-release-assets.md
Normal 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
|
||||
1
changelog.d/maintenance/11924-type-the-frozen-any.md
Normal file
1
changelog.d/maintenance/11924-type-the-frozen-any.md
Normal 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)
|
||||
1
changelog.d/maintenance/11965-nightly-jobs-omni-light.md
Normal file
1
changelog.d/maintenance/11965-nightly-jobs-omni-light.md
Normal 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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ≈ 14–16 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` (~5–6 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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user