@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/dist/Doctor.d.ts +12 -1
- package/dist/Doctor.d.ts.map +1 -1
- package/dist/Doctor.js +46 -2
- package/dist/Doctor.js.map +1 -1
- package/dist/FlowRunner.d.ts +2 -0
- package/dist/FlowRunner.d.ts.map +1 -1
- package/dist/FlowRunner.js +35 -9
- package/dist/FlowRunner.js.map +1 -1
- package/dist/GeminiBridgeServe.d.ts +35 -0
- package/dist/GeminiBridgeServe.d.ts.map +1 -0
- package/dist/GeminiBridgeServe.js +62 -0
- package/dist/GeminiBridgeServe.js.map +1 -0
- package/dist/NodeGeminiAcpBridge.d.ts +22 -0
- package/dist/NodeGeminiAcpBridge.d.ts.map +1 -1
- package/dist/NodeGeminiAcpBridge.js +58 -7
- package/dist/NodeGeminiAcpBridge.js.map +1 -1
- package/dist/NodeGeminiModelBridge.d.ts +58 -0
- package/dist/NodeGeminiModelBridge.d.ts.map +1 -0
- package/dist/NodeGeminiModelBridge.js +195 -0
- package/dist/NodeGeminiModelBridge.js.map +1 -0
- package/dist/PiModels.d.ts +13 -0
- package/dist/PiModels.d.ts.map +1 -1
- package/dist/PiModels.js +8 -0
- package/dist/PiModels.js.map +1 -1
- package/package.json +5 -3
- package/src/Doctor.ts +54 -2
- package/src/FlowRunner.ts +43 -9
- package/src/GeminiBridgeServe.ts +128 -0
- package/src/NodeGeminiAcpBridge.ts +73 -8
- package/src/NodeGeminiModelBridge.ts +340 -0
- package/src/PiModels.ts +20 -0
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|