@bto-labs/agy-bridge 0.5.0 → 0.6.0
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/README.md +9 -1
- package/dist/index.js +61 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -89,10 +89,18 @@ On first use the bridge runs `agy models` (cached for the process lifetime) and
|
|
|
89
89
|
agy never surfaces quota exhaustion in print mode — it silently retries the 429 until its print-timeout, then exits 0 with empty output, which used to look like an indefinite hang. The bridge now watches each run's log file (via `--log-file`) and on `RESOURCE_EXHAUSTED (code 429)`:
|
|
90
90
|
|
|
91
91
|
1. kills the agy process group immediately (no waiting out the timeout),
|
|
92
|
-
2. parses the reset time ("Resets in 4h24m") into
|
|
92
|
+
2. parses the reset time ("Resets in 4h24m") into a cooldown record,
|
|
93
93
|
3. retries the same prompt on the next model in the tool's chain,
|
|
94
94
|
4. skips cooled-down models on all subsequent calls until their quota resets.
|
|
95
95
|
|
|
96
|
+
Cooldowns are shared **across every concurrent agy-bridge process** on a host, not just
|
|
97
|
+
within one — the bridge runs as a separate process per MCP client/session, and several can
|
|
98
|
+
share the same underlying account's quota. Each model's cooldown deadline is recorded as the
|
|
99
|
+
mtime of a small marker file under `~/.cache/agy-bridge/cooldowns/`, so one process's quota
|
|
100
|
+
hit immediately protects every other concurrent process from retrying the same
|
|
101
|
+
already-exhausted model. A cooldown is never shortened by a later, shorter-duration write
|
|
102
|
+
(e.g. a per-minute rate limit racing a per-day quota on the same model) — only extended.
|
|
103
|
+
|
|
96
104
|
Failovers are annotated in the response footer (`failover: <model>: quota exhausted (resets in 4h24m)`). Only when every candidate is exhausted does the call fail — in seconds, with reset times listed — instead of hanging.
|
|
97
105
|
|
|
98
106
|
### Timeouts and cancellation
|
package/dist/index.js
CHANGED
|
@@ -120,11 +120,14 @@ ${available.join("\n")}`
|
|
|
120
120
|
import { execFile, spawn } from "child_process";
|
|
121
121
|
import { promisify } from "util";
|
|
122
122
|
import { readFile, rm } from "fs/promises";
|
|
123
|
-
import { homedir, tmpdir } from "os";
|
|
123
|
+
import { homedir as homedir2, tmpdir } from "os";
|
|
124
124
|
import { randomUUID } from "crypto";
|
|
125
|
-
import
|
|
125
|
+
import path2 from "path";
|
|
126
126
|
|
|
127
127
|
// src/quota.ts
|
|
128
|
+
import { mkdir, open, stat, utimes } from "fs/promises";
|
|
129
|
+
import { homedir } from "os";
|
|
130
|
+
import path from "path";
|
|
128
131
|
var DEFAULT_COOLDOWN_SEC = 15 * 60;
|
|
129
132
|
var QUOTA_RE = /RESOURCE_EXHAUSTED \(code 429\)/;
|
|
130
133
|
var RESET_RE = /Resets in ((?:\d+h)?(?:\d+m)?(?:\d+s)?)\b/;
|
|
@@ -164,29 +167,67 @@ var QuotaError = class extends Error {
|
|
|
164
167
|
resetSeconds;
|
|
165
168
|
resetText;
|
|
166
169
|
};
|
|
170
|
+
var DEFAULT_COOLDOWN_DIR = path.join(homedir(), ".cache", "agy-bridge", "cooldowns");
|
|
171
|
+
function slugifyModel(model) {
|
|
172
|
+
return model.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "model";
|
|
173
|
+
}
|
|
174
|
+
async function ensureDir(dir) {
|
|
175
|
+
try {
|
|
176
|
+
await mkdir(dir, { recursive: true });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err.code !== "EEXIST") throw err;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function defaultCooldownDeps(dir = DEFAULT_COOLDOWN_DIR) {
|
|
182
|
+
const filePath = (model) => path.join(dir, `${slugifyModel(model)}.cooldown`);
|
|
183
|
+
return {
|
|
184
|
+
async readDeadline(model) {
|
|
185
|
+
try {
|
|
186
|
+
return (await stat(filePath(model))).mtimeMs;
|
|
187
|
+
} catch {
|
|
188
|
+
return void 0;
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
async writeDeadline(model, untilMs) {
|
|
192
|
+
try {
|
|
193
|
+
await ensureDir(dir);
|
|
194
|
+
const file = filePath(model);
|
|
195
|
+
const handle = await open(file, "a");
|
|
196
|
+
await handle.close();
|
|
197
|
+
const when = new Date(untilMs);
|
|
198
|
+
await utimes(file, when, when);
|
|
199
|
+
} catch {
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
167
204
|
var CooldownRegistry = class {
|
|
168
|
-
constructor(now = Date.now) {
|
|
205
|
+
constructor(now = Date.now, deps = defaultCooldownDeps()) {
|
|
169
206
|
this.now = now;
|
|
207
|
+
this.deps = deps;
|
|
170
208
|
}
|
|
171
209
|
now;
|
|
172
|
-
|
|
173
|
-
set(model, resetSeconds) {
|
|
174
|
-
|
|
210
|
+
deps;
|
|
211
|
+
async set(model, resetSeconds) {
|
|
212
|
+
const until = this.now() + (resetSeconds ?? DEFAULT_COOLDOWN_SEC) * 1e3;
|
|
213
|
+
const existing = await this.deps.readDeadline(model);
|
|
214
|
+
if (existing !== void 0 && existing >= until) return;
|
|
215
|
+
await this.deps.writeDeadline(model, until);
|
|
175
216
|
}
|
|
176
|
-
cooling(model) {
|
|
177
|
-
const t = this.
|
|
217
|
+
async cooling(model) {
|
|
218
|
+
const t = await this.deps.readDeadline(model);
|
|
178
219
|
return t !== void 0 && t > this.now();
|
|
179
220
|
}
|
|
180
|
-
describe(model) {
|
|
181
|
-
const t = this.
|
|
221
|
+
async describe(model) {
|
|
222
|
+
const t = await this.deps.readDeadline(model);
|
|
182
223
|
return formatDuration(t === void 0 ? 0 : (t - this.now()) / 1e3);
|
|
183
224
|
}
|
|
184
225
|
};
|
|
185
226
|
|
|
186
227
|
// src/runner.ts
|
|
187
228
|
var execFileAsync = promisify(execFile);
|
|
188
|
-
var SESSIONS_FILE =
|
|
189
|
-
|
|
229
|
+
var SESSIONS_FILE = path2.join(
|
|
230
|
+
homedir2(),
|
|
190
231
|
".gemini",
|
|
191
232
|
"antigravity-cli",
|
|
192
233
|
"cache",
|
|
@@ -258,7 +299,7 @@ var defaultDeps = {
|
|
|
258
299
|
},
|
|
259
300
|
removeLog: (logPath) => rm(logPath, { force: true }),
|
|
260
301
|
readSessionsFile: () => readFile(SESSIONS_FILE, "utf8"),
|
|
261
|
-
makeLogPath: () =>
|
|
302
|
+
makeLogPath: () => path2.join(tmpdir(), `agy-bridge-${process.pid}-${randomUUID()}.log`)
|
|
262
303
|
};
|
|
263
304
|
function buildArgs(req, cfg, logPath) {
|
|
264
305
|
const timeoutSec = req.timeoutSec ?? cfg.timeoutSec;
|
|
@@ -383,7 +424,7 @@ async function runAgy(req, cfg, deps = defaultDeps) {
|
|
|
383
424
|
let sessionId;
|
|
384
425
|
try {
|
|
385
426
|
const map = JSON.parse(await deps.readSessionsFile());
|
|
386
|
-
sessionId = map[
|
|
427
|
+
sessionId = map[path2.resolve(req.cwd)];
|
|
387
428
|
} catch {
|
|
388
429
|
sessionId = void 0;
|
|
389
430
|
}
|
|
@@ -391,11 +432,11 @@ async function runAgy(req, cfg, deps = defaultDeps) {
|
|
|
391
432
|
}
|
|
392
433
|
|
|
393
434
|
// src/tools.ts
|
|
394
|
-
import
|
|
435
|
+
import path3 from "path";
|
|
395
436
|
import { z } from "zod";
|
|
396
437
|
var OUTPUT_RULES = "Answer directly with no preamble or closing remarks. Be thorough but concise. Cite file:line for every code-level finding.";
|
|
397
438
|
function resolveFiles(files, cwd) {
|
|
398
|
-
return files.map((f) =>
|
|
439
|
+
return files.map((f) => path3.isAbsolute(f) ? f : path3.resolve(cwd, f));
|
|
399
440
|
}
|
|
400
441
|
var commonShape = {
|
|
401
442
|
cwd: z.string().optional().describe(
|
|
@@ -534,8 +575,8 @@ function createToolHandler(tool, cfg, registry, deps = defaultDeps, cooldowns =
|
|
|
534
575
|
let result;
|
|
535
576
|
let used;
|
|
536
577
|
for (const model of resolution.models) {
|
|
537
|
-
if (model && cooldowns.cooling(model)) {
|
|
538
|
-
attempts.push(`${model}: quota cooldown, ${cooldowns.describe(model)} left`);
|
|
578
|
+
if (model && await cooldowns.cooling(model)) {
|
|
579
|
+
attempts.push(`${model}: quota cooldown, ${await cooldowns.describe(model)} left`);
|
|
539
580
|
continue;
|
|
540
581
|
}
|
|
541
582
|
try {
|
|
@@ -548,7 +589,7 @@ function createToolHandler(tool, cfg, registry, deps = defaultDeps, cooldowns =
|
|
|
548
589
|
break;
|
|
549
590
|
} catch (err) {
|
|
550
591
|
if (err instanceof QuotaError && model) {
|
|
551
|
-
cooldowns.set(model, err.resetSeconds);
|
|
592
|
+
await cooldowns.set(model, err.resetSeconds);
|
|
552
593
|
attempts.push(
|
|
553
594
|
`${model}: quota exhausted${err.resetText ? ` (resets in ${err.resetText})` : ""}`
|
|
554
595
|
);
|
|
@@ -599,7 +640,7 @@ function createServer() {
|
|
|
599
640
|
return stdout;
|
|
600
641
|
});
|
|
601
642
|
const cooldowns = new CooldownRegistry();
|
|
602
|
-
const server = new McpServer({ name: "agy-bridge", version: "0.
|
|
643
|
+
const server = new McpServer({ name: "agy-bridge", version: "0.6.0" });
|
|
603
644
|
for (const tool of TOOLS) {
|
|
604
645
|
server.registerTool(
|
|
605
646
|
tool.name,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bto-labs/agy-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "MCP bridge that lets Claude Code delegate heavy tasks to the Antigravity CLI (agy) — purpose-built tools, model routing with fallback, and multi-turn session continuity.",
|
|
5
5
|
"mcpName": "io.github.bto-labs.agy-bridge",
|
|
6
6
|
"type": "module",
|