hydramcp 1.0.1 → 1.0.4

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.
@@ -1,27 +1,30 @@
1
1
  /**
2
2
  * Subscription Provider — use your monthly subscriptions as an API.
3
3
  *
4
- * Wraps installed CLI tools (Gemini CLI, Claude Code, Codex CLI) that
5
- * authenticate via OAuth to your subscription accounts. No API keys needed.
4
+ * Reads OAuth tokens stored by CLI tools (Claude Code, Gemini CLI, Codex CLI)
5
+ * and makes direct HTTP requests to provider-internal APIs.
6
6
  *
7
- * HydraMCP spawns the CLIs in non-interactive mode, captures their output,
8
- * and normalizes it to the Provider interface. The CLIs handle all auth.
7
+ * Token locations:
8
+ * Claude → ~/.claude/.credentials.json
9
+ * Gemini → ~/.gemini/oauth_creds.json
10
+ * Codex → ~/.codex/auth.json
9
11
  *
10
- * 100% our code. No CLIProxyAPI dependency.
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
+ *
17
+ * Approach learned from CLIProxyAPI (github.com/router-for-me/CLIProxyAPI).
11
18
  */
12
19
  import { Provider, ModelInfo, QueryOptions, QueryResponse } from "./provider.js";
13
20
  export declare class SubscriptionProvider implements Provider {
14
21
  name: string;
15
22
  private backends;
16
- /** Map model ID → backend that serves it */
17
23
  private modelToBackend;
18
- /**
19
- * Detect which CLI tools are installed on this machine.
20
- * Must be called before using the provider.
21
- */
24
+ private tokenCache;
22
25
  detect(): Promise<number>;
23
26
  healthCheck(): Promise<boolean>;
24
27
  listModels(): Promise<ModelInfo[]>;
25
28
  query(model: string, prompt: string, options?: QueryOptions): Promise<QueryResponse>;
26
- private runBackend;
29
+ private runQuery;
27
30
  }
@@ -1,148 +1,538 @@
1
1
  /**
2
2
  * Subscription Provider — use your monthly subscriptions as an API.
3
3
  *
4
- * Wraps installed CLI tools (Gemini CLI, Claude Code, Codex CLI) that
5
- * authenticate via OAuth to your subscription accounts. No API keys needed.
4
+ * Reads OAuth tokens stored by CLI tools (Claude Code, Gemini CLI, Codex CLI)
5
+ * and makes direct HTTP requests to provider-internal APIs.
6
6
  *
7
- * HydraMCP spawns the CLIs in non-interactive mode, captures their output,
8
- * and normalizes it to the Provider interface. The CLIs handle all auth.
7
+ * Token locations:
8
+ * Claude → ~/.claude/.credentials.json
9
+ * Gemini → ~/.gemini/oauth_creds.json
10
+ * Codex → ~/.codex/auth.json
9
11
  *
10
- * 100% our code. No CLIProxyAPI dependency.
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
+ *
17
+ * Approach learned from CLIProxyAPI (github.com/router-for-me/CLIProxyAPI).
11
18
  */
