hydramcp 1.0.3 → 1.0.5

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.
@@ -2,15 +2,19 @@
2
2
  * Subscription Provider — use your monthly subscriptions as an API.
3
3
  *
4
4
  * Reads OAuth tokens stored by CLI tools (Claude Code, Gemini CLI, Codex CLI)
5
- * and makes direct HTTP requests to provider APIs. No subprocess spawning.
5
+ * and makes direct HTTP requests to provider-internal APIs.
6
6
  *
7
7
  * Token locations:
8
8
  * Claude → ~/.claude/.credentials.json
9
9
  * Gemini → ~/.gemini/oauth_creds.json
10
10
  * Codex → ~/.codex/auth.json
11
11
  *
12
+ * Endpoints (from CLIProxyAPI & Gemini CLI source):
13
+ * Gemini → https://cloudcode-pa.googleapis.com/v1internal:generateContent
14
+ * Codex → https://chatgpt.com/backend-api/codex/responses (SSE)
15
+ * Claude → CLI subprocess (api.anthropic.com rejects OAuth without TLS fingerprint)
16
+ *
12
17
  * Approach learned from CLIProxyAPI (github.com/router-for-me/CLIProxyAPI).
13
- * 100% our code. Zero external dependencies.
14
18
  */
15
19
  import { Provider, ModelInfo, QueryOptions, QueryResponse } from "./provider.js";
