@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.
package/src/Doctor.ts CHANGED
@@ -9,8 +9,10 @@ import { nodeProcessExecutor } from "./NodeProcessExecutor.ts"
9
9
  import {
10
10
  defaultReadPiModelsJson as readPiModels,
11
11
  geminiBridgePort as bridgePort,
12
- piModelsConfig as parsePiModels
12
+ piModelsConfig as parsePiModels,
13
+ geminiBridgeMode
13
14
  } from "./PiModels.ts"
15
+ import { systemAdminPolicies, systemPolicyProblem } from "./NodeGeminiModelBridge.ts"
14
16
  import { otelConfig, otelDoctorLine, probeOtelEndpoint, type OtelConfig } from "./Otel.ts"
15
17
 
16
18
  const credentialKeys = [
@@ -210,6 +212,50 @@ export const geminiBridgePrerequisites = (
210
212
  }
211
213
  }
212
214
 
215
+ /** Gemini CLI's own words on third-party access (0.62.0, `docs/resources/tos-privacy.md`). */
216
+ export const geminiTermsClause =
217
+ "Directly accessing the services powering Gemini CLI (for example, the Gemini Code Assist " +
218
+ "service) using third-party software, tools, or services (for example, using OpenClaw with " +
219
+ "Gemini CLI OAuth) is a violation of applicable terms and policies. Such actions may be " +
220
+ "grounds for suspension or termination of your account."
221
+
222
+ /**
223
+ * The model mode of the bridge (ADR 0037): whether a system admin policy
224
+ * would keep Gemini's tools in view, how pi's provider must be shaped, and
225
+ * the terms clause the customer confirms against. `undefined` unless the
226
+ * bridge runs in model mode.
227
+ */
228
+ export const geminiModelModePrerequisites = (
229
+ environment: Readonly<Record<string, string | undefined>>,
230
+ systemPolicies: ReadonlyArray<string>
231
+ ): PrerequisiteReport | undefined => {
232
+ const mode = geminiBridgeMode(environment)
233
+ if (!truthy(environment.LLM4TS_GEMINI_BRIDGE) || mode._tag !== "Mode" || mode.mode !== "model") {
234
+ return undefined
235
+ }
236
+ const detail = [
237
+ 'pi\'s bridge provider in ~/.pi/agent/models.json needs "api": "openai-completions"',
238
+ "(the route is /v1/chat/completions); only Gemini CLI talks to Google, under its own",
239
+ "login, and llm4ts never reads or forwards its credentials. Gemini CLI's terms say:",
240
+ ` "${geminiTermsClause}"`,
241
+ "Using Gemini CLI as a model for pi stays close to that clause: confirm with Google",
242
+ "before relying on it."
243
+ ]
244
+ return systemPolicies.length > 0
245
+ ? {
246
+ satisfied: false,
247
+ summary: `system admin policies make Gemini CLI ignore the bridge's: ${systemPolicies.join(", ")}`,
248
+ hint: systemPolicyProblem(systemPolicies),
249
+ detail
250
+ }
251
+ : {
252
+ satisfied: true,
253
+ summary:
254
+ "model mode: Gemini CLI answers as a plain model, its prompt replaced and every tool denied",
255
+ detail
256
+ }
257
+ }
258
+
213
259
  export const makeDoctorProgram = (
214
260
  registry: ConnectorRegistryShape = nodeFlowRunnerDependencies().registry,
215
261
  environment: Readonly<Record<string, string | undefined>> = process.env,
@@ -223,7 +269,9 @@ export const makeDoctorProgram = (
223
269
  environment
224
270
  ),
225
271
  /** Whether the OTLP endpoint answers (ADR 0026); the network probe by default. */
226
- probeOtel: (config: OtelConfig) => Effect.Effect<boolean | undefined> = probeOtelEndpoint
272
+ probeOtel: (config: OtelConfig) => Effect.Effect<boolean | undefined> = probeOtelEndpoint,
273
+ /** Gemini CLI's system admin policy files (ADR 0037); this machine's by default. */
274
+ systemPolicies: () => ReadonlyArray<string> = systemAdminPolicies
227
275
  ): Effect.Effect<string> => {
228
276
  const otel = otelConfig(environment)
229
277
  return Effect.map(
@@ -266,6 +314,7 @@ export const makeDoctorProgram = (
266
314
  id.value === ConnectorIds.GeminiCli.value || id.value === ConnectorIds.GeminiApi.value
267
315
  )
268
316
  const bridge = geminiBridgePrerequisites(environment, readPiModelsJson())
317
+ const modelMode = geminiModelModePrerequisites(environment, systemPolicies())
269
318
  if (geminiRelevant || bridge !== undefined) {
270
319
  lines.push("")
271
320
  lines.push("prerequisites:")
@@ -284,6 +333,9 @@ export const makeDoctorProgram = (
284
333
  if (bridge !== undefined) {
285
334
  pushReport("pi-gemini-bridge", bridge)
286
335
  }
336
+ if (modelMode !== undefined) {
337
+ pushReport("pi-gemini-bridge model mode", modelMode)
338
+ }
287
339
  }
288
340
 
289
341
  lines.push("")
package/src/FlowRunner.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { mkdtempSync, rmSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
1
3
  import { join } from "node:path"
2
4
  import * as Cause from "effect/Cause"
3
5
  import * as Clock from "effect/Clock"
@@ -25,11 +27,13 @@ import { ConnectorIds } from "@llm4ts/core/Models"
25
27
  import { ProviderError } from "@llm4ts/core/Errors"
26
28
  import { makeCostTracker, type CostTracker } from "@llm4ts/flow/CostTracker"
27
29
  import { runGeminiAcpBridge } from "./NodeGeminiAcpBridge.ts"
30
+ import { runGeminiModelBridge } from "./NodeGeminiModelBridge.ts"
28
31
  import {
29
32
  bridgeModelProblem,
30
33
  defaultReadPiModelsJson,
31
34
  geminiBridgePort,
32
- resolveBridgeModel
35
+ resolveBridgeModel,
36
+ geminiBridgeMode
33
37
  } from "./PiModels.ts"
34
38
  import {
35
39
  appendCostRecord,
@@ -128,6 +132,8 @@ export interface FlowRunnerDependencies {
128
132
  readonly files: PlainFileStoreShape
129
133
  /** pi's own model config (ADR 0016), injectable so a test never reads $HOME. */
130
134
  readonly readPiModelsJson?: () => string | undefined
135
+ /** Files the Gemini model bridge writes per call (ADR 0037); temporary files on disk by default. */
136
+ readonly temporaryFiles?: TemporaryFilesShape
131
137
  /** For the hosted TypeSafe judgment backend; defaults to the Node client. */
132
138
  readonly http?: HttpClientShape
133
139
  /** Where `--transcript` writes, given its directory; files on disk by default. */
@@ -280,14 +286,42 @@ const geminiBridgeSeats = Effect.fn("@llm4ts/runner/FlowRunner.geminiBridgeSeats
280
286
  }
281
287
  const model = resolution.model
282
288
 
283
- yield* runGeminiAcpBridge({
284
- port: Number(port),
285
- cwd: options.workDir,
286
- executor: dependencies.process,
287
- ...(environment.LLM4TS_GEMINI_MODEL === undefined
288
- ? {}
289
- : { model: environment.LLM4TS_GEMINI_MODEL })
290
- }).pipe(Effect.mapError(FlowLlmError.from))
289
+ const mode = geminiBridgeMode(environment)
290
+ if (mode._tag === "Unknown") {
291
+ return yield* FlowLlmError.from(
292
+ ProviderError.make({
293
+ message: `LLM4TS_GEMINI_BRIDGE_MODE is "${mode.value}"; use acp (the default) or model`
294
+ })
295
+ )
296
+ }
297
+ const geminiModel =
298
+ environment.LLM4TS_GEMINI_MODEL === undefined ? {} : { model: environment.LLM4TS_GEMINI_MODEL }
299
+ if (mode.mode === "model") {
300
+ // ADR 0037: one locked-down `gemini` call per request, in an empty
301
+ // directory so no project GEMINI.md reaches the model.
302
+ const scratch = yield* Effect.acquireRelease(
303
+ Effect.sync(() => mkdtempSync(join(tmpdir(), "llm4ts-gemini-model-"))),
304
+ (directory) => Effect.sync(() => rmSync(directory, { recursive: true, force: true }))
305
+ )
306
+ yield* runGeminiModelBridge({
307
+ port: Number(port),
308
+ cwd: scratch,
309
+ executor: dependencies.process,
310
+ temporaryFiles: dependencies.temporaryFiles ?? nodeTemporaryFiles,
311
+ ...geminiModel,
312
+ ...(environment.LLM4TS_GEMINI_BRIDGE_TOKEN === undefined ||
313
+ environment.LLM4TS_GEMINI_BRIDGE_TOKEN.length === 0
314
+ ? {}
315
+ : { token: environment.LLM4TS_GEMINI_BRIDGE_TOKEN })
316
+ }).pipe(Effect.mapError(FlowLlmError.from))
317
+ } else {
318
+ yield* runGeminiAcpBridge({
319
+ port: Number(port),
320
+ cwd: options.workDir,
321
+ executor: dependencies.process,
322
+ ...geminiModel
323
+ }).pipe(Effect.mapError(FlowLlmError.from))
324
+ }
291
325
 
292
326
  return (config: ConnectorConfig): ConnectorConfig =>
293
327
  isPiSeat(config) && config.model === undefined
@@ -0,0 +1,128 @@
1
+ import { mkdtempSync, rmSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import * as Effect from "effect/Effect"
5
+ import type { LlmError } from "@llm4ts/core/Errors"
6
+ import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
7
+ import type { TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
8
+ import { runGeminiAcpBridge } from "./NodeGeminiAcpBridge.ts"
9
+ import { runGeminiModelBridge, systemAdminPolicies } from "./NodeGeminiModelBridge.ts"
10
+
11
+ /**
12
+ * `llm4ts gemini-bridge`: the Gemini bridge on its own, outside any flow
13
+ * run, so `pi` (or any OpenAI-compatible client) can use the Gemini CLI
14
+ * subscription directly. ADR 0016 deferred this entry point; ADR 0037's
15
+ * model mode makes it an OpenAI-compatible server like LM Studio's. The
16
+ * bridge's code is the flow runner's; only the caller changes.
17
+ */
18
+
19
+ export interface GeminiBridgeOptions {
20
+ readonly port: number
21
+ /** `model` (OpenAI chat completions, ADR 0037) or `acp` (Anthropic Messages, ADR 0016). */
22
+ readonly mode: "model" | "acp"
23
+ /** The Gemini model every request runs on, whatever the client names. */
24
+ readonly model?: string
25
+ /** A token requests must carry (model mode), printed as pi's `apiKey`. */
26
+ readonly token?: string
27
+ }
28
+
29
+ export interface GeminiBridgeDependencies {
30
+ readonly executor: ProcessExecutorShape
31
+ readonly temporaryFiles: TemporaryFilesShape
32
+ /** Gemini CLI's system admin policy files; this machine's by default. */
33
+ readonly systemPolicies?: ReadonlyArray<string>
34
+ readonly log: (line: string) => Effect.Effect<void>
35
+ /** Runs while the bridge serves; the bridge stops when it ends (Ctrl-C in the CLI). */
36
+ readonly until: (baseUrl: string) => Effect.Effect<void>
37
+ }
38
+
39
+ /** The model id the printed pi config names when `--model` names none. */
40
+ export const defaultBridgeModelId = "gemini-3.8-flash"
41
+
42
+ /** pi's provider entry for `~/.pi/agent/models.json`, for this mode and port. */
43
+ export const piProviderSnippet = (
44
+ mode: "model" | "acp",
45
+ port: number,
46
+ model: string,
47
+ token?: string
48
+ ): string =>
49
+ JSON.stringify(
50
+ {
51
+ providers: {
52
+ "gemini-bridge": {
53
+ baseUrl: mode === "model" ? `http://127.0.0.1:${port}/v1` : `http://127.0.0.1:${port}`,
54
+ api: mode === "model" ? "openai-completions" : "anthropic-messages",
55
+ apiKey: token ?? "bridge-unused",
56
+ models: [{ id: model }]
57
+ }
58
+ }
59
+ },
60
+ null,
61
+ 2
62
+ )
63
+
64
+ export const makeGeminiBridgeProgram = (
65
+ options: GeminiBridgeOptions,
66
+ dependencies: GeminiBridgeDependencies
67
+ ): Effect.Effect<void, LlmError> =>
68
+ Effect.scoped(
69
+ Effect.gen(function* () {
70
+ const model = options.model === undefined ? {} : { model: options.model }
71
+ const running =
72
+ options.mode === "model"
73
+ ? yield* Effect.flatMap(
74
+ // An empty directory, so no project GEMINI.md reaches the model.
75
+ Effect.acquireRelease(
76
+ Effect.sync(() => mkdtempSync(join(tmpdir(), "llm4ts-gemini-model-"))),
77
+ (directory) =>
78
+ Effect.sync(() => rmSync(directory, { recursive: true, force: true }))
79
+ ),
80
+ (scratch) =>
81
+ runGeminiModelBridge({
82
+ port: options.port,
83
+ cwd: scratch,
84
+ executor: dependencies.executor,
85
+ temporaryFiles: dependencies.temporaryFiles,
86
+ systemPolicies: dependencies.systemPolicies ?? systemAdminPolicies(),
87
+ ...model,
88
+ ...(options.token === undefined ? {} : { token: options.token })
89
+ })
90
+ )
91
+ : yield* runGeminiAcpBridge({
92
+ port: options.port,
93
+ cwd: process.cwd(),
94
+ executor: dependencies.executor,
95
+ ...model
96
+ })
97
+ const id = options.model ?? defaultBridgeModelId
98
+ const url = options.mode === "model" ? `${running.baseUrl}/v1` : running.baseUrl
99
+ yield* Effect.forEach(
100
+ [
101
+ `gemini bridge (${options.mode === "model" ? "model mode: OpenAI chat completions" : "ACP mode: Anthropic Messages"}) listening on ${url}`,
102
+ "",
103
+ "pi provider for ~/.pi/agent/models.json:",
104
+ piProviderSnippet(
105
+ options.mode,
106
+ running.port,
107
+ id,
108
+ options.mode === "model" ? options.token : undefined
109
+ ),
110
+ "",
111
+ `then: pi --model gemini-bridge/${id}`,
112
+ ...(options.mode === "model"
113
+ ? [
114
+ "",
115
+ "Only Gemini CLI talks to Google, under its own login. Its terms forbid direct",
116
+ "third-party access to its services; confirm with Google before relying on this",
117
+ "(`LLM4TS_GEMINI_BRIDGE=1 LLM4TS_GEMINI_BRIDGE_MODE=model llm4ts doctor` quotes them)."
118
+ ]
119
+ : []),
120
+ "",
121
+ "Ctrl-C stops it."
122
+ ],
123
+ dependencies.log,
124
+ { discard: true }
125
+ )
126
+ yield* dependencies.until(running.baseUrl)
127
+ })
128
+ )
@@ -1,4 +1,11 @@
1
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
1
+ import { timingSafeEqual } from "node:crypto"
2
+ import {
3
+ createServer,
4
+ type IncomingHttpHeaders,
5
+ type IncomingMessage,
6
+ type Server,
7
+ type ServerResponse
8
+ } from "node:http"
2
9
  import * as Deferred from "effect/Deferred"
3
10
  import * as Effect from "effect/Effect"
4
11
  import * as Option from "effect/Option"
@@ -400,10 +407,59 @@ export const handleMessagesRequest = Effect.fn(
400
407
  return anthropicResponse(requestModel(body), outcome)
401
408
  })
402
409
 
403
- const readBody = (request: IncomingMessage): Effect.Effect<string, ProviderError> =>
410
+ /**
411
+ * Why a request may not reach the bridge, or nothing when it may: both
412
+ * routes answer local tools only. pi and gemini post JSON from Node, with
413
+ * no Origin; a browser page posting to this loopback port (a cross-site
414
+ * form needs no preflight as text/plain) carries one, and a DNS name
415
+ * rebound to 127.0.0.1 still sends its own Host. With a token, the request
416
+ * must carry it as a bearer (pi sends its `apiKey` that way).
417
+ */
418
+ export const localRequestProblem = (
419
+ headers: IncomingHttpHeaders,
420
+ token?: string
421
+ ): string | undefined => {
422
+ const contentType = headers["content-type"] ?? ""
423
+ const host = (headers.host ?? "").replace(/:\d+$/u, "")
424
+ if (headers.origin !== undefined) {
425
+ return "the bridge answers local tools, not browser pages"
426
+ }
427
+ if (!contentType.includes("application/json")) {
428
+ return "the bridge answers JSON requests only"
429
+ }
430
+ if (!["127.0.0.1", "localhost", "[::1]"].includes(host)) {
431
+ return "the bridge answers requests to 127.0.0.1 only"
432
+ }
433
+ if (token !== undefined) {
434
+ const given = Buffer.from(headers.authorization ?? "")
435
+ const wanted = Buffer.from(`Bearer ${token}`)
436
+ if (given.length !== wanted.length || !timingSafeEqual(given, wanted)) {
437
+ return "the bridge's token is missing or wrong"
438
+ }
439
+ }
440
+ return undefined
441
+ }
442
+
443
+ /** The largest request either route reads: a long conversation, not an unbounded upload. */
444
+ export const maxRequestBytes = 32 * 1024 * 1024
445
+
446
+ /** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
447
+ export const readBody = (
448
+ request: IncomingMessage,
449
+ maxBytes: number = Number.POSITIVE_INFINITY
450
+ ): Effect.Effect<string, ProviderError> =>
404
451
  Effect.callback((resume) => {
405
452
  const chunks: Array<Buffer> = []
406
- request.on("data", (chunk: Buffer) => chunks.push(chunk))
453
+ let size = 0
454
+ request.on("data", (chunk: Buffer) => {
455
+ size += chunk.length
456
+ if (size > maxBytes) {
457
+ request.destroy()
458
+ resume(Effect.fail(ProviderError.make({ message: `request body over ${maxBytes} bytes` })))
459
+ return
460
+ }
461
+ chunks.push(chunk)
462
+ })
407
463
  request.on("end", () => resume(Effect.succeed(Buffer.concat(chunks).toString("utf8"))))
408
464
  request.on("error", (error) =>
409
465
  resume(ProviderError.make({ message: "failed to read request body", cause: error }))
@@ -412,7 +468,8 @@ const readBody = (request: IncomingMessage): Effect.Effect<string, ProviderError
412
468
 
413
469
  const parseJson = (raw: string): JsonValue => JSON.parse(raw)
414
470
 
415
- const parseJsonBody = (raw: string): Effect.Effect<JsonValue, InvalidRequestError> =>
471
+ /** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
472
+ export const parseJsonBody = (raw: string): Effect.Effect<JsonValue, InvalidRequestError> =>
416
473
  Effect.try({
417
474
  try: () => parseJson(raw),
418
475
  catch: () => InvalidRequestError.make({ message: "request body is not valid JSON" })
@@ -459,13 +516,15 @@ const respondStreaming = (
459
516
  })
460
517
  )
461
518
 
462
- const writeJson = (response: ServerResponse, status: number, body: JsonValue): void => {
519
+ /** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
520
+ export const writeJson = (response: ServerResponse, status: number, body: JsonValue): void => {
463
521
  const text = JSON.stringify(body)
464
522
  response.writeHead(status, { "Content-Type": "application/json" })
465
523
  response.end(text)
466
524
  }
467
525
 
468
- const errorStatus = (error: LlmError | InvalidRequestError): number =>
526
+ /** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
527
+ export const errorStatus = (error: LlmError | InvalidRequestError): number =>
469
528
  error._tag === "InvalidRequestError" ? 400 : error._tag === "AuthenticationError" ? 401 : 502
470
529
 
471
530
  export interface GeminiAcpBridgeConfig {
@@ -489,7 +548,8 @@ const bindError = (port: number, cause: unknown): ProviderError =>
489
548
  cause
490
549
  })
491
550
 
492
- const acquireServer = (
551
+ /** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
552
+ export const acquireServer = (
493
553
  port: number,
494
554
  listener: (request: IncomingMessage, response: ServerResponse) => void
495
555
  ): Effect.Effect<Server, ProviderError, Scope.Scope> =>
@@ -535,9 +595,14 @@ export const runGeminiAcpBridge = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.
535
595
  writeJson(response, 404, { error: { message: "not found" } })
536
596
  return
537
597
  }
598
+ const problem = localRequestProblem(request.headers)
599
+ if (problem !== undefined) {
600
+ writeJson(response, 403, { error: { message: problem } })
601
+ return
602
+ }
538
603
 
539
604
  const handled = Effect.gen(function* () {
540
- const raw = yield* readBody(request)
605
+ const raw = yield* readBody(request, maxRequestBytes)
541
606
  const body = yield* parseJsonBody(raw)
542
607
  if (path === "/mcp") {
543
608
  const tools = yield* Ref.get(state.tools)