@stage-labs/metro 0.1.0-beta.145 → 0.1.0-beta.147

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.
@@ -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