@stage-labs/metro 0.1.0-beta.150 → 0.1.0-beta.152
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +30 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-client.ts +46 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +31 -20
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +19 -8
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +71 -7
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +48 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +13 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.152",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.152",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
3
|
|
|
4
|
-
export const GEMINI_CLIENT_ID = '
|
|
5
|
-
export const GEMINI_CLIENT_SECRET = 'GOCSPX-
|
|
4
|
+
export const GEMINI_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
|
5
|
+
export const GEMINI_CLIENT_SECRET = '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';
|
|
6
6
|
export const GEMINI_AUTH_BASE = 'https://accounts.google.com';
|
|
7
7
|
export const GEMINI_TOKEN_BASE = 'https://oauth2.googleapis.com';
|
|
8
|
-
export const GEMINI_REDIRECT = '
|
|
9
|
-
const SCOPE =
|
|
8
|
+
export const GEMINI_REDIRECT = 'http://localhost:51121/oauth-callback';
|
|
9
|
+
const SCOPE = [
|
|
10
|
+
'https://www.googleapis.com/auth/cloud-platform',
|
|
11
|
+
'https://www.googleapis.com/auth/userinfo.email',
|
|
12
|
+
'https://www.googleapis.com/auth/userinfo.profile',
|
|
13
|
+
'https://www.googleapis.com/auth/cclog',
|
|
14
|
+
'https://www.googleapis.com/auth/experimentsandconfigs',
|
|
15
|
+
].join(' ');
|
|
10
16
|
const PENDING_TTL_MS = 10 * 60_000;
|
|
11
17
|
const EXPIRY_MARGIN_MS = 5 * 60_000;
|
|
12
18
|
const DEFAULT_TTL_MS = 55 * 60_000;
|
|
@@ -107,15 +113,31 @@ async function tokenCall(form: Record<string, string>, base: string, fetchImpl:
|
|
|
107
113
|
return body;
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
export
|
|
116
|
+
export function codeIn(pasted: string, state: string): string {
|
|
117
|
+
const trimmed = pasted.trim();
|
|
118
|
+
if (trimmed === '') throw new GeminiAuthError('paste the address the browser landed on, or the code in it');
|
|
119
|
+
if (!/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
120
|
+
let url: URL;
|
|
121
|
+
try {
|
|
122
|
+
url = new URL(trimmed);
|
|
123
|
+
} catch {
|
|
124
|
+
throw new GeminiAuthError('that is not a full address');
|
|
125
|
+
}
|
|
126
|
+
const code = url.searchParams.get('code') ?? '';
|
|
127
|
+
const carried = url.searchParams.get('state');
|
|
128
|
+
if (code === '') throw new GeminiAuthError(`that address carries no code${url.searchParams.get('error') === null ? '' : ` (Google said ${url.searchParams.get('error') ?? ''})`}`);
|
|
129
|
+
if (carried !== null && carried !== state) throw new GeminiAuthError('that address belongs to another sign-in; start again and paste the new one');
|
|
130
|
+
return code;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function exchangeCode(pasted: string, state: string, base = GEMINI_TOKEN_BASE, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<GeminiTokens> {
|
|
111
134
|
sweep(now);
|
|
112
135
|
const entry = pending.get(state);
|
|
113
136
|
if (entry === undefined) throw new GeminiAuthError('that sign-in has expired; start it again');
|
|
114
137
|
pending.delete(state);
|
|
115
|
-
const
|
|
116
|
-
if (trimmed === '') throw new GeminiAuthError('paste the code Google showed');
|
|
138
|
+
const code = codeIn(pasted, state);
|
|
117
139
|
const body = await tokenCall(
|
|
118
|
-
{ client_id: GEMINI_CLIENT_ID, client_secret: GEMINI_CLIENT_SECRET, grant_type: 'authorization_code', code
|
|
140
|
+
{ client_id: GEMINI_CLIENT_ID, client_secret: GEMINI_CLIENT_SECRET, grant_type: 'authorization_code', code, code_verifier: entry.verifier, redirect_uri: GEMINI_REDIRECT },
|
|
119
141
|
base,
|
|
120
142
|
fetchImpl,
|
|
121
143
|
);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const CODE_ASSIST_BASE = 'https://cloudcode-pa.googleapis.com';
|
|
4
|
+
export const CODE_ASSIST_DAILY = 'https://daily-cloudcode-pa.googleapis.com';
|
|
5
|
+
export const API = 'v1internal';
|
|
6
|
+
export const APP_VERSION = '2.0.3';
|
|
7
|
+
export const CLIENT_VERSION = '1.110.0';
|
|
8
|
+
export const CLIENT_NAME = 'antigravity';
|
|
9
|
+
export const IDE_TYPE = 9;
|
|
10
|
+
export const PLUGIN_TYPE = 2;
|
|
11
|
+
export const SYSTEM_PREFIX =
|
|
12
|
+
'You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**';
|
|
13
|
+
|
|
14
|
+
const PLATFORMS: Record<string, number> = { 'darwin-x64': 1, 'darwin-arm64': 2, 'linux-x64': 3, 'linux-arm64': 4, 'win32-x64': 5 };
|
|
15
|
+
|
|
16
|
+
const osName = (): string => (process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux');
|
|
17
|
+
|
|
18
|
+
export const platformCode = (): number => PLATFORMS[`${process.platform}-${process.arch}`] ?? 0;
|
|
19
|
+
|
|
20
|
+
export const clientMetadata = (project: string | null): Record<string, unknown> => ({
|
|
21
|
+
ideType: IDE_TYPE,
|
|
22
|
+
platform: platformCode(),
|
|
23
|
+
pluginType: PLUGIN_TYPE,
|
|
24
|
+
...(project === null ? {} : { duetProject: project }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const clientHeaders = (): Record<string, string> => ({
|
|
28
|
+
'user-agent': `${CLIENT_NAME}/${APP_VERSION} ${osName()}/${process.arch}`,
|
|
29
|
+
'x-client-name': CLIENT_NAME,
|
|
30
|
+
'x-client-version': CLIENT_VERSION,
|
|
31
|
+
'x-goog-api-client': 'gl-node/18.18.2 fire/0.8.6 grpc/1.10.x',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type Bases = string | string[];
|
|
35
|
+
|
|
36
|
+
const listed = (override: Bases): string[] => (typeof override === 'string' ? [override] : override);
|
|
37
|
+
|
|
38
|
+
export const generateBases = (override?: Bases): string[] => (override === undefined ? [CODE_ASSIST_DAILY, CODE_ASSIST_BASE] : listed(override));
|
|
39
|
+
|
|
40
|
+
export const setupBases = (override?: Bases): string[] => (override === undefined ? [CODE_ASSIST_BASE, CODE_ASSIST_DAILY] : listed(override));
|
|
41
|
+
|
|
42
|
+
export const requestId = (): string => `agent-${randomUUID()}`;
|
|
43
|
+
|
|
44
|
+
const sessionId = randomUUID();
|
|
45
|
+
|
|
46
|
+
export const machineSessionId = (): string => sessionId;
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
2
2
|
import { log } from '@metro-labs/core/log';
|
|
3
3
|
import { GeminiAuthError, type GeminiTokens } from './gemini-auth.js';
|
|
4
|
+
import { API, clientHeaders, clientMetadata, setupBases, type Bases } from './gemini-client.js';
|
|
4
5
|
|
|
5
|
-
export const CODE_ASSIST_BASE = 'https://cloudcode-pa.googleapis.com';
|
|
6
|
-
const API = 'v1internal';
|
|
7
|
-
const METADATA = { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' };
|
|
8
|
-
const CLI_VERSION = '0.60.0';
|
|
9
6
|
const FREE_TIER = 'free-tier';
|
|
10
7
|
const PROJECT_RE = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/;
|
|
11
8
|
const POLL_MS = 5000;
|
|
@@ -18,8 +15,6 @@ export interface Onboarded {
|
|
|
18
15
|
|
|
19
16
|
const text = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
|
|
20
17
|
|
|
21
|
-
export const userAgent = (model: string): string => `GeminiCLI/${CLI_VERSION}/${model} (${process.platform}; ${process.arch}; terminal)`;
|
|
22
|
-
|
|
23
18
|
export function parseGeminiProject(value: unknown): string | null {
|
|
24
19
|
const project = typeof value === 'string' ? value.trim() : '';
|
|
25
20
|
if (project === '') return null;
|
|
@@ -27,28 +22,41 @@ export function parseGeminiProject(value: unknown): string | null {
|
|
|
27
22
|
return project;
|
|
28
23
|
}
|
|
29
24
|
|
|
30
|
-
|
|
25
|
+
class Retryable extends Error {}
|
|
31
26
|
|
|
32
|
-
async function
|
|
27
|
+
async function callOne(base: string, method: string, token: string, body: unknown, fetchImpl: typeof fetch): Promise<Record<string, unknown>> {
|
|
33
28
|
let res: Response;
|
|
34
29
|
try {
|
|
35
30
|
res = await fetchImpl(`${base}/${API}:${method}`, {
|
|
36
31
|
method: 'POST',
|
|
37
|
-
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json',
|
|
32
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...clientHeaders() },
|
|
38
33
|
body: JSON.stringify(body),
|
|
39
34
|
signal: AbortSignal.timeout(30_000),
|
|
40
35
|
});
|
|
41
36
|
} catch (err) {
|
|
42
|
-
throw new
|
|
37
|
+
throw new Retryable(`could not reach Google Code Assist: ${err instanceof Error ? err.message : String(err)}`);
|
|
43
38
|
}
|
|
44
39
|
const answer: unknown = await res.json().catch(() => null);
|
|
45
40
|
if (!res.ok) {
|
|
46
|
-
const detail = isRecord(answer) && isRecord(answer.error) ? text(answer.error.message) : null
|
|
47
|
-
throw
|
|
41
|
+
const detail = (isRecord(answer) && isRecord(answer.error) ? text(answer.error.message) : null) ?? `Google Code Assist answered ${String(res.status)} on ${method}`;
|
|
42
|
+
throw res.status >= 500 ? new Retryable(detail) : new GeminiAuthError(detail);
|
|
48
43
|
}
|
|
49
44
|
return isRecord(answer) ? answer : {};
|
|
50
45
|
}
|
|
51
46
|
|
|
47
|
+
async function call(bases: string[], method: string, token: string, body: unknown, fetchImpl: typeof fetch): Promise<Record<string, unknown>> {
|
|
48
|
+
let last = 'no Code Assist endpoint to call';
|
|
49
|
+
for (const base of bases) {
|
|
50
|
+
try {
|
|
51
|
+
return await callOne(base, method, token, body, fetchImpl);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (!(err instanceof Retryable)) throw err;
|
|
54
|
+
last = err.message;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw new GeminiAuthError(last);
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
const tierOf = (load: Record<string, unknown>): { id: string | null; name: string | null } => {
|
|
53
61
|
const tier = isRecord(load.paidTier) ? load.paidTier : isRecord(load.currentTier) ? load.currentTier : null;
|
|
54
62
|
return { id: tier === null ? null : text(tier.id), name: tier === null ? null : text(tier.name) };
|
|
@@ -60,7 +68,7 @@ async function waitOperation(base: string, token: string, first: Record<string,
|
|
|
60
68
|
const name = text(op.name);
|
|
61
69
|
if (name === null) break;
|
|
62
70
|
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
63
|
-
const res = await fetchImpl(`${base}/${API}/${name}`, { headers: { authorization: `Bearer ${token}
|
|
71
|
+
const res = await fetchImpl(`${base}/${API}/${name}`, { headers: { authorization: `Bearer ${token}`, ...clientHeaders() }, signal: AbortSignal.timeout(30_000) });
|
|
64
72
|
const answer: unknown = await res.json().catch(() => null);
|
|
65
73
|
op = isRecord(answer) ? answer : {};
|
|
66
74
|
}
|
|
@@ -75,7 +83,7 @@ const REASONS: Record<string, string> = {
|
|
|
75
83
|
UNKNOWN_LOCATION: 'Google could not tell where this account is located',
|
|
76
84
|
RESTRICTED_NETWORK: 'the network this box is on is restricted',
|
|
77
85
|
INELIGIBLE_ACCOUNT: 'Google marks this account as not eligible',
|
|
78
|
-
UNSUPPORTED_CLIENT: 'Google
|
|
86
|
+
UNSUPPORTED_CLIENT: 'Google no longer serves the client metro presents; nothing on this box can fix that',
|
|
79
87
|
};
|
|
80
88
|
|
|
81
89
|
function refuseIneligible(load: Record<string, unknown>): void {
|
|
@@ -107,7 +115,7 @@ const summary = (load: Record<string, unknown>): Record<string, unknown> => ({
|
|
|
107
115
|
function onboardBody(load: Record<string, unknown>, project: string | null): { body: Record<string, unknown>; name: string | null } {
|
|
108
116
|
const wanted = tierToOnboard(load);
|
|
109
117
|
const paid = wanted.id !== FREE_TIER && project !== null;
|
|
110
|
-
return { body: { tierId: wanted.id, metadata:
|
|
118
|
+
return { body: { tierId: wanted.id, metadata: clientMetadata(paid ? project : null) }, name: wanted.name ?? wanted.id };
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
const NO_ASSIGNMENT = 'Google did not assign a Code Assist project to this account; enter the id of the Google Cloud project that carries your licence and connect again';
|
|
@@ -119,19 +127,22 @@ const assignedProject = (op: Record<string, unknown>): string | null => {
|
|
|
119
127
|
return isRecord(response.cloudaicompanionProject) ? text(response.cloudaicompanionProject.id) : null;
|
|
120
128
|
};
|
|
121
129
|
|
|
122
|
-
async function onboardNew(load: Record<string, unknown>, tokens: GeminiTokens, project: string | null,
|
|
130
|
+
async function onboardNew(load: Record<string, unknown>, tokens: GeminiTokens, project: string | null, bases: string[], fetchImpl: typeof fetch): Promise<Onboarded> {
|
|
123
131
|
const { body, name } = onboardBody(load, project);
|
|
124
|
-
const
|
|
132
|
+
const first = await call(bases, 'onboardUser', tokens.accessToken, body, fetchImpl);
|
|
133
|
+
const op = await waitOperation(bases[0] ?? '', tokens.accessToken, first, fetchImpl);
|
|
125
134
|
const assigned = assignedProject(op) ?? project;
|
|
126
135
|
if (assigned === null) throw new GeminiAuthError(NO_ASSIGNMENT);
|
|
127
136
|
return { project: assigned, tier: name };
|
|
128
137
|
}
|
|
129
138
|
|
|
130
|
-
export async function onboard(tokens: GeminiTokens, project: string | null = null, base
|
|
131
|
-
const
|
|
139
|
+
export async function onboard(tokens: GeminiTokens, project: string | null = null, base?: Bases, fetchImpl: typeof fetch = fetch): Promise<Onboarded> {
|
|
140
|
+
const bases = setupBases(base);
|
|
141
|
+
const asked = { metadata: clientMetadata(project), mode: 1, ...withProject(project) };
|
|
142
|
+
const load = await call(bases, 'loadCodeAssist', tokens.accessToken, asked, fetchImpl);
|
|
132
143
|
log.info(summary(load), 'gemini: Code Assist answered loadCodeAssist');
|
|
133
144
|
refuseIneligible(load);
|
|
134
|
-
if (!isRecord(load.currentTier)) return onboardNew(load, tokens, project,
|
|
145
|
+
if (!isRecord(load.currentTier)) return onboardNew(load, tokens, project, [...bases].reverse(), fetchImpl);
|
|
135
146
|
const known = text(load.cloudaicompanionProject) ?? project;
|
|
136
147
|
if (known === null) throw new GeminiAuthError(NO_PROJECT);
|
|
137
148
|
const tier = tierOf(load);
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
3
|
import { ToolNames } from './codex-translate.js';
|
|
4
|
+
import { CLIENT_NAME, requestId, SYSTEM_PREFIX } from './gemini-client.js';
|
|
4
5
|
|
|
5
6
|
type Item = Record<string, unknown>;
|
|
6
7
|
|
|
7
8
|
export const SIGNATURE_PREFIX = 'metro-gemini:';
|
|
9
|
+
export const SKIP_SIGNATURE = 'skip_thought_signature_validator';
|
|
8
10
|
const IMAGE_NOTE = '[an image was attached here; this model cannot see it]';
|
|
9
11
|
const SCHEMA_DROP = new Set(['$schema', '$id', 'additionalProperties', 'examples', 'default', 'title']);
|
|
10
12
|
const SIGNATURES_MAX = 2000;
|
|
13
|
+
const MAX_OUTPUT_TOKENS = 16384;
|
|
11
14
|
|
|
12
15
|
const textOf = (value: unknown): string => (typeof value === 'string' ? value : '');
|
|
13
16
|
|
|
@@ -77,8 +80,7 @@ function callPart(block: Item, names: ToolNames, calls: Map<string, string>): It
|
|
|
77
80
|
const id = textOf(block.id);
|
|
78
81
|
const name = names.alias(textOf(block.name));
|
|
79
82
|
calls.set(id, name);
|
|
80
|
-
|
|
81
|
-
return { functionCall: { id, name, args: isRecord(block.input) ? block.input : {} }, ...(signature === undefined ? {} : { thoughtSignature: signature }) };
|
|
83
|
+
return { functionCall: { id, name, args: isRecord(block.input) ? block.input : {} }, thoughtSignature: callSignatures.get(id) ?? SKIP_SIGNATURE };
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
function assistantPart(block: Item, names: ToolNames, calls: Map<string, string>): Item | null {
|
|
@@ -156,7 +158,7 @@ function toolConfig(choice: unknown, names: ToolNames): Item | null {
|
|
|
156
158
|
|
|
157
159
|
function generationConfig(body: Item): Item {
|
|
158
160
|
const out: Item = {};
|
|
159
|
-
if (typeof body.max_tokens === 'number') out.maxOutputTokens = body.max_tokens;
|
|
161
|
+
if (typeof body.max_tokens === 'number') out.maxOutputTokens = Math.min(body.max_tokens, MAX_OUTPUT_TOKENS);
|
|
160
162
|
if (typeof body.temperature === 'number') out.temperature = body.temperature;
|
|
161
163
|
if (typeof body.top_p === 'number') out.topP = body.top_p;
|
|
162
164
|
if (isRecord(body.thinking) && body.thinking.type === 'enabled') out.thinkingConfig = { includeThoughts: true };
|
|
@@ -166,25 +168,34 @@ function generationConfig(body: Item): Item {
|
|
|
166
168
|
export interface GeminiRequest {
|
|
167
169
|
model: string;
|
|
168
170
|
project: string;
|
|
169
|
-
|
|
171
|
+
userAgent: string;
|
|
172
|
+
requestType: string;
|
|
173
|
+
requestId: string;
|
|
170
174
|
request: Item;
|
|
171
175
|
}
|
|
172
176
|
|
|
177
|
+
const systemParts = (system: string): Item[] => [
|
|
178
|
+
{ text: SYSTEM_PREFIX },
|
|
179
|
+
{ text: `Please ignore the following [ignore]${SYSTEM_PREFIX}[/ignore]` },
|
|
180
|
+
...(system === '' ? [] : [{ text: system }]),
|
|
181
|
+
];
|
|
182
|
+
|
|
173
183
|
export function toGeminiRequest(body: Item, model: string, project: string, promptId: string, names = new ToolNames()): GeminiRequest {
|
|
174
|
-
const system = systemText(body.system);
|
|
175
184
|
const declarations = toolDeclarations(body.tools, names);
|
|
176
185
|
const config = toolConfig(body.tool_choice, names);
|
|
177
186
|
return {
|
|
178
187
|
model,
|
|
179
188
|
project,
|
|
180
|
-
|
|
189
|
+
userAgent: CLIENT_NAME,
|
|
190
|
+
requestType: 'agent',
|
|
191
|
+
requestId: requestId(),
|
|
181
192
|
request: {
|
|
182
193
|
contents: contentsOf(body.messages, names),
|
|
183
|
-
|
|
194
|
+
systemInstruction: { role: 'user', parts: systemParts(systemText(body.system)) },
|
|
184
195
|
...(declarations.length === 0 ? {} : { tools: [{ functionDeclarations: declarations }] }),
|
|
185
196
|
...(config === null ? {} : { toolConfig: config }),
|
|
186
197
|
generationConfig: generationConfig(body),
|
|
187
|
-
|
|
198
|
+
sessionId: promptId,
|
|
188
199
|
},
|
|
189
200
|
};
|
|
190
201
|
}
|
|
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
2
2
|
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
3
|
import { errMsg } from '@metro-labs/core/log';
|
|
4
4
|
import { refreshTokens, tokensStale, type GeminiTokens } from './gemini-auth.js';
|
|
5
|
-
import {
|
|
5
|
+
import { API, clientHeaders, generateBases, machineSessionId, type Bases } from './gemini-client.js';
|
|
6
6
|
import { GeminiStreamTranslator } from './gemini-stream.js';
|
|
7
7
|
import { toGeminiRequest } from './gemini-translate.js';
|
|
8
8
|
import { ToolNames } from './codex-translate.js';
|
|
@@ -14,10 +14,8 @@ import { UsageScanner } from './usage.js';
|
|
|
14
14
|
const PING_MS = 25_000;
|
|
15
15
|
const STATUS_OF: Record<string, number> = { rate_limit_error: 429, invalid_request_error: 400, permission_error: 403, overloaded_error: 529 };
|
|
16
16
|
|
|
17
|
-
export const KNOWN_GEMINI_MODELS = ['gemini-3.1-pro-preview', 'gemini-3-pro-preview', 'gemini-3-flash-preview', 'gemini-3.1-flash-lite', 'gemini-2.5-pro', 'gemini-2.5-flash'];
|
|
18
|
-
|
|
19
17
|
export interface GeminiDeps {
|
|
20
|
-
base?:
|
|
18
|
+
base?: Bases;
|
|
21
19
|
tokenBase?: string;
|
|
22
20
|
fetchImpl?: typeof fetch;
|
|
23
21
|
save: (tokens: GeminiTokens) => void;
|
|
@@ -80,18 +78,43 @@ interface Call {
|
|
|
80
78
|
names: ToolNames;
|
|
81
79
|
}
|
|
82
80
|
|
|
83
|
-
|
|
81
|
+
const headersFor = (token: string, stream: boolean): Record<string, string> => ({
|
|
82
|
+
authorization: `Bearer ${token}`,
|
|
83
|
+
'content-type': 'application/json',
|
|
84
|
+
...clientHeaders(),
|
|
85
|
+
'x-machine-session-id': machineSessionId(),
|
|
86
|
+
...(stream ? { accept: 'text/event-stream' } : {}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
async function sendTo(base: string, call: Call, tokens: GeminiTokens, stream: boolean): Promise<Response> {
|
|
84
90
|
const request = toGeminiRequest(call.body, call.model, tokens.project, call.promptId, call.names);
|
|
85
91
|
const method = stream ? 'streamGenerateContent?alt=sse' : 'generateContent';
|
|
86
|
-
return (call.deps.fetchImpl ?? fetch)(`${
|
|
92
|
+
return (call.deps.fetchImpl ?? fetch)(`${base}/${API}:${method}`, {
|
|
87
93
|
method: 'POST',
|
|
88
|
-
headers:
|
|
94
|
+
headers: headersFor(tokens.accessToken, stream),
|
|
89
95
|
body: JSON.stringify(request),
|
|
90
96
|
signal: call.watch.signal,
|
|
91
97
|
redirect: 'manual',
|
|
92
98
|
});
|
|
93
99
|
}
|
|
94
100
|
|
|
101
|
+
async function send(call: Call, tokens: GeminiTokens, stream: boolean): Promise<Response> {
|
|
102
|
+
const bases = generateBases(call.deps.base);
|
|
103
|
+
let last: Response | null = null;
|
|
104
|
+
for (const base of bases) {
|
|
105
|
+
if (last !== null) await last.body?.cancel();
|
|
106
|
+
try {
|
|
107
|
+
last = await sendTo(base, call, tokens, stream);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (base === bases.at(-1) || call.watch.signal.aborted) throw err;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (last.status < 500) return last;
|
|
113
|
+
}
|
|
114
|
+
if (last === null) throw new GatewayError(502, 'api_error', 'no Code Assist endpoint to call');
|
|
115
|
+
return last;
|
|
116
|
+
}
|
|
117
|
+
|
|
95
118
|
async function reach(call: Call, cfg: ModelConfig, state: GeminiState, stream: boolean): Promise<Response> {
|
|
96
119
|
let tokens = await currentGeminiTokens(cfg, call.deps, state);
|
|
97
120
|
let upstream = await send(call, tokens, stream);
|
|
@@ -103,6 +126,47 @@ async function reach(call: Call, cfg: ModelConfig, state: GeminiState, stream: b
|
|
|
103
126
|
return upstream;
|
|
104
127
|
}
|
|
105
128
|
|
|
129
|
+
export interface GeminiModel {
|
|
130
|
+
id: string;
|
|
131
|
+
name: string;
|
|
132
|
+
remaining: number | null;
|
|
133
|
+
resetAt: string | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const modelOf = (id: string, raw: unknown): GeminiModel => {
|
|
137
|
+
const entry = isRecord(raw) ? raw : {};
|
|
138
|
+
const quota = isRecord(entry.quotaInfo) ? entry.quotaInfo : null;
|
|
139
|
+
const fraction = quota !== null && typeof quota.remainingFraction === 'number' ? quota.remainingFraction : null;
|
|
140
|
+
const resetAt = quota !== null && typeof quota.resetTime === 'string' ? quota.resetTime : null;
|
|
141
|
+
return { id, name: typeof entry.displayName === 'string' ? entry.displayName : id, remaining: fraction ?? (resetAt === null ? null : 0), resetAt };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export async function listGeminiModels(tokens: GeminiTokens, deps: GeminiDeps): Promise<GeminiModel[]> {
|
|
145
|
+
let last = 'no Code Assist endpoint to call';
|
|
146
|
+
for (const base of generateBases(deps.base)) {
|
|
147
|
+
try {
|
|
148
|
+
const res = await (deps.fetchImpl ?? fetch)(`${base}/${API}:fetchAvailableModels`, {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: headersFor(tokens.accessToken, false),
|
|
151
|
+
body: JSON.stringify({ project: tokens.project }),
|
|
152
|
+
signal: AbortSignal.timeout(30_000),
|
|
153
|
+
});
|
|
154
|
+
const body: unknown = await res.json().catch(() => null);
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
last = upstreamMessage(JSON.stringify(body), `Google answered ${String(res.status)}`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const models = isRecord(body) && isRecord(body.models) ? body.models : {};
|
|
160
|
+
return Object.entries(models)
|
|
161
|
+
.filter(([id]) => id.startsWith('gemini'))
|
|
162
|
+
.map(([id, raw]) => modelOf(id, raw));
|
|
163
|
+
} catch (err) {
|
|
164
|
+
last = errMsg(err);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
throw new GatewayError(502, 'api_error', `could not list the Gemini models: ${last}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
106
170
|
const parseData = (raw: string): Record<string, unknown> | null => {
|
|
107
171
|
try {
|
|
108
172
|
const parsed: unknown = JSON.parse(raw);
|
|
@@ -8,12 +8,12 @@ import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
|
|
|
8
8
|
import { codexModels, currentTokens, freshCodexState } from './codex.js';
|
|
9
9
|
import { beginLogin as beginGeminiLogin, exchangeCode as exchangeGeminiCode, GeminiAuthError, userEmail } from './gemini-auth.js';
|
|
10
10
|
import { onboard, parseGeminiProject } from './gemini-setup.js';
|
|
11
|
-
import {
|
|
11
|
+
import { currentGeminiTokens, freshGeminiState, listGeminiModels, type GeminiDeps } from './gemini.js';
|
|
12
12
|
import { openrouterCredits, openrouterModels, openrouterZdrModels } from './openrouter.js';
|
|
13
13
|
import { anthropicModels, bedrockModels } from './provider-models.js';
|
|
14
14
|
import { syncAvailableModelsQuietly, type SetupDeps } from '../claude/setup.js';
|
|
15
15
|
import { lastServed } from './served.js';
|
|
16
|
-
import { noteUsage, openrouterUsage, usageOf, usageSeen } from './usage.js';
|
|
16
|
+
import { geminiUsage, noteUsage, openrouterUsage, usageOf, usageSeen } from './usage.js';
|
|
17
17
|
import type { CodexTokens } from './codex-auth.js';
|
|
18
18
|
import { GatewayError } from './forward.js';
|
|
19
19
|
import {
|
|
@@ -92,8 +92,48 @@ async function refreshCredits(cfg: ModelConfig, deps: ModelApiDeps, now = Date.n
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
const geminiApiState = freshGeminiState();
|
|
96
|
+
|
|
97
|
+
const geminiDeps = (deps: ModelApiDeps, store: Store): GeminiDeps => ({
|
|
98
|
+
base: deps.geminiBase,
|
|
99
|
+
tokenBase: deps.geminiTokenBase,
|
|
100
|
+
fetchImpl: deps.fetchImpl,
|
|
101
|
+
save: (t) => {
|
|
102
|
+
store.write(setGeminiAuth(store.read(), t));
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
async function geminiModels(deps: ModelApiDeps, store: Store): Promise<Awaited<ReturnType<typeof listGeminiModels>>> {
|
|
107
|
+
const cfg = store.read();
|
|
108
|
+
if (cfg.gemini.auth === null) throw new ApiError('Gemini is not connected: sign in with Google first', 400);
|
|
109
|
+
const tokens = await currentGeminiTokens(cfg, geminiDeps(deps, store), geminiApiState);
|
|
110
|
+
return listGeminiModels(tokens, geminiDeps(deps, store));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function geminiModelsInUse(cfg: ModelConfig): Set<string> {
|
|
114
|
+
const served = lastServed();
|
|
115
|
+
return new Set([cfg.gemini.model, served?.provider === 'gemini' ? served.model : ''].filter((m) => m !== ''));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function refreshGeminiQuota(deps: ModelApiDeps, store: Store, now = Date.now()): Promise<void> {
|
|
119
|
+
const cfg = store.read();
|
|
120
|
+
if (cfg.gemini.auth === null) return;
|
|
121
|
+
const inUse = geminiModelsInUse(cfg);
|
|
122
|
+
const seen = usageOf('gemini');
|
|
123
|
+
const covers = seen !== undefined && [...inUse].every((id) => seen.windows.some((w) => w.label === id));
|
|
124
|
+
if (seen !== undefined && covers && now - Date.parse(seen.at) < CREDITS_TTL_MS) return;
|
|
125
|
+
try {
|
|
126
|
+
const rows = (await geminiModels(deps, store)).filter((m) => inUse.has(m.id));
|
|
127
|
+
const usage = geminiUsage(rows, new Date(now));
|
|
128
|
+
if (usage !== null) noteUsage('gemini', usage);
|
|
129
|
+
} catch (err) {
|
|
130
|
+
log.warn({ err: errMsg(err) }, 'model-api: could not read the Gemini quota');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function settingsWithUsage(cfg: ModelConfig, deps: ModelApiDeps, store: Store): Promise<Record<string, unknown>> {
|
|
96
135
|
await refreshCredits(cfg, deps);
|
|
136
|
+
await refreshGeminiQuota(deps, store);
|
|
97
137
|
return settingsBody(cfg);
|
|
98
138
|
}
|
|
99
139
|
|
|
@@ -218,7 +258,10 @@ const GEMINI_ROUTES: Record<string, Route> = {
|
|
|
218
258
|
return Promise.resolve(settingsBody(cfg));
|
|
219
259
|
},
|
|
220
260
|
},
|
|
221
|
-
models: {
|
|
261
|
+
models: {
|
|
262
|
+
method: 'GET',
|
|
263
|
+
run: async (_req, deps, store) => ({ models: (await geminiModels(deps, store).catch(asApiError)).map((m) => m.id) }),
|
|
264
|
+
},
|
|
222
265
|
};
|
|
223
266
|
|
|
224
267
|
const OPENROUTER_ROUTES: Record<string, Route> = {
|
|
@@ -268,7 +311,7 @@ function bundleRoute(path: string, method: string | undefined): Route | number {
|
|
|
268
311
|
}
|
|
269
312
|
|
|
270
313
|
function settingsRoute(method: string | undefined): Route | number {
|
|
271
|
-
if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(store.read(), deps) };
|
|
314
|
+
if (method === 'GET') return { method: 'GET', run: (_req, deps, store) => settingsWithUsage(store.read(), deps, store) };
|
|
272
315
|
if (method === 'PUT') return { method: 'POST', run: (req, deps, store) => update(req, store, deps) };
|
|
273
316
|
return 405;
|
|
274
317
|
}
|
|
@@ -219,6 +219,19 @@ export function noteUsageHeaders(provider: 'anthropic' | 'codex', headers: Heade
|
|
|
219
219
|
|
|
220
220
|
const dollars = (value: number): string => `$${value.toFixed(2)}`;
|
|
221
221
|
|
|
222
|
+
export interface QuotaRow {
|
|
223
|
+
id: string;
|
|
224
|
+
remaining: number | null;
|
|
225
|
+
resetAt: string | null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function geminiUsage(rows: QuotaRow[], now = new Date()): Reported | null {
|
|
229
|
+
const windows: UsageWindow[] = rows
|
|
230
|
+
.filter((row) => row.remaining !== null)
|
|
231
|
+
.map((row) => ({ label: row.id, used: clamp(1 - (row.remaining ?? 0)), resetAt: row.resetAt, detail: null }));
|
|
232
|
+
return windows.length === 0 ? null : { windows, note: null, at: now.toISOString() };
|
|
233
|
+
}
|
|
234
|
+
|
|
222
235
|
export function openrouterUsage(total: number, spent: number, now = new Date()): Reported {
|
|
223
236
|
const used = total > 0 ? clamp(spent / total) : null;
|
|
224
237
|
return {
|
package/runtime/runtime.json
CHANGED