@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 +1 -1
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gateway.ts +28 -10
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-auth.ts +160 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-setup.ts +88 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-stream.ts +148 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini-translate.ts +190 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/gemini.ts +196 -0
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +43 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +31 -6
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/usage.ts +1 -1
- package/runtime/runtime.json +1 -1
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/runtime/runtime.json
CHANGED