@llm4ts/runner 2.42.0 → 2.44.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.
@@ -0,0 +1,340 @@
1
+ import { existsSync, readdirSync } from "node:fs"
2
+ import type { IncomingMessage, ServerResponse } from "node:http"
3
+ import { join } from "node:path"
4
+ import * as Effect from "effect/Effect"
5
+ import type * as Scope from "effect/Scope"
6
+ import * as Stream from "effect/Stream"
7
+ import { InvalidRequestError, ProviderError, type LlmError } from "@llm4ts/core/Errors"
8
+ import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
9
+ import type { TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
10
+ import type { JsonValue } from "@llm4ts/core/providers/CliSupport"
11
+ import { parseGeminiCliStreamEvent } from "@llm4ts/core/providers/GeminiCliProvider"
12
+ import {
13
+ chatRequestFrom,
14
+ completionChunks,
15
+ completionJson,
16
+ correctionTranscript,
17
+ denyAllToolsPolicyToml,
18
+ modelModeSettingsJson,
19
+ openAiUsage,
20
+ parseModelReply,
21
+ systemFileFor,
22
+ transcriptFor,
23
+ type GeminiTokens,
24
+ type ModelReply,
25
+ type OpenAiUsage
26
+ } from "@llm4ts/core/providers/GeminiModelBridge"
27
+ import {
28
+ acquireServer,
29
+ errorStatus,
30
+ localRequestProblem,
31
+ maxRequestBytes,
32
+ parseJsonBody,
33
+ readBody,
34
+ requestPath,
35
+ writeJson
36
+ } from "./NodeGeminiAcpBridge.ts"
37
+
38
+ /**
39
+ * The bridge's model route (ADR 0037): `/v1/chat/completions` in OpenAI's
40
+ * shape, for pi's `"api": "openai-completions"` custom provider. Each
41
+ * request is one `gemini` call with Gemini CLI's own agent switched off —
42
+ * pi's system prompt in place of Gemini's (`GEMINI_SYSTEM_MD`), every tool
43
+ * denied by an admin policy, sub-agents off in the system settings — so
44
+ * Gemini answers as a plain model and pi runs its own tools. Only Gemini
45
+ * CLI talks to Google, under its own login; this module never reads or
46
+ * forwards a credential.
47
+ */
48
+
49
+ export interface GeminiModelCallConfig {
50
+ readonly executor: ProcessExecutorShape
51
+ readonly temporaryFiles: TemporaryFilesShape
52
+ /** Where `gemini` runs: an empty directory, so no project GEMINI.md is read. */
53
+ readonly cwd: string
54
+ /** The Gemini model, overriding the one pi's request names (`LLM4TS_GEMINI_MODEL`). */
55
+ readonly model?: string
56
+ readonly envVars?: Readonly<Record<string, string>>
57
+ }
58
+
59
+ export interface GeminiModelBridgeConfig extends GeminiModelCallConfig {
60
+ readonly port: number
61
+ /**
62
+ * `.toml` files in Gemini CLI's standard system policy directory: with
63
+ * any, the CLI ignores `--admin-policy`, so the tools would stay in view.
64
+ * Default: what is on this machine (`systemAdminPolicies`).
65
+ */
66
+ readonly systemPolicies?: ReadonlyArray<string>
67
+ /** A token every request must carry as a bearer (pi's `apiKey`); none by default. */
68
+ readonly token?: string
69
+ }
70
+
71
+ export interface RunningGeminiModelBridge {
72
+ readonly port: number
73
+ readonly baseUrl: string
74
+ }
75
+
76
+ /** The standard directories Gemini CLI reads system (admin) policies from. */
77
+ export const systemPolicyDirectories: ReadonlyArray<string> = [
78
+ "/etc/gemini-cli/policies",
79
+ "/Library/Application Support/GeminiCli/policies",
80
+ "C:\\ProgramData\\gemini-cli\\policies"
81
+ ]
82
+
83
+ /** The admin policy files on this machine that would make Gemini CLI ignore `--admin-policy`. */
84
+ export const systemAdminPolicies = (): ReadonlyArray<string> =>
85
+ systemPolicyDirectories.flatMap((directory) => {
86
+ try {
87
+ return existsSync(directory)
88
+ ? readdirSync(directory)
89
+ .filter((file) => file.endsWith(".toml"))
90
+ .map((file) => join(directory, file))
91
+ : []
92
+ } catch {
93
+ return []
94
+ }
95
+ })
96
+
97
+ export const systemPolicyProblem = (policies: ReadonlyArray<string>): string =>
98
+ [
99
+ `Gemini CLI has system admin policies (${policies.join(", ")}), so it ignores the`,
100
+ "--admin-policy the model bridge relies on to take every tool out of the model's view.",
101
+ "The bridge will not run Gemini with its tools still in reach (ADR 0037); use",
102
+ "LLM4TS_GEMINI_BRIDGE_MODE=acp, or ask the administrator to allow this policy."
103
+ ].join(" ")
104
+
105
+ interface GeminiTurn {
106
+ readonly text: string
107
+ readonly tokens: GeminiTokens | undefined
108
+ }
109
+
110
+ /** One `gemini` call over `transcript`, its system file and lock-down written for the call. */
111
+ const geminiTurn = (
112
+ config: GeminiModelCallConfig,
113
+ model: string,
114
+ system: string,
115
+ transcript: string
116
+ ): Effect.Effect<GeminiTurn, LlmError> =>
117
+ Effect.scoped(
118
+ Effect.gen(function* () {
119
+ const systemFile = yield* config.temporaryFiles.write("gemini-system-", ".md", system)
120
+ const settings = yield* config.temporaryFiles.write(
121
+ "gemini-settings-",
122
+ ".json",
123
+ modelModeSettingsJson
124
+ )
125
+ const policy = yield* config.temporaryFiles.write(
126
+ "gemini-deny-all-",
127
+ ".toml",
128
+ denyAllToolsPolicyToml
129
+ )
130
+ const events = yield* Stream.runCollect(
131
+ config.executor
132
+ .runStreamingWithStdin(
133
+ [
134
+ "gemini",
135
+ // Bound to its flag, so it can never read as a flag of its own.
136
+ `--model=${model}`,
137
+ "--output-format",
138
+ "stream-json",
139
+ "-e",
140
+ "none",
141
+ "--admin-policy",
142
+ policy
143
+ ],
144
+ config.cwd,
145
+ {
146
+ ...config.envVars,
147
+ GEMINI_CLI_TRUST_WORKSPACE: "true",
148
+ GEMINI_SYSTEM_MD: systemFile,
149
+ GEMINI_CLI_SYSTEM_SETTINGS_PATH: settings
150
+ },
151
+ transcript
152
+ )
153
+ .pipe(Stream.map(parseGeminiCliStreamEvent))
154
+ )
155
+ let text = ""
156
+ let tokens: GeminiTokens | undefined
157
+ for (const event of events) {
158
+ switch (event._tag) {
159
+ case "Message":
160
+ if (event.role === "assistant" && event.content !== undefined) {
161
+ text += event.content
162
+ }
163
+ break
164
+ case "Error":
165
+ return yield* ProviderError.make({
166
+ message: `gemini: ${event.message ?? "error"}`
167
+ })
168
+ case "Result":
169
+ if (event.status === "error") {
170
+ return yield* ProviderError.make({
171
+ message: `gemini: ${event.errorMessage ?? "the call failed"}`
172
+ })
173
+ }
174
+ tokens =
175
+ event.stats === undefined
176
+ ? undefined
177
+ : {
178
+ ...(event.stats.inputTokens === undefined
179
+ ? {}
180
+ : { inputTokens: event.stats.inputTokens }),
181
+ ...(event.stats.outputTokens === undefined
182
+ ? {}
183
+ : { outputTokens: event.stats.outputTokens }),
184
+ ...(event.stats.cached === undefined ? {} : { cached: event.stats.cached })
185
+ }
186
+ break
187
+ default:
188
+ break
189
+ }
190
+ }
191
+ return { text, tokens }
192
+ })
193
+ )
194
+
195
+ const addTokens = (
196
+ left: GeminiTokens | undefined,
197
+ right: GeminiTokens | undefined
198
+ ): GeminiTokens => ({
199
+ inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
200
+ outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0),
201
+ cached: (left?.cached ?? 0) + (right?.cached ?? 0)
202
+ })
203
+
204
+ /** What a Gemini model id looks like: `gemini-3.8-flash`, `gemini-2.5-pro-001`. */
205
+ const modelName = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u
206
+
207
+ export interface ChatCompletionOutcome {
208
+ readonly model: string
209
+ readonly reply: ModelReply
210
+ readonly usage: OpenAiUsage
211
+ }
212
+
213
+ /**
214
+ * One chat completion: a `gemini` call, and a second one only when the
215
+ * first reply holds a tool call that cannot be used — the model then sees
216
+ * its reply and why. A second bad reply goes back to pi as text.
217
+ */
218
+ export const handleChatCompletion = Effect.fn("@llm4ts/runner/NodeGeminiModelBridge.handle")(
219
+ function* (
220
+ config: GeminiModelCallConfig,
221
+ body: JsonValue
222
+ ): Effect.fn.Return<ChatCompletionOutcome, LlmError | InvalidRequestError> {
223
+ const request = chatRequestFrom(body)
224
+ if (request._tag === "Invalid") {
225
+ return yield* InvalidRequestError.make({ message: request.reason })
226
+ }
227
+ const model = config.model ?? request.model
228
+ // The model reaches Gemini CLI's arguments: a name only, never a flag.
229
+ if (!modelName.test(model)) {
230
+ return yield* InvalidRequestError.make({
231
+ message: `"${model}" is not a model name (letters, digits, . _ : -, starting with a letter or digit)`
232
+ })
233
+ }
234
+ const system = systemFileFor(request)
235
+ const transcript = transcriptFor(request.messages)
236
+ const toolNames = request.tools.map((tool) => tool.name)
237
+ const first = yield* geminiTurn(config, model, system, transcript)
238
+ const reply = parseModelReply(first.text, toolNames)
239
+ if (reply._tag !== "Malformed") {
240
+ return { model, reply, usage: openAiUsage(first.tokens) }
241
+ }
242
+ const second = yield* geminiTurn(
243
+ config,
244
+ model,
245
+ system,
246
+ correctionTranscript(transcript, first.text, reply.error)
247
+ )
248
+ return {
249
+ model,
250
+ reply: parseModelReply(second.text, toolNames),
251
+ usage: openAiUsage(addTokens(first.tokens, second.tokens))
252
+ }
253
+ }
254
+ )
255
+
256
+ let completions = 0
257
+ const nextIds = (): { readonly id: string; readonly callId: string } => {
258
+ completions += 1
259
+ return { id: `chatcmpl-llm4ts-${completions}`, callId: `call_llm4ts_${completions}` }
260
+ }
261
+
262
+ const writeChunks = (response: ServerResponse, chunks: ReadonlyArray<JsonValue>): void => {
263
+ response.writeHead(200, {
264
+ "Content-Type": "text/event-stream",
265
+ "Cache-Control": "no-cache",
266
+ Connection: "keep-alive"
267
+ })
268
+ for (const chunk of chunks) {
269
+ response.write(`data: ${JSON.stringify(chunk)}\n\n`)
270
+ }
271
+ response.write("data: [DONE]\n\n")
272
+ response.end()
273
+ }
274
+
275
+ const streamWanted = (body: JsonValue): boolean => {
276
+ const request = chatRequestFrom(body)
277
+ return request._tag === "Request" && request.stream
278
+ }
279
+
280
+ export const runGeminiModelBridge = Effect.fn("@llm4ts/runner/NodeGeminiModelBridge.run")(
281
+ function* (
282
+ config: GeminiModelBridgeConfig
283
+ ): Effect.fn.Return<RunningGeminiModelBridge, LlmError, Scope.Scope> {
284
+ const policies = config.systemPolicies ?? systemAdminPolicies()
285
+ if (policies.length > 0) {
286
+ return yield* ProviderError.make({ message: systemPolicyProblem(policies) })
287
+ }
288
+
289
+ const listener = (request: IncomingMessage, response: ServerResponse): void => {
290
+ if (request.method !== "POST" || requestPath(request.url) !== "/v1/chat/completions") {
291
+ writeJson(response, 404, { error: { message: "not found" } })
292
+ return
293
+ }
294
+ const problem = localRequestProblem(request.headers, config.token)
295
+ if (problem !== undefined) {
296
+ writeJson(response, 403, { error: { message: problem } })
297
+ return
298
+ }
299
+ const handled = Effect.gen(function* () {
300
+ const body = yield* parseJsonBody(yield* readBody(request, maxRequestBytes))
301
+ const outcome = yield* handleChatCompletion(config, body)
302
+ const { id, callId } = nextIds()
303
+ const created = Math.floor(Date.now() / 1000)
304
+ if (streamWanted(body)) {
305
+ return yield* Effect.sync(() =>
306
+ writeChunks(
307
+ response,
308
+ completionChunks(id, outcome.model, outcome.reply, outcome.usage, created, callId)
309
+ )
310
+ )
311
+ }
312
+ return yield* Effect.sync(() =>
313
+ writeJson(
314
+ response,
315
+ 200,
316
+ completionJson(id, outcome.model, outcome.reply, outcome.usage, created, callId)
317
+ )
318
+ )
319
+ }).pipe(
320
+ Effect.catch((error) =>
321
+ Effect.sync(() => {
322
+ if (!response.headersSent) {
323
+ writeJson(response, errorStatus(error), { error: { message: error.message } })
324
+ }
325
+ })
326
+ )
327
+ )
328
+ Effect.runPromise(handled).catch((defect: unknown) => {
329
+ if (!response.headersSent) {
330
+ writeJson(response, 500, { error: { message: String(defect) } })
331
+ }
332
+ })
333
+ }
334
+
335
+ const server = yield* acquireServer(config.port, listener)
336
+ const address = server.address()
337
+ const port = address !== null && typeof address !== "string" ? address.port : config.port
338
+ return { port, baseUrl: `http://127.0.0.1:${port}` }
339
+ }
340
+ )
package/src/PiModels.ts CHANGED
@@ -34,6 +34,26 @@ export const geminiBridgePort = (
34
34
  ? environment.LLM4TS_GEMINI_BRIDGE_PORT.trim()
35
35
  : defaultGeminiBridgePort
36
36
 
37
+ /**
38
+ * Which route the bridge serves (ADR 0037): `acp` (ADR 0016's Anthropic
39
+ * Messages over a Gemini ACP session, the default) or `model` (OpenAI chat
40
+ * completions over one locked-down `gemini` call per request).
41
+ */
42
+ export type GeminiBridgeMode =
43
+ | { readonly _tag: "Mode"; readonly mode: "acp" | "model" }
44
+ | { readonly _tag: "Unknown"; readonly value: string }
45
+
46
+ export const geminiBridgeMode = (
47
+ environment: Readonly<Record<string, string | undefined>>
48
+ ): GeminiBridgeMode => {
49
+ const value = (environment.LLM4TS_GEMINI_BRIDGE_MODE ?? "").trim().toLowerCase()
50
+ return value === "" || value === "acp"
51
+ ? { _tag: "Mode", mode: "acp" }
52
+ : value === "model"
53
+ ? { _tag: "Mode", mode: "model" }
54
+ : { _tag: "Unknown", value }
55
+ }
56
+
37
57
  /**
38
58
  * One value pi accepts for `--model`: `<provider>/<model>`, where the
39
59
  * provider is a key under `providers` in `~/.pi/agent/models.json` and the