@wenathlan/getry 2.0.162 → 2.0.164

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.ts CHANGED
@@ -44,7 +44,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
44
44
  import { basename, join, relative, resolve } from "node:path";
45
45
  import { stdin, stdout } from "node:process";
46
46
  import { createInterface, type Interface } from "node:readline/promises";
47
- import { main } from "./server.js";
48
47
 
49
48
  // ---------------------------------------------------------------------------
50
49
  // arg parsing — zero dependency
@@ -795,6 +794,13 @@ async function cmdserve(portarg: string | undefined): Promise<void> {
795
794
  const port = Number(portarg) || Number(process.env.PORT) || 3001;
796
795
  process.env.PORT = String(port);
797
796
  console.log(`${colors.cyan}starting gateway on port ${port}${colors.reset}`);
797
+ /* the transport main loads lazily (the published-envelope law): the
798
+ * console family — init through export, chat, help — runs on node
799
+ * builtins alone inside the published envelope, and the serve seat
800
+ * imports the engine transport only when the caller actually serves,
801
+ * so a documentation envelope never crashes on a module it does not
802
+ * ship before a command ever dispatches. */
803
+ const { main } = await import("./server.js");
798
804
  await main();
799
805
  }
800
806
 
@@ -820,6 +826,93 @@ async function cmdexport(dirarg: string | undefined): Promise<void> {
820
826
  console.log(`\nexported to ${target} — deploy this folder to vercel or netlify`);
821
827
  }
822
828
 
829
+ // ---------------------------------------------------------------------------
830
+ // chat — the windows 11 console seat of the clibin family (pr13: same
831
+ // design as the web / desktop surface, the difference is only the
832
+ // responsiveness / dpi — native detection, 1x / 1.5x / 2x / 3x, never xdpi)
833
+ // ---------------------------------------------------------------------------
834
+
835
+ /** the chat accent of the prompt and the titlebar name (ansi 117 — the family accent #a3d7e6). */
836
+ const CHATACCENT = "\x1b[38;5;117m" as const;
837
+ /** the focus ink of the active top rule (ansi 75 — the family focus #4cc2ff). */
838
+ const CHATFOCUS = "\x1b[38;5;75m" as const;
839
+ /** the hover red of the unknown-command rows (ansi 203 — rgb(232, 17, 35)). */
840
+ const CHATHOVER = "\x1b[38;5;203m" as const;
841
+ /** the dim ink of the description rows (the minimal text law). */
842
+ const CHATINK = "\x1b[2m" as const;
843
+ /** the reset token that closes every colored run. */
844
+ const CHATRESET = "\x1b[0m" as const;
845
+ /** the single glyph of the rules and the rails. */
846
+ const CHATBAR = "─" as const;
847
+ /** the engine ownership one-liner of the seat, quoted from the help banner. */
848
+ const CHATENGINE = "the universal ai gateway library — any llm any baseurl any api key (the help banner)" as const;
849
+ /** the scale ladder the native detection resolves (the automatic scaling law). */
850
+ const CHATSCALESTEPS = [1, 1.5, 2, 3] as const;
851
+
852
+ /** the dpi frame the chat chrome renders inside: the resolved scale seat, the terminal columns and the rail length the titlebar draws. */
853
+ interface chatframe {
854
+ /** the scale seat of the ladder (1, 1.5, 2 or 3). */
855
+ scale: number;
856
+ /** the columns the terminal answers (windows terminal, conpty, xterm, tmux — every pty reports them). */
857
+ columns: number;
858
+ /** the titlebar rail length in cells (the pixel rail folded to the grid and clamped so the line never wraps). */
859
+ rail: number;
860
+ }
861
+
862
+ /** resolves the dpi frame natively, zero dependencies: the columns come from stdout (every windows and linux terminal answers them), the scale seat from the column ladder — the explicit GETRY_DPI environment override wins first (the native scaling knob). */
863
+ function chatdetectframe(): chatframe {
864
+ const columns = Number.isFinite(stdout.columns) && Number(stdout.columns) > 0 ? Number(stdout.columns) : 80;
865
+ const override = Number(process.env.GETRY_DPI ?? "");
866
+ const scale =
867
+ Number.isFinite(override) && override > 0 && CHATSCALESTEPS.includes(override as (typeof CHATSCALESTEPS)[number])
868
+ ? override
869
+ : columns >= 200
870
+ ? 3
871
+ : columns >= 160
872
+ ? 2
873
+ : columns >= 120
874
+ ? 1.5
875
+ : 1;
876
+ const rail = Math.max(4, Math.min(Math.round(24 * scale), columns - "getry".length - 16));
877
+ return { scale, columns, rail };
878
+ }
879
+
880
+ /** runs the chat console: the titlebar, the top rule, the ❯ prompt and the dispatch (help, version, dpi, engine, exit) — the same chat look the web / desktop surfaces carry, an eof on stdin closing the console the same way exit does. */
881
+ async function cmdchat(): Promise<void> {
882
+ const frame = chatdetectframe();
883
+ const seatversion = version();
884
+ const rl = createInterface({ input: stdin, output: stdout });
885
+ let eof = false;
886
+ rl.on("close", () => { eof = true; });
887
+ const rule = CHATBAR.repeat(Math.max(3, Math.round(3 * frame.scale)));
888
+ console.log(`${CHATACCENT}┌ getry ─ cli${CHATRESET} ${CHATINK}${CHATBAR.repeat(frame.rail)} ─ ❐ ✕${CHATRESET}`);
889
+ console.log(`${CHATFOCUS}${rule} getry ${rule}${CHATRESET}`);
890
+ console.log(`${CHATACCENT}getry${CHATRESET} ${CHATINK}v${seatversion}${CHATRESET}`);
891
+ console.log(`${CHATINK}the getry console — same design as web / desktop, only the DPI differs. type help.${CHATRESET}`);
892
+ rl.setPrompt(`${CHATACCENT}❯${CHATRESET} `);
893
+ rl.prompt();
894
+ for await (const raw of rl) {
895
+ const line = raw.trim();
896
+ const command = line.length > 0 ? line.split(/\s+/)[0]!.toLowerCase() : "";
897
+ if (command === "exit") rl.close();
898
+ else if (command === "help") {
899
+ console.log(`${CHATINK}commands${CHATRESET}`);
900
+ console.log(` help ${CHATINK}this list${CHATRESET}`);
901
+ console.log(` version ${CHATINK}the package version, read from package.json at runtime${CHATRESET}`);
902
+ console.log(` dpi ${CHATINK}the resolved dpi seat of this terminal (scale, columns, rail)${CHATRESET}`);
903
+ console.log(` engine ${CHATINK}the engine ownership one-liner of the app${CHATRESET}`);
904
+ console.log(` exit ${CHATINK}close the console${CHATRESET}`);
905
+ } else if (command === "version") console.log(`${CHATACCENT}getry${CHATRESET} ${seatversion}`);
906
+ else if (command === "dpi") console.log(`${CHATACCENT}getry${CHATRESET} ${CHATINK}dpi seat ${frame.scale}x on ${frame.columns} columns — rail ${frame.rail} cells, ladder 1x / 1.5x / 2x / 3x${CHATRESET}`);
907
+ else if (command === "engine") console.log(`${CHATINK}${CHATENGINE}${CHATRESET}`);
908
+ else if (command.length > 0) console.log(`${CHATHOVER}✕${CHATRESET} ${CHATINK}unknown command: ${command} — type help${CHATRESET}`);
909
+ if (command === "exit") break;
910
+ /* the prompt is cosmetic chrome: a piped stdin that already reached eof skips it, the buffered lines still answer. */
911
+ if (!eof) rl.prompt();
912
+ }
913
+ console.log(`${CHATINK}getry console closed.${CHATRESET}`);
914
+ }
915
+
823
916
  // ---------------------------------------------------------------------------
824
917
  // help
825
918
  // ---------------------------------------------------------------------------