16
20
  export declare class SubscriptionProvider implements Provider {
@@ -18,10 +22,6 @@ export declare class SubscriptionProvider implements Provider {
18
22
  private backends;
19
23
  private modelToBackend;
20
24
  private tokenCache;
21
- /**
22
- * Detect which subscription tokens exist on disk.
23
- * Returns the number of backends with valid tokens.
24
- */
25
25
  detect(): Promise<number>;
26
26
  healthCheck(): Promise<boolean>;
27
27
  listModels(): Promise<ModelInfo[]>;
@@ -2,20 +2,28 @@
2
2
  * Subscription Provider — use your monthly subscriptions as an API.
3
3
  *
4
4
  * Reads OAuth tokens stored by CLI tools (Claude Code, Gemini CLI, Codex CLI)
5
- * and makes direct HTTP requests to provider APIs. No subprocess spawning.
5
+ * and makes direct HTTP requests to provider-internal APIs.
6
6
  *
7
7
  * Token locations:
8
8
  * Claude → ~/.claude/.credentials.json
9
9
  * Gemini → ~/.gemini/oauth_creds.json
10
10
  * Codex → ~/.codex/auth.json
11
11
  *
12
+ * Endpoints (from CLIProxyAPI & Gemini CLI source):
13
+ * Gemini → https://cloudcode-pa.googleapis.com/v1internal:generateContent
14
+ * Codex → https://chatgpt.com/backend-api/codex/responses (SSE)
15
+ * Claude → CLI subprocess (api.anthropic.com rejects OAuth without TLS fingerprint)
16
+ *
12
17
  * Approach learned from CLIProxyAPI (github.com/router-for-me/CLIProxyAPI).
13
- * 100% our code. Zero external dependencies.
14
18
  */
15
19
  import { readFileSync, writeFileSync } from "node:fs";
16
20
  import { join } from "node:path";
17
21
  import { homedir } from "node:os";
22
+ import { spawn } from "node:child_process";
18
23
  import { logger } from "../utils/logger.js";
24
+ // ---------------------------------------------------------------------------
25
+ // Token file readers
26
+ // ---------------------------------------------------------------------------
19
27
  function readClaudeTokens() {
20
28
  try {
21
29
  const raw = readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf-8");
@@ -56,18 +64,18 @@ function readCodexTokens() {
56
64
  const tokens = data.tokens;
57
65
  if (!tokens?.access_token || !tokens?.refresh_token)
58
66
  return null;
59
- // Codex access_token is a JWT — extract exp from payload
60
67
  let expiresAt = 0;
61
68
  try {
62
69
  const payload = JSON.parse(Buffer.from(tokens.access_token.split(".")[1], "base64").toString());
63
70
  if (payload.exp)
64
- expiresAt = payload.exp * 1000; // sec → ms
71
+ expiresAt = payload.exp * 1000;
65
72
  }
66
- catch { /* non-JWT or malformed — treat as no expiry */ }
73
+ catch { /* non-JWT or malformed */ }
67
74
  return {
68
75
  accessToken: tokens.access_token,
69
76
  refreshToken: tokens.refresh_token,
70
77
  expiresAt,
78
+ accountId: tokens.account_id ?? undefined,
71
79
  };
72
80
  }
73
81
  catch {
@@ -96,7 +104,6 @@ async function refreshClaudeToken(refreshToken) {
96
104
  const expiresIn = data.expires_in ?? 86400;
97
105
  if (!accessToken)
98
106
  return null;
99
- // Write back to credentials file
100
107
  try {
101
108
  const credPath = join(homedir(), ".claude", ".credentials.json");
102
109
  const existing = JSON.parse(readFileSync(credPath, "utf-8"));
@@ -105,7 +112,7 @@ async function refreshClaudeToken(refreshToken) {
105
112
  existing.claudeAiOauth.expiresAt = Date.now() + expiresIn * 1000;
106
113
  writeFileSync(credPath, JSON.stringify(existing), "utf-8");
107
114
  }
108
- catch { /* non-fatal — token still works for this session */ }
115
+ catch { /* non-fatal */ }
109
116
  return {
110
117
  accessToken,
111
118
  refreshToken: newRefresh || refreshToken,
@@ -136,7 +143,6 @@ async function refreshGeminiToken(refreshToken) {
136
143
  const expiresIn = data.expires_in ?? 3600;
137
144
  if (!accessToken)
138
145
  return null;
139
- // Write back
140
146
  try {
141
147
  const credPath = join(homedir(), ".gemini", "oauth_creds.json");
142
148
  const existing = JSON.parse(readFileSync(credPath, "utf-8"));
@@ -149,7 +155,7 @@ async function refreshGeminiToken(refreshToken) {
149
155
  catch { /* non-fatal */ }
150
156
  return {
151
157
  accessToken,
152
- refreshToken, // Google doesn't rotate refresh tokens
158
+ refreshToken,
153
159
  expiresAt: Date.now() + expiresIn * 1000,
154
160
  };
155
161
  }
@@ -178,10 +184,11 @@ async function refreshCodexToken(refreshToken) {
178
184
  const expiresIn = data.expires_in ?? 864000;
179
185
  if (!accessToken)
180
186
  return null;
181
- // Write back
187
+ let accountId;
182
188
  try {
183
189
  const credPath = join(homedir(), ".codex", "auth.json");
184
190
  const existing = JSON.parse(readFileSync(credPath, "utf-8"));
191
+ accountId = existing.tokens?.account_id;
185
192
  existing.tokens.access_token = accessToken;
186
193
  existing.tokens.refresh_token = newRefresh || refreshToken;
187
194
  if (data.id_token)
@@ -194,58 +201,159 @@ async function refreshCodexToken(refreshToken) {
194
201
  accessToken,
195
202
  refreshToken: newRefresh || refreshToken,
196
203
  expiresAt: Date.now() + expiresIn * 1000,
204
+ accountId,
197
205
  };
198
206
  }
199
207
  catch {
200
208
  return null;
201
209
  }
202
210
  }
203
- async function queryOpenAI(token, model, prompt, options) {
204
- const startTime = Date.now();
205
- const body = {
206
- model,
207
- messages: [
208
- ...(options?.system_prompt ? [{ role: "system", content: options.system_prompt }] : []),
209
- { role: "user", content: prompt },
210
- ],
211
- stream: false,
212
- };
213
- if (options?.temperature !== undefined)
214
- body.temperature = options.temperature;
215
- if (options?.max_tokens !== undefined)
216
- body.max_tokens = options.max_tokens;
217
- const res = await fetch("https://api.openai.com/v1/chat/completions", {
211
+ // ---------------------------------------------------------------------------
212
+ // Gemini project ID — resolved via Cloud Code Assist loadCodeAssist API
213
+ // ---------------------------------------------------------------------------
214
+ let cachedGeminiProjectId = null;
215
+ async function getGeminiProjectId(token) {
216
+ if (cachedGeminiProjectId)
217
+ return cachedGeminiProjectId;
218
+ const res = await fetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", {
218
219
  method: "POST",
219
220
  headers: {
220
221
  "Content-Type": "application/json",
221
222
  "Authorization": `Bearer ${token}`,
223
+ "User-Agent": "google-api-nodejs-client/9.15.1",
224
+ "X-Goog-Api-Client": "gl-node/22.17.0",
222
225
  },
223
- body: JSON.stringify(body),
226
+ body: JSON.stringify({
227
+ metadata: {
228
+ ideType: "IDE_UNSPECIFIED",
229
+ platform: "PLATFORM_UNSPECIFIED",
230
+ pluginType: "GEMINI",
231
+ },
232
+ }),
224
233
  });
225
234
  if (!res.ok) {
226
235
  const err = await res.text();
227
- throw new Error(`OpenAI subscription query failed (${res.status}): ${err}`);
236
+ throw new Error(`Gemini loadCodeAssist failed (${res.status}): ${err}`);
228
237
  }
229
238
  const data = (await res.json());
230
- const choices = data.choices;
231
- const choice = choices?.[0];
232
- const message = choice?.message;
233
- const usage = data.usage;
239
+ let projectId = null;
240
+ if (typeof data.cloudaicompanionProject === "string") {
241
+ projectId = data.cloudaicompanionProject;
242
+ }
243
+ else if (data.cloudaicompanionProject &&
244
+ typeof data.cloudaicompanionProject === "object" &&
245
+ typeof data.cloudaicompanionProject.id === "string") {
246
+ projectId = data.cloudaicompanionProject.id;
247
+ }
248
+ if (!projectId && Array.isArray(data.allowedTiers)) {
249
+ const defaultTier = data.allowedTiers.find((t) => t.isDefault === true);
250
+ if (typeof defaultTier?.id === "string") {
251
+ projectId = defaultTier.id;
252
+ }
253
+ }
254
+ if (!projectId) {
255
+ throw new Error("Gemini: no project ID from loadCodeAssist. Run `gemini` CLI once to set up your account.");
256
+ }
257
+ cachedGeminiProjectId = projectId;
258
+ logger.info(`Gemini project ID resolved: ${projectId}`);
259
+ return projectId;
260
+ }
261
+ // ---------------------------------------------------------------------------
262
+ // Query: Codex — chatgpt.com/backend-api/codex SSE streaming
263
+ // ---------------------------------------------------------------------------
264
+ async function queryCodex(tokens, model, prompt, options) {
265
+ const startTime = Date.now();
266
+ const input = [];
267
+ if (options?.system_prompt) {
268
+ input.push({ role: "developer", content: options.system_prompt });
269
+ }
270
+ input.push({ role: "user", content: prompt });
271
+ const body = {
272
+ model,
273
+ instructions: "",
274
+ input,
275
+ stream: true,
276
+ store: false,
277
+ };
278
+ // Note: chatgpt.com/backend-api/codex does NOT support temperature or
279
+ // max_output_tokens for Codex reasoning models. Omitting these params.
280
+ const headers = {
281
+ "Content-Type": "application/json",
282
+ "Authorization": `Bearer ${tokens.accessToken}`,
283
+ "Accept": "text/event-stream",
284
+ "Version": "0.98.0",
285
+ "Openai-Beta": "responses=experimental",
286
+ "User-Agent": "codex_cli_rs/0.98.0",
287
+ "Originator": "codex_cli_rs",
288
+ "Connection": "Keep-Alive",
289
+ };
290
+ if (tokens.accountId) {
291
+ headers["Chatgpt-Account-Id"] = tokens.accountId;
292
+ }
293
+ const res = await fetch("https://chatgpt.com/backend-api/codex/responses", { method: "POST", headers, body: JSON.stringify(body) });
294
+ if (!res.ok) {
295
+ const err = await res.text();
296
+ throw new Error(`Codex subscription query failed (${res.status}): ${err}`);
297
+ }
298
+ // Parse SSE stream — look for response.completed event
299
+ const text = await res.text();
300
+ const lines = text.split("\n");
301
+ let content = "";
302
+ let usage;
303
+ let finishReason;
304
+ for (const line of lines) {
305
+ if (!line.startsWith("data: "))
306
+ continue;
307
+ try {
308
+ const event = JSON.parse(line.slice(6));
309
+ if (event.type === "response.output_text.done") {
310
+ content += event.text ?? "";
311
+ }
312
+ else if (event.type === "response.completed") {
313
+ const resp = event.response;
314
+ if (resp?.usage)
315
+ usage = resp.usage;
316
+ finishReason = resp?.status;
317
+ // Also extract content from completed response if not yet captured
318
+ if (!content && resp?.output) {
319
+ for (const item of resp.output) {
320
+ if (item.type === "message" && item.content) {
321
+ for (const block of item.content) {
322
+ if (block.type === "output_text")
323
+ content += block.text ?? "";
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ }
330
+ catch { /* skip non-JSON lines */ }
331
+ }
234
332
  return {
235
333
  model,
236
- content: message?.content ?? "",
237
- usage,
334
+ content,
335
+ usage: usage
336
+ ? {
337
+ prompt_tokens: usage.input_tokens ?? 0,
338
+ completion_tokens: usage.output_tokens ?? 0,
339
+ total_tokens: usage.total_tokens ?? 0,
340
+ }
341
+ : undefined,
238
342
  latency_ms: Date.now() - startTime,
239
- finish_reason: choice?.finish_reason ?? undefined,
343
+ finish_reason: finishReason,
240
344
  };
241
345
  }
242
- async function queryGemini(token, model, prompt, options) {
346
+ // ---------------------------------------------------------------------------
347
+ // Query: Gemini — Cloud Code Assist direct HTTP
348
+ // ---------------------------------------------------------------------------
349
+ async function queryGemini(tokens, model, prompt, options) {
243
350
  const startTime = Date.now();
244
- const body = {
351
+ const projectId = await getGeminiProjectId(tokens.accessToken);
352
+ const request = {
245
353
  contents: [{ role: "user", parts: [{ text: prompt }] }],
246
354
  };
247
355
  if (options?.system_prompt) {
248
- body.systemInstruction = { parts: [{ text: options.system_prompt }] };
356
+ request.systemInstruction = { parts: [{ text: options.system_prompt }] };
249
357
  }
250
358
  const genConfig = {};
251
359
  if (options?.temperature !== undefined)
@@ -253,12 +361,17 @@ async function queryGemini(token, model, prompt, options) {
253
361
  if (options?.max_tokens !== undefined)
254
362
  genConfig.maxOutputTokens = options.max_tokens;
255
363
  if (Object.keys(genConfig).length > 0)
256
- body.generationConfig = genConfig;
257
- const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, {
364
+ request.generationConfig = genConfig;
365
+ const body = { model, project: projectId, request };
366
+ const res = await fetch("https://cloudcode-pa.googleapis.com/v1internal:generateContent", {
258
367
  method: "POST",
259
368
  headers: {
260
369
  "Content-Type": "application/json",
261
- "Authorization": `Bearer ${token}`,
370
+ "Accept": "application/json",
371
+ "Authorization": `Bearer ${tokens.accessToken}`,
372
+ "User-Agent": "google-api-nodejs-client/9.15.1",
373
+ "X-Goog-Api-Client": "gl-node/22.17.0",
374
+ "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
262
375
  },
263
376
  body: JSON.stringify(body),
264
377
  });
@@ -266,74 +379,111 @@ async function queryGemini(token, model, prompt, options) {
266
379
  const err = await res.text();
267
380
  throw new Error(`Gemini subscription query failed (${res.status}): ${err}`);
268
381
  }
382
+ // Cloud Code Assist wraps the standard Gemini response in a "response" field
269
383
  const data = (await res.json());
270
- const candidates = data.candidates;
384
+ const inner = (data.response ?? data);
385
+ const candidates = inner.candidates;
271
386
  const parts = candidates?.[0]?.content?.parts;
272
387
  const content = parts?.map((p) => p.text ?? "").join("") ?? "";
273
- const meta = data.usageMetadata;
388
+ const meta = inner.usageMetadata;
274
389
  return {
275
390
  model,
276
391
  content,
277
- usage: meta ? {
278
- prompt_tokens: meta.promptTokenCount ?? 0,
279
- completion_tokens: meta.candidatesTokenCount ?? 0,
280
- total_tokens: meta.totalTokenCount ?? 0,
281
- } : undefined,
392
+ usage: meta
393
+ ? {
394
+ prompt_tokens: meta.promptTokenCount ?? 0,
395
+ completion_tokens: meta.candidatesTokenCount ?? 0,
396
+ total_tokens: meta.totalTokenCount ?? 0,
397
+ }
398
+ : undefined,
282
399
  latency_ms: Date.now() - startTime,
283
400
  finish_reason: candidates?.[0]?.finishReason ?? undefined,
284
401
  };
285
402
  }
286
- async function queryAnthropic(token, model, prompt, options) {
287
- const startTime = Date.now();
288
- const body = {
289
- model,
290
- max_tokens: options?.max_tokens ?? 4096,
291
- messages: [{ role: "user", content: prompt }],
292
- };
293
- if (options?.system_prompt)
294
- body.system = options.system_prompt;
295
- if (options?.temperature !== undefined)
296
- body.temperature = options.temperature;
297
- const res = await fetch("https://api.anthropic.com/v1/messages", {
298
- method: "POST",
299
- headers: {
300
- "Content-Type": "application/json",
301
- "Authorization": `Bearer ${token}`,
302
- "anthropic-version": "2023-06-01",
303
- },
304
- body: JSON.stringify(body),
403
+ // ---------------------------------------------------------------------------
404
+ // Query: Claude — CLI subprocess (api.anthropic.com requires TLS fingerprint
405
+ // bypass for OAuth tokens, which needs Go's utls library. Node.js can't do it
406
+ // natively, so we use the Claude CLI as a subprocess.)
407
+ // ---------------------------------------------------------------------------
408
+ function execCLI(command, args, stdinData, timeoutMs = 120_000) {
409
+ return new Promise((resolve) => {
410
+ const isWin = process.platform === "win32";
411
+ const proc = spawn(command, args, {
412
+ shell: isWin,
413
+ env: { ...process.env },
414
+ stdio: ["pipe", "pipe", "pipe"],
415
+ });
416
+ let stdout = "";
417
+ let stderr = "";
418
+ const timer = setTimeout(() => {
419
+ proc.kill();
420
+ resolve({ stdout, stderr: stderr + "\n[TIMEOUT]", code: 124 });
421
+ }, timeoutMs);
422
+ proc.stdout.on("data", (d) => { stdout += d.toString(); });
423
+ proc.stderr.on("data", (d) => { stderr += d.toString(); });
424
+ proc.on("close", (code) => {
425
+ clearTimeout(timer);
426
+ resolve({ stdout, stderr, code: code ?? 1 });
427
+ });
428
+ proc.on("error", (err) => {
429
+ clearTimeout(timer);
430
+ resolve({ stdout, stderr: err.message, code: 1 });
431
+ });
432
+ if (stdinData && proc.stdin) {
433
+ proc.stdin.write(stdinData);
434
+ proc.stdin.end();
435
+ }
305
436
  });
306
- if (!res.ok) {
307
- const err = await res.text();
308
- throw new Error(`Anthropic subscription query failed (${res.status}): ${err}`);
437
+ }
438
+ async function queryClaude(_tokens, model, prompt, options) {
439
+ const startTime = Date.now();
440
+ const args = [
441
+ "--output-format", "json",
442
+ "-p", "-", // Read prompt from stdin
443
+ "--model", model,
444
+ ];
445
+ if (options?.max_tokens)
446
+ args.push("--max-tokens", String(options.max_tokens));
447
+ const result = await execCLI("claude", args, prompt, 120_000);
448
+ if (result.code !== 0) {
449
+ throw new Error(`Claude CLI failed (code ${result.code}): ${result.stderr.substring(0, 200)}`);
450
+ }
451
+ // Parse JSON output from claude --output-format json
452
+ let content = "";
453
+ try {
454
+ const data = JSON.parse(result.stdout);
455
+ if (typeof data.result === "string") {
456
+ content = data.result;
457
+ }
458
+ else if (typeof data.content === "string") {
459
+ content = data.content;
460
+ }
461
+ else if (Array.isArray(data.content)) {
462
+ content = data.content
463
+ .filter((b) => b.type === "text")
464
+ .map((b) => b.text ?? "")
465
+ .join("");
466
+ }
467
+ else {
468
+ content = result.stdout;
469
+ }
470
+ }
471
+ catch {
472
+ content = result.stdout;
309
473
  }
310
- const data = (await res.json());
311
- const contentBlocks = data.content;
312
- const text = contentBlocks?.filter((b) => b.type === "text").map((b) => b.text ?? "").join("") ?? "";
313
- const usage = data.usage;
314
474
  return {
315
475
  model,
316
- content: text,
317
- usage: usage ? {
318
- prompt_tokens: usage.input_tokens,
319
- completion_tokens: usage.output_tokens,
320
- total_tokens: usage.input_tokens + usage.output_tokens,
321
- } : undefined,
476
+ content,
322
477
  latency_ms: Date.now() - startTime,
323
- finish_reason: data.stop_reason ?? undefined,
324
478
  };
325
479
  }
326
- // ---------------------------------------------------------------------------
327
- // Backend configs
328
- // ---------------------------------------------------------------------------
329
480
  const CLAUDE_BACKEND = {
330
481
  id: "claude-sub",
331
482
  displayName: "Claude (subscription)",
332
483
  readTokens: readClaudeTokens,
333
484
  refreshTokens: refreshClaudeToken,
334
- query: queryAnthropic,
485
+ query: queryClaude,
335
486
  models: [
336
- { id: "claude-opus-4-6", name: "Claude Opus 4.6" },
337
487
  { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" },
338
488
  { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
339
489
  ],
@@ -355,11 +505,11 @@ const CODEX_BACKEND = {
355
505
  displayName: "Codex (subscription)",
356
506
  readTokens: readCodexTokens,
357
507
  refreshTokens: refreshCodexToken,
358
- query: queryOpenAI,
508
+ query: queryCodex,
359
509
  models: [
360
- { id: "gpt-4o", name: "GPT-4o" },
361
- { id: "o3", name: "o3" },
362
- { id: "o4-mini", name: "o4-mini" },
510
+ { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
511
+ { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
512
+ { id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
363
513
  ],
364
514
  };
365
515
  const ALL_BACKENDS = [CLAUDE_BACKEND, GEMINI_BACKEND, CODEX_BACKEND];
@@ -371,10 +521,6 @@ export class SubscriptionProvider {
371
521
  backends = [];
372
522
  modelToBackend = new Map();
373
523
  tokenCache = new Map();
374
- /**
375
- * Detect which subscription tokens exist on disk.
376
- * Returns the number of backends with valid tokens.
377
- */
378
524
  async detect() {
379
525
  for (const backend of ALL_BACKENDS) {
380
526
  const tokens = backend.readTokens();
@@ -402,7 +548,6 @@ export class SubscriptionProvider {
402
548
  async query(model, prompt, options) {
403
549
  const backend = this.modelToBackend.get(model);
404
550
  if (!backend) {
405
- // Partial match fallback
406
551
  const match = this.backends.find((b) => b.models.some((m) => model.includes(m.id) || m.id.includes(model)));
407
552
  if (!match) {
408
553
  throw new Error(`No subscription handles model "${model}". ` +
@@ -433,6 +578,6 @@ export class SubscriptionProvider {
433
578
  logger.warn(`Subscription: ${backend.displayName} token refresh failed, trying existing token`);
434
579
  }
435
580
  }
436
- return backend.query(tokens.accessToken, model, prompt, options);
581
+ return backend.query(tokens, model, prompt, options);
437
582
  }
438
583
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hydramcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Multi-model MCP server — compare, vote, and synthesize across GPT, Gemini, Claude, and local models from one terminal",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",