converse-mcp-server 2.27.0 → 2.28.0
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/README.md +2 -2
- package/docs/API.md +21 -19
- package/docs/ARCHITECTURE.md +0 -1
- package/docs/PROVIDERS.md +33 -32
- package/package.json +8 -10
- package/src/config.js +17 -4
- package/src/prompts/helpPrompt.js +2 -2
- package/src/providers/copilot.js +7 -27
- package/src/providers/gemini-cli.js +665 -438
- package/src/tools/chat.js +26 -7
- package/src/tools/consensus.js +31 -3
- package/src/tools/conversation.js +20 -4
- package/src/utils/modelRouting.js +50 -0
|
@@ -1,81 +1,113 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Gemini CLI Provider
|
|
2
|
+
* Gemini CLI Provider (Antigravity CLI / agy subprocess)
|
|
3
3
|
*
|
|
4
|
-
* Provider implementation for Google's Gemini models
|
|
5
|
-
*
|
|
4
|
+
* Provider implementation for Google's Gemini models via the Antigravity CLI
|
|
5
|
+
* (`agy`, v1.0.7+) in print mode (`agy -p`), authenticated through the user's
|
|
6
|
+
* Antigravity Google OAuth login. Replaces the previous
|
|
7
|
+
* `ai-sdk-provider-gemini-cli` implementation, whose OAuth credentials Google
|
|
8
|
+
* sunsets on 2026-06-18.
|
|
6
9
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
10
|
+
* Architecture: one-shot subprocess wrapper. Each invoke() serializes the full
|
|
11
|
+
* messages array into a single prompt, spawns `agy` under a pseudo-terminal,
|
|
12
|
+
* collects the printed response, and returns it. A PTY is REQUIRED: agy print
|
|
13
|
+
* mode silently drops stdout in any non-TTY context (upstream bug
|
|
14
|
+
* google-antigravity/antigravity-cli#76, unfixed as of v1.0.7).
|
|
12
15
|
*
|
|
13
16
|
* Authentication:
|
|
14
|
-
* - Requires
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* - Requires the Antigravity CLI (`agy`) installed and authenticated once
|
|
18
|
+
* interactively (`agy`) via Google OAuth. The first interactive login also
|
|
19
|
+
* establishes workspace trust for the user's home directory.
|
|
20
|
+
*
|
|
21
|
+
* The provider registry key remains 'gemini-cli' and the user-facing alias
|
|
22
|
+
* remains 'gemini' for routing/normalization stability. Only three user-facing
|
|
23
|
+
* model names are exposed: gemini (= gemini:pro), gemini:pro, gemini:flash.
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
|
-
import { existsSync } from 'node:fs';
|
|
20
|
-
import { homedir } from 'node:os';
|
|
21
|
-
import { join } from 'node:path';
|
|
26
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
27
|
+
import { homedir, tmpdir } from 'node:os';
|
|
28
|
+
import { join, delimiter } from 'node:path';
|
|
29
|
+
import { randomUUID } from 'node:crypto';
|
|
22
30
|
import { debugLog, debugError } from '../utils/console.js';
|
|
23
31
|
import { ProviderError, ErrorCodes, StopReasons } from './interface.js';
|
|
24
32
|
|
|
25
|
-
//
|
|
33
|
+
// Prompts at or below this length pass directly as the -p argv value (fast
|
|
34
|
+
// path). Larger prompts are written to a file and -p carries a bootstrap
|
|
35
|
+
// instruction. Keeps argv well under the Windows 32,767-char CreateProcess
|
|
36
|
+
// ceiling (error 206).
|
|
37
|
+
const ARGV_PROMPT_LIMIT = 24000;
|
|
38
|
+
|
|
39
|
+
// Default print timeout (ms) when the tool layer passes none.
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 600000;
|
|
41
|
+
|
|
42
|
+
// Extra wall-clock grace before the JS-side hard kill fires (ms).
|
|
43
|
+
const HARD_KILL_GRACE_MS = 15000;
|
|
44
|
+
|
|
45
|
+
// After pty.kill(), force-settle if onExit never fires (ms).
|
|
46
|
+
const POST_KILL_GRACE_MS = 5000;
|
|
47
|
+
|
|
48
|
+
// PTY width: wide enough that response lines rarely soft-wrap (soft-wrap inserts
|
|
49
|
+
// \r\n indistinguishable from real newlines). rows are irrelevant in print mode.
|
|
50
|
+
const PTY_COLS = 1000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Supported Gemini models. Only three user-facing names are exposed; each maps
|
|
54
|
+
* to an agy display-name base that gets a reasoning-effort suffix appended at
|
|
55
|
+
* spawn time. All are text-only (print mode has no image input channel).
|
|
56
|
+
*/
|
|
26
57
|
const SUPPORTED_MODELS = {
|
|
27
58
|
gemini: {
|
|
28
59
|
modelName: 'gemini',
|
|
29
|
-
friendlyName: 'Gemini 3.
|
|
30
|
-
contextWindow: 1048576,
|
|
60
|
+
friendlyName: 'Gemini 3.1 Pro (via Antigravity CLI)',
|
|
61
|
+
contextWindow: 1048576,
|
|
62
|
+
maxOutputTokens: 65536,
|
|
63
|
+
supportsStreaming: true,
|
|
64
|
+
supportsImages: false,
|
|
65
|
+
supportsTemperature: false,
|
|
66
|
+
supportsWebSearch: false,
|
|
67
|
+
supportsThinking: true,
|
|
68
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
69
|
+
description:
|
|
70
|
+
'Gemini 3.1 Pro via Antigravity CLI (agy) - requires Antigravity Google OAuth login',
|
|
71
|
+
aliases: ['gemini-cli'],
|
|
72
|
+
// agy display-name base; reasoning_effort selects the parenthesized variant
|
|
73
|
+
agyModelBase: 'Gemini 3.1 Pro',
|
|
74
|
+
},
|
|
75
|
+
'gemini:pro': {
|
|
76
|
+
modelName: 'gemini:pro',
|
|
77
|
+
friendlyName: 'Gemini 3.1 Pro (via Antigravity CLI)',
|
|
78
|
+
contextWindow: 1048576,
|
|
31
79
|
maxOutputTokens: 65536,
|
|
32
80
|
supportsStreaming: true,
|
|
33
|
-
supportsImages:
|
|
34
|
-
supportsTemperature:
|
|
81
|
+
supportsImages: false,
|
|
82
|
+
supportsTemperature: false,
|
|
83
|
+
supportsWebSearch: false,
|
|
35
84
|
supportsThinking: true,
|
|
36
|
-
|
|
37
|
-
timeout: 600000, // 10 minutes
|
|
85
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
38
86
|
description:
|
|
39
|
-
'Gemini 3.
|
|
40
|
-
aliases: [
|
|
41
|
-
|
|
42
|
-
'gemini-3.5-flash',
|
|
43
|
-
'gemini-3.5',
|
|
44
|
-
'gemini3.5',
|
|
45
|
-
'flash',
|
|
46
|
-
'flash-3.5',
|
|
47
|
-
'gemini-flash',
|
|
48
|
-
'gemini-flash-3.5',
|
|
49
|
-
],
|
|
50
|
-
// Internal SDK model name passed to the Google Cloud Code endpoint
|
|
51
|
-
sdkModelName: 'gemini-3.5-flash',
|
|
87
|
+
'Gemini 3.1 Pro via Antigravity CLI (agy) - explicit alias of `gemini`',
|
|
88
|
+
aliases: [],
|
|
89
|
+
agyModelBase: 'Gemini 3.1 Pro',
|
|
52
90
|
},
|
|
53
|
-
'gemini
|
|
54
|
-
modelName: 'gemini
|
|
55
|
-
friendlyName: 'Gemini 3.
|
|
56
|
-
contextWindow: 1048576,
|
|
57
|
-
maxOutputTokens:
|
|
91
|
+
'gemini:flash': {
|
|
92
|
+
modelName: 'gemini:flash',
|
|
93
|
+
friendlyName: 'Gemini 3.5 Flash (via Antigravity CLI)',
|
|
94
|
+
contextWindow: 1048576,
|
|
95
|
+
maxOutputTokens: 65536,
|
|
58
96
|
supportsStreaming: true,
|
|
59
|
-
supportsImages:
|
|
60
|
-
supportsTemperature:
|
|
97
|
+
supportsImages: false,
|
|
98
|
+
supportsTemperature: false,
|
|
99
|
+
supportsWebSearch: false,
|
|
61
100
|
supportsThinking: true,
|
|
62
|
-
|
|
63
|
-
timeout: 600000, // 10 minutes
|
|
101
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
64
102
|
description:
|
|
65
|
-
'Gemini 3.
|
|
66
|
-
aliases: [
|
|
67
|
-
|
|
68
|
-
'gemini-3.1',
|
|
69
|
-
'gemini-pro',
|
|
70
|
-
'gemini-3-pro',
|
|
71
|
-
'pro',
|
|
72
|
-
],
|
|
73
|
-
sdkModelName: 'gemini-3.1-pro-preview',
|
|
103
|
+
'Gemini 3.5 Flash via Antigravity CLI (agy) - requires Antigravity Google OAuth login',
|
|
104
|
+
aliases: ['flash'],
|
|
105
|
+
agyModelBase: 'Gemini 3.5 Flash',
|
|
74
106
|
},
|
|
75
107
|
};
|
|
76
108
|
|
|
77
109
|
/**
|
|
78
|
-
* Custom error class for Gemini CLI provider errors
|
|
110
|
+
* Custom error class for Gemini CLI (agy) provider errors
|
|
79
111
|
*/
|
|
80
112
|
class GeminiCliProviderError extends ProviderError {
|
|
81
113
|
constructor(message, code, originalError = null) {
|
|
@@ -84,484 +116,679 @@ class GeminiCliProviderError extends ProviderError {
|
|
|
84
116
|
}
|
|
85
117
|
}
|
|
86
118
|
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Pure helpers (exported for unit testing)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
let _cachedAgyPath; // undefined = not probed; null = probed, not found
|
|
124
|
+
|
|
87
125
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
126
|
+
* Locate the agy binary: PATH first, then the platform install fallback.
|
|
127
|
+
* Result is cached at module level (null cached if not found).
|
|
128
|
+
* @returns {string|null} Absolute path to agy, or null if not found
|
|
90
129
|
*/
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return existsSync(credsPath);
|
|
95
|
-
} catch (error) {
|
|
96
|
-
debugError('[Gemini CLI] Error checking OAuth credentials', error);
|
|
97
|
-
return false;
|
|
130
|
+
export function findAgyBinary() {
|
|
131
|
+
if (_cachedAgyPath !== undefined) {
|
|
132
|
+
return _cachedAgyPath;
|
|
98
133
|
}
|
|
99
|
-
}
|
|
100
134
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
135
|
+
const isWindows = process.platform === 'win32';
|
|
136
|
+
const exe = isWindows ? 'agy.exe' : 'agy';
|
|
137
|
+
|
|
138
|
+
// 1. PATH lookup
|
|
139
|
+
const pathEnv = process.env.PATH || process.env.Path || '';
|
|
140
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
141
|
+
if (!dir) continue;
|
|
142
|
+
try {
|
|
143
|
+
const candidate = join(dir, exe);
|
|
144
|
+
if (existsSync(candidate)) {
|
|
145
|
+
_cachedAgyPath = candidate;
|
|
146
|
+
return _cachedAgyPath;
|
|
147
|
+
}
|
|
148
|
+
} catch {
|
|
149
|
+
// ignore malformed PATH entries
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. Platform install fallback
|
|
106
154
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
155
|
+
if (isWindows) {
|
|
156
|
+
const localAppData =
|
|
157
|
+
process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
|
|
158
|
+
const candidate = join(localAppData, 'agy', 'bin', 'agy.exe');
|
|
159
|
+
if (existsSync(candidate)) {
|
|
160
|
+
_cachedAgyPath = candidate;
|
|
161
|
+
return _cachedAgyPath;
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
const candidate = join(homedir(), '.local', 'bin', 'agy');
|
|
165
|
+
if (existsSync(candidate)) {
|
|
166
|
+
_cachedAgyPath = candidate;
|
|
167
|
+
return _cachedAgyPath;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// ignore
|
|
116
172
|
}
|
|
173
|
+
|
|
174
|
+
_cachedAgyPath = null;
|
|
175
|
+
return _cachedAgyPath;
|
|
117
176
|
}
|
|
118
177
|
|
|
119
178
|
/**
|
|
120
|
-
*
|
|
179
|
+
* Map a reasoning_effort value to the agy parenthesized variant suffix.
|
|
180
|
+
* Flash supports Low/Medium/High; Pro supports Low/High (no Medium).
|
|
181
|
+
* @param {string} base - agy model base ('Gemini 3.5 Flash' / 'Gemini 3.1 Pro')
|
|
182
|
+
* @param {string} [reasoningEffort]
|
|
183
|
+
* @returns {string} e.g. '(Low)', '(Medium)', '(High)'
|
|
121
184
|
*/
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
185
|
+
function effortSuffix(base, reasoningEffort) {
|
|
186
|
+
const isPro = /pro/i.test(base);
|
|
187
|
+
const effort = (reasoningEffort || '').toLowerCase();
|
|
188
|
+
|
|
189
|
+
switch (effort) {
|
|
190
|
+
case 'none':
|
|
191
|
+
case 'minimal':
|
|
192
|
+
case 'low':
|
|
193
|
+
return '(Low)';
|
|
194
|
+
case 'medium':
|
|
195
|
+
// Pro has no Medium variant — fall back to High
|
|
196
|
+
return isPro ? '(High)' : '(Medium)';
|
|
197
|
+
case 'high':
|
|
198
|
+
case 'max':
|
|
199
|
+
return '(High)';
|
|
200
|
+
default:
|
|
201
|
+
// unset → High
|
|
202
|
+
return '(High)';
|
|
132
203
|
}
|
|
133
204
|
}
|
|
134
205
|
|
|
135
206
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
207
|
+
* Resolve a user-facing model name + reasoning_effort to the agy display name
|
|
208
|
+
* passed via --model. Strips the gemini: prefix (case-insensitive), maps the
|
|
209
|
+
* alias, and appends the effort suffix. Full agy display names pass through
|
|
210
|
+
* verbatim so power users aren't blocked.
|
|
211
|
+
* @param {string} model - e.g. 'gemini', 'gemini:flash', or a full agy name
|
|
212
|
+
* @param {string} [reasoningEffort]
|
|
213
|
+
* @returns {string} agy --model value, e.g. 'Gemini 3.1 Pro (High)'
|
|
138
214
|
*/
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
messages,
|
|
142
|
-
options,
|
|
143
|
-
signal,
|
|
144
|
-
userFacingModelName = 'gemini',
|
|
145
|
-
) {
|
|
146
|
-
const { streamText } = await getAISDK();
|
|
215
|
+
export function resolveAgyModel(model, reasoningEffort) {
|
|
216
|
+
const raw = typeof model === 'string' ? model.trim() : '';
|
|
147
217
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
...options,
|
|
153
|
-
};
|
|
218
|
+
// Full agy display-name passthrough (already contains a parenthesized variant)
|
|
219
|
+
if (/\(.*\)\s*$/.test(raw) && /gemini/i.test(raw)) {
|
|
220
|
+
return raw;
|
|
221
|
+
}
|
|
154
222
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
223
|
+
let name = raw;
|
|
224
|
+
if (name.toLowerCase().startsWith('gemini:')) {
|
|
225
|
+
name = name.slice('gemini:'.length).trim();
|
|
226
|
+
}
|
|
158
227
|
|
|
159
|
-
|
|
228
|
+
const nameLower = name.toLowerCase();
|
|
229
|
+
|
|
230
|
+
// Determine the agy base
|
|
231
|
+
let base;
|
|
232
|
+
if (
|
|
233
|
+
!nameLower ||
|
|
234
|
+
nameLower === 'gemini' ||
|
|
235
|
+
nameLower === 'gemini-cli' ||
|
|
236
|
+
nameLower === 'pro'
|
|
237
|
+
) {
|
|
238
|
+
base = SUPPORTED_MODELS.gemini.agyModelBase;
|
|
239
|
+
} else if (nameLower === 'flash') {
|
|
240
|
+
base = SUPPORTED_MODELS['gemini:flash'].agyModelBase;
|
|
241
|
+
} else {
|
|
242
|
+
// Unknown suffix: pass through verbatim (power-user agy display name)
|
|
243
|
+
return raw;
|
|
244
|
+
}
|
|
160
245
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
type: 'start',
|
|
164
|
-
provider: 'gemini-cli',
|
|
165
|
-
model: userFacingModelName,
|
|
166
|
-
};
|
|
246
|
+
return `${base} ${effortSuffix(base, reasoningEffort)}`;
|
|
247
|
+
}
|
|
167
248
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Serialize the full messages array into a single role-labeled prompt string.
|
|
251
|
+
* System message becomes a <system> preamble; prior turns render as
|
|
252
|
+
* User:/Assistant: blocks; ends with an instruction to answer the final user
|
|
253
|
+
* message directly without role labels. Throws on image content parts.
|
|
254
|
+
* @param {Array} messages - Converse-format messages
|
|
255
|
+
* @returns {string}
|
|
256
|
+
*/
|
|
257
|
+
export function buildPrompt(messages) {
|
|
258
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
259
|
+
throw new GeminiCliProviderError(
|
|
260
|
+
'Messages must be a non-empty array',
|
|
261
|
+
ErrorCodes.INVALID_MESSAGES,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
174
264
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
data: {
|
|
179
|
-
textDelta: chunk,
|
|
180
|
-
},
|
|
181
|
-
};
|
|
265
|
+
const renderContent = (content) => {
|
|
266
|
+
if (typeof content === 'string') {
|
|
267
|
+
return content;
|
|
182
268
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
type
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
cached_input_tokens: 0,
|
|
198
|
-
},
|
|
199
|
-
};
|
|
269
|
+
if (Array.isArray(content)) {
|
|
270
|
+
const parts = [];
|
|
271
|
+
for (const part of content) {
|
|
272
|
+
if (part?.type === 'image') {
|
|
273
|
+
throw new GeminiCliProviderError(
|
|
274
|
+
'Images are not supported by the gemini provider (Antigravity CLI print mode has no image input channel)',
|
|
275
|
+
ErrorCodes.INVALID_REQUEST,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (part?.type === 'text' && typeof part.text === 'string') {
|
|
279
|
+
parts.push(part.text);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return parts.join('\n');
|
|
200
283
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
284
|
+
return '';
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const systemParts = [];
|
|
288
|
+
const turns = [];
|
|
289
|
+
|
|
290
|
+
for (const message of messages) {
|
|
291
|
+
const role = message?.role;
|
|
292
|
+
const text = renderContent(message?.content);
|
|
293
|
+
if (role === 'system') {
|
|
294
|
+
if (text) systemParts.push(text);
|
|
295
|
+
} else if (role === 'assistant') {
|
|
296
|
+
turns.push(`Assistant: ${text}`);
|
|
297
|
+
} else {
|
|
298
|
+
// treat anything else (user/tool/unknown) as a user turn
|
|
299
|
+
turns.push(`User: ${text}`);
|
|
211
300
|
}
|
|
212
|
-
throw error;
|
|
213
301
|
}
|
|
302
|
+
|
|
303
|
+
const sections = [];
|
|
304
|
+
if (systemParts.length > 0) {
|
|
305
|
+
sections.push(`<system>\n${systemParts.join('\n\n')}\n</system>`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (turns.length > 1) {
|
|
309
|
+
sections.push(
|
|
310
|
+
'The following is a conversation transcript. Read the full transcript, then write the assistant\'s next reply to the final User message. Respond directly without any role label.',
|
|
311
|
+
);
|
|
312
|
+
sections.push(turns.join('\n\n'));
|
|
313
|
+
} else {
|
|
314
|
+
// Single user turn — strip the label, just ask directly.
|
|
315
|
+
const onlyTurn = turns[0] || '';
|
|
316
|
+
sections.push(onlyTurn.replace(/^User:\s*/, ''));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return sections.join('\n\n');
|
|
214
320
|
}
|
|
215
321
|
|
|
216
322
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
323
|
+
* Clean agy PTY output: strip ANSI escape sequences (CSI, OSC, charset
|
|
324
|
+
* selection), resolve carriage-return overwrites, trim trailing whitespace.
|
|
325
|
+
* Pure function so it can be unit-tested against captured raw output.
|
|
326
|
+
* @param {string} raw
|
|
327
|
+
* @returns {string}
|
|
220
328
|
*/
|
|
221
|
-
function
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
typeof finishReason === 'object' ? finishReason?.unified : finishReason;
|
|
225
|
-
|
|
226
|
-
switch (reason) {
|
|
227
|
-
case 'stop':
|
|
228
|
-
return StopReasons.STOP;
|
|
229
|
-
case 'length':
|
|
230
|
-
case 'max-tokens':
|
|
231
|
-
return StopReasons.LENGTH;
|
|
232
|
-
case 'content-filter':
|
|
233
|
-
return StopReasons.CONTENT_FILTER;
|
|
234
|
-
case 'tool-calls':
|
|
235
|
-
return StopReasons.TOOL_USE;
|
|
236
|
-
case 'error':
|
|
237
|
-
return StopReasons.ERROR;
|
|
238
|
-
default:
|
|
239
|
-
return StopReasons.OTHER;
|
|
329
|
+
export function cleanAgyOutput(raw) {
|
|
330
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
331
|
+
return '';
|
|
240
332
|
}
|
|
333
|
+
|
|
334
|
+
let s = raw;
|
|
335
|
+
|
|
336
|
+
// Strip OSC sequences: ESC ] ... terminated by BEL (\x07) or ST (ESC \).
|
|
337
|
+
s = s.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '');
|
|
338
|
+
|
|
339
|
+
// Strip CSI sequences: ESC [ parameter-bytes (0x30-0x3F) intermediate-bytes
|
|
340
|
+
// (0x20-0x2F) final-byte (0x40-0x7E). Full grammar so truecolor / less common
|
|
341
|
+
// sequences don't leak their tail as text.
|
|
342
|
+
s = s.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '');
|
|
343
|
+
|
|
344
|
+
// Strip charset selection (ESC ( X / ESC ) X) and other single-char escapes
|
|
345
|
+
// (ESC =, ESC >, and any remaining ESC + final byte).
|
|
346
|
+
s = s.replace(/\x1b[()][AB0-2]/g, '');
|
|
347
|
+
s = s.replace(/\x1b[=>]/g, '');
|
|
348
|
+
s = s.replace(/\x1b[@-Z\\-_]/g, '');
|
|
349
|
+
|
|
350
|
+
// Resolve carriage-return overwrites within each line: a lone \r (not part of
|
|
351
|
+
// a \r\n line break) means the cursor returned to column 0 and overwrote.
|
|
352
|
+
// Normalize CRLF first so we only handle bare CRs.
|
|
353
|
+
s = s.replace(/\r\n/g, '\n');
|
|
354
|
+
s = s
|
|
355
|
+
.split('\n')
|
|
356
|
+
.map((line) => {
|
|
357
|
+
if (!line.includes('\r')) return line;
|
|
358
|
+
// Last CR-delimited segment wins (spinner frames overwrite each other)
|
|
359
|
+
const segments = line.split('\r');
|
|
360
|
+
return segments[segments.length - 1];
|
|
361
|
+
})
|
|
362
|
+
.join('\n');
|
|
363
|
+
|
|
364
|
+
// Strip any remaining lone control chars (BEL, etc.) except tab and newline.
|
|
365
|
+
s = s.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
366
|
+
|
|
367
|
+
// Trim trailing whitespace/newlines
|
|
368
|
+
return s.replace(/\s+$/, '');
|
|
241
369
|
}
|
|
242
370
|
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Subprocess runner
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
243
375
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* @param {Object|string} finishReason - The finish reason
|
|
247
|
-
* @returns {string} The raw finish reason string
|
|
376
|
+
* Lazily import @lydell/node-pty. Kept lazy so the module loads even when the
|
|
377
|
+
* native binary is unavailable (e.g. unit tests that mock the layer).
|
|
248
378
|
*/
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
379
|
+
async function getPty() {
|
|
380
|
+
try {
|
|
381
|
+
const mod = await import('@lydell/node-pty');
|
|
382
|
+
return mod.default || mod;
|
|
383
|
+
} catch (error) {
|
|
384
|
+
throw new GeminiCliProviderError(
|
|
385
|
+
'@lydell/node-pty is not installed. Run: pnpm add @lydell/node-pty',
|
|
386
|
+
ErrorCodes.API_ERROR,
|
|
387
|
+
error,
|
|
388
|
+
);
|
|
252
389
|
}
|
|
253
|
-
return finishReason || 'stop';
|
|
254
390
|
}
|
|
255
391
|
|
|
256
392
|
/**
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* @param {
|
|
261
|
-
* @
|
|
393
|
+
* Spawn agy under a PTY, deliver the prompt, collect output, resolve on exit.
|
|
394
|
+
*
|
|
395
|
+
* @param {object} params
|
|
396
|
+
* @param {string} params.prompt - Fully serialized prompt
|
|
397
|
+
* @param {string} params.model - Resolved agy --model value
|
|
398
|
+
* @param {number} params.timeoutMs - Print timeout in ms
|
|
399
|
+
* @param {AbortSignal} [params.signal]
|
|
400
|
+
* @param {object} [params.ptyLib] - Injected pty module (tests)
|
|
401
|
+
* @param {string} [params.agyPath] - Override binary path (tests)
|
|
402
|
+
* @returns {Promise<{output: string, exitCode: number}>}
|
|
262
403
|
*/
|
|
263
|
-
function
|
|
264
|
-
|
|
265
|
-
|
|
404
|
+
export async function runAgy({
|
|
405
|
+
prompt,
|
|
406
|
+
model,
|
|
407
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
408
|
+
signal,
|
|
409
|
+
ptyLib,
|
|
410
|
+
agyPath,
|
|
411
|
+
}) {
|
|
412
|
+
const binary = agyPath || findAgyBinary();
|
|
413
|
+
if (!binary) {
|
|
414
|
+
throw new GeminiCliProviderError(
|
|
415
|
+
'Antigravity CLI (agy) not found. Install it and run `agy` once to log in (https://antigravity.google)',
|
|
416
|
+
ErrorCodes.MISSING_API_KEY,
|
|
417
|
+
);
|
|
266
418
|
}
|
|
267
419
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const input = usage.inputTokens.total || 0;
|
|
271
|
-
const output = usage.outputTokens?.total || 0;
|
|
272
|
-
return { input, output, total: input + output };
|
|
420
|
+
if (signal?.aborted) {
|
|
421
|
+
throw new GeminiCliProviderError('Request cancelled', 'CANCELLED');
|
|
273
422
|
}
|
|
274
423
|
|
|
275
|
-
|
|
276
|
-
const input = usage.promptTokens || usage.inputTokens || 0;
|
|
277
|
-
const output = usage.completionTokens || usage.outputTokens || 0;
|
|
278
|
-
const total = usage.totalTokens || input + output;
|
|
279
|
-
return { input, output, total };
|
|
280
|
-
}
|
|
424
|
+
const pty = ptyLib || (await getPty());
|
|
281
425
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
426
|
+
// Per-call cwd under HOME (covered by agy workspace trust on first login).
|
|
427
|
+
// On POSIX, restrict the dir to the owner (0700) since it may hold prompt.md.
|
|
428
|
+
const runId = randomUUID();
|
|
429
|
+
let runDir = join(homedir(), '.converse', 'agy-runs', runId);
|
|
430
|
+
const mkOpts =
|
|
431
|
+
process.platform === 'win32'
|
|
432
|
+
? { recursive: true }
|
|
433
|
+
: { recursive: true, mode: 0o700 };
|
|
434
|
+
try {
|
|
435
|
+
mkdirSync(runDir, mkOpts);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
// Fall back to a per-call dir under tmpdir if HOME isn't writable. Still a
|
|
438
|
+
// unique dir (never a bare tmpdir) so concurrent calls don't collide and
|
|
439
|
+
// cleanup still applies.
|
|
440
|
+
debugError(
|
|
441
|
+
'[Gemini CLI] Failed to create run dir, falling back to tmp',
|
|
442
|
+
error,
|
|
443
|
+
);
|
|
444
|
+
runDir = join(tmpdir(), 'converse-agy-runs', runId);
|
|
445
|
+
try {
|
|
446
|
+
mkdirSync(runDir, mkOpts);
|
|
447
|
+
} catch (fallbackErr) {
|
|
448
|
+
throw new GeminiCliProviderError(
|
|
449
|
+
`Failed to create agy run directory: ${fallbackErr.message}`,
|
|
450
|
+
ErrorCodes.API_ERROR,
|
|
451
|
+
fallbackErr,
|
|
452
|
+
);
|
|
303
453
|
}
|
|
454
|
+
}
|
|
455
|
+
// Decide prompt delivery: argv (fast) vs file (large-prompt bootstrap).
|
|
456
|
+
let promptArg;
|
|
457
|
+
if (prompt.length > ARGV_PROMPT_LIMIT) {
|
|
458
|
+
const promptFile = join(runDir, 'prompt.md');
|
|
459
|
+
// 0600 on POSIX — prompt may contain sensitive context.
|
|
460
|
+
const writeOpts =
|
|
461
|
+
process.platform === 'win32'
|
|
462
|
+
? { encoding: 'utf8' }
|
|
463
|
+
: { encoding: 'utf8', mode: 0o600 };
|
|
464
|
+
writeFileSync(promptFile, prompt, writeOpts);
|
|
465
|
+
// Reference the absolute path to minimize the agent's file-search flailing.
|
|
466
|
+
promptArg = `Read the file located at ${promptFile} and respond to its contents directly. Do not summarize the file; answer it.`;
|
|
467
|
+
} else {
|
|
468
|
+
promptArg = prompt;
|
|
469
|
+
}
|
|
304
470
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
471
|
+
const timeoutSeconds = Math.ceil(timeoutMs / 1000);
|
|
472
|
+
// --sandbox is intentionally omitted: it blocks the large-prompt file read in
|
|
473
|
+
// print mode (verified 2026-06-10 — agy times out unable to read prompt.md).
|
|
474
|
+
const args = [
|
|
475
|
+
'-p',
|
|
476
|
+
promptArg,
|
|
477
|
+
'--model',
|
|
478
|
+
model,
|
|
479
|
+
'--print-timeout',
|
|
480
|
+
`${timeoutSeconds}s`,
|
|
481
|
+
];
|
|
482
|
+
|
|
483
|
+
return new Promise((resolve, reject) => {
|
|
484
|
+
let child;
|
|
485
|
+
let output = '';
|
|
486
|
+
let settled = false;
|
|
487
|
+
// Set when abort/timeout has requested termination. Once set, a subsequent
|
|
488
|
+
// onExit (which kill() may fire synchronously) must NOT resolve as a normal
|
|
489
|
+
// exit — the termination error wins.
|
|
490
|
+
let terminationError = null;
|
|
491
|
+
let hardTimer = null;
|
|
492
|
+
let postKillTimer = null;
|
|
493
|
+
let onDataSub = null;
|
|
494
|
+
let onExitSub = null;
|
|
495
|
+
|
|
496
|
+
const cleanup = () => {
|
|
497
|
+
if (hardTimer) {
|
|
498
|
+
clearTimeout(hardTimer);
|
|
499
|
+
hardTimer = null;
|
|
500
|
+
}
|
|
501
|
+
if (postKillTimer) {
|
|
502
|
+
clearTimeout(postKillTimer);
|
|
503
|
+
postKillTimer = null;
|
|
504
|
+
}
|
|
505
|
+
if (signal) {
|
|
506
|
+
signal.removeEventListener('abort', onAbort);
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
onDataSub?.dispose?.();
|
|
510
|
+
} catch {
|
|
511
|
+
/* ignore */
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
onExitSub?.dispose?.();
|
|
515
|
+
} catch {
|
|
516
|
+
/* ignore */
|
|
517
|
+
}
|
|
518
|
+
// Best-effort run-dir cleanup; never throw. On abort the killed process
|
|
519
|
+
// may still hold a handle on prompt.md (EBUSY), so retry once detached.
|
|
520
|
+
try {
|
|
521
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
522
|
+
} catch (err) {
|
|
523
|
+
debugLog('[Gemini CLI] Run dir cleanup failed, retrying: %s', err?.message);
|
|
524
|
+
setTimeout(() => {
|
|
525
|
+
try {
|
|
526
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
527
|
+
} catch (retryErr) {
|
|
528
|
+
debugLog(
|
|
529
|
+
'[Gemini CLI] Run dir cleanup retry failed: %s',
|
|
530
|
+
retryErr?.message,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}, 2000).unref?.();
|
|
534
|
+
}
|
|
535
|
+
};
|
|
312
536
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
537
|
+
const settleResolve = (value) => {
|
|
538
|
+
if (settled) return;
|
|
539
|
+
settled = true;
|
|
540
|
+
cleanup();
|
|
541
|
+
resolve(value);
|
|
542
|
+
};
|
|
320
543
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
544
|
+
const settleReject = (err) => {
|
|
545
|
+
if (settled) return;
|
|
546
|
+
settled = true;
|
|
547
|
+
cleanup();
|
|
548
|
+
reject(err);
|
|
549
|
+
};
|
|
325
550
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
551
|
+
// Terminate the child for a known reason (abort/timeout). Records the error
|
|
552
|
+
// first so a synchronous onExit from kill() rejects rather than resolves,
|
|
553
|
+
// then schedules a post-kill grace timer so we never hang if onExit never
|
|
554
|
+
// fires.
|
|
555
|
+
const terminate = (err) => {
|
|
556
|
+
if (settled) return;
|
|
557
|
+
terminationError = err;
|
|
558
|
+
try {
|
|
559
|
+
child?.kill();
|
|
560
|
+
} catch (killErr) {
|
|
561
|
+
debugLog('[Gemini CLI] pty.kill() failed: %s', killErr?.message);
|
|
562
|
+
}
|
|
563
|
+
if (!postKillTimer) {
|
|
564
|
+
postKillTimer = setTimeout(() => {
|
|
565
|
+
settleReject(terminationError);
|
|
566
|
+
}, POST_KILL_GRACE_MS);
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
function onAbort() {
|
|
571
|
+
terminate(new GeminiCliProviderError('Request cancelled', 'CANCELLED'));
|
|
572
|
+
}
|
|
330
573
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
574
|
+
try {
|
|
575
|
+
child = pty.spawn(binary, args, {
|
|
576
|
+
name: 'xterm-256color',
|
|
577
|
+
cols: PTY_COLS,
|
|
578
|
+
rows: 30,
|
|
579
|
+
cwd: runDir,
|
|
580
|
+
env: process.env,
|
|
334
581
|
});
|
|
582
|
+
} catch (error) {
|
|
583
|
+
cleanup();
|
|
584
|
+
reject(
|
|
585
|
+
new GeminiCliProviderError(
|
|
586
|
+
`Failed to spawn agy: ${error.message}`,
|
|
587
|
+
ErrorCodes.API_ERROR,
|
|
588
|
+
error,
|
|
589
|
+
),
|
|
590
|
+
);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
onDataSub = child.onData((data) => {
|
|
595
|
+
output += data;
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
onExitSub = child.onExit(({ exitCode }) => {
|
|
599
|
+
// If termination was requested, the abort/timeout error wins over a
|
|
600
|
+
// (possibly kill()-induced) exit.
|
|
601
|
+
if (terminationError) {
|
|
602
|
+
settleReject(terminationError);
|
|
603
|
+
} else {
|
|
604
|
+
settleResolve({ output, exitCode });
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
hardTimer = setTimeout(() => {
|
|
609
|
+
terminate(
|
|
610
|
+
new GeminiCliProviderError(
|
|
611
|
+
`Antigravity CLI (agy) timed out after ${timeoutMs}ms`,
|
|
612
|
+
ErrorCodes.TIMEOUT_ERROR,
|
|
613
|
+
),
|
|
614
|
+
);
|
|
615
|
+
}, timeoutMs + HARD_KILL_GRACE_MS);
|
|
335
616
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
617
|
+
if (signal) {
|
|
618
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
619
|
+
// Guard the window between the early aborted-check and listener
|
|
620
|
+
// registration: if it aborted in between, the listener won't fire.
|
|
621
|
+
if (signal.aborted) {
|
|
622
|
+
onAbort();
|
|
623
|
+
}
|
|
340
624
|
}
|
|
625
|
+
});
|
|
626
|
+
}
|
|
341
627
|
|
|
342
|
-
|
|
343
|
-
|
|
628
|
+
/**
|
|
629
|
+
* Yield the passthrough event sequence for stream mode:
|
|
630
|
+
* start -> delta(fullText) -> usage(zeroed) -> end
|
|
631
|
+
*/
|
|
632
|
+
async function* createStreamingGenerator(fullText, userFacingModel) {
|
|
633
|
+
yield {
|
|
634
|
+
type: 'start',
|
|
635
|
+
provider: 'gemini-cli',
|
|
636
|
+
model: userFacingModel,
|
|
637
|
+
};
|
|
638
|
+
yield {
|
|
639
|
+
type: 'delta',
|
|
640
|
+
data: { textDelta: fullText },
|
|
641
|
+
};
|
|
642
|
+
yield {
|
|
643
|
+
type: 'usage',
|
|
644
|
+
usage: {
|
|
645
|
+
input_tokens: 0,
|
|
646
|
+
output_tokens: 0,
|
|
647
|
+
total_tokens: 0,
|
|
648
|
+
cached_input_tokens: 0,
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
yield {
|
|
652
|
+
type: 'end',
|
|
653
|
+
stop_reason: 'stop',
|
|
654
|
+
finish_reason: 'stop',
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Run agy and return the cleaned response text, mapping failure modes to
|
|
660
|
+
* provider errors.
|
|
661
|
+
*/
|
|
662
|
+
async function executeAgy(messages, options) {
|
|
663
|
+
const { model = 'gemini', reasoning_effort, signal, timeout } = options;
|
|
664
|
+
|
|
665
|
+
const prompt = buildPrompt(messages);
|
|
666
|
+
const agyModel = resolveAgyModel(model, reasoning_effort);
|
|
667
|
+
const timeoutMs =
|
|
668
|
+
typeof timeout === 'number' && timeout > 0 ? timeout : DEFAULT_TIMEOUT_MS;
|
|
669
|
+
|
|
670
|
+
const { output, exitCode } = await runAgy({
|
|
671
|
+
prompt,
|
|
672
|
+
model: agyModel,
|
|
673
|
+
timeoutMs,
|
|
674
|
+
signal,
|
|
344
675
|
});
|
|
676
|
+
|
|
677
|
+
const cleaned = cleanAgyOutput(output);
|
|
678
|
+
|
|
679
|
+
if (exitCode !== 0) {
|
|
680
|
+
const tail = cleaned.slice(-500);
|
|
681
|
+
throw new GeminiCliProviderError(
|
|
682
|
+
`Antigravity CLI (agy) exited with code ${exitCode}. ${tail ? `Output tail: ${tail}` : 'No output.'} If this persists, run \`agy\` interactively once to authenticate (Antigravity Google OAuth).`,
|
|
683
|
+
ErrorCodes.API_ERROR,
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (!cleaned) {
|
|
688
|
+
throw new GeminiCliProviderError(
|
|
689
|
+
'Antigravity CLI (agy) returned empty output. This usually means the CLI is not authenticated — run `agy` interactively once to authenticate (Antigravity Google OAuth). (See upstream bug google-antigravity/antigravity-cli#76 for the non-TTY case.)',
|
|
690
|
+
ErrorCodes.API_ERROR,
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
return cleaned;
|
|
345
695
|
}
|
|
346
696
|
|
|
347
697
|
/**
|
|
348
|
-
* Gemini CLI Provider Implementation
|
|
698
|
+
* Gemini CLI (Antigravity) Provider Implementation
|
|
349
699
|
*/
|
|
350
700
|
export const geminiCliProvider = {
|
|
351
701
|
/**
|
|
352
|
-
* Invoke
|
|
702
|
+
* Invoke agy with messages and options.
|
|
353
703
|
* @param {Array} messages - Message array (Converse format)
|
|
354
704
|
* @param {Object} options - Invocation options
|
|
355
705
|
* @returns {Promise<Object>|AsyncGenerator} Response or stream generator
|
|
356
706
|
*/
|
|
357
707
|
async invoke(messages, options = {}) {
|
|
358
|
-
const {
|
|
359
|
-
model = 'gemini',
|
|
360
|
-
config,
|
|
361
|
-
stream = false,
|
|
362
|
-
signal,
|
|
363
|
-
reasoning_effort,
|
|
364
|
-
temperature,
|
|
365
|
-
use_websearch,
|
|
366
|
-
} = options;
|
|
367
|
-
|
|
368
|
-
// Validate configuration
|
|
369
|
-
if (!config) {
|
|
370
|
-
throw new GeminiCliProviderError(
|
|
371
|
-
'Configuration is required',
|
|
372
|
-
ErrorCodes.MISSING_API_KEY,
|
|
373
|
-
);
|
|
374
|
-
}
|
|
708
|
+
const { model = 'gemini', stream = false, signal } = options;
|
|
375
709
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
throw new GeminiCliProviderError(
|
|
379
|
-
'Gemini CLI authentication required. Run: gemini (interactive CLI) to authenticate',
|
|
380
|
-
ErrorCodes.INVALID_API_KEY,
|
|
381
|
-
);
|
|
710
|
+
if (signal?.aborted) {
|
|
711
|
+
throw new GeminiCliProviderError('Request cancelled', 'CANCELLED');
|
|
382
712
|
}
|
|
383
713
|
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
throw new GeminiCliProviderError(
|
|
389
|
-
`Model ${model} not supported by Gemini CLI provider`,
|
|
390
|
-
ErrorCodes.MODEL_NOT_FOUND,
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Get the SDK model name (e.g., "gemini" -> "gemini-3-pro-preview")
|
|
395
|
-
const sdkModelName = modelConfig.sdkModelName || model;
|
|
396
|
-
|
|
397
|
-
// Get SDKs
|
|
398
|
-
const createGeminiProvider = await getGeminiCliSDK();
|
|
399
|
-
const { generateText } = await getAISDK();
|
|
400
|
-
|
|
401
|
-
// Create provider instance with OAuth authentication
|
|
402
|
-
const gemini = createGeminiProvider({
|
|
403
|
-
authType: 'oauth-personal',
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
// Create model instance with SDK model name
|
|
407
|
-
const modelInstance = gemini(sdkModelName);
|
|
408
|
-
|
|
409
|
-
// Convert messages from Converse format to AI SDK ModelMessage format
|
|
410
|
-
const convertedMessages = convertToModelMessages(messages);
|
|
411
|
-
|
|
412
|
-
// Build AI SDK options
|
|
413
|
-
const aiOptions = {
|
|
414
|
-
messages: convertedMessages,
|
|
415
|
-
};
|
|
416
|
-
|
|
417
|
-
// Add optional parameters
|
|
418
|
-
if (temperature !== undefined) {
|
|
419
|
-
aiOptions.temperature = temperature;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Note: reasoning_effort and use_websearch are not directly supported by AI SDK
|
|
423
|
-
// These would need to be handled at the API level if the provider supports them
|
|
424
|
-
if (reasoning_effort !== undefined) {
|
|
425
|
-
debugLog(
|
|
426
|
-
'[Gemini CLI] Parameter "reasoning_effort" not directly supported (ignored)',
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
if (use_websearch) {
|
|
430
|
-
debugLog(
|
|
431
|
-
'[Gemini CLI] Parameter "use_websearch" not directly supported (ignored)',
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Streaming mode
|
|
436
|
-
if (stream) {
|
|
437
|
-
return createStreamingGenerator(
|
|
438
|
-
modelInstance,
|
|
439
|
-
convertedMessages,
|
|
440
|
-
aiOptions,
|
|
441
|
-
signal,
|
|
442
|
-
model, // Pass user-facing model name for metadata
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Synchronous mode
|
|
447
|
-
const startTime = Date.now();
|
|
448
|
-
|
|
449
|
-
const result = await generateText({
|
|
450
|
-
model: modelInstance,
|
|
451
|
-
...aiOptions,
|
|
452
|
-
...(signal && { abortSignal: signal }),
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
const responseTime = Date.now() - startTime;
|
|
456
|
-
|
|
457
|
-
// Extract content from AI SDK v6 response format
|
|
458
|
-
const content = result.content?.[0]?.text || result.text || '';
|
|
459
|
-
|
|
460
|
-
// Extract usage tokens with AI SDK v6 compatibility
|
|
461
|
-
const tokens = extractUsageTokens(result.usage);
|
|
462
|
-
|
|
463
|
-
return {
|
|
464
|
-
content,
|
|
465
|
-
stop_reason: mapFinishReason(result.finishReason),
|
|
466
|
-
rawResponse: result,
|
|
467
|
-
metadata: {
|
|
468
|
-
provider: 'gemini-cli',
|
|
469
|
-
model,
|
|
470
|
-
usage: result.usage
|
|
471
|
-
? {
|
|
472
|
-
input_tokens: tokens.input,
|
|
473
|
-
output_tokens: tokens.output,
|
|
474
|
-
total_tokens: tokens.total,
|
|
475
|
-
cached_input_tokens: 0,
|
|
476
|
-
}
|
|
477
|
-
: null,
|
|
478
|
-
response_time_ms: responseTime,
|
|
479
|
-
finish_reason: getRawFinishReason(result.finishReason),
|
|
480
|
-
},
|
|
481
|
-
};
|
|
482
|
-
} catch (error) {
|
|
483
|
-
debugError('[Gemini CLI] Execution error', error);
|
|
484
|
-
|
|
485
|
-
// Map common errors to standard error codes
|
|
486
|
-
if (
|
|
487
|
-
error.message?.includes('authentication') ||
|
|
488
|
-
error.message?.includes('oauth') ||
|
|
489
|
-
error.message?.includes('credentials')
|
|
490
|
-
) {
|
|
491
|
-
throw new GeminiCliProviderError(
|
|
492
|
-
'Gemini CLI authentication failed. Run: gemini (interactive CLI) to authenticate',
|
|
493
|
-
ErrorCodes.INVALID_API_KEY,
|
|
494
|
-
error,
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
if (error.message?.includes('rate limit')) {
|
|
499
|
-
throw new GeminiCliProviderError(
|
|
500
|
-
'Rate limit exceeded',
|
|
501
|
-
ErrorCodes.RATE_LIMIT_EXCEEDED,
|
|
502
|
-
error,
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
if (error.message?.includes('timeout')) {
|
|
507
|
-
throw new GeminiCliProviderError(
|
|
508
|
-
'Request timeout',
|
|
509
|
-
ErrorCodes.TIMEOUT_ERROR,
|
|
510
|
-
error,
|
|
511
|
-
);
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Re-throw as Gemini CLI error
|
|
515
|
-
throw new GeminiCliProviderError(
|
|
516
|
-
error.message || 'Gemini CLI execution failed',
|
|
517
|
-
ErrorCodes.API_ERROR,
|
|
518
|
-
error,
|
|
519
|
-
);
|
|
714
|
+
if (stream) {
|
|
715
|
+
// Run agy first (one-shot), then replay as a passthrough stream.
|
|
716
|
+
const fullText = await executeAgy(messages, options);
|
|
717
|
+
return createStreamingGenerator(fullText, model);
|
|
520
718
|
}
|
|
719
|
+
|
|
720
|
+
const startTime = Date.now();
|
|
721
|
+
const content = await executeAgy(messages, options);
|
|
722
|
+
const responseTime = Date.now() - startTime;
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
content,
|
|
726
|
+
stop_reason: StopReasons.STOP,
|
|
727
|
+
rawResponse: { content },
|
|
728
|
+
metadata: {
|
|
729
|
+
provider: 'gemini-cli',
|
|
730
|
+
model,
|
|
731
|
+
usage: null,
|
|
732
|
+
response_time_ms: responseTime,
|
|
733
|
+
finish_reason: 'stop',
|
|
734
|
+
},
|
|
735
|
+
};
|
|
521
736
|
},
|
|
522
737
|
|
|
523
738
|
/**
|
|
524
|
-
* Validate
|
|
525
|
-
*
|
|
739
|
+
* Validate configuration. agy uses OAuth (no env keys); always true.
|
|
740
|
+
* Availability is determined by isAvailable (binary presence).
|
|
526
741
|
*/
|
|
527
742
|
validateConfig(_config) {
|
|
528
|
-
|
|
529
|
-
return hasOAuthCredentials();
|
|
743
|
+
return true;
|
|
530
744
|
},
|
|
531
745
|
|
|
532
746
|
/**
|
|
533
|
-
* Check if
|
|
747
|
+
* Check if the provider is available (agy binary present).
|
|
534
748
|
*/
|
|
535
|
-
isAvailable(
|
|
536
|
-
return
|
|
749
|
+
isAvailable(_config) {
|
|
750
|
+
return findAgyBinary() !== null;
|
|
537
751
|
},
|
|
538
752
|
|
|
539
753
|
/**
|
|
540
|
-
* Get supported Gemini
|
|
754
|
+
* Get supported Gemini models.
|
|
541
755
|
*/
|
|
542
756
|
getSupportedModels() {
|
|
543
757
|
return SUPPORTED_MODELS;
|
|
544
758
|
},
|
|
545
759
|
|
|
546
760
|
/**
|
|
547
|
-
* Get model configuration for specific model
|
|
761
|
+
* Get model configuration for a specific model (alias-aware, prefix-aware).
|
|
548
762
|
*/
|
|
549
763
|
getModelConfig(modelName) {
|
|
550
|
-
|
|
764
|
+
if (typeof modelName !== 'string') return null;
|
|
765
|
+
|
|
766
|
+
const name = modelName.toLowerCase().trim();
|
|
551
767
|
|
|
552
|
-
//
|
|
553
|
-
if (
|
|
554
|
-
return SUPPORTED_MODELS[
|
|
768
|
+
// Full agy display-name passthrough → matching base config.
|
|
769
|
+
if (/gemini 3\.5 flash/i.test(modelName)) {
|
|
770
|
+
return SUPPORTED_MODELS['gemini:flash'];
|
|
771
|
+
}
|
|
772
|
+
if (/gemini 3\.1 pro/i.test(modelName)) {
|
|
773
|
+
return SUPPORTED_MODELS.gemini;
|
|
555
774
|
}
|
|
556
775
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
776
|
+
if (name === 'pro') {
|
|
777
|
+
return SUPPORTED_MODELS['gemini:pro'];
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Exact key match (gemini, gemini:pro, gemini:flash)
|
|
781
|
+
if (SUPPORTED_MODELS[name]) {
|
|
782
|
+
return SUPPORTED_MODELS[name];
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Alias match
|
|
786
|
+
for (const config of Object.values(SUPPORTED_MODELS)) {
|
|
787
|
+
if (
|
|
788
|
+
config.aliases &&
|
|
789
|
+
config.aliases.some((alias) => alias.toLowerCase() === name)
|
|
790
|
+
) {
|
|
791
|
+
return config;
|
|
565
792
|
}
|
|
566
793
|
}
|
|
567
794
|
|