pi-antigravity-rotator 2.1.6 → 2.2.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.2.2] - 2026-06-21
6
+
7
+ ### Fixed
8
+ - **Telemetry HTTPS Error**: Reverted the default telemetry endpoint from `https://` back to `http://` because the backend server does not support SSL, which was causing silent "packet length too long" errors and preventing telemetry and broadcast notifications from working.
9
+
10
+ ### Refactored
11
+ - **Compatibility Layer**: Massively refactored the 2,400-line `src/compat.ts` into smaller, single-responsibility modules under `src/compat/` for easier maintenance. (PR [#11](https://github.com/tuxevil/pi-antigravity-rotator/pull/11) by [@josenicomaia](https://github.com/josenicomaia))
12
+
13
+ ## [2.2.1] - 2026-06-20
14
+
15
+ ### Fixed
16
+ - **Support tool response images**: Extracted any image content (`image_url` or `image` formats) from OpenAI tool response content and mapped them to Antigravity `inlineData` parts. (PR [#9](https://github.com/tuxevil/pi-antigravity-rotator/pull/9) by [@josenicomaia](https://github.com/josenicomaia))
17
+ - **Ordered tool results**: Guaranteed all tool results remain clustered at the top of the parts array, complying with Claude's strict layout requirements. (PR [#9](https://github.com/tuxevil/pi-antigravity-rotator/pull/9) by [@josenicomaia](https://github.com/josenicomaia))
18
+ - **Dangling tool call filtering**: Filtered out any `tool_calls` that do not have a matching `tool_result` in the subsequent message to prevent `400 Bad Request`. (PR [#9](https://github.com/tuxevil/pi-antigravity-rotator/pull/9) by [@josenicomaia](https://github.com/josenicomaia))
19
+ - **Inline JSON-Schema union types**: Collapsed Draft-2020-12 inline union type arrays (e.g. `type: ["number", "null"]`) to the first non-null type and lifted nullability into the proto-supported `nullable` flag to fix 400 errors. (PR [#10](https://github.com/tuxevil/pi-antigravity-rotator/pull/10) by [@yashyadav711](https://github.com/yashyadav711))
20
+
21
+ ## [2.2.0] - 2026-06-16
22
+
23
+ ### Security
24
+
25
+ ### Security
26
+ - **Admin token autogeneration**: On first run with no `PI_ROTATOR_ADMIN_TOKEN` env var, a cryptographically secure token is generated, persisted to `.admin-token` (mode 0600), and printed once to the operator. Dashboard and `/api/*` routes are now protected by default on fresh installs. Override the generated token by setting `PI_ROTATOR_ADMIN_TOKEN` in the env. `.admin-token` added to `.gitignore`.
27
+ - **Querystring secret redaction**: `redactSensitive()` now also redacts `access_token`, `token`, `api_key`, `apikey`, `key`, `refresh_token` when they appear in querystring (`?key=val&...`) or URL fragment.
28
+ - **OAuth fallback warning**: New `warnIfUsingFallbackOAuthCreds()` detects when `ANTIGRAVITY_CLIENT_ID` and/or `ANTIGRAVITY_CLIENT_SECRET` are missing and emits a one-time warning that the rotator is using the public Antigravity IDE client.
29
+ - **Removed open CORS**: `Access-Control-Allow-Origin: *` removed from `/api/status` and `/api/config`. Replaced with `Cache-Control: no-store`. Dashboard still works same-origin.
30
+ - **Truncated/redacted validation logs**: New `logValidationFailure()` truncates payloads to 200 chars and runs them through `redactSensitive` before logging. Applied to OpenAI messages validation and stream error logs in `compat.ts`.
31
+
32
+ ### Added
33
+ - **`Config.modelSpecs`**: Operators can now override the per-model thinking/output spec table used by the compat layer without recompiling. Add a `modelSpecs` field to `accounts.json` and call `setModelSpecsOverride()` at boot (done automatically by `index.ts`).
34
+ - **`warnIfInsecureTelemetryEndpoint()`**: Detects plain `http://` telemetry endpoints and emits a one-time warning. Default endpoint switched to `https://`. Override via `PI_ROTATOR_TELEMETRY_URL`. Silence via `PI_ROTATOR_TELEMETRY_INSECURE_OK=1`.
35
+ - **Persistent `responsesStore`**: The OpenAI Responses API store (used by Codex via `previous_response_id`) is now persisted to `<configDir>/responses.json` with atomic writes and a 1.5s debounce. Restart-safe: in-flight Codex conversations continue across rotator restarts. New `src/responses-store.ts` with `load()`, `flush()`, `flushSync()`. Corrupt files are moved aside to `.corrupt-<ts>.bak` on startup.
36
+ - **Debounced state writes**: Hot paths (`recordRequest`, `markExhausted`, `markError`, `markFlagged`) now call `scheduleStateSave()` instead of `saveState()`, coalescing multiple writes within a 1s window into a single disk write. New `flushPendingStateSaveSync()` in the SIGTERM/SIGINT shutdown handler drains the queue synchronously to minimise data loss.
37
+ - **GitHub Actions CI**: New `.github/workflows/ci.yml` runs `npm ci` + `npm run check` (typecheck + 191 tests) on push and PR to `main`. Node 22 with npm cache. PRs without green checks cannot be merged.
38
+ - **8 e2e proxy tests** (`test/proxy-e2e.test.ts`): Cover the full proxy flow with a local HTTP server as mock Antigravity — 200 happy path, 429 rate-limited (Retry-After and RESOURCE_EXHAUSTED), 401 unauthorized, 403 flagged and non-flagged, 500 server error, endpoint cascade (daily→prod).
39
+ - **7 dashboard tests** (`test/dashboard.test.ts`): Verify utf-8/viewport meta tags, all 12 admin API endpoints are referenced, the `escapeHtml`/`jsString`/`maskText`/`maskEmail` helpers are present and `escapeHtml` correctly escapes the 5 HTML-sensitive characters, no hardcoded OAuth secrets or refresh tokens leak into the HTML.
40
+
41
+ ### Changed
42
+ - **Refactored `proxy.ts`**: Extracted `classifyUpstreamResponse()` that returns a discriminated `UpstreamAction`. Both `withRotation()` and `handleProxyRequest()` dispatch against the helper instead of duplicating the 401/403/404/400/429/5xx branches. ~150 lines of parallel code removed. New `UpstreamAction` type with 9 action kinds.
43
+
44
+ ### Cleanup
45
+ - **Removed `src/antigravity-prompt.ts`**: 80-line `ANTIGRAVITY_IDENTITY_PROMPT` export with 0 references in the repo.
46
+ - **Consolidated agent docs**: `CLAUDE.md` now points to `AGENTS.md` as the single source of truth. The duplicated BEADS INTEGRATION block is gone.
47
+ - **Moved scripts to `scripts/`**: 10 one-off utility scripts (`mitm.js`, `mock_google.js`, `query_models.{js,ts}`, `test-compat.ts`, `test-direct.js`, `test_generate.js`, `test_loop.js`, `test-http.cjs`, `test-openai.cjs`) moved from the repo root. `query_models.ts` and `test-compat.ts` updated to use `../src/...` relative imports after the move.
48
+
49
+ ## [2.2.1] - 2026-06-16
50
+
51
+ ### Fixed
52
+ - **SSE usage extraction cross-event matching**: The old `extractTokenUsage()` ran a regex on the last 32KB of the upstream body, which could match across SSE event boundaries and return incorrect `(input, output)` pairs. Replaced with `SseEventAccumulator` + `extractUsageFromSseEvent()` that buffer complete SSE events (split on `\n\n`), parse each `data:` payload as JSON, and recursively search for `usageMetadata` (Gemini) or `usage` (OpenAI/Anthropic). Regex remains as a last-resort fallback for malformed JSON. Real-time streaming is preserved — the `res.write(chunk)` in `onData` is unchanged. Resolves ROADMAP §2.
53
+
5
54
  ## [2.1.6] - 2026-06-12
6
55
 
7
56
  ### Fixed
package/README.md CHANGED
@@ -153,7 +153,7 @@ If login fails at project discovery:
153
153
 
154
154
  After starting the proxy, open `http://localhost:51200/dashboard` or `http://<your-server-ip>:51200/dashboard` from any machine on the same network (the proxy binds to `0.0.0.0`).
155
155
 
156
- If `PI_ROTATOR_ADMIN_TOKEN` is unset, dashboard and `/api/*` access remains open for backwards compatibility. v2.0 now surfaces loud warnings about that state in startup logs, `/api/status`, and the dashboard itself.
156
+ If `PI_ROTATOR_ADMIN_TOKEN` is unset, the proxy automatically generates a cryptographically secure token on first run and saves it to a `.admin-token` file in the root directory. This token will be printed to the console on first startup. You must append `?token=<your-token>` to the dashboard URL to access it, or set `PI_ROTATOR_ADMIN_TOKEN` in your environment to override it.
157
157
 
158
158
  The dashboard shows:
159
159
 
@@ -285,7 +285,7 @@ Config files (`accounts.json`, `state.json`) are stored in `~/.pi-antigravity-ro
285
285
  # Environment variables
286
286
  export PI_ROTATOR_DIR=/path/to/config
287
287
  export PI_ROTATOR_QUOTA_USER_AGENT="antigravity/1.107.0 darwin/arm64"
288
- # Optional: require this token for dashboard/API access. If unset, legacy open access is preserved.
288
+ # Optional: require this token for dashboard/API access. If unset, a secure token is auto-generated.
289
289
  export PI_ROTATOR_ADMIN_TOKEN="change-me"
290
290
  # Optional: bind the proxy to a safer local-only interface.
291
291
  export PI_ROTATOR_BIND_HOST="127.0.0.1"
@@ -410,7 +410,7 @@ Login now fails if Google does not return a project ID. No shared fallback.
410
410
  | `POST` | `/v1/chat/completions` | OpenAI-compatible non-streaming chat adapter |
411
411
  | `POST` | `/v1/messages` | Anthropic-compatible non-streaming messages adapter |
412
412
 
413
- If `PI_ROTATOR_ADMIN_TOKEN` is set, dashboard/API requests must include either `Authorization: Bearer <token>`, `X-Rotator-Admin-Token: <token>`, or `?token=<token>` for browser dashboard access. The native pi proxy endpoint and compatibility adapters remain unauthenticated so existing clients keep working. Put this service behind a trusted local boundary if exposing beyond localhost/LAN.
413
+ Dashboard and internal `/api/*` requests must include either `Authorization: Bearer <token>`, `X-Rotator-Admin-Token: <token>`, or `?token=<token>` for browser dashboard access. The native pi proxy endpoint and compatibility adapters (`/v1/*`) remain unauthenticated by design, so your AI agents and existing clients will keep working without requiring a token. Put this service behind a trusted local boundary if exposing beyond localhost/LAN.
414
414
 
415
415
  ### Compatibility Adapters
416
416
 
@@ -471,7 +471,8 @@ Current adapter scope:
471
471
 
472
472
  Thanks to these amazing people who have contributed to the project:
473
473
 
474
- - **[@josenicomaia](https://github.com/josenicomaia)** (José Nicodemos Maia Neto) — Fixed streaming pass-through to emit correct `finish_reason` for tool calls, fixing tool execution on ZED editor. ([PR #8](https://github.com/tuxevil/pi-antigravity-rotator/pull/8))
474
+ - **[@josenicomaia](https://github.com/josenicomaia)** (José Nicodemos Maia Neto) — Modularized the compatibility layer architecture, added multimodal tool response support, and fixed streaming pass-through for tool executions. ([PR #8](https://github.com/tuxevil/pi-antigravity-rotator/pull/8), [PR #9](https://github.com/tuxevil/pi-antigravity-rotator/pull/9), [PR #11](https://github.com/tuxevil/pi-antigravity-rotator/pull/11))
475
+ - **[@yashyadav711](https://github.com/yashyadav711)** (Yash) — Fixed Draft-2020-12 inline JSON-Schema union type mapping for Gemini tools support. ([PR #10](https://github.com/tuxevil/pi-antigravity-rotator/pull/10))
475
476
  - **[@javargasm](https://github.com/javargasm)** (Jeisson Alexander Vargas Marroquin) — Anthropic tool-use compatibility layer (`tool_use`/`tool_result` content block conversion), JSON schema round-trip fixes, and compat test suite expansion. ([PR #3](https://github.com/tuxevil/pi-antigravity-rotator/pull/3), [PR #7](https://github.com/tuxevil/pi-antigravity-rotator/pull/7))
476
477
 
477
478
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-antigravity-rotator",
3
- "version": "2.1.6",
3
+ "version": "2.2.2",
4
4
  "description": "Multi-account rotation proxy for Google Antigravity with per-model routing, real-time quota tracking, and infringement detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,9 @@
12
12
  "login": "tsx src/cli.ts login",
13
13
  "typecheck": "tsc --noEmit",
14
14
  "test": "node --import tsx/esm --test \"test/**/*.test.ts\"",
15
- "check": "npm run typecheck && npm test"
15
+ "lint": "eslint src test",
16
+ "coverage": "c8 --reporter=text --reporter=html npm test",
17
+ "check": "npm run typecheck && npm test && npm run lint"
16
18
  },
17
19
  "files": [
18
20
  "bin/",
@@ -40,10 +42,16 @@
40
42
  },
41
43
  "author": "Sebastián Real (tuxevil)",
42
44
  "dependencies": {
45
+ "pg": "^8.22.0",
43
46
  "tsx": "^4.19.0"
44
47
  },
45
48
  "devDependencies": {
49
+ "@eslint/js": "^10.0.1",
46
50
  "@types/node": "^22.0.0",
47
- "typescript": "^5.7.0"
51
+ "@types/pg": "^8.20.0",
52
+ "c8": "^11.0.0",
53
+ "eslint": "^10.5.0",
54
+ "typescript": "^5.7.0",
55
+ "typescript-eslint": "^8.61.1"
48
56
  }
49
57
  }
@@ -102,7 +102,37 @@ export function saveAccountsConfig(config: Config): void {
102
102
  writeJsonFileAtomic(ACCOUNTS_FILE, applyConfigDefaults(config));
103
103
  }
104
104
 
105
+ // Reasonable upper bounds on per-account fields. These are defensive
106
+ // limits to prevent a malicious or buggy caller from growing
107
+ // accounts.json without bound, which would slow every subsequent
108
+ // saveState. The numbers are well above any realistic real value.
109
+ export const MAX_EMAIL_LENGTH = 254; // RFC 5321
110
+ export const MAX_LABEL_LENGTH = 100;
111
+ export const MAX_PROJECT_ID_LENGTH = 100;
112
+ export const MAX_REFRESH_TOKEN_LENGTH = 4096;
113
+
114
+ function validateAccountConfigLengths(entry: AccountConfig): void {
115
+ const checks: Array<[string, number]> = [
116
+ ["email", MAX_EMAIL_LENGTH],
117
+ ["label", MAX_LABEL_LENGTH],
118
+ ["projectId", MAX_PROJECT_ID_LENGTH],
119
+ ["refreshToken", MAX_REFRESH_TOKEN_LENGTH],
120
+ ];
121
+ for (const [field, max] of checks) {
122
+ const value = entry[field as keyof AccountConfig];
123
+ if (typeof value === "string" && value.length > max) {
124
+ throw new Error(
125
+ `Account ${field} exceeds maximum length ${max} (got ${value.length}). ` +
126
+ `This usually indicates a malformed input — refusing to write to accounts.json.`,
127
+ );
128
+ }
129
+ }
130
+ }
131
+
132
+ export { validateAccountConfigLengths };
133
+
105
134
  export function addAccountToConfig(entry: AccountConfig): { isNew: boolean } {
135
+ validateAccountConfigLengths(entry);
106
136
  const config = loadOrCreateAccountsConfig();
107
137
  const existing = config.accounts.findIndex((a) => a.email === entry.email);
108
138
 
package/src/admin-auth.ts CHANGED
@@ -1,13 +1,96 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { randomBytes } from "node:crypto";
3
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
4
+ import { join } from "node:path";
2
5
 
3
6
  interface AdminAuthRequest {
4
7
  url?: string;
5
8
  headers: IncomingMessage["headers"];
6
9
  }
7
10
 
11
+ const ADMIN_TOKEN_FILENAME = ".admin-token";
12
+
13
+ let persistedToken: string | null = null;
14
+
15
+ /**
16
+ * Set the persisted admin token at runtime. Called by index.ts after
17
+ * ensureAdminToken resolves the effective token. Subsequent calls to
18
+ * getConfiguredAdminToken() will return this token if no env var is set.
19
+ */
20
+ export function setPersistedAdminToken(token: string | null): void {
21
+ persistedToken = token && token.length > 0 ? token : null;
22
+ }
23
+
8
24
  export function getConfiguredAdminToken(env: NodeJS.ProcessEnv = process.env): string | null {
9
25
  const token = env.PI_ROTATOR_ADMIN_TOKEN?.trim();
10
- return token ? token : null;
26
+ if (token) return token;
27
+ return persistedToken;
28
+ }
29
+
30
+ /**
31
+ * Generate a cryptographically secure admin token (32 random bytes, hex).
32
+ * 64 hex characters = 256 bits of entropy.
33
+ */
34
+ export function generateAdminToken(): string {
35
+ return randomBytes(32).toString("hex");
36
+ }
37
+
38
+ export function readPersistedAdminToken(configDir: string): string | null {
39
+ const tokenPath = join(configDir, ADMIN_TOKEN_FILENAME);
40
+ if (!existsSync(tokenPath)) return null;
41
+ try {
42
+ const raw = readFileSync(tokenPath, "utf-8").trim();
43
+ return raw ? raw : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function writePersistedAdminToken(configDir: string, token: string): void {
50
+ const tokenPath = join(configDir, ADMIN_TOKEN_FILENAME);
51
+ writeFileSync(tokenPath, token, { mode: 0o600, encoding: "utf-8" });
52
+ try {
53
+ chmodSync(tokenPath, 0o600);
54
+ } catch {
55
+ // Best effort: some filesystems (e.g. Windows) don't support POSIX perms.
56
+ }
57
+ }
58
+
59
+ export interface ResolvedAdminToken {
60
+ token: string;
61
+ source: "env" | "file" | "generated";
62
+ generated: boolean;
63
+ }
64
+
65
+ /**
66
+ * Resolve the effective admin token, generating and persisting one if needed.
67
+ * Priority: PI_ROTATOR_ADMIN_TOKEN env var > .admin-token file > generate new.
68
+ *
69
+ * When a token is generated, it is persisted to .admin-token with 0600 perms
70
+ * and returned. The caller is responsible for printing it to the operator.
71
+ */
72
+ export function ensureAdminToken(
73
+ configDir: string,
74
+ env: NodeJS.ProcessEnv = process.env,
75
+ ): ResolvedAdminToken {
76
+ const envToken = env.PI_ROTATOR_ADMIN_TOKEN?.trim();
77
+ if (envToken) {
78
+ return { token: envToken, source: "env", generated: false };
79
+ }
80
+
81
+ const fileToken = readPersistedAdminToken(configDir);
82
+ if (fileToken) {
83
+ return { token: fileToken, source: "file", generated: false };
84
+ }
85
+
86
+ const newToken = generateAdminToken();
87
+ try {
88
+ writePersistedAdminToken(configDir, newToken);
89
+ } catch {
90
+ // If we cannot write the file (read-only fs, perms), still return the
91
+ // token for this session. The next restart will simply generate again.
92
+ }
93
+ return { token: newToken, source: "generated", generated: true };
11
94
  }
12
95
 
13
96
  export function getRequestAdminToken(req: AdminAuthRequest): string | null {
@@ -0,0 +1,42 @@
1
+ import { ResponsesStore, type StoredResponseEntry } from "../responses-store.js";
2
+
3
+ export const thoughtSignatureCache = new Map<string, string>();
4
+ const THOUGHT_SIGNATURE_CACHE_MAX = 500;
5
+ export const responsesStore = new ResponsesStore();
6
+
7
+ export function makeCompatId(prefix: string): string {
8
+ return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
9
+ }
10
+
11
+ export function getStoredResponse(id: string): StoredResponseEntry | null {
12
+ return responsesStore.get(id);
13
+ }
14
+
15
+ export function setStoredResponse(id: string, entry: StoredResponseEntry): void {
16
+ responsesStore.set(id, entry);
17
+ }
18
+
19
+ export function resetResponsesStoreForTests(): void {
20
+ responsesStore.clear();
21
+ }
22
+
23
+ export async function loadResponsesStore(): Promise<void> {
24
+ await responsesStore.load();
25
+ }
26
+
27
+ export async function flushResponsesStore(): Promise<void> {
28
+ await responsesStore.flush();
29
+ }
30
+
31
+ export function flushResponsesStoreSync(): void {
32
+ responsesStore.flushSync();
33
+ }
34
+
35
+ export function cacheThoughtSignature(callId: string, signature: string): void {
36
+ if (thoughtSignatureCache.size >= THOUGHT_SIGNATURE_CACHE_MAX) {
37
+ // Evict the oldest entry
38
+ const firstKey = thoughtSignatureCache.keys().next().value;
39
+ if (firstKey !== undefined) thoughtSignatureCache.delete(firstKey);
40
+ }
41
+ thoughtSignatureCache.set(callId, signature);
42
+ }
@@ -0,0 +1,78 @@
1
+ export interface ModelSpec {
2
+ maxOutputTokens: number;
3
+ thinkingBudget: number; // -1 = adaptive (model decides), >=0 = fixed
4
+ isThinking: boolean;
5
+ }
6
+
7
+ export const DEFAULT_MODEL_SPECS: Record<string, ModelSpec> = {
8
+ "gemini-pro-agent": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
9
+ "gemini-3-flash-agent": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
10
+ "gemini-3-pro-high": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
11
+ "gemini-3-pro-low": { maxOutputTokens: 65535, thinkingBudget: 1001, isThinking: true },
12
+ "gemini-3.1-pro": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
13
+ "gemini-3.1-pro-high": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
14
+ "gemini-3.1-pro-low": { maxOutputTokens: 65535, thinkingBudget: 1001, isThinking: true },
15
+ "gemini-3.1-pro-preview": { maxOutputTokens: 65535, thinkingBudget: 10001, isThinking: true },
16
+ "gemini-3.5-flash": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
17
+ "gemini-3.5-flash-medium": { maxOutputTokens: 65536, thinkingBudget: 4000, isThinking: true },
18
+ "gemini-3.5-flash-low": { maxOutputTokens: 65536, thinkingBudget: 4000, isThinking: true },
19
+ "gemini-3.5-flash-high": { maxOutputTokens: 65536, thinkingBudget: 10000, isThinking: true },
20
+ "gemini-3-flash": { maxOutputTokens: 65536, thinkingBudget: 4000, isThinking: true },
21
+ "gemini-2.5-flash": { maxOutputTokens: 65535, thinkingBudget: 24576, isThinking: true },
22
+ "gemini-2.5-pro": { maxOutputTokens: 65535, thinkingBudget: 1024, isThinking: true },
23
+ "claude-sonnet-4-6": { maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
24
+ "claude-sonnet-4-6-thinking":{ maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
25
+ "claude-opus-4-6-thinking": { maxOutputTokens: 64000, thinkingBudget: 32768, isThinking: true },
26
+ "gpt-oss-120b-medium": { maxOutputTokens: 32768, thinkingBudget: 8192, isThinking: true },
27
+ "gpt-oss-120b": { maxOutputTokens: 32768, thinkingBudget: 8192, isThinking: true },
28
+ };
29
+
30
+ let modelSpecsOverride: Record<string, ModelSpec> | null = null;
31
+
32
+ /**
33
+ * Replace the bundled model spec table with operator-provided overrides.
34
+ * Pass `null` to restore defaults. Called once at startup from index.ts.
35
+ */
36
+ export function setModelSpecsOverride(specs: Record<string, ModelSpec> | null): void {
37
+ modelSpecsOverride = specs && Object.keys(specs).length > 0 ? specs : null;
38
+ }
39
+
40
+ export function getActiveModelSpecs(): Record<string, ModelSpec> {
41
+ return modelSpecsOverride ?? DEFAULT_MODEL_SPECS;
42
+ }
43
+
44
+ const GEMINI_MAX_OUTPUT_TOKENS = 65536;
45
+ const CLAUDE_MAX_OUTPUT_TOKENS = 64000;
46
+ const FALLBACK_THINKING_BUDGET = 24576;
47
+ const CLAUDE_DEFAULT_THINKING_BUDGET = 32768;
48
+
49
+ export function getModelFamily(model: string): "claude" | "gemini" | "unknown" {
50
+ const l = model.toLowerCase();
51
+ if (l.includes("claude")) return "claude";
52
+ if (l.includes("gemini")) return "gemini";
53
+ return "unknown";
54
+ }
55
+
56
+ export function getModelSpec(model: string): ModelSpec {
57
+ const specs = getActiveModelSpecs();
58
+ const lower = model.toLowerCase();
59
+ if (specs[lower]) return specs[lower];
60
+ for (const [key, spec] of Object.entries(specs)) {
61
+ if (lower.includes(key)) return spec;
62
+ }
63
+ const family = getModelFamily(model);
64
+ if (family === "claude") return { maxOutputTokens: CLAUDE_MAX_OUTPUT_TOKENS, thinkingBudget: CLAUDE_DEFAULT_THINKING_BUDGET, isThinking: true };
65
+ if (family === "gemini") return { maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS, thinkingBudget: FALLBACK_THINKING_BUDGET, isThinking: true };
66
+ return { maxOutputTokens: 65536, thinkingBudget: FALLBACK_THINKING_BUDGET, isThinking: false };
67
+ }
68
+
69
+ export function isThinkingModel(model: string): boolean {
70
+ const spec = getModelSpec(model);
71
+ if (spec.isThinking) return true;
72
+ const l = model.toLowerCase();
73
+ if (l.includes("gemini")) {
74
+ const m = l.match(/gemini-(\d+)/);
75
+ if (m && parseInt(m[1], 10) >= 3) return true;
76
+ }
77
+ return false;
78
+ }