@stage-labs/metro 0.1.0-beta.146 → 0.1.0-beta.148

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/route.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { agentsDir } from './local.js';
4
- const PROVIDERS = ['bedrock', 'openrouter', 'codex'];
4
+ const PROVIDERS = ['bedrock', 'openrouter', 'codex', 'gemini'];
5
5
  export function currentRoute(dir = agentsDir()) {
6
6
  const path = join(dir, 'model.json');
7
7
  if (!existsSync(path))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.146",
3
+ "version": "0.1.0-beta.148",
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.146",
3
+ "version": "0.1.0-beta.148",
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",
@@ -10,8 +10,12 @@ import {
10
10
  type Adaptations,
11
11
  } from './bedrock.js';
12
12
  import { anthropicHeaders, forwardedHeaders, GatewayError, parseJson, pipeResponse, readBody, sendError, watchUpstream } from './forward.js';
13
- import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, writeModelConfig, type ModelConfig, type Route } from './model-config.js';
14
- import { codexCount, codexMessages, freshCodexState, type CodexDeps } from './codex.js';
13
+ import { notReady, readModelConfig, resolveRoute, routeLabel, setCodexAuth, setGeminiAuth, writeModelConfig, type ModelConfig, type Route } from './model-config.js';
14
+ import { codexCount, codexMessages, freshCodexState } from './codex.js';
15
+ import { freshGeminiState, geminiCount, geminiMessages } from './gemini.js';
16
+ import type { GeminiDeps } from './gemini.js';
17
+ import type { CodexDeps } from './codex.js';
18
+ import type { GeminiTokens } from './gemini-auth.js';
15
19
  import { OPENROUTER_BASE } from './openrouter.js';
16
20
  import { isRecord } from '@metro-labs/core/is-record';
17
21
  import { forgetServed, noteServed } from './served.js';
@@ -32,10 +36,12 @@ export interface GatewayDeps {
32
36
  bedrockBase?: string;
33
37
  openrouterBase?: string;
34
38
  codex?: Partial<CodexDeps>;
39
+ gemini?: Partial<GeminiDeps>;
35
40
  }
36
41
 
37
42
  const learned: Adaptations = freshAdaptations();
38
43
  const codexState = freshCodexState();
44
+ const geminiState = freshGeminiState();
39
45
 