19
+ import { readFileSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { homedir } from "node:os";
12
22
  import { spawn } from "node:child_process";
13
23
  import { logger } from "../utils/logger.js";
14
- const GEMINI_CLI = {
15
- id: "gemini-cli",
16
- displayName: "Gemini CLI",
17
- command: "gemini",
18
- versionArgs: ["--version"],
19
- buildArgs(model, prompt) {
20
- return ["-p", prompt, "-m", model];
21
- },
22
- parseOutput(stdout) {
23
- return stdout.trim();
24
- },
25
- models: [
26
- { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
27
- { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
28
- { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
29
- { id: "gemini-3-flash", name: "Gemini 3 Flash" },
30
- { id: "gemini-3-pro", name: "Gemini 3 Pro" },
31
- ],
32
- timeout: 120_000,
33
- };
34
- const CLAUDE_CLI = {
35
- id: "claude-cli",
36
- displayName: "Claude Code",
37
- command: "claude",
38
- versionArgs: ["--version"],
39
- buildArgs(model, prompt) {
40
- return ["-p", prompt, "--output-format", "json", "--model", model];
41
- },
42
- parseOutput(stdout) {
43
- // Claude Code --output-format json returns a JSON object
44
- // with a "result" field containing the response text.
24
+ // ---------------------------------------------------------------------------
25
+ // Token file readers
26
+ // ---------------------------------------------------------------------------
27
+ function readClaudeTokens() {
28
+ try {
29
+ const raw = readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf-8");
30
+ const data = JSON.parse(raw);
31
+ const oauth = data.claudeAiOauth;
32
+ if (!oauth?.accessToken || !oauth?.refreshToken)
33
+ return null;
34
+ return {
35
+ accessToken: oauth.accessToken,
36
+ refreshToken: oauth.refreshToken,
37
+ expiresAt: oauth.expiresAt ?? 0,
38
+ };
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function readGeminiTokens() {
45
+ try {
46
+ const raw = readFileSync(join(homedir(), ".gemini", "oauth_creds.json"), "utf-8");
47
+ const data = JSON.parse(raw);
48
+ if (!data.access_token || !data.refresh_token)
49
+ return null;
50
+ return {
51
+ accessToken: data.access_token,
52
+ refreshToken: data.refresh_token,
53
+ expiresAt: data.expiry_date ?? 0,
54
+ };
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ function readCodexTokens() {
61
+ try {
62
+ const raw = readFileSync(join(homedir(), ".codex", "auth.json"), "utf-8");
63
+ const data = JSON.parse(raw);
64
+ const tokens = data.tokens;
65
+ if (!tokens?.access_token || !tokens?.refresh_token)
66
+ return null;
67
+ let expiresAt = 0;
45
68
  try {
46
- const data = JSON.parse(stdout);
47
- return data.result ?? data.content ?? stdout.trim();
69
+ const payload = JSON.parse(Buffer.from(tokens.access_token.split(".")[1], "base64").toString());
70
+ if (payload.exp)
71
+ expiresAt = payload.exp * 1000;
48
72
  }
49
- catch {
50
- // If JSON parsing fails, return raw text
51
- return stdout.trim();
73
+ catch { /* non-JWT or malformed */ }
74
+ return {
75
+ accessToken: tokens.access_token,
76
+ refreshToken: tokens.refresh_token,
77
+ expiresAt,
78
+ accountId: tokens.account_id ?? undefined,
79
+ };
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Token refresh
87
+ // ---------------------------------------------------------------------------
88
+ async function refreshClaudeToken(refreshToken) {
89
+ try {
90
+ const res = await fetch("https://console.anthropic.com/v1/oauth/token", {
91
+ method: "POST",
92
+ headers: { "Content-Type": "application/json" },
93
+ body: JSON.stringify({
94
+ client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
95
+ grant_type: "refresh_token",
96
+ refresh_token: refreshToken,
97
+ }),
98
+ });
99
+ if (!res.ok)
100
+ return null;
101
+ const data = (await res.json());
102
+ const accessToken = data.access_token;
103
+ const newRefresh = data.refresh_token;
104
+ const expiresIn = data.expires_in ?? 86400;
105
+ if (!accessToken)
106
+ return null;
107
+ try {
108
+ const credPath = join(homedir(), ".claude", ".credentials.json");
109
+ const existing = JSON.parse(readFileSync(credPath, "utf-8"));
110
+ existing.claudeAiOauth.accessToken = accessToken;
111
+ existing.claudeAiOauth.refreshToken = newRefresh || refreshToken;
112
+ existing.claudeAiOauth.expiresAt = Date.now() + expiresIn * 1000;
113
+ writeFileSync(credPath, JSON.stringify(existing), "utf-8");
52
114
  }
53
- },
54
- models: [
55
- { id: "claude-opus-4-6", name: "Claude Opus 4.6" },
56
- { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" },
57
- { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
58
- ],
59
- timeout: 180_000,
60
- };
61
- const CODEX_CLI = {
62
- id: "codex-cli",
63
- displayName: "Codex CLI",
64
- command: "codex",
65
- versionArgs: ["--version"],
66
- buildArgs(_model, prompt) {
67
- // Codex exec streams progress to stderr, final message to stdout
68
- return ["exec", prompt];
69
- },
70
- parseOutput(stdout) {
71
- return stdout.trim();
72
- },
73
- models: [
74
- { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
75
- { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
76
- ],
77
- timeout: 180_000,
78
- };
79
- /** All supported CLI backends, tried in order. */
80
- const ALL_BACKENDS = [GEMINI_CLI, CLAUDE_CLI, CODEX_CLI];
81
- function execCLI(command, args, timeout) {
82
- return new Promise((resolve, reject) => {
83
- let child;
115
+ catch { /* non-fatal */ }
116
+ return {
117
+ accessToken,
118
+ refreshToken: newRefresh || refreshToken,
119
+ expiresAt: Date.now() + expiresIn * 1000,
120
+ };
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ async function refreshGeminiToken(refreshToken) {
127
+ try {
128
+ const body = new URLSearchParams({
129
+ client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
130
+ client_secret: "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",
131
+ refresh_token: refreshToken,
132
+ grant_type: "refresh_token",
133
+ });
134
+ const res = await fetch("https://oauth2.googleapis.com/token", {
135
+ method: "POST",
136
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
137
+ body: body.toString(),
138
+ });
139
+ if (!res.ok)
140
+ return null;
141
+ const data = (await res.json());
142
+ const accessToken = data.access_token;
143
+ const expiresIn = data.expires_in ?? 3600;
144
+ if (!accessToken)
145
+ return null;
84
146
  try {
85
- child = spawn(command, args, {
86
- stdio: ["pipe", "pipe", "pipe"],
87
- // On Windows, .cmd shims from npm need a shell to resolve.
88
- // Arguments are passed as an array so Node quotes them safely.
89
- shell: process.platform === "win32",
90
- });
147
+ const credPath = join(homedir(), ".gemini", "oauth_creds.json");
148
+ const existing = JSON.parse(readFileSync(credPath, "utf-8"));
149
+ existing.access_token = accessToken;
150
+ existing.expiry_date = Date.now() + expiresIn * 1000;
151
+ if (data.id_token)
152
+ existing.id_token = data.id_token;
153
+ writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8");
154
+ }
155
+ catch { /* non-fatal */ }
156
+ return {
157
+ accessToken,
158
+ refreshToken,
159
+ expiresAt: Date.now() + expiresIn * 1000,
160
+ };
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ async function refreshCodexToken(refreshToken) {
167
+ try {
168
+ const body = new URLSearchParams({
169
+ client_id: "app_EMoamEEZ73f0CkXaXp7hrann",
170
+ grant_type: "refresh_token",
171
+ refresh_token: refreshToken,
172
+ scope: "openid profile email",
173
+ });
174
+ const res = await fetch("https://auth.openai.com/oauth/token", {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
177
+ body: body.toString(),
178
+ });
179
+ if (!res.ok)
180
+ return null;
181
+ const data = (await res.json());
182
+ const accessToken = data.access_token;
183
+ const newRefresh = data.refresh_token;
184
+ const expiresIn = data.expires_in ?? 864000;
185
+ if (!accessToken)
186
+ return null;
187
+ let accountId;
188
+ try {
189
+ const credPath = join(homedir(), ".codex", "auth.json");
190
+ const existing = JSON.parse(readFileSync(credPath, "utf-8"));
191
+ accountId = existing.tokens?.account_id;
192
+ existing.tokens.access_token = accessToken;
193
+ existing.tokens.refresh_token = newRefresh || refreshToken;
194
+ if (data.id_token)
195
+ existing.tokens.id_token = data.id_token;
196
+ existing.last_refresh = new Date().toISOString();
197
+ writeFileSync(credPath, JSON.stringify(existing, null, 2), "utf-8");
198
+ }
199
+ catch { /* non-fatal */ }
200
+ return {
201
+ accessToken,
202
+ refreshToken: newRefresh || refreshToken,
203
+ expiresAt: Date.now() + expiresIn * 1000,
204
+ accountId,
205
+ };
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
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", {
219
+ method: "POST",
220
+ headers: {
221
+ "Content-Type": "application/json",
222
+ "Authorization": `Bearer ${token}`,
223
+ "User-Agent": "google-api-nodejs-client/9.15.1",
224
+ "X-Goog-Api-Client": "gl-node/22.17.0",
225
+ },
226
+ body: JSON.stringify({
227
+ metadata: {
228
+ ideType: "IDE_UNSPECIFIED",
229
+ platform: "PLATFORM_UNSPECIFIED",
230
+ pluginType: "GEMINI",
231
+ },
232
+ }),
233
+ });
234
+ if (!res.ok) {
235
+ const err = await res.text();
236
+ throw new Error(`Gemini loadCodeAssist failed (${res.status}): ${err}`);
237
+ }
238
+ const data = (await res.json());
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;
91
252
  }
92
- catch (err) {
93
- reject(err);
94
- return;
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
+ if (options?.temperature !== undefined)
279
+ body.temperature = options.temperature;
280
+ if (options?.max_tokens !== undefined)
281
+ body.max_output_tokens = options.max_tokens;
282
+ const headers = {
283
+ "Content-Type": "application/json",
284
+ "Authorization": `Bearer ${tokens.accessToken}`,
285
+ "Accept": "text/event-stream",
286
+ "Version": "0.98.0",
287
+ "Openai-Beta": "responses=experimental",
288
+ "User-Agent": "codex_cli_rs/0.98.0",
289
+ "Originator": "codex_cli_rs",
290
+ "Connection": "Keep-Alive",
291
+ };
292
+ if (tokens.accountId) {
293
+ headers["Chatgpt-Account-Id"] = tokens.accountId;
294
+ }
295
+ const res = await fetch("https://chatgpt.com/backend-api/codex/responses", { method: "POST", headers, body: JSON.stringify(body) });
296
+ if (!res.ok) {
297
+ const err = await res.text();
298
+ throw new Error(`Codex subscription query failed (${res.status}): ${err}`);
299
+ }
300
+ // Parse SSE stream — look for response.completed event
301
+ const text = await res.text();
302
+ const lines = text.split("\n");
303
+ let content = "";
304
+ let usage;
305
+ let finishReason;
306
+ for (const line of lines) {
307
+ if (!line.startsWith("data: "))
308
+ continue;
309
+ try {
310
+ const event = JSON.parse(line.slice(6));
311
+ if (event.type === "response.output_text.done") {
312
+ content += event.text ?? "";
313
+ }
314
+ else if (event.type === "response.completed") {
315
+ const resp = event.response;
316
+ if (resp?.usage)
317
+ usage = resp.usage;
318
+ finishReason = resp?.status;
319
+ // Also extract content from completed response if not yet captured
320
+ if (!content && resp?.output) {
321
+ for (const item of resp.output) {
322
+ if (item.type === "message" && item.content) {
323
+ for (const block of item.content) {
324
+ if (block.type === "output_text")
325
+ content += block.text ?? "";
326
+ }
327
+ }
328
+ }
329
+ }
330
+ }
95
331
  }
332
+ catch { /* skip non-JSON lines */ }
333
+ }
334
+ return {
335
+ model,
336
+ content,
337
+ usage: usage
338
+ ? {
339
+ prompt_tokens: usage.input_tokens ?? 0,
340
+ completion_tokens: usage.output_tokens ?? 0,
341
+ total_tokens: usage.total_tokens ?? 0,
342
+ }
343
+ : undefined,
344
+ latency_ms: Date.now() - startTime,
345
+ finish_reason: finishReason,
346
+ };
347
+ }
348
+ // ---------------------------------------------------------------------------
349
+ // Query: Gemini — Cloud Code Assist direct HTTP
350
+ // ---------------------------------------------------------------------------
351
+ async function queryGemini(tokens, model, prompt, options) {
352
+ const startTime = Date.now();
353
+ const projectId = await getGeminiProjectId(tokens.accessToken);
354
+ const request = {
355
+ contents: [{ role: "user", parts: [{ text: prompt }] }],
356
+ };
357
+ if (options?.system_prompt) {
358
+ request.systemInstruction = { parts: [{ text: options.system_prompt }] };
359
+ }
360
+ const genConfig = {};
361
+ if (options?.temperature !== undefined)
362
+ genConfig.temperature = options.temperature;
363
+ if (options?.max_tokens !== undefined)
364
+ genConfig.maxOutputTokens = options.max_tokens;
365
+ if (Object.keys(genConfig).length > 0)
366
+ request.generationConfig = genConfig;
367
+ const body = { model, project: projectId, request };
368
+ const res = await fetch("https://cloudcode-pa.googleapis.com/v1internal:generateContent", {
369
+ method: "POST",
370
+ headers: {
371
+ "Content-Type": "application/json",
372
+ "Accept": "application/json",
373
+ "Authorization": `Bearer ${tokens.accessToken}`,
374
+ "User-Agent": "google-api-nodejs-client/9.15.1",
375
+ "X-Goog-Api-Client": "gl-node/22.17.0",
376
+ "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
377
+ },
378
+ body: JSON.stringify(body),
379
+ });
380
+ if (!res.ok) {
381
+ const err = await res.text();
382
+ throw new Error(`Gemini subscription query failed (${res.status}): ${err}`);
383
+ }
384
+ // Cloud Code Assist wraps the standard Gemini response in a "response" field
385
+ const data = (await res.json());
386
+ const inner = (data.response ?? data);
387
+ const candidates = inner.candidates;
388
+ const parts = candidates?.[0]?.content?.parts;
389
+ const content = parts?.map((p) => p.text ?? "").join("") ?? "";
390
+ const meta = inner.usageMetadata;
391
+ return {
392
+ model,
393
+ content,
394
+ usage: meta
395
+ ? {
396
+ prompt_tokens: meta.promptTokenCount ?? 0,
397
+ completion_tokens: meta.candidatesTokenCount ?? 0,
398
+ total_tokens: meta.totalTokenCount ?? 0,
399
+ }
400
+ : undefined,
401
+ latency_ms: Date.now() - startTime,
402
+ finish_reason: candidates?.[0]?.finishReason ?? undefined,
403
+ };
404
+ }
405
+ // ---------------------------------------------------------------------------
406
+ // Query: Claude — CLI subprocess (api.anthropic.com requires TLS fingerprint
407
+ // bypass for OAuth tokens, which needs Go's utls library. Node.js can't do it
408
+ // natively, so we use the Claude CLI as a subprocess.)
409
+ // ---------------------------------------------------------------------------
410
+ function execCLI(command, args, stdinData, timeoutMs = 120_000) {
411
+ return new Promise((resolve) => {
412
+ const isWin = process.platform === "win32";
413
+ const proc = spawn(command, args, {
414
+ shell: isWin,
415
+ env: { ...process.env },
416
+ stdio: ["pipe", "pipe", "pipe"],
417
+ });
96
418
  let stdout = "";
97
419
  let stderr = "";
98
- child.stdout?.on("data", (d) => {
99
- stdout += d.toString();
100
- });
101
- child.stderr?.on("data", (d) => {
102
- stderr += d.toString();
103
- });
104
420
  const timer = setTimeout(() => {
105
- child.kill("SIGTERM");
106
- reject(new Error(`${command} timed out after ${timeout}ms`));
107
- }, timeout);
108
- child.on("error", (err) => {
421
+ proc.kill();
422
+ resolve({ stdout, stderr: stderr + "\n[TIMEOUT]", code: 124 });
423
+ }, timeoutMs);
424
+ proc.stdout.on("data", (d) => { stdout += d.toString(); });
425
+ proc.stderr.on("data", (d) => { stderr += d.toString(); });
426
+ proc.on("close", (code) => {
109
427
  clearTimeout(timer);
110
- reject(err);
428
+ resolve({ stdout, stderr, code: code ?? 1 });
111
429
  });
112
- child.on("close", (code) => {
430
+ proc.on("error", (err) => {
113
431
  clearTimeout(timer);
114
- resolve({ stdout, stderr, exitCode: code ?? 1 });
432
+ resolve({ stdout, stderr: err.message, code: 1 });
115
433
  });
116
- // Close stdin — we pass the prompt via args, not stdin
117
- child.stdin?.end();
434
+ if (stdinData && proc.stdin) {
435
+ proc.stdin.write(stdinData);
436
+ proc.stdin.end();
437
+ }
118
438
  });
119
439
  }
440
+ async function queryClaude(_tokens, model, prompt, options) {
441
+ const startTime = Date.now();
442
+ const args = [
443
+ "--output-format", "json",
444
+ "-p", "-", // Read prompt from stdin
445
+ "--model", model,
446
+ ];
447
+ if (options?.max_tokens)
448
+ args.push("--max-tokens", String(options.max_tokens));
449
+ const result = await execCLI("claude", args, prompt, 120_000);
450
+ if (result.code !== 0) {
451
+ throw new Error(`Claude CLI failed (code ${result.code}): ${result.stderr.substring(0, 200)}`);
452
+ }
453
+ // Parse JSON output from claude --output-format json
454
+ let content = "";
455
+ try {
456
+ const data = JSON.parse(result.stdout);
457
+ if (typeof data.result === "string") {
458
+ content = data.result;
459
+ }
460
+ else if (typeof data.content === "string") {
461
+ content = data.content;
462
+ }
463
+ else if (Array.isArray(data.content)) {
464
+ content = data.content
465
+ .filter((b) => b.type === "text")
466
+ .map((b) => b.text ?? "")
467
+ .join("");
468
+ }
469
+ else {
470
+ content = result.stdout;
471
+ }
472
+ }
473
+ catch {
474
+ content = result.stdout;
475
+ }
476
+ return {
477
+ model,
478
+ content,
479
+ latency_ms: Date.now() - startTime,
480
+ };
481
+ }
482
+ const CLAUDE_BACKEND = {
483
+ id: "claude-sub",
484
+ displayName: "Claude (subscription)",
485
+ readTokens: readClaudeTokens,
486
+ refreshTokens: refreshClaudeToken,
487
+ query: queryClaude,
488
+ models: [
489
+ { id: "claude-sonnet-4-5-20250929", name: "Claude Sonnet 4.5" },
490
+ { id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5" },
491
+ ],
492
+ };
493
+ const GEMINI_BACKEND = {
494
+ id: "gemini-sub",
495
+ displayName: "Gemini (subscription)",
496
+ readTokens: readGeminiTokens,
497
+ refreshTokens: refreshGeminiToken,
498
+ query: queryGemini,
499
+ models: [
500
+ { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
501
+ { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
502
+ { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
503
+ ],
504
+ };
505
+ const CODEX_BACKEND = {
506
+ id: "codex-sub",
507
+ displayName: "Codex (subscription)",
508
+ readTokens: readCodexTokens,
509
+ refreshTokens: refreshCodexToken,
510
+ query: queryCodex,
511
+ models: [
512
+ { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
513
+ { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
514
+ { id: "gpt-5.1-codex-mini", name: "GPT-5.1 Codex Mini" },
515
+ ],
516
+ };
517
+ const ALL_BACKENDS = [CLAUDE_BACKEND, GEMINI_BACKEND, CODEX_BACKEND];
120
518
  // ---------------------------------------------------------------------------
121
519
  // SubscriptionProvider
122
520
  // ---------------------------------------------------------------------------
123
521
  export class SubscriptionProvider {
124
522
  name = "Subscription";
125
523
  backends = [];
126
- /** Map model ID → backend that serves it */
127
524
  modelToBackend = new Map();
128
- /**
129
- * Detect which CLI tools are installed on this machine.
130
- * Must be called before using the provider.
131
- */
525
+ tokenCache = new Map();
132
526
  async detect() {
133
527
  for (const backend of ALL_BACKENDS) {
134
- try {
135
- const result = await execCLI(backend.command, backend.versionArgs, 10_000);
136
- if (result.exitCode === 0) {
137
- this.backends.push(backend);
138
- for (const model of backend.models) {
139
- this.modelToBackend.set(model.id, backend);
140
- }
141
- logger.info(`Subscription: ${backend.displayName} detected ✓`);
528
+ const tokens = backend.readTokens();
529
+ if (tokens) {
530
+ this.backends.push(backend);
531
+ this.tokenCache.set(backend.id, tokens);
532
+ for (const model of backend.models) {
533
+ this.modelToBackend.set(model.id, backend);
142
534
  }
143
- }
144
- catch {
145
- // CLI not installed — skip silently
535
+ logger.info(`Subscription: ${backend.displayName} detected (token on disk)`);
146
536
  }
147
537
  }
148
538
  return this.backends.length;
@@ -160,34 +550,36 @@ export class SubscriptionProvider {
160
550
  async query(model, prompt, options) {
161
551
  const backend = this.modelToBackend.get(model);
162
552
  if (!backend) {
163
- // Try to find a backend by partial match
164
553
  const match = this.backends.find((b) => b.models.some((m) => model.includes(m.id) || m.id.includes(model)));
165
554
  if (!match) {
166
- throw new Error(`No subscription CLI handles model "${model}". ` +
555
+ throw new Error(`No subscription handles model "${model}". ` +
167
556
  `Available: ${[...this.modelToBackend.keys()].join(", ")}`);
168
557
  }
169
- return this.runBackend(match, model, prompt, options);
558
+ return this.runQuery(match, model, prompt, options);
170
559
  }
171
- return this.runBackend(backend, model, prompt, options);
172
- }
173
- async runBackend(backend, model, prompt, options) {
174
- const startTime = Date.now();
175
- const args = backend.buildArgs(model, prompt, options);
176
- logger.info(`Subscription: querying ${backend.displayName} (${model})`);
177
- const result = await execCLI(backend.command, args, backend.timeout);
178
- if (result.exitCode !== 0) {
179
- throw new Error(`${backend.displayName} exited with code ${result.exitCode}: ${result.stderr.slice(0, 500)}`);
560
+ return this.runQuery(backend, model, prompt, options);
561
+ }
562
+ async runQuery(backend, model, prompt, options) {
563
+ let tokens = this.tokenCache.get(backend.id);
564
+ if (!tokens) {
565
+ const fresh = backend.readTokens();
566
+ if (!fresh)
567
+ throw new Error(`${backend.displayName}: no tokens found`);
568
+ tokens = fresh;
569
+ this.tokenCache.set(backend.id, tokens);
180
570
  }
181
- const content = backend.parseOutput(result.stdout);
182
- const latency_ms = Date.now() - startTime;
183
- if (!content) {
184
- throw new Error(`${backend.displayName} returned empty response. stderr: ${result.stderr.slice(0, 500)}`);
571
+ // Refresh if expired (with 60s buffer)
572
+ if (tokens.expiresAt > 0 && tokens.expiresAt < Date.now() + 60_000) {
573
+ logger.info(`Subscription: refreshing ${backend.displayName} token`);
574
+ const refreshed = await backend.refreshTokens(tokens.refreshToken);
575
+ if (refreshed) {
576
+ tokens = refreshed;
577
+ this.tokenCache.set(backend.id, tokens);
578
+ }
579
+ else {
580
+ logger.warn(`Subscription: ${backend.displayName} token refresh failed, trying existing token`);
581
+ }
185
582
  }
186
- return {
187
- model,
188
- content,
189
- latency_ms,
190
- finish_reason: "stop",
191
- };
583
+ return backend.query(tokens, model, prompt, options);
192
584
  }
193
585
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hydramcp",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
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",