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.
- package/dist/providers/subscription.d.ts +14 -11
- package/dist/providers/subscription.js +526 -134
- package/package.json +1 -1
|
@@ -1,27 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Subscription Provider — use your monthly subscriptions as an API.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Token locations:
|
|
8
|
+
* Claude → ~/.claude/.credentials.json
|
|
9
|
+
* Gemini → ~/.gemini/oauth_creds.json
|
|
10
|
+
* Codex → ~/.codex/auth.json
|
|
9
11
|
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Token locations:
|
|
8
|
+
* Claude → ~/.claude/.credentials.json
|
|
9
|
+
* Gemini → ~/.gemini/oauth_creds.json
|
|
10
|
+
* Codex → ~/.codex/auth.json
|
|
9
11
|
*
|
|
10
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
},
|
|
108
|
-
|
|
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
|
-
|
|
428
|
+
resolve({ stdout, stderr, code: code ?? 1 });
|
|
111
429
|
});
|
|
112
|
-
|
|
430
|
+
proc.on("error", (err) => {
|
|
113
431
|
clearTimeout(timer);
|
|
114
|
-
resolve({ stdout, stderr,
|
|
432
|
+
resolve({ stdout, stderr: err.message, code: 1 });
|
|
115
433
|
});
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
555
|
+
throw new Error(`No subscription handles model "${model}". ` +
|
|
167
556
|
`Available: ${[...this.modelToBackend.keys()].join(", ")}`);
|
|
168
557
|
}
|
|
169
|
-
return this.
|
|
558
|
+
return this.runQuery(match, model, prompt, options);
|
|
170
559
|
}
|
|
171
|
-
return this.
|
|
172
|
-
}
|
|
173
|
-
async
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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