40
46
  export function resetGatewayState(): void {
41
47
  forgetServed();
@@ -43,12 +49,17 @@ export function resetGatewayState(): void {
43
49
  learned.fields.clear();
44
50
  learned.dropBetas = false;
45
51
  Object.assign(codexState, freshCodexState());
52
+ Object.assign(geminiState, freshGeminiState());
46
53
  }
47
54
 
48
55
  const saveCodexTokens = (tokens: CodexTokens): void => {
49
56
  writeModelConfig(setCodexAuth(readModelConfig(), tokens));
50
57
  };
51
58
 
59
+ const saveGeminiTokens = (tokens: GeminiTokens): void => {
60
+ writeModelConfig(setGeminiAuth(readModelConfig(), tokens));
61
+ };
62
+
52
63
  const keyOf = (req: IncomingMessage): string => {
53
64
  const raw = req.headers['x-metro-key'];
54
65
  return (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
@@ -64,6 +75,8 @@ function modelsBody(cfg: ModelConfig): Record<string, unknown> {
64
75
  data.push({ id: `openrouter:${cfg.openrouter.model}`, display_name: `OpenRouter · ${cfg.openrouter.model}`, description: 'Through metro, billed to OpenRouter' });
65
76
  if (cfg.codex.model !== '')
66
77
  data.push({ id: `codex:${cfg.codex.model}`, display_name: `Codex · ${cfg.codex.model}`, description: 'Through metro, on your ChatGPT subscription' });
78
+ if (cfg.gemini.model !== '')
79
+ data.push({ id: `gemini:${cfg.gemini.model}`, display_name: `Gemini · ${cfg.gemini.model}`, description: 'Through metro, on your Google account' });
67
80
  return { data, has_more: false, first_id: data[0]?.id ?? null, last_id: data.at(-1)?.id ?? null };
68
81
  }
69
82
 
@@ -94,7 +107,7 @@ async function toAnthropic(
94
107
  throw new GatewayError(
95
108
  403,
96
109
  'permission_error',
97
- 'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter or Codex on the Model page, or sign in on the Claude tab',
110
+ 'Claude Code on this machine has no Anthropic login of its own; choose Bedrock, OpenRouter, Codex or Gemini on the Model page, or sign in on the Claude tab',
98
111
  );
99
112
  const watch = watchUpstream(res);
100
113
  const upstream = await fetch(url, {
@@ -156,6 +169,16 @@ function noteRefusal(provider: string, model: string, upstream: Response): void
156
169
  if (!upstream.ok) log.warn({ provider, model, status: upstream.status }, 'gateway: the provider refused the request');
157
170
  }
158
171
 
172
+ async function toSubscription(req: IncomingMessage, res: ServerResponse, path: string, body: Record<string, unknown>, route: Route, cfg: ModelConfig, deps: GatewayDeps): Promise<void> {
173
+ if (route.provider === 'gemini') {
174
+ if (path === COUNT) geminiCount(res, body);
175
+ else await geminiMessages(req, res, body, route.model, cfg, { save: saveGeminiTokens, ...deps.gemini }, geminiState, watchUpstream(res));
176
+ return;
177
+ }
178
+ if (path === COUNT) codexCount(res, body);
179
+ else await codexMessages(req, res, body, route.model, cfg, { save: saveCodexTokens, ...deps.codex }, codexState, watchUpstream(res));
180
+ }
181
+
159
182
  async function dispatch(req: IncomingMessage, res: ServerResponse, path: string, deps: GatewayDeps): Promise<void> {
160
183
  const cfg = deps.config();
161
184
  const raw = await readBody(req);
@@ -175,13 +198,8 @@ async function dispatch(req: IncomingMessage, res: ServerResponse, path: string,
175
198
  await toOpenRouter(req, res, body, route, cfg, deps);
176
199
  return;
177
200
  }
178
- if (route.provider === 'codex') {
179
- if (path === COUNT) {
180
- codexCount(res, body);
181
- return;
182
- }
183
- const codexDeps: CodexDeps = { save: saveCodexTokens, ...deps.codex };
184
- await codexMessages(req, res, body, route.model, cfg, codexDeps, codexState, watchUpstream(res));
201
+ if (route.provider === 'codex' || route.provider === 'gemini') {
202
+ await toSubscription(req, res, path, body, route, cfg, deps);
185
203
  return;
186
204
  }
187
205
  await toAnthropic(req, res, raw, body, route, deps);
@@ -0,0 +1,160 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+
4
+ export const GEMINI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
5
+ export const GEMINI_CLIENT_SECRET = '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';
6
+ export const GEMINI_AUTH_BASE = 'https://accounts.google.com';
7
+ export const GEMINI_TOKEN_BASE = 'https://oauth2.googleapis.com';
8
+ export const GEMINI_REDIRECT = 'https://codeassist.google.com/authcode';
9
+ const SCOPE = 'https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile';
10
+ const PENDING_TTL_MS = 10 * 60_000;
11
+ const EXPIRY_MARGIN_MS = 5 * 60_000;
12
+ const DEFAULT_TTL_MS = 55 * 60_000;
13
+
14
+ export interface GeminiTokens {
15
+ accessToken: string;
16
+ refreshToken: string;
17
+ expiresAt: number;
18
+ email: string | null;
19
+ project: string;
20
+ tier: string | null;
21
+ savedAt: string;
22
+ }
23
+
24
+ export class GeminiAuthError extends Error {}
25
+
26
+ interface Pending {
27
+ verifier: string;
28
+ at: number;
29
+ }
30
+
31
+ const pending = new Map<string, Pending>();
32
+
33
+ const b64url = (buf: Buffer): string => buf.toString('base64url');
34
+
35
+ export function newPkce(): { verifier: string; challenge: string } {
36
+ const verifier = b64url(randomBytes(32));
37
+ return { verifier, challenge: b64url(createHash('sha256').update(verifier).digest()) };
38
+ }
39
+
40
+ export function authorizeUrl(state: string, challenge: string, base = GEMINI_AUTH_BASE): string {
41
+ const params = new URLSearchParams({
42
+ client_id: GEMINI_CLIENT_ID,
43
+ redirect_uri: GEMINI_REDIRECT,
44
+ response_type: 'code',
45
+ scope: SCOPE,
46
+ access_type: 'offline',
47
+ prompt: 'consent',
48
+ code_challenge: challenge,
49
+ code_challenge_method: 'S256',
50
+ state,
51
+ });
52
+ return `${base}/o/oauth2/v2/auth?${params.toString()}`;
53
+ }
54
+
55
+ function sweep(now: number): void {
56
+ for (const [state, entry] of pending) if (now - entry.at > PENDING_TTL_MS) pending.delete(state);
57
+ }
58
+
59
+ export function beginLogin(base = GEMINI_AUTH_BASE, now = Date.now()): { url: string; state: string } {
60
+ sweep(now);
61
+ const { verifier, challenge } = newPkce();
62
+ const state = b64url(randomBytes(16));
63
+ pending.set(state, { verifier, at: now });
64
+ return { url: authorizeUrl(state, challenge, base), state };
65
+ }
66
+
67
+ const text = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
68
+
69
+ const refusalIn = (body: Record<string, unknown>): string => text(body.error_description) ?? text(body.error) ?? 'Google answered without an access token';
70
+
71
+ function grantedTokens(body: unknown, previous: GeminiTokens | null): { accessToken: string; refreshToken: string; ttl: number } {
72
+ if (!isRecord(body)) throw new GeminiAuthError('Google answered without tokens');
73
+ const accessToken = text(body.access_token);
74
+ if (accessToken === null) throw new GeminiAuthError(refusalIn(body));
75
+ const refreshToken = text(body.refresh_token) ?? previous?.refreshToken ?? '';
76
+ if (refreshToken === '') throw new GeminiAuthError('Google issued no refresh token; sign in again');
77
+ return { accessToken, refreshToken, ttl: typeof body.expires_in === 'number' ? body.expires_in * 1000 : DEFAULT_TTL_MS };
78
+ }
79
+
80
+ function tokensOf(body: unknown, previous: GeminiTokens | null, now: number): GeminiTokens {
81
+ const granted = grantedTokens(body, previous);
82
+ return {
83
+ accessToken: granted.accessToken,
84
+ refreshToken: granted.refreshToken,
85
+ expiresAt: now + granted.ttl,
86
+ email: previous?.email ?? null,
87
+ project: previous?.project ?? '',
88
+ tier: previous?.tier ?? null,
89
+ savedAt: new Date(now).toISOString(),
90
+ };
91
+ }
92
+
93
+ async function tokenCall(form: Record<string, string>, base: string, fetchImpl: typeof fetch): Promise<unknown> {
94
+ let res: Response;
95
+ try {
96
+ res = await fetchImpl(`${base}/token`, {
97
+ method: 'POST',
98
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
99
+ body: new URLSearchParams(form).toString(),
100
+ signal: AbortSignal.timeout(30_000),
101
+ });
102
+ } catch (err) {
103
+ throw new GeminiAuthError(`could not reach Google: ${err instanceof Error ? err.message : String(err)}`);
104
+ }
105
+ const body: unknown = await res.json().catch(() => null);
106
+ if (!res.ok) throw new GeminiAuthError(isRecord(body) ? (text(body.error_description) ?? text(body.error) ?? `Google answered ${String(res.status)}`) : `Google answered ${String(res.status)}`);
107
+ return body;
108
+ }
109
+
110
+ export async function exchangeCode(code: string, state: string, base = GEMINI_TOKEN_BASE, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<GeminiTokens> {
111
+ sweep(now);
112
+ const entry = pending.get(state);
113
+ if (entry === undefined) throw new GeminiAuthError('that sign-in has expired; start it again');
114
+ pending.delete(state);
115
+ const trimmed = code.trim();
116
+ if (trimmed === '') throw new GeminiAuthError('paste the code Google showed');
117
+ const body = await tokenCall(
118
+ { client_id: GEMINI_CLIENT_ID, client_secret: GEMINI_CLIENT_SECRET, grant_type: 'authorization_code', code: trimmed, code_verifier: entry.verifier, redirect_uri: GEMINI_REDIRECT },
119
+ base,
120
+ fetchImpl,
121
+ );
122
+ return tokensOf(body, null, now);
123
+ }
124
+
125
+ export async function refreshTokens(previous: GeminiTokens, base = GEMINI_TOKEN_BASE, fetchImpl: typeof fetch = fetch, now = Date.now()): Promise<GeminiTokens> {
126
+ const body = await tokenCall(
127
+ { client_id: GEMINI_CLIENT_ID, client_secret: GEMINI_CLIENT_SECRET, grant_type: 'refresh_token', refresh_token: previous.refreshToken },
128
+ base,
129
+ fetchImpl,
130
+ );
131
+ return tokensOf(body, previous, now);
132
+ }
133
+
134
+ export const tokensStale = (tokens: GeminiTokens, now = Date.now()): boolean => tokens.expiresAt - EXPIRY_MARGIN_MS <= now;
135
+
136
+ export async function userEmail(tokens: GeminiTokens, base = 'https://www.googleapis.com', fetchImpl: typeof fetch = fetch): Promise<string | null> {
137
+ try {
138
+ const res = await fetchImpl(`${base}/oauth2/v2/userinfo`, { headers: { authorization: `Bearer ${tokens.accessToken}` }, signal: AbortSignal.timeout(15_000) });
139
+ const body: unknown = await res.json().catch(() => null);
140
+ return res.ok && isRecord(body) ? text(body.email) : null;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ export function tokensFromDisk(raw: unknown): GeminiTokens | null {
147
+ if (!isRecord(raw)) return null;
148
+ const accessToken = text(raw.accessToken);
149
+ const refreshToken = text(raw.refreshToken);
150
+ if (accessToken === null || refreshToken === null) return null;
151
+ return {
152
+ accessToken,
153
+ refreshToken,
154
+ expiresAt: typeof raw.expiresAt === 'number' ? raw.expiresAt : 0,
155
+ email: text(raw.email),
156
+ project: text(raw.project) ?? '',
157
+ tier: text(raw.tier),
158
+ savedAt: text(raw.savedAt) ?? '',
159
+ };
160
+ }
@@ -0,0 +1,88 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { GeminiAuthError, type GeminiTokens } from './gemini-auth.js';
3
+
4
+ export const CODE_ASSIST_BASE = 'https://cloudcode-pa.googleapis.com';
5
+ const API = 'v1internal';
6
+ const METADATA = { ideType: 'IDE_UNSPECIFIED', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' };
7
+ const FREE_TIER = 'free-tier';
8
+ const POLL_MS = 5000;
9
+ const POLL_MAX = 24;
10
+
11
+ export interface Onboarded {
12
+ project: string;
13
+ tier: string | null;
14
+ }
15
+
16
+ const text = (value: unknown): string | null => (typeof value === 'string' && value !== '' ? value : null);
17
+
18
+ async function call(base: string, method: string, token: string, body: unknown, fetchImpl: typeof fetch): Promise<Record<string, unknown>> {
19
+ let res: Response;
20
+ try {
21
+ res = await fetchImpl(`${base}/${API}:${method}`, {
22
+ method: 'POST',
23
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
24
+ body: JSON.stringify(body),
25
+ signal: AbortSignal.timeout(30_000),
26
+ });
27
+ } catch (err) {
28
+ throw new GeminiAuthError(`could not reach Google Code Assist: ${err instanceof Error ? err.message : String(err)}`);
29
+ }
30
+ const answer: unknown = await res.json().catch(() => null);
31
+ if (!res.ok) {
32
+ const detail = isRecord(answer) && isRecord(answer.error) ? text(answer.error.message) : null;
33
+ throw new GeminiAuthError(detail ?? `Google Code Assist answered ${String(res.status)} on ${method}`);
34
+ }
35
+ return isRecord(answer) ? answer : {};
36
+ }
37
+
38
+ const tierOf = (load: Record<string, unknown>): { id: string | null; name: string | null } => {
39
+ const tier = isRecord(load.paidTier) ? load.paidTier : isRecord(load.currentTier) ? load.currentTier : null;
40
+ return { id: tier === null ? null : text(tier.id), name: tier === null ? null : text(tier.name) };
41
+ };
42
+
43
+ async function waitOperation(base: string, token: string, first: Record<string, unknown>, fetchImpl: typeof fetch): Promise<Record<string, unknown>> {
44
+ let op = first;
45
+ for (let i = 0; op.done !== true && i < POLL_MAX; i += 1) {
46
+ const name = text(op.name);
47
+ if (name === null) break;
48
+ await new Promise((r) => setTimeout(r, POLL_MS));
49
+ const res = await fetchImpl(`${base}/${API}/${name}`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(30_000) });
50
+ const answer: unknown = await res.json().catch(() => null);
51
+ op = isRecord(answer) ? answer : {};
52
+ }
53
+ return op;
54
+ }
55
+
56
+ const REASONS: Record<string, string> = {
57
+ DASHER_USER: 'this is a Google Workspace account; the individual tier needs a personal Google account',
58
+ NON_USER_ACCOUNT: 'this is not a user account',
59
+ RESTRICTED_AGE: 'the account does not meet the age requirement',
60
+ UNSUPPORTED_LOCATION: 'Gemini Code Assist for individuals is not offered where this account is located',
61
+ UNKNOWN_LOCATION: 'Google could not tell where this account is located',
62
+ RESTRICTED_NETWORK: 'the network this box is on is restricted',
63
+ INELIGIBLE_ACCOUNT: 'Google marks this account as not eligible',
64
+ };
65
+
66
+ function refuseIneligible(load: Record<string, unknown>): void {
67
+ if (isRecord(load.currentTier) || !Array.isArray(load.ineligibleTiers)) return;
68
+ const tiers = load.ineligibleTiers.filter(isRecord);
69
+ if (tiers.length === 0) return;
70
+ const validation = tiers.find((t) => t.reasonCode === 'VALIDATION_REQUIRED' && text(t.validationUrl) !== null);
71
+ if (validation !== undefined) throw new GeminiAuthError(`Google asks you to validate the account first: open ${text(validation.validationUrl) ?? ''} and then connect again`);
72
+ const said = tiers.map((t) => `${text(t.reasonMessage) ?? 'not eligible'} [${text(t.reasonCode) ?? 'UNKNOWN'}: ${REASONS[text(t.reasonCode) ?? ''] ?? 'no known reason'}]`);
73
+ throw new GeminiAuthError(said.join('; '));
74
+ }
75
+
76
+ export async function onboard(tokens: GeminiTokens, base = CODE_ASSIST_BASE, fetchImpl: typeof fetch = fetch): Promise<Onboarded> {
77
+ const load = await call(base, 'loadCodeAssist', tokens.accessToken, { metadata: METADATA }, fetchImpl);
78
+ refuseIneligible(load);
79
+ const tier = tierOf(load);
80
+ const known = text(load.cloudaicompanionProject);
81
+ if (known !== null) return { project: known, tier: tier.name ?? tier.id };
82
+ const tierId = tier.id ?? FREE_TIER;
83
+ const op = await waitOperation(base, tokens.accessToken, await call(base, 'onboardUser', tokens.accessToken, { tierId, metadata: METADATA }, fetchImpl), fetchImpl);
84
+ const response = isRecord(op.response) ? op.response : {};
85
+ const project = isRecord(response.cloudaicompanionProject) ? text(response.cloudaicompanionProject.id) : null;
86
+ if (project === null) throw new GeminiAuthError('Google did not assign a Code Assist project to this account; sign in to https://geminicli.com once with the Gemini CLI, then try again');
87
+ return { project, tier: tier.name ?? tierId };
88
+ }
@@ -0,0 +1,148 @@
1
+ import { isRecord } from '@metro-labs/core/is-record';
2
+ import { encodeSignature, newCallId, rememberSignature } from './gemini-translate.js';
3
+
4
+ type Item = Record<string, unknown>;
5
+
6
+ interface Open {
7
+ index: number;
8
+ kind: 'text' | 'thinking';
9
+ signature: string | null;
10
+ }
11
+
12
+ interface Usage {
13
+ input_tokens: number;
14
+ output_tokens: number;
15
+ cache_read_input_tokens: number;
16
+ }
17
+
18
+ const frame = (event: string, data: unknown): string => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
19
+ const str = (value: unknown): string => (typeof value === 'string' ? value : '');
20
+ const num = (value: unknown): number => (typeof value === 'number' ? value : 0);
21
+
22
+ const STOP_OF: Record<string, string> = { STOP: 'end_turn', MAX_TOKENS: 'max_tokens' };
23
+ const REFUSALS = new Set(['SAFETY', 'RECITATION', 'BLOCKLIST', 'PROHIBITED_CONTENT', 'SPII', 'MALFORMED_FUNCTION_CALL']);
24
+
25
+ function usageOf(meta: Item): Usage {
26
+ const cached = num(meta.cachedContentTokenCount);
27
+ return {
28
+ input_tokens: Math.max(0, num(meta.promptTokenCount) - cached),
29
+ output_tokens: num(meta.candidatesTokenCount) + num(meta.thoughtsTokenCount),
30
+ cache_read_input_tokens: cached,
31
+ };
32
+ }
33
+
34
+ export class GeminiStreamTranslator {
35
+ private next = 0;
36
+ private open: Open | null = null;
37
+ private started = false;
38
+ private done = false;
39
+ private toolCalls = 0;
40
+ private usage: Usage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
41
+ private stop = 'end_turn';
42
+ private refusal: string | null = null;
43
+ private readonly model: string;
44
+ private readonly restore: (name: string) => string;
45
+
46
+ constructor(model: string, restore: (name: string) => string = (name) => name) {
47
+ this.model = model;
48
+ this.restore = restore;
49
+ }
50
+
51
+ get finished(): boolean {
52
+ return this.done;
53
+ }
54
+
55
+ private start(): string {
56
+ if (this.started) return '';
57
+ this.started = true;
58
+ return frame('message_start', {
59
+ type: 'message_start',
60
+ message: { id: 'msg_gemini', type: 'message', role: 'assistant', model: this.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } },
61
+ });
62
+ }
63
+
64
+ private closeOpen(): string {
65
+ const block = this.open;
66
+ if (block === null) return '';
67
+ this.open = null;
68
+ const signature = block.kind === 'thinking' ? frame('content_block_delta', { type: 'content_block_delta', index: block.index, delta: { type: 'signature_delta', signature: encodeSignature(block.signature ?? '') } }) : '';
69
+ return signature + frame('content_block_stop', { type: 'content_block_stop', index: block.index });
70
+ }
71
+
72
+ private ensure(kind: Open['kind']): string {
73
+ if (this.open?.kind === kind) return '';
74
+ const closed = this.closeOpen();
75
+ this.open = { index: this.next, kind, signature: null };
76
+ this.next += 1;
77
+ const content = kind === 'text' ? { type: 'text', text: '' } : { type: 'thinking', thinking: '' };
78
+ return closed + frame('content_block_start', { type: 'content_block_start', index: this.open.index, content_block: content });
79
+ }
80
+
81
+ private textPart(part: Item): string {
82
+ const kind = part.thought === true ? 'thinking' : 'text';
83
+ const text = str(part.text);
84
+ let out = this.ensure(kind);
85
+ const block = this.open;
86
+ if (block !== null && str(part.thoughtSignature) !== '') block.signature = str(part.thoughtSignature);
87
+ if (text === '') return out;
88
+ const delta = kind === 'text' ? { type: 'text_delta', text } : { type: 'thinking_delta', thinking: text };
89
+ out += frame('content_block_delta', { type: 'content_block_delta', index: block?.index ?? 0, delta });
90
+ return out;
91
+ }
92
+
93
+ private callPart(call: Item, signature: string): string {
94
+ this.toolCalls += 1;
95
+ const id = str(call.id) || newCallId();
96
+ if (signature !== '') rememberSignature(id, signature);
97
+ const index = this.next;
98
+ this.next += 1;
99
+ return (
100
+ this.closeOpen() +
101
+ frame('content_block_start', { type: 'content_block_start', index, content_block: { type: 'tool_use', id, name: this.restore(str(call.name)), input: {} } }) +
102
+ frame('content_block_delta', { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: JSON.stringify(isRecord(call.args) ? call.args : {}) } }) +
103
+ frame('content_block_stop', { type: 'content_block_stop', index })
104
+ );
105
+ }
106
+
107
+ private part(part: Item): string {
108
+ if (isRecord(part.functionCall)) return this.callPart(part.functionCall, str(part.thoughtSignature));
109
+ if (typeof part.text === 'string') return this.textPart(part);
110
+ return '';
111
+ }
112
+
113
+ private candidate(candidate: Item): string {
114
+ const content = isRecord(candidate.content) ? candidate.content : {};
115
+ const parts = Array.isArray(content.parts) ? content.parts.filter(isRecord) : [];
116
+ const out = parts.map((part) => this.part(part)).join('');
117
+ const finish = str(candidate.finishReason);
118
+ const stop = STOP_OF[finish];
119
+ if (stop !== undefined) this.stop = stop;
120
+ else if (REFUSALS.has(finish)) this.refusal = `Gemini stopped the answer (${finish})`;
121
+ return out;
122
+ }
123
+
124
+ push(data: Item): string {
125
+ if (this.done) return '';
126
+ const response = isRecord(data.response) ? data.response : data;
127
+ const out = this.start();
128
+ if (isRecord(response.usageMetadata)) this.usage = usageOf(response.usageMetadata);
129
+ const candidate = Array.isArray(response.candidates) ? response.candidates.find(isRecord) : undefined;
130
+ if (candidate !== undefined) return out + this.candidate(candidate);
131
+ const feedback = isRecord(response.promptFeedback) ? str(response.promptFeedback.blockReason) : '';
132
+ if (feedback !== '') this.refusal = `Gemini blocked the prompt (${feedback})`;
133
+ return out;
134
+ }
135
+
136
+ close(error?: string): string {
137
+ if (this.done) return '';
138
+ this.done = true;
139
+ let out = this.start() + this.closeOpen();
140
+ const failure = error ?? this.refusal;
141
+ if (failure !== null && failure !== undefined && this.toolCalls === 0 && this.next === 0)
142
+ return out + frame('error', { type: 'error', error: { type: 'api_error', message: failure } });
143
+ const stop = this.toolCalls > 0 ? 'tool_use' : this.stop;
144
+ out += frame('message_delta', { type: 'message_delta', delta: { stop_reason: stop, stop_sequence: null }, usage: this.usage });
145
+ out += frame('message_stop', { type: 'message_stop' });
146
+ return out;
147
+ }
148
+ }
@@ -0,0 +1,190 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { ToolNames } from './codex-translate.js';
4
+
5
+ type Item = Record<string, unknown>;
6
+
7
+ export const SIGNATURE_PREFIX = 'metro-gemini:';
8
+ const IMAGE_NOTE = '[an image was attached here; this model cannot see it]';
9
+ const SCHEMA_DROP = new Set(['$schema', '$id', 'additionalProperties', 'examples', 'default', 'title']);
10
+ const SIGNATURES_MAX = 2000;
11
+
12
+ const textOf = (value: unknown): string => (typeof value === 'string' ? value : '');
13
+
14
+ const callSignatures = new Map<string, string>();
15
+
16
+ export function rememberSignature(id: string, signature: string): void {
17
+ callSignatures.set(id, signature);
18
+ if (callSignatures.size > SIGNATURES_MAX) {
19
+ const first = callSignatures.keys().next().value;
20
+ if (first !== undefined) callSignatures.delete(first);
21
+ }
22
+ }
23
+
24
+ export const newCallId = (): string => `toolu_gm_${randomBytes(12).toString('base64url')}`;
25
+
26
+ export const encodeSignature = (signature: string): string => `${SIGNATURE_PREFIX}${Buffer.from(signature).toString('base64url')}`;
27
+
28
+ export function decodeSignature(signature: unknown): string | null {
29
+ if (typeof signature !== 'string' || !signature.startsWith(SIGNATURE_PREFIX)) return null;
30
+ const raw = Buffer.from(signature.slice(SIGNATURE_PREFIX.length), 'base64url').toString('utf8');
31
+ return raw === '' ? null : raw;
32
+ }
33
+
34
+ export function systemText(system: unknown): string {
35
+ if (typeof system === 'string') return system.trim();
36
+ if (!Array.isArray(system)) return '';
37
+ return system
38
+ .filter(isRecord)
39
+ .map((block) => textOf(block.text))
40
+ .filter((t) => t !== '')
41
+ .join('\n\n')
42
+ .trim();
43
+ }
44
+
45
+ function userPart(block: Item): Item | null {
46
+ if (block.type === 'text') return { text: textOf(block.text) };
47
+ if (block.type === 'image' && isRecord(block.source) && block.source.type === 'base64')
48
+ return { inlineData: { mimeType: textOf(block.source.media_type), data: textOf(block.source.data) } };
49
+ return null;
50
+ }
51
+
52
+ const partText = (part: Item): string => (part.type === 'text' ? textOf(part.text) : part.type === 'image' ? IMAGE_NOTE : '');
53
+
54
+ function resultText(block: Item): string {
55
+ const content = block.content;
56
+ const text = typeof content === 'string' ? content : Array.isArray(content) ? content.filter(isRecord).map(partText).filter((t) => t !== '').join('\n') : '';
57
+ return block.is_error === true ? `[tool error] ${text}` : text;
58
+ }
59
+
60
+ function userParts(content: unknown, calls: Map<string, string>): Item[] {
61
+ if (typeof content === 'string') return content === '' ? [] : [{ text: content }];
62
+ if (!Array.isArray(content)) return [];
63
+ const out: Item[] = [];
64
+ for (const block of content.filter(isRecord)) {
65
+ if (block.type === 'tool_result') {
66
+ const id = textOf(block.tool_use_id);
67
+ out.push({ functionResponse: { id, name: calls.get(id) ?? 'tool', response: { result: resultText(block) } } });
68
+ continue;
69
+ }
70
+ const part = userPart(block);
71
+ if (part !== null) out.push(part);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function callPart(block: Item, names: ToolNames, calls: Map<string, string>): Item {
77
+ const id = textOf(block.id);
78
+ const name = names.alias(textOf(block.name));
79
+ calls.set(id, name);
80
+ const signature = callSignatures.get(id);
81
+ return { functionCall: { id, name, args: isRecord(block.input) ? block.input : {} }, ...(signature === undefined ? {} : { thoughtSignature: signature }) };
82
+ }
83
+
84
+ function assistantPart(block: Item, names: ToolNames, calls: Map<string, string>): Item | null {
85
+ if (block.type === 'text') return { text: textOf(block.text) };
86
+ if (block.type === 'tool_use') return callPart(block, names, calls);
87
+ if (block.type !== 'thinking') return null;
88
+ const signature = decodeSignature(block.signature);
89
+ return signature === null ? null : { text: textOf(block.thinking) || ' ', thought: true, thoughtSignature: signature };
90
+ }
91
+
92
+ function assistantParts(content: unknown, names: ToolNames, calls: Map<string, string>): Item[] {
93
+ if (typeof content === 'string') return content === '' ? [] : [{ text: content }];
94
+ if (!Array.isArray(content)) return [];
95
+ const out: Item[] = [];
96
+ for (const block of content.filter(isRecord)) {
97
+ const part = assistantPart(block, names, calls);
98
+ if (part !== null) out.push(part);
99
+ }
100
+ return out;
101
+ }
102
+
103
+ function appendTurn(out: Item[], role: string, parts: Item[]): void {
104
+ if (parts.length === 0) return;
105
+ const last = out.at(-1);
106
+ const held = last?.role === role && Array.isArray(last.parts) ? (last.parts as Item[]) : null;
107
+ if (held !== null) held.push(...parts);
108
+ else out.push({ role, parts });
109
+ }
110
+
111
+ export function contentsOf(messages: unknown, names = new ToolNames()): Item[] {
112
+ const out: Item[] = [];
113
+ if (!Array.isArray(messages)) return out;
114
+ const calls = new Map<string, string>();
115
+ for (const message of messages.filter(isRecord)) {
116
+ const model = message.role === 'assistant';
117
+ appendTurn(out, model ? 'model' : 'user', model ? assistantParts(message.content, names, calls) : userParts(message.content, calls));
118
+ }
119
+ return out;
120
+ }
121
+
122
+ export function cleanSchema(schema: unknown): unknown {
123
+ if (Array.isArray(schema)) return schema.map(cleanSchema);
124
+ if (!isRecord(schema)) return schema;
125
+ const out: Item = {};
126
+ for (const [key, value] of Object.entries(schema)) {
127
+ if (SCHEMA_DROP.has(key)) continue;
128
+ if (key === 'const') {
129
+ out.enum = [value];
130
+ continue;
131
+ }
132
+ out[key] = cleanSchema(value);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ export function toolDeclarations(tools: unknown, names = new ToolNames()): Item[] {
138
+ if (!Array.isArray(tools)) return [];
139
+ return tools
140
+ .filter(isRecord)
141
+ .filter((tool) => typeof tool.name === 'string' && (tool.type === undefined || tool.type === 'custom'))
142
+ .map((tool) => ({
143
+ name: names.alias(textOf(tool.name)),
144
+ description: textOf(tool.description),
145
+ parameters: cleanSchema(isRecord(tool.input_schema) ? tool.input_schema : { type: 'object', properties: {} }),
146
+ }));
147
+ }
148
+
149
+ function toolConfig(choice: unknown, names: ToolNames): Item | null {
150
+ if (!isRecord(choice)) return null;
151
+ if (choice.type === 'any') return { functionCallingConfig: { mode: 'ANY' } };
152
+ if (choice.type === 'none') return { functionCallingConfig: { mode: 'NONE' } };
153
+ if (choice.type === 'tool') return { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [names.alias(textOf(choice.name))] } };
154
+ return null;
155
+ }
156
+
157
+ function generationConfig(body: Item): Item {
158
+ const out: Item = {};
159
+ if (typeof body.max_tokens === 'number') out.maxOutputTokens = body.max_tokens;
160
+ if (typeof body.temperature === 'number') out.temperature = body.temperature;
161
+ if (typeof body.top_p === 'number') out.topP = body.top_p;
162
+ if (isRecord(body.thinking) && body.thinking.type === 'enabled') out.thinkingConfig = { includeThoughts: true };
163
+ return out;
164
+ }
165
+
166
+ export interface GeminiRequest {
167
+ model: string;
168
+ project: string;
169
+ user_prompt_id: string;
170
+ request: Item;
171
+ }
172
+
173
+ export function toGeminiRequest(body: Item, model: string, project: string, promptId: string, names = new ToolNames()): GeminiRequest {
174
+ const system = systemText(body.system);
175
+ const declarations = toolDeclarations(body.tools, names);
176
+ const config = toolConfig(body.tool_choice, names);
177
+ return {
178
+ model,
179
+ project,
180
+ user_prompt_id: promptId,
181
+ request: {
182
+ contents: contentsOf(body.messages, names),
183
+ ...(system === '' ? {} : { systemInstruction: { role: 'user', parts: [{ text: system }] } }),
184
+ ...(declarations.length === 0 ? {} : { tools: [{ functionDeclarations: declarations }] }),
185
+ ...(config === null ? {} : { toolConfig: config }),
186
+ generationConfig: generationConfig(body),
187
+ session_id: promptId,
188
+ },
189
+ };
190
+ }
@@ -0,0 +1,196 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { isRecord } from '@metro-labs/core/is-record';
3
+ import { errMsg } from '@metro-labs/core/log';
4
+ import { refreshTokens, tokensStale, type GeminiTokens } from './gemini-auth.js';
5
+ import { CODE_ASSIST_BASE } from './gemini-setup.js';
6
+ import { GeminiStreamTranslator } from './gemini-stream.js';
7
+ import { toGeminiRequest } from './gemini-translate.js';
8
+ import { ToolNames } from './codex-translate.js';
9
+ import { assembleMessage, SseParser } from './codex-stream.js';
10
+ import { GatewayError, idleMessage, providerStatus, sendError, upstreamMessage, type Watch } from './forward.js';
11
+ import type { ModelConfig } from './model-config.js';
12
+ import { UsageScanner } from './usage.js';
13
+
14
+ const PING_MS = 25_000;
15
+ const CLI_VERSION = '0.62.0';
16
+ const STATUS_OF: Record<string, number> = { rate_limit_error: 429, invalid_request_error: 400, permission_error: 403, overloaded_error: 529 };
17
+
18
+ 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'];
19
+
20
+ export interface GeminiDeps {
21
+ base?: string;
22
+ tokenBase?: string;
23
+ fetchImpl?: typeof fetch;
24
+ save: (tokens: GeminiTokens) => void;
25
+ }
26
+
27
+ export interface GeminiState {
28
+ refreshing: Promise<GeminiTokens> | null;
29
+ latest: GeminiTokens | null;
30
+ }
31
+
32
+ export const freshGeminiState = (): GeminiState => ({ refreshing: null, latest: null });
33
+
34
+ export const userAgent = (model: string): string => `GeminiCLI/${CLI_VERSION}/${model} (${process.platform}; ${process.arch}; metro)`;
35
+
36
+ const newerThan = (a: GeminiTokens, b: GeminiTokens): boolean => Date.parse(a.savedAt) > Date.parse(b.savedAt);
37
+
38
+ function refreshed(tokens: GeminiTokens, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
39
+ const latest = state.latest;
40
+ if (latest !== null && newerThan(latest, tokens) && !tokensStale(latest)) return Promise.resolve(latest);
41
+ if (state.refreshing !== null) return state.refreshing;
42
+ const run = refreshTokens(tokens, deps.tokenBase, deps.fetchImpl)
43
+ .then((fresh) => {
44
+ deps.save(fresh);
45
+ state.latest = fresh;
46
+ return fresh;
47
+ })
48
+ .catch((err: unknown) => {
49
+ throw new GatewayError(403, 'permission_error', `Google sign-in expired (${errMsg(err)}): connect again on the Model page`);
50
+ })
51
+ .finally(() => {
52
+ state.refreshing = null;
53
+ });
54
+ state.refreshing = run;
55
+ return run;
56
+ }
57
+
58
+ export function currentGeminiTokens(cfg: ModelConfig, deps: GeminiDeps, state: GeminiState): Promise<GeminiTokens> {
59
+ const tokens = cfg.gemini.auth;
60
+ if (tokens === null) throw new GatewayError(400, 'invalid_request_error', 'Gemini is not connected: sign in with Google on the Model page.');
61
+ return tokensStale(tokens) ? refreshed(tokens, deps, state) : Promise.resolve(tokens);
62
+ }
63
+
64
+ function errorKind(status: number): string {
65
+ if (status === 401 || status === 403) return 'permission_error';
66
+ if (status === 429) return 'rate_limit_error';
67
+ if (status === 400 || status === 404) return 'invalid_request_error';
68
+ return 'api_error';
69
+ }
70
+
71
+ const promptIdOf = (req: IncomingMessage): string => {
72
+ const raw = req.headers['x-claude-code-session-id'];
73
+ const given = (Array.isArray(raw) ? raw[0] : raw)?.trim() ?? '';
74
+ return given === '' ? 'metro' : given;
75
+ };
76
+
77
+ interface Call {
78
+ body: Record<string, unknown>;
79
+ model: string;
80
+ promptId: string;
81
+ watch: Watch;
82
+ deps: GeminiDeps;
83
+ names: ToolNames;
84
+ }
85
+
86
+ function send(call: Call, tokens: GeminiTokens, stream: boolean): Promise<Response> {
87
+ const request = toGeminiRequest(call.body, call.model, tokens.project, call.promptId, call.names);
88
+ const method = stream ? 'streamGenerateContent?alt=sse' : 'generateContent';
89
+ return (call.deps.fetchImpl ?? fetch)(`${call.deps.base ?? CODE_ASSIST_BASE}/v1internal:${method}`, {
90
+ method: 'POST',
91
+ headers: { authorization: `Bearer ${tokens.accessToken}`, 'content-type': 'application/json', 'user-agent': userAgent(call.model) },
92
+ body: JSON.stringify(request),
93
+ signal: call.watch.signal,
94
+ redirect: 'manual',
95
+ });
96
+ }
97
+
98
+ async function reach(call: Call, cfg: ModelConfig, state: GeminiState, stream: boolean): Promise<Response> {
99
+ let tokens = await currentGeminiTokens(cfg, call.deps, state);
100
+ let upstream = await send(call, tokens, stream);
101
+ if (upstream.status === 401) {
102
+ await upstream.body?.cancel();
103
+ tokens = await refreshed(tokens, call.deps, state);
104
+ upstream = await send(call, tokens, stream);
105
+ }
106
+ return upstream;
107
+ }
108
+
109
+ const parseData = (raw: string): Record<string, unknown> | null => {
110
+ try {
111
+ const parsed: unknown = JSON.parse(raw);
112
+ return isRecord(parsed) ? parsed : null;
113
+ } catch {
114
+ return null;
115
+ }
116
+ };
117
+
118
+ async function relayStream(upstream: Response, res: ServerResponse, call: Call): Promise<void> {
119
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
120
+ const translator = new GeminiStreamTranslator(call.model, (name) => call.names.restore(name));
121
+ const body = upstream.body;
122
+ if (body === null) {
123
+ res.end(translator.close());
124
+ return;
125
+ }
126
+ const ping = setInterval(() => res.write('event: ping\ndata: {"type":"ping"}\n\n'), PING_MS);
127
+ const scanner = new UsageScanner('gemini');
128
+ const emit = (frames: string): void => {
129
+ if (frames === '') return;
130
+ scanner.feed(frames);
131
+ res.write(frames);
132
+ };
133
+ try {
134
+ const parser = new SseParser();
135
+ const decoder = new TextDecoder();
136
+ const reader = body.getReader();
137
+ for (;;) {
138
+ const { done, value } = await reader.read();
139
+ if (done) break;
140
+ call.watch.touch();
141
+ for (const raw of parser.push(decoder.decode(value, { stream: true }))) {
142
+ const data = parseData(raw.data);
143
+ if (data !== null) emit(translator.push(data));
144
+ }
145
+ }
146
+ emit(translator.close());
147
+ } catch (err) {
148
+ if (!call.watch.idle()) throw err;
149
+ res.write(translator.finished ? '' : translator.close(idleMessage(call.watch.ms)));
150
+ } finally {
151
+ clearInterval(ping);
152
+ call.watch.stop();
153
+ res.end();
154
+ scanner.done();
155
+ }
156
+ }
157
+
158
+ async function relayWhole(upstream: Response, res: ServerResponse, call: Call): Promise<void> {
159
+ const translator = new GeminiStreamTranslator(call.model, (name) => call.names.restore(name));
160
+ const data = parseData(await upstream.text());
161
+ const frames = (data === null ? '' : translator.push(data)) + translator.close();
162
+ const scanner = new UsageScanner('gemini');
163
+ scanner.feed(frames);
164
+ scanner.done();
165
+ const message = assembleMessage(frames);
166
+ const kind = isRecord(message.error) ? String(message.error.type) : '';
167
+ res.writeHead(message.type === 'error' ? (STATUS_OF[kind] ?? 502) : 200, { 'content-type': 'application/json' });
168
+ res.end(JSON.stringify(message));
169
+ }
170
+
171
+ export async function geminiMessages(
172
+ req: IncomingMessage,
173
+ res: ServerResponse,
174
+ body: Record<string, unknown>,
175
+ model: string,
176
+ cfg: ModelConfig,
177
+ deps: GeminiDeps,
178
+ state: GeminiState,
179
+ watch: Watch,
180
+ ): Promise<void> {
181
+ const stream = body.stream === true;
182
+ const call: Call = { body, model, promptId: promptIdOf(req), watch, deps, names: new ToolNames() };
183
+ const upstream = await reach(call, cfg, state, stream);
184
+ if (!upstream.ok) {
185
+ const text = await upstream.text();
186
+ sendError(res, providerStatus(upstream.status), errorKind(upstream.status), upstreamMessage(text, `Gemini answered ${String(upstream.status)}`));
187
+ return;
188
+ }
189
+ if (stream) await relayStream(upstream, res, call);
190
+ else await relayWhole(upstream, res, call);
191
+ }
192
+
193
+ export function geminiCount(res: ServerResponse, body: Record<string, unknown>): void {
194
+ res.writeHead(200, { 'content-type': 'application/json' });
195
+ res.end(JSON.stringify({ input_tokens: Math.ceil(JSON.stringify(body).length / 4) }));
196
+ }
@@ -6,6 +6,9 @@ import { errMsg, log } from '@metro-labs/core/log';
6
6
  import { beginLogin, CodexAuthError, finishLogin, readCodexCliAuth } from './codex-auth.js';
7
7
  import { beginDeviceLogin, pollDeviceLogin } from './codex-device.js';
8
8
  import { codexModels, currentTokens, freshCodexState } from './codex.js';
9
+ import { beginLogin as beginGeminiLogin, exchangeCode as exchangeGeminiCode, GeminiAuthError, userEmail } from './gemini-auth.js';
10
+ import { onboard } from './gemini-setup.js';
11
+ import { KNOWN_GEMINI_MODELS } from './gemini.js';
9
12
  import { openrouterCredits, openrouterModels, openrouterZdrModels } from './openrouter.js';
10
13
  import { anthropicModels, bedrockModels } from './provider-models.js';
11
14
  import { syncAvailableModelsQuietly, type SetupDeps } from '../claude/setup.js';
@@ -20,12 +23,14 @@ import {
20
23
  publicModelConfig,
21
24
  readModelConfig,
22
25
  setCodexAuth,
26
+ setGeminiAuth,
23
27
  writeModelConfig,
24
28
  type ModelConfig,
25
29
  } from './model-config.js';
26
30
 
27
31
  const PATH = '/api/model';
28
32
  const CODEX = '/api/model/codex/';
33
+ const GEMINI = '/api/model/gemini/';
29
34
  const OPENROUTER = '/api/model/openrouter/';
30
35
  const ANTHROPIC = '/api/model/anthropic/';
31
36
  const BEDROCK = '/api/model/bedrock/';
@@ -44,6 +49,10 @@ export interface ModelApiDeps {
44
49
  fetchImpl?: typeof fetch;
45
50
  codexHome?: string;
46
51
  codexBase?: string;
52
+ geminiAuthBase?: string;
53
+ geminiTokenBase?: string;
54
+ geminiUserBase?: string;
55
+ geminiBase?: string;
47
56
  openrouterBase?: string;
48
57
  anthropicBase?: string;
49
58
  bedrockControlBase?: string;
@@ -89,7 +98,7 @@ async function settingsWithUsage(cfg: ModelConfig, deps: ModelApiDeps): Promise<
89
98
  }
90
99
 
91
100
  function asApiError(err: unknown): never {
92
- if (err instanceof ModelConfigError || err instanceof CodexAuthError) throw new ApiError(err.message, 400);
101
+ if (err instanceof ModelConfigError || err instanceof CodexAuthError || err instanceof GeminiAuthError) throw new ApiError(err.message, 400);
93
102
  if (err instanceof GatewayError) throw new ApiError(err.message, err.status >= 400 && err.status < 500 ? 400 : 502);
94
103
  throw err;
95
104
  }
@@ -172,6 +181,37 @@ const CODEX_ROUTES: Record<string, Route> = {
172
181
  },
173
182
  };
174
183
 
184
+ async function connectGemini(req: IncomingMessage, deps: ModelApiDeps, store: Store): Promise<unknown> {
185
+ const body = await readJsonBody(req, BODY_MAX);
186
+ const code = isRecord(body) && typeof body.code === 'string' ? body.code : '';
187
+ const state = isRecord(body) && typeof body.state === 'string' ? body.state : '';
188
+ const tokens = await exchangeGeminiCode(code, state, deps.geminiTokenBase, deps.fetchImpl).catch(asApiError);
189
+ const email = await userEmail(tokens, deps.geminiUserBase, deps.fetchImpl);
190
+ const onboarded = await onboard(tokens, deps.geminiBase, deps.fetchImpl).catch(asApiError);
191
+ const cfg = setGeminiAuth(store.read(), { ...tokens, email, project: onboarded.project, tier: onboarded.tier });
192
+ store.write(cfg);
193
+ log.info({ tier: onboarded.tier }, 'model-api: Gemini connected');
194
+ return settingsBody(cfg);
195
+ }
196
+
197
+ const GEMINI_ROUTES: Record<string, Route> = {
198
+ login: {
199
+ method: 'POST',
200
+ run: (_req, deps) => Promise.resolve(beginGeminiLogin(deps.geminiAuthBase)),
201
+ },
202
+ code: { method: 'POST', run: connectGemini },
203
+ logout: {
204
+ method: 'POST',
205
+ run: (_req, _deps, store) => {
206
+ const cfg = setGeminiAuth(store.read(), null);
207
+ store.write(cfg);
208
+ log.info('model-api: Gemini disconnected');
209
+ return Promise.resolve(settingsBody(cfg));
210
+ },
211
+ },
212
+ models: { method: 'GET', run: () => Promise.resolve({ models: KNOWN_GEMINI_MODELS }) },
213
+ };
214
+
175
215
  const OPENROUTER_ROUTES: Record<string, Route> = {
176
216
  models: {
177
217
  method: 'GET',
@@ -232,7 +272,7 @@ function codexRoute(rest: string, method: string | undefined): Route | number {
232
272
  }
233
273
 
234
274
  const mine = (path: string): boolean =>
235
- path === PATH || path === BUNDLE || path === RESTORE || path.startsWith(CODEX) || path.startsWith(OPENROUTER) || path.startsWith(ANTHROPIC) || path.startsWith(BEDROCK);
275
+ path === PATH || path === BUNDLE || path === RESTORE || path.startsWith(CODEX) || path.startsWith(GEMINI) || path.startsWith(OPENROUTER) || path.startsWith(ANTHROPIC) || path.startsWith(BEDROCK);
236
276
 
237
277
  function routeFor(path: string, method: string | undefined): Route | number {
238
278
  if (path === PATH) return settingsRoute(method);
@@ -240,6 +280,7 @@ function routeFor(path: string, method: string | undefined): Route | number {
240
280
  if (path.startsWith(OPENROUTER)) return named(OPENROUTER_ROUTES, path.slice(OPENROUTER.length), method);
241
281
  if (path.startsWith(ANTHROPIC)) return named(ANTHROPIC_ROUTES, path.slice(ANTHROPIC.length), method);
242
282
  if (path.startsWith(BEDROCK)) return named(BEDROCK_ROUTES, path.slice(BEDROCK.length), method);
283
+ if (path.startsWith(GEMINI)) return named(GEMINI_ROUTES, path.slice(GEMINI.length), method);
243
284
  return codexRoute(path.slice(CODEX.length), method);
244
285
  }
245
286
 
@@ -3,8 +3,9 @@ import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
3
3
  import { agentsDir } from '../agents/files.js';
4
4
  import { isRecord } from '@metro-labs/core/is-record';
5
5
  import type { CodexTokens } from './codex-auth.js';
6
+ import { tokensFromDisk as geminiTokensFromDisk, type GeminiTokens } from './gemini-auth.js';
6
7
 
7
- export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex'] as const;
8
+ export const PROVIDERS = ['anthropic', 'bedrock', 'openrouter', 'codex', 'gemini'] as const;
8
9
  export type Provider = (typeof PROVIDERS)[number];
9
10
 
10
11
  export interface AnthropicSettings {
@@ -29,6 +30,11 @@ export interface CodexSettings {
29
30
  auth: CodexTokens | null;
30
31
  }
31
32
 
33
+ export interface GeminiSettings {
34
+ model: string;
35
+ auth: GeminiTokens | null;
36
+ }
37
+
32
38
  export interface ModelConfig {
33
39
  version: 1;
34
40
  provider: Provider;
@@ -36,6 +42,7 @@ export interface ModelConfig {
36
42
  bedrock: BedrockSettings;
37
43
  openrouter: OpenRouterSettings;
38
44
  codex: CodexSettings;
45
+ gemini: GeminiSettings;
39
46
  }
40
47
 
41
48
  export interface Route {
@@ -47,7 +54,7 @@ export class ModelConfigError extends Error {}
47
54
 
48
55
  export const MODEL_FILE = 'model.json';
49
56
  const MAX_FIELD = 512;
50
- const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex):(.+)$/;
57
+ const PREFIX_RE = /^(anthropic|bedrock|openrouter|codex|gemini):(.+)$/;
51
58
  const SMALL_RE = /haiku/i;
52
59
 
53
60
  const empty = (): ModelConfig => ({
@@ -57,6 +64,7 @@ const empty = (): ModelConfig => ({
57
64
  bedrock: { region: '', apiKey: '', model: '' },
58
65
  openrouter: { apiKey: '', model: '', zdr: false },
59
66
  codex: { model: '', auth: null },
67
+ gemini: { model: '', auth: null },
60
68
  });
61
69
 
62
70
  const isProvider = (value: unknown): value is Provider =>
@@ -89,6 +97,7 @@ export function parseModelConfig(raw: unknown): ModelConfig {
89
97
  const bedrock = isRecord(raw.bedrock) ? raw.bedrock : {};
90
98
  const openrouter = isRecord(raw.openrouter) ? raw.openrouter : {};
91
99
  const codex = isRecord(raw.codex) ? raw.codex : {};
100
+ const gemini = isRecord(raw.gemini) ? raw.gemini : {};
92
101
  return {
93
102
  version: 1,
94
103
  provider: isProvider(raw.provider) ? raw.provider : 'anthropic',
@@ -96,6 +105,7 @@ export function parseModelConfig(raw: unknown): ModelConfig {
96
105
  bedrock: { region: text(bedrock.region), apiKey: text(bedrock.apiKey), model: text(bedrock.model) },
97
106
  openrouter: { apiKey: text(openrouter.apiKey), model: text(openrouter.model), zdr: openrouter.zdr === true },
98
107
  codex: { model: text(codex.model), auth: tokensFromDisk(codex.auth) },
108
+ gemini: { model: text(gemini.model), auth: geminiTokensFromDisk(gemini.auth) },
99
109
  };
100
110
  }
101
111
 
@@ -130,6 +140,7 @@ export function applyModelUpdate(cfg: ModelConfig, patch: unknown): ModelConfig
130
140
  const bedrock = isRecord(patch.bedrock) ? patch.bedrock : {};
131
141
  const openrouter = isRecord(patch.openrouter) ? patch.openrouter : {};
132
142
  const codex = isRecord(patch.codex) ? patch.codex : {};
143
+ const gemini = isRecord(patch.gemini) ? patch.gemini : {};
133
144
  return {
134
145
  version: 1,
135
146
  provider,
@@ -148,11 +159,14 @@ export function applyModelUpdate(cfg: ModelConfig, patch: unknown): ModelConfig
148
159
  zdr: flag(openrouter, 'zdr', cfg.openrouter.zdr, 'OpenRouter zero data retention'),
149
160
  },
150
161
  codex: { model: field(codex, 'model', cfg.codex.model, 'Codex model'), auth: cfg.codex.auth },
162
+ gemini: { model: field(gemini, 'model', cfg.gemini.model, 'Gemini model'), auth: cfg.gemini.auth },
151
163
  };
152
164
  }
153
165
 
154
166
  export const setCodexAuth = (cfg: ModelConfig, auth: CodexTokens | null): ModelConfig => ({ ...cfg, codex: { ...cfg.codex, auth } });
155
167
 
168
+ export const setGeminiAuth = (cfg: ModelConfig, auth: GeminiTokens | null): ModelConfig => ({ ...cfg, gemini: { ...cfg.gemini, auth } });
169
+
156
170
  const CHECKS: Record<Provider, [(cfg: ModelConfig) => boolean, string][]> = {
157
171
  anthropic: [],
158
172
  bedrock: [
@@ -167,6 +181,10 @@ const CHECKS: Record<Provider, [(cfg: ModelConfig) => boolean, string][]> = {
167
181
  [(cfg) => cfg.codex.auth === null, 'Codex is not connected: sign in with ChatGPT on the Model page.'],
168
182
  [(cfg) => cfg.codex.model === '', 'Codex needs a model id: choose one on the Model page.'],
169
183
  ],
184
+ gemini: [
185
+ [(cfg) => cfg.gemini.auth === null, 'Gemini is not connected: sign in with Google on the Model page.'],
186
+ [(cfg) => cfg.gemini.model === '', 'Gemini needs a model id: choose one on the Model page.'],
187
+ ],
170
188
  };
171
189
 
172
190
  export function notReady(cfg: ModelConfig, provider: Provider = cfg.provider): string | null {
@@ -175,6 +193,7 @@ export function notReady(cfg: ModelConfig, provider: Provider = cfg.provider): s
175
193
 
176
194
  export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
177
195
  const auth = cfg.codex.auth;
196
+ const gemini = cfg.gemini.auth;
178
197
  return {
179
198
  provider: cfg.provider,
180
199
  ready: notReady(cfg) === null,
@@ -183,16 +202,22 @@ export function publicModelConfig(cfg: ModelConfig): Record<string, unknown> {
183
202
  bedrock: { region: cfg.bedrock.region, model: cfg.bedrock.model, hasKey: cfg.bedrock.apiKey !== '' },
184
203
  openrouter: { model: cfg.openrouter.model, hasKey: cfg.openrouter.apiKey !== '', zdr: cfg.openrouter.zdr },
185
204
  codex: { model: cfg.codex.model, signedIn: auth !== null, account: auth?.email ?? null, plan: auth?.plan ?? null },
205
+ gemini: { model: cfg.gemini.model, signedIn: gemini !== null, account: gemini?.email ?? null, plan: gemini?.tier ?? null },
186
206
  };
187
207
  }
188
208
 
189
209
  export const isSmallModel = (requested: string): boolean => SMALL_RE.test(requested);
190
210
 
211
+ const DEFAULTS: Record<Provider, (requested: string, cfg: ModelConfig) => string> = {
212
+ openrouter: (requested, cfg) => (requested.includes('/') ? requested : cfg.openrouter.model),
213
+ bedrock: (requested, cfg) => (cfg.bedrock.model === '' ? requested : cfg.bedrock.model),
214
+ codex: (requested, cfg) => (requested.startsWith('gpt-') ? requested : cfg.codex.model),
215
+ gemini: (requested, cfg) => (requested.startsWith('gemini-') ? requested : cfg.gemini.model),
216
+ anthropic: (requested, cfg) => (cfg.anthropic.model === '' || isSmallModel(requested) ? requested : cfg.anthropic.model),
217
+ };
218
+
191
219
  function defaultModelFor(provider: Provider, requested: string, cfg: ModelConfig): string {
192
- if (provider === 'openrouter') return requested.includes('/') ? requested : cfg.openrouter.model;
193
- if (provider === 'bedrock') return cfg.bedrock.model === '' ? requested : cfg.bedrock.model;
194
- if (provider === 'codex') return requested.startsWith('gpt-') ? requested : cfg.codex.model;
195
- return cfg.anthropic.model === '' || isSmallModel(requested) ? requested : cfg.anthropic.model;
220
+ return DEFAULTS[provider](requested, cfg);
196
221
  }
197
222
 
198
223
  export function resolveRoute(requested: string, cfg: ModelConfig): Route {
@@ -1,4 +1,4 @@
1
- export type UsageProvider = 'anthropic' | 'codex' | 'openrouter' | 'bedrock';
1
+ export type UsageProvider = 'anthropic' | 'codex' | 'openrouter' | 'bedrock' | 'gemini';
2
2
 
3
3
  export interface UsageWindow {
4
4
  label: string;
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.146"
2
+ "version": "0.1.0-beta.148"
3
3
  }