@@ -843,6 +936,7 @@ function printhelp(): void {
843
936
  " models <version> list models for a version",
844
937
  " serve [--port <n>] start the gateway server",
845
938
  " export [--dir <path>] export the web folder scaffold",
939
+ " chat the windows 11 chat console seat (same design as web / desktop)",
846
940
  " help show this help",
847
941
  "",
848
942
  "Library usage:",
@@ -910,6 +1004,9 @@ async function consolemain(): Promise<void> {
910
1004
  case "export":
911
1005
  await cmdexport(stringflag(parsed, "dir"));
912
1006
  break;
1007
+ case "chat":
1008
+ await cmdchat();
1009
+ break;
913
1010
  case "help":
914
1011
  case "--help":
915
1012
  case "-h":
@@ -0,0 +1,9 @@
1
+ # Getry acceptable use policy
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The Getry source code is licensed under the GNU General Public License version 3.0 only. This policy governs hosted services, credentials, runners and integrations operated with Getry; it does not reduce rights granted by the GPL.
6
+
7
+ Operators must use Getry lawfully, respect the terms of each connected provider, protect credentials, and obtain authorization before accessing systems or data. Operators must not use Getry to distribute malware, evade access controls, attack third-party systems, violate privacy, infringe intellectual property, or exceed provider quotas.
8
+
9
+ The project may suspend a hosted integration or reject an unsafe request when necessary to protect users, providers or the public. These operational controls do not restrict lawful use, modification or redistribution of the GPL-licensed software itself.
@@ -0,0 +1,16 @@
1
+ # Getry authors
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The project authors and historical copyright holders are:
6
+
7
+ | Contributor | Role |
8
+ |---|---|
9
+ | devthink | Historical author and project contributor |
10
+ | nathlan | Project owner and maintainer |
11
+ | iakadion | Historical author and project contributor |
12
+ | nathu filho | Historical author and project contributor |
13
+ | allan neris | Historical author and project contributor |
14
+ | andraneris | Historical author and project contributor |
15
+
16
+ Getry is maintained at https://github.com/wenathlan/devthink/tree/main/getry (the DevThink monorepo) and distributed under GPL-3.0-only. Contributors retain copyright in their original contributions unless a separate written agreement states otherwise.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: Bug Report
3
+ about: Report a reproducible issue in Getry
4
+ title: "[BUG]"
5
+ labels: bug
6
+ ---
7
+
8
+ # Getry is licensed under GPL-3.0-only. Please do not include secrets or private data.
9
+
10
+ **Describe the issue:**
11
+
12
+ **File / Line:**
13
+
14
+ **Expected behavior:**
15
+
16
+ **Additional context:**
package/docs/cla.md ADDED
@@ -0,0 +1,9 @@
1
+ # Getry contributor terms
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Getry does not require an exclusive proprietary assignment of contributions. A contributor submitting a change represents that the contributor has the right to submit it and grants the project the permissions necessary to review, merge, reproduce, distribute and maintain the contribution under GPL-3.0-only.
6
+
7
+ The contributor keeps copyright in the contribution. The project may include the contribution in source and binary releases under GPL-3.0-only and may preserve attribution and required notices.
8
+
9
+ If a separate contributor agreement is required for a particular institutional or commercial arrangement, it must be reviewed and accepted in writing before the contribution is merged. This file is not a replacement for legal advice or a bespoke agreement.
@@ -0,0 +1,9 @@
1
+ # Getry code of conduct
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The Getry community follows a respectful, inclusive and technically constructive standard. Participants communicate in good faith, explain technical concerns with evidence, respect different backgrounds and experience levels, and protect private information.
6
+
7
+ Unacceptable conduct includes harassment, discrimination, threats, deliberate disclosure of private information, malicious interference, impersonation, spam, and behavior that violates applicable law or the project policies.
8
+
9
+ Project maintainers may moderate discussions, remove harmful content, or restrict participation when necessary. Reports should be sent through the repository security or support channels, with private disclosure used for security-sensitive matters.
@@ -0,0 +1,9 @@
1
+ # Contributing to Getry
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Getry welcomes contributions that improve the gateway, its provider routes, the documentation, the tests and the security posture. Contributions are licensed under GPL-3.0-only unless a different compatible arrangement is agreed in writing before acceptance.
6
+
7
+ A pull request should explain the change, include focused verification, preserve the root-first architecture and avoid credentials, private data or generated artifacts. Security vulnerabilities must use the private process in `SECURITY.md` rather than a public issue.
8
+
9
+ Maintainers may review, request changes, decline or merge a contribution. No contributor is required to transfer exclusive ownership of their work merely to submit a pull request.
@@ -0,0 +1,7 @@
1
+ # Getry copyright notice
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The Getry source code and original documentation are distributed under the GNU General Public License version 3.0 only. The complete license text is kept in the repository `LICENSE` file (the saddle seat carries the family copy) and must be distributed with covered copies.
6
+
7
+ Copyright ownership remains with the respective authors and contributors. Copyright ownership does not remove the rights granted by GPL-3.0-only. The Getry name, logos and other marks are covered separately by `trademarkpolicy.md`.
@@ -0,0 +1,7 @@
1
+ # Getry disclaimer
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Getry is provided under GPL-3.0-only and without warranty to the maximum extent permitted by law. The project does not guarantee availability, correctness, security, compatibility, performance or fitness for a particular purpose.
6
+
7
+ Operators remain responsible for reviewing source code, configuring integrations, protecting credentials, validating outputs and complying with the terms of connected services. Nothing in this disclaimer removes rights or obligations established by GPL-3.0-only.
@@ -0,0 +1,19 @@
1
+ # Distribution lanes
2
+
3
+ The eleven distribution lanes of the PR14 law and the honest status each lane answers for getry at the 2.0.163 family rung. A lane never ships metadata its real surface cannot back: a lane without a real carrier stays a documented stub until the surface lands, and no version ever bumps to fake a lane.
4
+
5
+ | Lane | Carrier | Status | Note |
6
+ | --- | --- | --- | --- |
7
+ | npmjs | package.json — @wenathlan/getry | live | the family-npmjs lane publishes the envelope every release rung |
8
+ | GitHub Packages npm | package.json — @wenathlan/getry | live | the family-githubnpm lane publishes the same envelope idempotently |
9
+ | Maven | pom.xml — io.github.wenathlan:getry | live | the family-maven lane deploys the jar |
10
+ | NuGet | getry.csproj | live | the family-nuget lane packs the envelope |
11
+ | Gradle | build.gradle.kts — com.wenathlan:getry | live | the family-gradle lane publishes the module |
12
+ | Ruby Gems | getry.gemspec | live | the family-rubygems lane pushes the gem |
13
+ | Docker Container | Dockerfile | live | the family-ghcr lane pushes ghcr.io/wenathlan/getry (the vault, saddle, devthink and debonair siblings its Dockerfile COPYs ride the staged context) and the family-container lane attaches the release archive |
14
+ | Go Get | go.mod | stub | no Go source surface ships yet — the lane lands when a real Go adapter exists, never as an empty module |
15
+ | Cargo Crates | Cargo.toml | stub | no Rust source surface ships yet — the lane lands with a real crate, never as an empty manifest |
16
+ | PyPi | pyproject.toml | stub | no Python package surface ships yet — the lane lands with a real wheel, never as an empty project table |
17
+ | Homebrew | Formula | stub | no tap repository ships yet — the formula lands pointing at the real release binaries with real checksums, never at a placeholder |
18
+
19
+ The engines block of package.json keeps Go, Rust and Python as declared toolchain floors — a catalog row is not a distribution lane. The dual npm law answers through the npmjs and GitHub Packages rows; the release binaries (the desktop bundles, the mobile artifacts, the container archive) ride the family-desktop, family-mobile and family-container lanes every release rung.
package/docs/eula.md ADDED
@@ -0,0 +1,7 @@
1
+ # Getry end user license notice
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Getry source code, scripts and covered binaries are licensed under the GNU General Public License version 3.0 only. The repository `LICENSE` file (the saddle seat carries the family copy) controls the copyright license and grants the permissions described there.
6
+
7
+ This notice does not add a proprietary restriction to GPL-licensed code. Separate terms may apply to a hosted service, a third-party provider, a trademark, or data supplied by an operator. Those terms cannot remove rights granted by the GPL for the covered source code.
@@ -0,0 +1,17 @@
1
+ GETRY - EXPORT CONTROL NOTICE
2
+ Version 1.0, August 2026
3
+
4
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors. Licensed under GPL-3.0-only.
5
+ Project: getry
6
+
7
+ Project Getry is open-source software under GPL-3.0-only. Export,
8
+ re-export and sanctions laws may still apply to a particular operator,
9
+ destination, service or use case.
10
+
11
+ You agree not to export, re-export, or transfer Project getry to any
12
+ prohibited destination, entity, or person, or for any prohibited end-use.
13
+
14
+ Operators are responsible for checking applicable rules before
15
+ transferring source, binaries, credentials or data across borders.
16
+
17
+ END OF EXPORT CONTROL NOTICE
package/docs/features.md CHANGED
File without changes
package/docs/gateway.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  ## module inventory
11
11
 
12
- - `gateway.ts` — the gateway versions domain: `GATEWAYROUTES` (the 7 route kinds), `GATEWAYVERSIONS` (the v1–v5 catalog), `PARSERSTEPS` (the streaming parser pattern), `listversions`, `findversion`, `totalroutes`, `liveversions`.
12
+ - `gateway.ts` — the gateway versions domain: `GATEWAYROUTES` (the 7 route kinds), `GATEWAYVERSIONS` (the v1–v5 catalog), `PARSERSTEPS` (the streaming parser pattern), `listversions`, `findversion`, `totalroutes`, `liveversions`, and `thinkingchain` (the level-routed cross-provider chain the thinking seat computes over this catalog).
13
13
  - `gatewayconfig.ts` — the embedded single-version gateway definition (v1 zai sdk, keyless).
14
14
  - `gatewayviewconfig.ts` — the shipped multi-version catalog (v1–v5) the console page renders; the config loader probes `sol/config.mjs|ts|js` for user overrides.
15
15
  - `engine.ts` — the universal gateway engine: `createversion(cfg)` produces all route handlers from one `gatewayconfig`.
@@ -20,13 +20,14 @@
20
20
  - `llm.ts` — the model call core: request building, provider calls, stream delta parsing, command guard, plan drafting, replan on fail, step reflection, usage accounting.
21
21
  - `streaming.ts` — the SSE parser and stream normalizer: split-tolerant parsing, payload normalization, thinking signature cache, OpenAI/Anthropic transformers, auto-recovery.
22
22
  - `streamfallback.ts` — the upstream error catalog and the fallback runner that walks the route chain when one model dies.
23
- - `routerotation.ts` — weighted pool rotation with cooldown ledger (litellm-style knobs).
23
+ - `routerotation.ts` — the model pool rotation math: weighted draws, health-aware round-robin under a persisted cursor (`pickroundrobin`, `rotatepool`) and the cooldown ledger (litellm-style knobs).
24
24
  - `sessions.ts` — the per-(sessionId, provider) session context store and rotation index.
25
+ - `thinking.ts` — the thinking seat (`@wenathlan/getry/thinking`): the registerable 7-level reasoning ladder plus the routing the rungs drive — tier routing (`targettier`), the budget clamp (`clampbudget`), the two-calls verdict (`twocallspattern`, `TWOCALLSTHRESHOLD`), the fallback chain (`fallbackchain`, `modelcapable`), the session/stream lanes (`lanesof`) and the provider catalog chain (`providersserving`, `providerchain`) — pure dependency-free math over structural rows.
25
26
  - `quota.ts` — dual-source (Antigravity + Gemini CLI) quota manager with adaptive TTL cache.
26
27
  - `models.ts` — the merged model catalog (frozen 25/08/2026): model definitions, classifiers, thinking tables, endpoint routing.
27
28
  - `intelligence.ts` — provenance, token estimation, markdown chunking, rag manifest, llms.txt, metric store.
28
29
  - `oauth.ts` / `accounts.ts` / `recovery.ts` / `project.ts` — the provider oauth family, multi-account manager, recovery and project id discovery.
29
- - `db.ts` — the database contract: typed rows, in-memory seeds, HTTPS accessors, better-sqlite3 mirror.
30
+ - `db.ts` — the database contract: typed rows, in-memory seeds, HTTPS accessors, the health-aware round-robin key picker (`nextkeyrow`) and the better-sqlite3 mirror (onboarding states, labels and the `gateway_versions` route table).
30
31
  - `prismaconfig.ts` / `prismashim.ts` / `nodeshim.ts` — prisma schema path resolution and runtime shims.
31
32
  - `gatewayprobe.ts` — manual chat round trip against a deployed gateway (`GET /v1/models`, `POST /v1/chat/completions`).
32
33
  - `gatewaysmoke.ts` — offline container smoke: one full request cycle against the recorded openaicompat fixture.
@@ -69,8 +70,14 @@ the 7-level thinking ladder (`thinking.ts`), each level bound to a token budget:
69
70
  - `xhigh` — budget 68000, extra deep.
70
71
  - `max` — budget 68000, maximum.
71
72
 
72
- - helpers: `listlevels`, `budgetof`, `islevel`, `formatbudget`.
73
- - the engine maps the budget onto the provider param (`chattemplatekwargs` for v3, `thinking` for v1) and runs the two-calls pattern when the thinking budget plus the response cap crosses the threshold (default 98304).
73
+ - helpers: `listlevels`, `budgetof`, `islevel`, `formatbudget`; the registration surface (`registerthinkinglevels`, `GETRY_THINKING_LEVELS`) replaces the whole ladder with any count of rungs.
74
+ - the engine maps the budget onto the provider param (`chattemplatekwargs` for v3, `thinking` for v1) and takes the two-calls verdict from the seat (`twocallspattern`): the pattern fires when the thinking budget plus the response cap crosses the threshold (`TWOCALLSTHRESHOLD`, default 98304) or whenever the level thinks at all.
75
+ - the routing the level drives (all pure math in `thinking.ts`):
76
+ - tier routing — `targettier` maps the rung POSITION (never the name) to the model tier: none/minimal → 4 (economy), low/medium → 3 (mid), high → 2 (strong), xhigh/max → 1 (flagship); registered ladders route by the same fraction.
77
+ - token budgets — `clampbudget` clamps the level budget to the model's output ceiling.
78
+ - fallback chains — `fallbackchain` orders a catalog by tier distance from the target tier, then full-budget fit, then leaderboard score, then id; `modelcapable` never serves a reasoning-false model a thinking level.
79
+ - lanes — `lanesof` resolves the bind order: the session lane (per-session rotation cursor and budget) before the stream lane (reasoning deltas before content).
80
+ - provider catalog — `providersserving`/`providerchain` build the cross-provider walk (live providers first, paused providers last); `gateway.ts` wires the real v1–v5 rows through `thinkingchain`.
74
81
 
75
82
  ## auth and key rotation
76
83
 
@@ -78,7 +85,8 @@ the 7-level thinking ladder (`thinking.ts`), each level bound to a token budget:
78
85
  - key sources resolved in configured order, first hit wins: `db` (prisma model lookup), `env` (single or separator-joined), `inline` (config provided), `ephemeralurl` (babel town pattern).
79
86
  - key rotation modes when multiple keys exist: `single`, `roundrobin`, `lru`.
80
87
  - v3 carries the key pool: 22 nvidia keys registered into the `nvidiaKey` table, round-robin per request.
81
- - pool-level rotation (`routerotation.ts`): weight-based picks with a cooldown ledger — 3 allowed fails, 5s cooldown, 5s escalation step, 60s ceiling (litellm router defaults lifted as pure math).
88
+ - pool-level rotation (`routerotation.ts`): weight-based picks, health-aware round-robin under a persisted cursor and the cooldown ledger — 3 allowed fails, 5s cooldown, 5s escalation step, 60s ceiling (litellm router defaults lifted as pure math).
89
+ - key-row rotation (`db.ts` `nextkeyrow`): health-aware round-robin over the `ApiKeyRow` pool — active, unexpired keys only; never rate-limited, never erroring seats lead; an erroring key rides only when nothing healthy remains; selection answers the row, rendering masks (`maskkey`).
82
90
  - session-level rotation (`sessions.ts`): the devthink meta model rotates every `ROTATIONEVERY = 6` turns within a session.
83
91
  - errors from upstream land on the key counters (`applyerrortokey` on `ApiKeyRow`) and feed the fallback chain.
84
92
 
@@ -93,14 +101,15 @@ the 7-level thinking ladder (`thinking.ts`), each level bound to a token budget:
93
101
 
94
102
  - `schema.prisma` — sqlite datasource (prisma client with driver adapters; libsql http/https or file url for serverless deploy via `DATABASE_URL`).
95
103
  - six models: `ChatMessage` (every interaction log across v1–v5), `NvidiaKey`, `TownKey`, `ApiKey` (the key pools), `SessionContext` (per-session rotation and thinking state), `OnboardingState` (first-run flags).
96
- - `db.ts` — typed rows plus in-memory seeds (`KEYSEED`, `MESSAGESEED`, `SESSIONSEED`) the static build falls back to; the self-hosted deploy answers the same rows over HTTPS; a better-sqlite3 mirror at the foot of the file runs the local gateway runtime (prepared statements only, idempotent schema, dynamic import the browser never bundles).
104
+ - `db.ts` — typed rows plus in-memory seeds (`KEYSEED`, `MESSAGESEED`, `SESSIONSEED`, the `GATEWAYVERSIONS` family seed) the static build falls back to; the self-hosted deploy answers the same rows over HTTPS; a better-sqlite3 mirror at the foot of the file runs the local gateway runtime (prepared statements only, idempotent schema, dynamic import the browser never bundles).
105
+ - the route table is data-driven: the `gateway_versions` mirror table holds the instance rows (seed-on-first-run from the `GATEWAYVERSIONS` family seed, registration-safe upsert through `savegatewayversion`), the internal api (`readgatewayversionrows`, `listversionrows`) serves them, and the versions/home wingets read the rows — never a hardcoded TSX table.
97
106
  - family sync contract: identity pointers are text, counts are 32-bit ints, time is datetime or iso text.
98
107
 
99
108
  ## sol surface
100
109
 
101
110
  - `sol/Sol.tsx` mounts the route table: `/intro`, `/onboarding`, `/`, `/versions`, `/thinking`, `/sessions`, `/404`.
102
111
  - subapps: `home`, `intro`, `notfound`, `onboarding`, `sessions`, `shell`, `thinking`, `toast`, `versions`, `wingets` (HomeEntrygate, ShellBrandmark, ShellEmptystate).
103
- - `versions` — the gateway registry: five replica rows (version tag, provider chip, live dot, the 7 route chips), the parser pattern rail, quick-start calls. every row reads `gateway.ts`.
112
+ - `versions` — the gateway registry: five replica rows (version tag, provider chip, live dot, the 7 route chips), the parser pattern rail, quick-start calls. every row reads the db route table lane (`listversionrows`: the sqlite mirror of the gateway runtime, the family seed of `gateway.ts` under the static build).
104
113
  - `thinking` — the reasoning ladder: 7 rungs with hairline budget bars against the 68k ceiling, the default rung marked. every number reads `thinking.ts`.
105
114
  - `sessions` — the session registry: session contexts as the ledger table, rotation summary and masked provider keys as the meta rail, the recent chat log below. rows arrive over HTTPS from the self-hosted database with seed fallback.
106
115
 
@@ -113,4 +122,4 @@ the 7-level thinking ladder (`thinking.ts`), each level bound to a token budget:
113
122
 
114
123
  ## tests
115
124
 
116
- - 27 test files under `tests/` cover the gateway surface: gateway, server (auth, engine, property, utils), llm, modelroute, promptlibrary, routerotation, streamfallback, intelligence, jsonpolicy, family, design, cli, providercli, onboardingstate, sandboxmodality, debonairlane, torture.llm, plus the provider lineage (constants, fingerprint, models, oauth) and the shared fixtures (`gatewayserver.ts`, `providerfixtures.ts`, `researchfixtures.ts`).
125
+ - 29 test files under `tests/` cover the gateway surface: gateway, gatewayversions (the 5/35 route table law, the sqlite mirror rows, the thinking chain), server (auth, engine, property, utils), llm, modelroute, promptlibrary, keyrotation (the health-aware key round-robin and the masking discipline), routerotation, thinkingladder (the registerable ladder and the tier/budget/chain/lane routing), streamfallback, intelligence, jsonpolicy, family, design, cli, providercli, onboardingstate, sandboxmodality, debonairlane, torture.llm, plus the provider lineage (constants, fingerprint, models, oauth) and the shared fixtures (`gatewayserver.ts`, `providerfixtures.ts`, `researchfixtures.ts`).
@@ -0,0 +1,9 @@
1
+ # Getry governance
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Getry is maintained as a library-first open-source project under GPL-3.0-only. The repository owner coordinates releases, security response, release approvals and provider integrations. Contributors retain copyright in their contributions unless a separate written agreement states otherwise.
6
+
7
+ Changes should include focused reasoning, tests where practical, documentation for public behavior, and review of security-sensitive workflows. Release artifacts must be traceable to repository source and generated by the documented automation.
8
+
9
+ Technical governance is recorded in the repository plans and workflow files. This document describes project responsibility and does not override the GPL or a contributor's applicable rights.
package/docs/notice.md ADDED
@@ -0,0 +1,7 @@
1
+ # Getry notice
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Getry is open-source software licensed under the GNU General Public License version 3.0 only. The source code, generated artifacts and documentation may include work from third-party projects; each third-party component remains subject to its own license.
6
+
7
+ The project name and logos are trademarks of their respective owner. The trademark policy does not restrict rights granted by GPL-3.0-only to the source code.
@@ -0,0 +1,9 @@
1
+ # Getry privacy policy
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The Getry gateway library does not intentionally collect telemetry or transmit data without an operator-requested integration. Gateway, provider and session features can access data only when the operator configures and invokes them. Operators must review the configuration and the privacy policy of every connected provider.
6
+
7
+ The repository itself is hosted by third-party services such as GitHub. Those services process requests, account data, logs and cookies under their own policies. A deployment that adds analytics, account data, logs or external storage must document that behavior and provide the controls required by applicable law.
8
+
9
+ Privacy questions and reports should use the repository support channel. Security-sensitive reports must follow `SECURITY.md`.
@@ -0,0 +1,13 @@
1
+ <!-- Getry pull request template -->
2
+
3
+ # Description
4
+
5
+ ## Type of change
6
+
7
+ ## Verification
8
+
9
+ - [ ] The change preserves GPL-3.0-only metadata and required notices.
10
+ - [ ] Tests or a reason for not adding tests are included.
11
+ - [ ] Security-sensitive behavior and workflow changes were reviewed.
12
+ - [ ] No credentials, private data or generated build output was added.
13
+ - [ ] Public documentation was updated when behavior changed.
@@ -0,0 +1,7 @@
1
+ # Getry support
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Getry is open-source software under GPL-3.0-only. Questions about installation, configuration, usage and reproducible failures should include the version, target, relevant logs and a minimal reproduction without credentials or private data.
6
+
7
+ Security reports must use the private process in `SECURITY.md`. Requests involving a third-party provider should also include the provider and the operator-controlled configuration. Community support is provided on a best-effort basis and does not create a warranty, service-level agreement or maintenance obligation.
@@ -0,0 +1,9 @@
1
+ # Getry terms and conditions
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ These terms describe access to the Getry repository, documentation, releases and project services. The source code license is the GNU General Public License version 3.0 only; the repository `LICENSE` file (the saddle seat carries the family copy) controls permissions to copy, modify, run and redistribute covered works.
6
+
7
+ Users must comply with applicable law, third-party provider terms, the acceptable use policy, and the security policy. Users must not use a configured integration to access data or systems without authorization, expose credentials, distribute malware, or evade safety controls.
8
+
9
+ The repository and project services are provided without warranty to the maximum extent permitted by law. Separate provider terms apply to infrastructure, accounts, data and hosted services. These terms do not add restrictions to GPL-licensed source code.
@@ -0,0 +1,9 @@
1
+ # Getry terms of use
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Getry may be used, modified and redistributed under GPL-3.0-only. Use of a connected provider account or api key remains subject to the operator's authorization and that provider's terms.
6
+
7
+ Users must protect secrets, respect privacy, avoid unauthorized access, and validate outputs before relying on them. Feedback may be used by the project under the applicable copyright permissions; submitting feedback does not transfer unrelated rights in the contributor's code.
8
+
9
+ The project is provided as-is without a warranty or service-level commitment. The GPL and applicable law control where this document conflicts with a covered source license.
@@ -0,0 +1,7 @@
1
+ # Getry third-party notices
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ Getry includes or builds with third-party components. Their individual licenses remain in force and are not replaced by GPL-3.0-only. Package manifests, lockfiles and generated license inventories are the source of truth for the exact dependency set of each release.
6
+
7
+ When a component requires a notice, attribution or source offer, the release process must include it. A future dependency should be recorded with its name, version, license identifier, copyright notice and source URL.
@@ -0,0 +1,7 @@
1
+ # Getry trademark policy
2
+
3
+ Copyright (C) 2026 devthink, nathlan, iakadion, nathu filho, allan neris, andraneris, and contributors.
4
+
5
+ The name “Getry”, together with associated logos and marks, identifies the project and its official releases. GPL-3.0-only grants rights to the source code; it does not grant permission to use project marks in a way that suggests endorsement, affiliation or official distribution.
6
+
7
+ Forks and modified distributions should use distinct names and branding where necessary to avoid confusion. Descriptive references to compatibility, origin or modification are permitted when they are accurate and do not imply sponsorship.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/getry",
3
- "version": "2.0.162",
3
+ "version": "2.0.164",
4
4
  "description": "getry is the AI gateway of the wenathlan family: five provider gateways (v1 zai, v2 babel, v3 nvidia, v4 opencode+kilo, v5 openrouter) serving 35 OpenAI-compatible routes with the 7-level thinking system, the session store and the key rotation, described by schema.prisma and served as one static Sol surface. The envelope distributes the documentation set and the tests directory as one package.",
5
5
  "keywords": [
6
6
  "getry",
@@ -194,9 +194,11 @@
194
194
  "files": [
195
195
  "README.md",
196
196
  "docs",
197
- "tests"
197
+ "tests",
198
+ "cli.ts"
198
199
  ],
199
200
  "engines": {
201
+ "babel": "^7.28.0",
200
202
  "bare": "^1.18.0",
201
203
  "bun": "^1.4.4",
202
204
  "cargo": ">=1.85.0",
@@ -219,6 +221,7 @@
219
221
  "typescript": "^5.8.0",
220
222
  "v": "^0.5.2",
221
223
  "vscode": "^1.118.0",
224
+ "yaml": "^2.9.1",
222
225
  "yarn": "^4.17.1"
223
226
  },
224
227
  "peerDependencies": {
@@ -246,12 +249,12 @@
246
249
  "@libsql/client": "^0.18.0",
247
250
  "@prisma/adapter-libsql": "^7.10.1",
248
251
  "@prisma/client": "^7.10.1",
252
+ "@wenathlan/argan": "~2.0.155",
249
253
  "@wenathlan/cadria": "~2.0.155",
250
254
  "@wenathlan/debonair": "~2.0.155",
251
255
  "@wenathlan/devthink": "~2.0.154",
252
256
  "@wenathlan/getry": "~2.0.155",
253
257
  "@wenathlan/saddle": "~2.0.155",
254
- "@wenathlan/argan": "~2.0.155",
255
258
  "@wenathlan/vault": "~2.0.155",
256
259
  "better-sqlite3": "^13.0.3",
257
260
  "drizzle-orm": "^0.45.4",
@@ -364,22 +367,26 @@
364
367
  "zod": "^4.6.7"
365
368
  },
366
369
  "overrides": {
367
- "esbuild": "^0.28.2",
370
+ "uuid": "^11.1.1",
368
371
  "deepmerge-ts": "^8.0.0",
372
+ "esbuild": "^0.28.2",
373
+ "follow-redirects": "^1.16.2",
374
+ "minimatch": "^11.0.3",
375
+ "glob": "^14.0.3",
369
376
  "mysql2": "^3.24.6",
370
- "uuid": "^11.1.1",
371
377
  "postcss-selector-parser": "^7.1.6",
372
378
  "source-map-js": "1.2.2",
373
- "katex": "^0.18.2",
374
379
  "proxy-addr": "2.0.8",
375
380
  "body-parser": "^2.4.0",
376
381
  "fast-uri": "^3.1.9",
377
- "follow-redirects": "^1.16.2",
378
- "minimatch": "^11.0.3",
379
- "glob": "^14.0.3",
380
382
  "zod": "^4.6.7",
381
383
  "undici": "^7.30.1",
382
- "shell-quote": "^1.11.0"
384
+ "katex": "^0.18.2",
385
+ "shell-quote": "^1.11.0",
386
+ "xcode": {
387
+ "uuid": "11.1.1"
388
+ },
389
+ "@grpc/grpc-js": "1.14.5"
383
390
  },
384
391
  "publishConfig": {
385
392
  "access": "public",
package/tests/cli.test.ts CHANGED
File without changes
File without changes
@@ -0,0 +1,145 @@
1
+ /**
2
+ * gatewayversions.test.ts — the data-driven route table of the gateway: the
3
+ * family seed counts (5 providers, 7 route kinds, 35 routes), the sqlite
4
+ * mirror rows the node runtime serves (seed-on-first-run, registration-safe
5
+ * upsert) and the thinking chain the gateway wires over the catalog rows
6
+ * through the thinking seat. the suite pins its own DATABASE_URL before the
7
+ * db import so the mirror opens its own seat (one writer per wal file — the
8
+ * SQLITE_BUSY lesson). runnable with vitest:
9
+ * pnpm vitest run tests/gatewayversions.test.ts
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterAll, describe, it } from "vitest";
16
+
17
+ /* the db seat isolation law: DATABASE_URL is read at the db module eval, so
18
+ * the seat lands before the dynamic import below opens the mirror. */
19
+ process.env.DATABASE_URL = `file:${join(mkdtempSync(join(tmpdir(), "gateway-versions-")), "route-table.test.db")}`;
20
+
21
+ const db = await import("../db.ts");
22
+ const { GATEWAYROUTES, GATEWAYVERSIONS, thinkingchain, totalroutes } = await import("../gateway.ts");
23
+
24
+ afterAll(() => {
25
+ rmSync(join(process.env.DATABASE_URL!.slice("file:".length), ".."), { recursive: true, force: true });
26
+ });
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // the family seed counts — the pr19 by-example law: 5 providers, 35 routes
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe("the family seed route table (5 providers, 35 routes)", () => {
33
+ it("carries the five provider gateways", () => {
34
+ assert.equal(GATEWAYVERSIONS.length, 5);
35
+ assert.deepEqual(
36
+ GATEWAYVERSIONS.map((version) => version.provider),
37
+ ["zai", "babel town", "nvidia nim", "opencode zen plus kilo", "openrouter"],
38
+ );
39
+ });
40
+
41
+ it("serves the seven OpenAI-compatible route kinds per gateway", () => {
42
+ assert.equal(GATEWAYROUTES.length, 7);
43
+ for (const version of GATEWAYVERSIONS) {
44
+ assert.deepEqual(version.routes, [...GATEWAYROUTES], `${version.v} carries the seven route kinds`);
45
+ }
46
+ });
47
+
48
+ it("totals the thirty-five routes the gateway serves", () => {
49
+ assert.equal(totalroutes(), 35);
50
+ });
51
+ });
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // the sqlite mirror rows — instance rows live in the database
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe("the gateway_versions mirror (the data-driven route table)", () => {
58
+ it("seeds the five family rows on first use and answers them in version order", async () => {
59
+ const rows = await db.readgatewayversionrows();
60
+ assert.equal(rows.length, 5);
61
+ assert.deepEqual(
62
+ rows.map((row) => row.v),
63
+ ["v1", "v2", "v3", "v4", "v5"],
64
+ );
65
+ assert.equal(rows.reduce((sum, row) => sum + row.routes.length, 0), 35);
66
+ // the routes ride the json cell and parse back to the route kinds
67
+ assert.deepEqual(rows[0].routes, [...GATEWAYROUTES]);
68
+ });
69
+
70
+ it("keeps the seeding idempotent — a second pass lands nothing", async () => {
71
+ const seeded = await db.seedGatewayVersionRows();
72
+ assert.equal(seeded, 0);
73
+ assert.equal((await db.readgatewayversionrows()).length, 5);
74
+ });
75
+
76
+ it("writes and answers a registered row a later seed never overwrites", async () => {
77
+ const written = await db.savegatewayversion({
78
+ v: "v6",
79
+ provider: "a person registered gateway",
80
+ pattern: "registered through the internal api",
81
+ badge: "live",
82
+ models: "registered-model",
83
+ note: "the route table answers the registration, never a hardcoded row",
84
+ routes: ["chat/completions", "models"],
85
+ });
86
+ assert.equal(written?.provider, "a person registered gateway");
87
+ const rows = await db.readgatewayversionrows();
88
+ assert.equal(rows.length, 6);
89
+ const registered = rows.find((row) => row.v === "v6");
90
+ assert.deepEqual(registered?.routes, ["chat/completions", "models"]);
91
+ // the seed re-run respects the registration: v6 stays, nothing resets
92
+ assert.equal(await db.seedGatewayVersionRows(), 0);
93
+ assert.equal((await db.readgatewayversionrows()).find((row) => row.v === "v6")?.provider, "a person registered gateway");
94
+ });
95
+
96
+ it("falls back to the canonical route kinds when a routes cell arrives broken", async () => {
97
+ await db.savegatewayversion({
98
+ v: "v7",
99
+ provider: "broken cell gateway",
100
+ pattern: "a bad write never loses the routes",
101
+ badge: "paused",
102
+ models: "any",
103
+ note: "the projection keeps the family route kinds",
104
+ routes: [],
105
+ });
106
+ const rows = await db.readgatewayversionrows();
107
+ const broken = rows.find((row) => row.v === "v7");
108
+ assert.deepEqual(broken?.routes, [...GATEWAYROUTES]);
109
+ assert.equal(broken?.badge, "paused");
110
+ });
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // the thinking chain over the catalog — the level routes the provider rows
115
+ // ---------------------------------------------------------------------------
116
+
117
+ describe("thinkingchain — the level-routed cross-provider chain over the catalog", () => {
118
+ it("orders the live providers first and the paused provider last", () => {
119
+ const chain = thinkingchain("max");
120
+ const order: string[] = [];
121
+ for (const candidate of chain) {
122
+ if (order[order.length - 1] !== candidate.provider) order.push(candidate.provider);
123
+ }
124
+ assert.deepEqual(order, ["zai", "nvidia nim", "opencode zen plus kilo", "openrouter", "babel town"]);
125
+ });
126
+
127
+ it("leads with the flagship tier for the maximum level and never serves a reasoning-false model", () => {
128
+ const chain = thinkingchain("max");
129
+ assert.equal(chain[0].provider, "zai");
130
+ assert.equal(chain[0].model.id, "glm-5.3");
131
+ for (const candidate of chain) {
132
+ assert.notEqual(candidate.model.reasoning, false, `${candidate.provider}/${candidate.model.id} never carries a thinking level`);
133
+ }
134
+ // the first nvidia candidate is the tier-1 rotation backend the v3 lane rides
135
+ const firstnvidia = chain.find((candidate) => candidate.provider === "nvidia nim");
136
+ assert.equal(firstnvidia?.model.id, "moonshotai/kimi-k3");
137
+ });
138
+
139
+ it("walks the strong tier first for the default high level", () => {
140
+ const chain = thinkingchain("high");
141
+ assert.equal(chain[0].model.id, "glm-5.1");
142
+ const ids = chain.slice(0, 4).map((candidate) => candidate.model.id);
143
+ assert.deepEqual(ids, ["glm-5.1", "glm-5v", "glm-5.3", "glm-5.3-flash"]);
144
+ });
145
+ });
File without changes
@@ -0,0 +1,129 @@
1
+ /**
2
+ * keyrotation.test.ts — the provider key rotation over the ApiKeyRow pool:
3
+ * the health-aware round-robin of nextkeyrow (the pr19 key rotation law —
4
+ * never expose keys, rotation periods, per-provider health) beside the
5
+ * masking discipline the render paths owe. runnable with vitest:
6
+ * pnpm vitest run tests/keyrotation.test.ts
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { describe, it } from "vitest";
10
+ import { type ApiKeyRow, maskkey, nextkeyrow, rotationsummary } from "../db.ts";
11
+
12
+ /** the reference clock of the fixtures (a fixed epoch, no wall time). */
13
+ const NOW = 1_800_000_000_000;
14
+
15
+ /** Builds one key row fixture with every value the rotation reads. */
16
+ function key(over: Partial<ApiKeyRow> & { id: string }): ApiKeyRow {
17
+ return {
18
+ provider: "nvidia",
19
+ key: `nvapi-fixed-${over.id.padStart(4, "0")}-credential-shape`,
20
+ label: `nim rotation ${over.id}`,
21
+ active: true,
22
+ status: "active",
23
+ useCount: 0,
24
+ rotationCount: 0,
25
+ errorCount: 0,
26
+ rateLimitHit: false,
27
+ createdAt: "2026-09-12T10:00:00.000Z",
28
+ expiresAt: null,
29
+ ...over,
30
+ };
31
+ }
32
+
33
+ describe("nextkeyrow — the health-aware round-robin of the key pool", () => {
34
+ it("walks the pool in stable order and wraps the cursor", () => {
35
+ const pool = [key({ id: "k1" }), key({ id: "k2" }), key({ id: "k3" })];
36
+ const walk: string[] = [];
37
+ let cursor = 0;
38
+ for (let step = 0; step < 5; step++) {
39
+ const pick = nextkeyrow(pool, cursor, NOW);
40
+ assert.ok(pick, "an eligible pool always answers");
41
+ walk.push(pick.row.id);
42
+ cursor = pick.cursor;
43
+ }
44
+ assert.deepEqual(walk, ["k1", "k2", "k3", "k1", "k2"]);
45
+ });
46
+
47
+ it("never answers a revoked, inactive or expired key", () => {
48
+ const pool = [
49
+ key({ id: "revoked", status: "revoked" }),
50
+ key({ id: "inactive", active: false }),
51
+ key({ id: "expired", expiresAt: "2026-01-01T00:00:00.000Z" }),
52
+ key({ id: "live" }),
53
+ ];
54
+ for (let cursor = 0; cursor < 4; cursor++) {
55
+ const pick = nextkeyrow(pool, cursor, NOW);
56
+ assert.ok(pick);
57
+ assert.equal(pick.row.id, "live");
58
+ }
59
+ assert.equal(nextkeyrow(pool.slice(0, 3), 0, NOW), null);
60
+ });
61
+
62
+ it("keeps an unparseable expiry honest — only a real parse retires a key", () => {
63
+ const pool = [key({ id: "odd", expiresAt: "not-a-date" }), key({ id: "live" })];
64
+ const walk: string[] = [];
65
+ let cursor = 0;
66
+ for (let step = 0; step < 4; step++) {
67
+ const pick = nextkeyrow(pool, cursor, NOW);
68
+ assert.ok(pick);
69
+ walk.push(pick.row.id);
70
+ cursor = pick.cursor;
71
+ }
72
+ assert.deepEqual(walk, ["odd", "live", "odd", "live"]);
73
+ });
74
+
75
+ it("leads with the healthy seats — an erroring or rate-limited key rides only when nothing healthy remains", () => {
76
+ const pool = [
77
+ key({ id: "healthy-a" }),
78
+ key({ id: "erroring", errorCount: 3 }),
79
+ key({ id: "ratelimited", rateLimitHit: true }),
80
+ key({ id: "healthy-b" }),
81
+ ];
82
+ const walk: string[] = [];
83
+ let cursor = 0;
84
+ for (let step = 0; step < 4; step++) {
85
+ const pick = nextkeyrow(pool, cursor, NOW);
86
+ assert.ok(pick);
87
+ walk.push(pick.row.id);
88
+ cursor = pick.cursor;
89
+ }
90
+ assert.deepEqual(walk, ["healthy-a", "healthy-b", "healthy-a", "healthy-b"]);
91
+ // every healthy seat out of the pool: the erroring seats take the walk
92
+ const degraded = [key({ id: "erroring", errorCount: 3 }), key({ id: "ratelimited", rateLimitHit: true })];
93
+ const fallback = nextkeyrow(degraded, 0, NOW);
94
+ assert.ok(fallback);
95
+ assert.equal(fallback.row.id, "erroring");
96
+ assert.equal(fallback.cursor, 1);
97
+ });
98
+
99
+ it("normalizes any cursor the caller persisted", () => {
100
+ const pool = [key({ id: "k1" }), key({ id: "k2" }), key({ id: "k3" })];
101
+ assert.equal(nextkeyrow(pool, -1, NOW)?.row.id, "k3");
102
+ assert.equal(nextkeyrow(pool, 7, NOW)?.row.id, "k2");
103
+ });
104
+
105
+ it("answers an empty pool with null, never a thrown rotation", () => {
106
+ assert.equal(nextkeyrow([], 0, NOW), null);
107
+ });
108
+ });
109
+
110
+ describe("the key render discipline — selection answers rows, rendering masks", () => {
111
+ it("masks every key shape the routes answer", () => {
112
+ const short = "nvapi-short";
113
+ assert.equal(maskkey(short), "nvap****");
114
+ const long = "nvapi-1234567890abcdef-ijklmnop";
115
+ assert.equal(maskkey(long), "nvapi-12****mnop");
116
+ const masked = maskkey(key({ id: "k9" }).key);
117
+ assert.ok(!masked.includes("credential-shape"), "the mask never carries the key body");
118
+ });
119
+
120
+ it("summarizes the per-provider rotation health the rail renders", () => {
121
+ const pool = [
122
+ key({ id: "k1" }),
123
+ key({ id: "k2", rotationCount: 18 }),
124
+ key({ id: "k3", errorCount: 2 }),
125
+ key({ id: "k4", status: "revoked" }),
126
+ ];
127
+ assert.deepEqual(rotationsummary(pool), { active: 3, rotating: 1, erroring: 1 });
128
+ });
129
+ });
File without changes
File without changes
File without changes
@@ -12,8 +12,10 @@ import {
12
12
  notefailure,
13
13
  notesuccess,
14
14
  pickroute,
15
+ pickroundrobin,
15
16
  ROTATIONDEFAULTS,
16
17
  remainingcooldown,
18
+ rotatepool,
17
19
  } from "../routerotation.ts";
18
20
 
19
21
  /** the three-route pool the tests rotate over. */
@@ -95,3 +97,101 @@ describe("routerotation cooldown ledger", () => {
95
97
  assert.equal(healthyroutes(POOL, ledger, 1000).length, 3);
96
98
  });
97
99
  });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // round robin + the unified rotation entry (pr19: round-robin + weighted +
103
+ // health-aware in one rotation math)
104
+ // ---------------------------------------------------------------------------
105
+
106
+ describe("routerotation round robin", () => {
107
+ it("walks the healthy pool in stable order and wraps the cursor", () => {
108
+ const ledger = newledger();
109
+ const walk: string[] = [];
110
+ let cursor = 0;
111
+ for (let step = 0; step < 7; step++) {
112
+ const pick = pickroundrobin(POOL, ledger, cursor, 1000);
113
+ assert.ok(pick, "a healthy pool always answers");
114
+ walk.push(pick.route.id);
115
+ cursor = pick.cursor;
116
+ }
117
+ assert.deepEqual(walk, ["r1", "r2", "r3", "r1", "r2", "r3", "r1"]);
118
+ });
119
+
120
+ it("gives every healthy route an equal share over a full cycle", () => {
121
+ const ledger = newledger();
122
+ const shares = new Map<string, number>([["r1", 0], ["r2", 0], ["r3", 0]]);
123
+ let cursor = 0;
124
+ for (let step = 0; step < 300; step++) {
125
+ const pick = pickroundrobin(POOL, ledger, cursor, 1000);
126
+ assert.ok(pick);
127
+ shares.set(pick.route.id, (shares.get(pick.route.id) ?? 0) + 1);
128
+ cursor = pick.cursor;
129
+ }
130
+ assert.equal(shares.get("r1"), 100);
131
+ assert.equal(shares.get("r2"), 100);
132
+ assert.equal(shares.get("r3"), 100);
133
+ });
134
+
135
+ it("stays health-aware: cooling and zero-weight routes leave the walk", () => {
136
+ const ledger = newledger();
137
+ notefailure(ledger, "r2", { allowedFails: 0 }, 1000);
138
+ const routes = [...POOL, { id: "r0", provider: "babeltown", model: "paused", weight: 0 }];
139
+ const walk: string[] = [];
140
+ let cursor = 0;
141
+ for (let step = 0; step < 4; step++) {
142
+ const pick = pickroundrobin(routes, ledger, cursor, 1000);
143
+ assert.ok(pick);
144
+ walk.push(pick.route.id);
145
+ cursor = pick.cursor;
146
+ }
147
+ assert.deepEqual(walk, ["r1", "r3", "r1", "r3"]);
148
+ // the cooldown expiry walks r2 back in: the cursor resumes over the
149
+ // restored three-route pool (0 → r1) and the very next pick reaches r2
150
+ const back = pickroundrobin(routes, ledger, cursor, 1000 + ROTATIONDEFAULTS.cooldownMs + 1);
151
+ assert.ok(back);
152
+ assert.equal(back.route.id, "r1");
153
+ const then = pickroundrobin(routes, ledger, back.cursor, 1000 + ROTATIONDEFAULTS.cooldownMs + 1);
154
+ assert.equal(then?.route.id, "r2");
155
+ });
156
+
157
+ it("answers null when every route cools down, whatever the cursor", () => {
158
+ const ledger = newledger();
159
+ for (const route of POOL) notefailure(ledger, route.id, { allowedFails: 0 }, 1000);
160
+ assert.equal(pickroundrobin(POOL, ledger, 0, 1000), null);
161
+ assert.equal(pickroundrobin(POOL, ledger, 41, 1000), null);
162
+ });
163
+
164
+ it("normalizes any cursor the caller persisted onto the healthy pool", () => {
165
+ const ledger = newledger();
166
+ assert.equal(pickroundrobin(POOL, ledger, -1, 1000)?.route.id, "r3");
167
+ assert.equal(pickroundrobin(POOL, ledger, 7, 1000)?.route.id, "r2");
168
+ assert.equal(pickroundrobin(POOL, ledger, 8, 1000)?.route.id, "r3");
169
+ });
170
+ });
171
+
172
+ describe("rotatepool — the unified rotation entry", () => {
173
+ it("defaults to the round-robin walk", () => {
174
+ const ledger = newledger();
175
+ const first = rotatepool({ routes: POOL, ledger, cursor: 0, now: 1000 });
176
+ const second = rotatepool({ routes: POOL, ledger, cursor: first?.cursor, now: 1000 });
177
+ assert.equal(first?.route.id, "r1");
178
+ assert.equal(second?.route.id, "r2");
179
+ });
180
+
181
+ it("answers the weighted draw under the weighted strategy (cursor untouched)", () => {
182
+ const ledger = newledger();
183
+ const pick = rotatepool({ routes: POOL, ledger, strategy: "weighted", cursor: 9, rand: () => 0, now: 1000 });
184
+ assert.equal(pick?.route.id, "r1");
185
+ assert.equal(pick?.cursor, 9);
186
+ // rand just below 1 lands on the last healthy route
187
+ const tail = rotatepool({ routes: POOL, ledger, strategy: "weighted", rand: () => 0.9999, now: 1000 });
188
+ assert.equal(tail?.route.id, "r3");
189
+ });
190
+
191
+ it("answers null under both strategies when nothing is healthy", () => {
192
+ const ledger = newledger();
193
+ for (const route of POOL) notefailure(ledger, route.id, { allowedFails: 0 }, 1000);
194
+ assert.equal(rotatepool({ routes: POOL, ledger, strategy: "round-robin", now: 1000 }), null);
195
+ assert.equal(rotatepool({ routes: POOL, ledger, strategy: "weighted", rand: () => 0, now: 1000 }), null);
196
+ });
197
+ });
File without changes
File without changes
@@ -0,0 +1,221 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DEFAULTTHINKINGLEVEL,
4
+ THINKINGLEVELS,
5
+ TWOCALLSTHRESHOLD,
6
+ type ThinkingLevelName,
7
+ budgetof,
8
+ clampbudget,
9
+ defaultthinkinglevel,
10
+ fallbackchain,
11
+ islevel,
12
+ lanesof,
13
+ listlevels,
14
+ providerchain,
15
+ type thinkingproviderrow,
16
+ providersserving,
17
+ registerthinkinglevels,
18
+ targettier,
19
+ twocallspattern,
20
+ } from "../thinking.ts";
21
+ import { autothinking } from "../utils.ts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // the registerable ladder — the engine freedom law: levels depend on what
25
+ // the person registers, never a fixed 7 (pr5)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe("the thinking ladder registration (the engine freedom law)", () => {
29
+ it("answers the seven rung family seed when nothing is registered", () => {
30
+ expect(listlevels()).toEqual(THINKINGLEVELS);
31
+ expect(defaultthinkinglevel()).toBe(DEFAULTTHINKINGLEVEL);
32
+ expect(defaultthinkinglevel()).toBe("high");
33
+ });
34
+
35
+ it("accepts any count of registered rungs — more than seven rides", () => {
36
+ const twelve = Array.from({ length: 12 }, (_, i) => ({
37
+ level: `run${i}` as const,
38
+ budget: 1000 * (i + 1),
39
+ desc: `registered rung ${i}`,
40
+ }));
41
+ const live = registerthinkinglevels(twelve, { default: "run11" });
42
+ expect(live).toHaveLength(12);
43
+ expect(listlevels()).toHaveLength(12);
44
+ expect(islevel("run0")).toBe(true);
45
+ expect(islevel("run11")).toBe(true);
46
+ expect(islevel("high")).toBe(false);
47
+ expect(budgetof("run3")).toBe(4000);
48
+ expect(autothinking("RUN3")).toBe("run3");
49
+ expect(defaultthinkinglevel()).toBe("run11");
50
+ expect(autothinking("bogus")).toBe("run11");
51
+ });
52
+
53
+ it("accepts fewer rungs than seven — one rung rides", () => {
54
+ registerthinkinglevels([{ level: "deep", budget: 999999, desc: "the only rung" }], { default: "deep" });
55
+ expect(listlevels()).toHaveLength(1);
56
+ expect(budgetof("deep")).toBe(999999);
57
+ expect(autothinking("anything")).toBe("deep");
58
+ });
59
+
60
+ it("skips invalid entries and keeps the last duplicate", () => {
61
+ const live = registerthinkinglevels(
62
+ [
63
+ { level: "", budget: 5, desc: "nameless" },
64
+ { level: "solo", budget: 7, desc: "first" },
65
+ { level: "solo", budget: 9, desc: "last wins" },
66
+ { level: "negative", budget: -1, desc: "floors to zero" },
67
+ ],
68
+ { default: "solo" },
69
+ );
70
+ expect(live.map((rung) => rung.level)).toEqual(["solo", "negative"]);
71
+ expect(budgetof("solo")).toBe(9);
72
+ expect(budgetof("negative")).toBe(0);
73
+ });
74
+
75
+ it("an empty registration answers the ladder untouched", () => {
76
+ const before = listlevels();
77
+ expect(registerthinkinglevels([])).toEqual(before);
78
+ });
79
+
80
+ it("restores the family seed when the seed rides back", () => {
81
+ registerthinkinglevels(THINKINGLEVELS);
82
+ expect(listlevels()).toEqual(THINKINGLEVELS);
83
+ expect(islevel("high")).toBe(true);
84
+ expect(autothinking("maximum")).toBe("max");
85
+ expect(defaultthinkinglevel()).toBe("high");
86
+ });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // the thinking seat routing — the tier ladder, the budget clamp, the
91
+ // two-calls verdict, the fallback chain, the lanes and the provider catalog
92
+ // (the pr19 thinking seat: the level selects the model tier, the token
93
+ // budget and the fallback chain — pure math, no gateway import)
94
+ // ---------------------------------------------------------------------------
95
+
96
+ describe("the thinking seat routing (the family seed ladder)", () => {
97
+ it("routes each seed rung to its model tier by ladder position", () => {
98
+ registerthinkinglevels(THINKINGLEVELS, { default: "high" });
99
+ expect(targettier("none")).toBe(4);
100
+ expect(targettier("minimal")).toBe(4);
101
+ expect(targettier("low")).toBe(3);
102
+ expect(targettier("medium")).toBe(3);
103
+ expect(targettier("high")).toBe(2);
104
+ expect(targettier("xhigh")).toBe(1);
105
+ expect(targettier("max")).toBe(1);
106
+ });
107
+
108
+ it("routes an unknown level to the default rung's tier", () => {
109
+ registerthinkinglevels(THINKINGLEVELS, { default: "high" });
110
+ expect(targettier("bogus" as ThinkingLevelName)).toBe(2);
111
+ });
112
+
113
+ it("clamps the level budget to the model output ceiling", () => {
114
+ registerthinkinglevels(THINKINGLEVELS, { default: "high" });
115
+ expect(clampbudget("high")).toBe(68000);
116
+ expect(clampbudget("high", 16384)).toBe(16384);
117
+ expect(clampbudget("none", 16384)).toBe(0);
118
+ // a missing or invalid ceiling never clamps
119
+ expect(clampbudget("high", undefined)).toBe(68000);
120
+ expect(clampbudget("high", Number.NaN)).toBe(68000);
121
+ expect(clampbudget("high", -1)).toBe(68000);
122
+ });
123
+
124
+ it("answers the two-calls verdict the engine's zai lane always carried", () => {
125
+ registerthinkinglevels(THINKINGLEVELS, { default: "high" });
126
+ // a thinking level always rides the pattern
127
+ expect(twocallspattern({ level: "minimal", responsemax: 0 })).toBe(true);
128
+ expect(twocallspattern({ level: "high", responsemax: 1 })).toBe(true);
129
+ // none rides the pattern only when the windows cross the threshold
130
+ expect(twocallspattern({ level: "none", responsemax: 1000 })).toBe(false);
131
+ expect(twocallspattern({ level: "none", responsemax: TWOCALLSTHRESHOLD + 1 })).toBe(true);
132
+ // a config threshold and a config budget override win
133
+ expect(twocallspattern({ level: "none", responsemax: 100, threshold: 50 })).toBe(true);
134
+ expect(twocallspattern({ level: "none", responsemax: 100, budget: TWOCALLSTHRESHOLD })).toBe(true);
135
+ });
136
+
137
+ it("orders the fallback chain by tier distance, budget fit, score and id", () => {
138
+ registerthinkinglevels(THINKINGLEVELS, { default: "high" });
139
+ const chain = fallbackchain(
140
+ [
141
+ { id: "tier1-small", reasoning: true, maxoutput: 4096, rank: { score: 60, tier: 1 } },
142
+ { id: "tier2-a", reasoning: true, maxoutput: 131072, rank: { score: 40, tier: 2 } },
143
+ { id: "tier2-b", reasoning: true, maxoutput: 131072, rank: { score: 44, tier: 2 } },
144
+ { id: "tier1-big", reasoning: true, maxoutput: 131072, rank: { score: 55, tier: 1 } },
145
+ { id: "tier3", reasoning: true, maxoutput: 131072, rank: { score: 30, tier: 3 } },
146
+ { id: "unranked", reasoning: true, maxoutput: 131072 },
147
+ { id: "nonthinking", reasoning: false, maxoutput: 131072, rank: { score: 90, tier: 1 } },
148
+ ],
149
+ "high",
150
+ );
151
+ // level high targets tier 2: tier-2 rows first (score desc), then the
152
+ // tier-1 row that holds the full budget, then every remaining tier-1
153
+ // and tier-3 row by distance (fullfit before score — the small tier-1
154
+ // rides below the full-fit tier-3), the unranked row last; the
155
+ // reasoning-false row never serves
156
+ expect(chain.map((row) => row.id)).toEqual([
157
+ "tier2-b",
158
+ "tier2-a",
159
+ "tier1-big",
160
+ "tier3",
161
+ "tier1-small",
162
+ "unranked",
163
+ ]);
164
+ // the none level serves every row (no thinking budget to carry)
165
+ expect(fallbackchain([{ id: "nonthinking", reasoning: false }], "none")).toHaveLength(1);
166
+ });
167
+
168
+ it("resolves the session and stream lanes in bind order", () => {
169
+ expect(lanesof({})).toEqual([]);
170
+ expect(lanesof({ stream: false, sessionid: null })).toEqual([]);
171
+ expect(lanesof({ stream: true })).toEqual(["stream"]);
172
+ expect(lanesof({ sessionid: "chat-9f21" })).toEqual(["session"]);
173
+ expect(lanesof({ stream: true, sessionid: "chat-9f21" })).toEqual(["session", "stream"]);
174
+ expect(lanesof({ sessionid: " " })).toEqual([]);
175
+ });
176
+
177
+ it("orders the provider chain live first and serves only capable catalogs", () => {
178
+ registerthinkinglevels(THINKINGLEVELS, { default: "high" });
179
+ const catalog: thinkingproviderrow[] = [
180
+ { provider: "pausedone", badge: "paused", models: [{ id: "p1", reasoning: true, rank: { score: 50, tier: 2 } }] },
181
+ { provider: "liveone", badge: "live", models: [{ id: "l1", reasoning: true, rank: { score: 40, tier: 2 } }] },
182
+ { provider: "blind", badge: "live", models: [{ id: "b1", reasoning: false }] },
183
+ ];
184
+ expect(providersserving(catalog, "high").map((row) => row.provider)).toEqual(["liveone", "pausedone"]);
185
+ const chain = providerchain(catalog, "high");
186
+ expect(chain.map((candidate) => `${candidate.provider}/${candidate.model.id}`)).toEqual([
187
+ "liveone/l1",
188
+ "pausedone/p1",
189
+ ]);
190
+ });
191
+ });
192
+
193
+ describe("the thinking seat routing (a registered ladder)", () => {
194
+ it("routes the tier by position over any registered ladder length", () => {
195
+ registerthinkinglevels(
196
+ [
197
+ { level: "quick", budget: 100, desc: "registered floor" },
198
+ { level: "mid", budget: 900, desc: "registered middle" },
199
+ { level: "deep", budget: 90000, desc: "registered ceiling" },
200
+ ],
201
+ { default: "mid" },
202
+ );
203
+ expect(targettier("quick")).toBe(4);
204
+ expect(targettier("mid")).toBe(3);
205
+ expect(targettier("deep")).toBe(1);
206
+ expect(clampbudget("deep", 16384)).toBe(16384);
207
+ });
208
+
209
+ it("answers the ceiling a single-rung registration carries", () => {
210
+ registerthinkinglevels([{ level: "only", budget: 500, desc: "the only rung" }], { default: "only" });
211
+ expect(targettier("only")).toBe(1);
212
+ registerthinkinglevels([{ level: "off", budget: 0, desc: "no thinking at all" }], { default: "off" });
213
+ expect(targettier("off")).toBe(4);
214
+ });
215
+
216
+ it("restores the family seed for the suites that follow", () => {
217
+ registerthinkinglevels(THINKINGLEVELS, { default: "high" });
218
+ expect(listlevels()).toEqual(THINKINGLEVELS);
219
+ expect(targettier("high")).toBe(2);
220
+ });
221
+ });
File without changes