@llm4ts/runner 2.41.0 → 2.43.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/AgentTree.d.ts +11 -0
- package/dist/AgentTree.d.ts.map +1 -1
- package/dist/AgentTree.js +67 -15
- package/dist/AgentTree.js.map +1 -1
- 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 +33 -9
- package/dist/FlowRunner.js.map +1 -1
- package/dist/NodeGeminiAcpBridge.d.ts +11 -0
- package/dist/NodeGeminiAcpBridge.d.ts.map +1 -1
- package/dist/NodeGeminiAcpBridge.js +20 -6
- package/dist/NodeGeminiAcpBridge.js.map +1 -1
- package/dist/NodeGeminiModelBridge.d.ts +56 -0
- package/dist/NodeGeminiModelBridge.d.ts.map +1 -0
- package/dist/NodeGeminiModelBridge.js +206 -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 +4 -3
- package/src/AgentTree.ts +90 -13
- package/src/Doctor.ts +54 -2
- package/src/FlowRunner.ts +41 -9
- package/src/NodeGeminiAcpBridge.ts +23 -6
- package/src/NodeGeminiModelBridge.ts +350 -0
- package/src/PiModels.ts +20 -0
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,38 @@ 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
|
+
}).pipe(Effect.mapError(FlowLlmError.from))
|
|
313
|
+
} else {
|
|
314
|
+
yield* runGeminiAcpBridge({
|
|
315
|
+
port: Number(port),
|
|
316
|
+
cwd: options.workDir,
|
|
317
|
+
executor: dependencies.process,
|
|
318
|
+
...geminiModel
|
|
319
|
+
}).pipe(Effect.mapError(FlowLlmError.from))
|
|
320
|
+
}
|
|
291
321
|
|
|
292
322
|
return (config: ConnectorConfig): ConnectorConfig =>
|
|
293
323
|
isPiSeat(config) && config.model === undefined
|
|
@@ -749,6 +779,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
|
|
|
749
779
|
capacity: roster.capacity,
|
|
750
780
|
capacityChanged: roster.capacityChanged,
|
|
751
781
|
executor: held.executor,
|
|
782
|
+
clone: held.clone,
|
|
752
783
|
contractOf: (id) => roster.executors.find((spec) => spec.id === id)?.contract,
|
|
753
784
|
history: held.history
|
|
754
785
|
})
|
|
@@ -820,6 +851,7 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
|
|
|
820
851
|
events: laneEvents,
|
|
821
852
|
eager: true,
|
|
822
853
|
label,
|
|
854
|
+
...(contextOptions?.ifFree === true ? { ifFree: true } : {}),
|
|
823
855
|
...(contextOptions?.prefer === undefined
|
|
824
856
|
? {}
|
|
825
857
|
: { prefer: contextOptions.prefer })
|
|
@@ -400,10 +400,23 @@ export const handleMessagesRequest = Effect.fn(
|
|
|
400
400
|
return anthropicResponse(requestModel(body), outcome)
|
|
401
401
|
})
|
|
402
402
|
|
|
403
|
-
|
|
403
|
+
/** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
|
|
404
|
+
export const readBody = (
|
|
405
|
+
request: IncomingMessage,
|
|
406
|
+
maxBytes: number = Number.POSITIVE_INFINITY
|
|
407
|
+
): Effect.Effect<string, ProviderError> =>
|
|
404
408
|
Effect.callback((resume) => {
|
|
405
409
|
const chunks: Array<Buffer> = []
|
|
406
|
-
|
|
410
|
+
let size = 0
|
|
411
|
+
request.on("data", (chunk: Buffer) => {
|
|
412
|
+
size += chunk.length
|
|
413
|
+
if (size > maxBytes) {
|
|
414
|
+
request.destroy()
|
|
415
|
+
resume(Effect.fail(ProviderError.make({ message: `request body over ${maxBytes} bytes` })))
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
chunks.push(chunk)
|
|
419
|
+
})
|
|
407
420
|
request.on("end", () => resume(Effect.succeed(Buffer.concat(chunks).toString("utf8"))))
|
|
408
421
|
request.on("error", (error) =>
|
|
409
422
|
resume(ProviderError.make({ message: "failed to read request body", cause: error }))
|
|
@@ -412,7 +425,8 @@ const readBody = (request: IncomingMessage): Effect.Effect<string, ProviderError
|
|
|
412
425
|
|
|
413
426
|
const parseJson = (raw: string): JsonValue => JSON.parse(raw)
|
|
414
427
|
|
|
415
|
-
|
|
428
|
+
/** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
|
|
429
|
+
export const parseJsonBody = (raw: string): Effect.Effect<JsonValue, InvalidRequestError> =>
|
|
416
430
|
Effect.try({
|
|
417
431
|
try: () => parseJson(raw),
|
|
418
432
|
catch: () => InvalidRequestError.make({ message: "request body is not valid JSON" })
|
|
@@ -459,13 +473,15 @@ const respondStreaming = (
|
|
|
459
473
|
})
|
|
460
474
|
)
|
|
461
475
|
|
|
462
|
-
|
|
476
|
+
/** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
|
|
477
|
+
export const writeJson = (response: ServerResponse, status: number, body: JsonValue): void => {
|
|
463
478
|
const text = JSON.stringify(body)
|
|
464
479
|
response.writeHead(status, { "Content-Type": "application/json" })
|
|
465
480
|
response.end(text)
|
|
466
481
|
}
|
|
467
482
|
|
|
468
|
-
|
|
483
|
+
/** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
|
|
484
|
+
export const errorStatus = (error: LlmError | InvalidRequestError): number =>
|
|
469
485
|
error._tag === "InvalidRequestError" ? 400 : error._tag === "AuthenticationError" ? 401 : 502
|
|
470
486
|
|
|
471
487
|
export interface GeminiAcpBridgeConfig {
|
|
@@ -489,7 +505,8 @@ const bindError = (port: number, cause: unknown): ProviderError =>
|
|
|
489
505
|
cause
|
|
490
506
|
})
|
|
491
507
|
|
|
492
|
-
|
|
508
|
+
/** Shared with the model route (`NodeGeminiModelBridge.ts`, ADR 0037). */
|
|
509
|
+
export const acquireServer = (
|
|
493
510
|
port: number,
|
|
494
511
|
listener: (request: IncomingMessage, response: ServerResponse) => void
|
|
495
512
|
): Effect.Effect<Server, ProviderError, Scope.Scope> =>
|
|
@@ -0,0 +1,350 @@
|
|
|
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
|
+
parseJsonBody,
|
|
31
|
+
readBody,
|
|
32
|
+
requestPath,
|
|
33
|
+
writeJson
|
|
34
|
+
} from "./NodeGeminiAcpBridge.ts"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The bridge's model route (ADR 0037): `/v1/chat/completions` in OpenAI's
|
|
38
|
+
* shape, for pi's `"api": "openai-completions"` custom provider. Each
|
|
39
|
+
* request is one `gemini` call with Gemini CLI's own agent switched off —
|
|
40
|
+
* pi's system prompt in place of Gemini's (`GEMINI_SYSTEM_MD`), every tool
|
|
41
|
+
* denied by an admin policy, sub-agents off in the system settings — so
|
|
42
|
+
* Gemini answers as a plain model and pi runs its own tools. Only Gemini
|
|
43
|
+
* CLI talks to Google, under its own login; this module never reads or
|
|
44
|
+
* forwards a credential.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export interface GeminiModelCallConfig {
|
|
48
|
+
readonly executor: ProcessExecutorShape
|
|
49
|
+
readonly temporaryFiles: TemporaryFilesShape
|
|
50
|
+
/** Where `gemini` runs: an empty directory, so no project GEMINI.md is read. */
|
|
51
|
+
readonly cwd: string
|
|
52
|
+
/** The Gemini model, overriding the one pi's request names (`LLM4TS_GEMINI_MODEL`). */
|
|
53
|
+
readonly model?: string
|
|
54
|
+
readonly envVars?: Readonly<Record<string, string>>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface GeminiModelBridgeConfig extends GeminiModelCallConfig {
|
|
58
|
+
readonly port: number
|
|
59
|
+
/**
|
|
60
|
+
* `.toml` files in Gemini CLI's standard system policy directory: with
|
|
61
|
+
* any, the CLI ignores `--admin-policy`, so the tools would stay in view.
|
|
62
|
+
* Default: what is on this machine (`systemAdminPolicies`).
|
|
63
|
+
*/
|
|
64
|
+
readonly systemPolicies?: ReadonlyArray<string>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RunningGeminiModelBridge {
|
|
68
|
+
readonly port: number
|
|
69
|
+
readonly baseUrl: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The standard directories Gemini CLI reads system (admin) policies from. */
|
|
73
|
+
export const systemPolicyDirectories: ReadonlyArray<string> = [
|
|
74
|
+
"/etc/gemini-cli/policies",
|
|
75
|
+
"/Library/Application Support/GeminiCli/policies",
|
|
76
|
+
"C:\\ProgramData\\gemini-cli\\policies"
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/** The admin policy files on this machine that would make Gemini CLI ignore `--admin-policy`. */
|
|
80
|
+
export const systemAdminPolicies = (): ReadonlyArray<string> =>
|
|
81
|
+
systemPolicyDirectories.flatMap((directory) => {
|
|
82
|
+
try {
|
|
83
|
+
return existsSync(directory)
|
|
84
|
+
? readdirSync(directory)
|
|
85
|
+
.filter((file) => file.endsWith(".toml"))
|
|
86
|
+
.map((file) => join(directory, file))
|
|
87
|
+
: []
|
|
88
|
+
} catch {
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
export const systemPolicyProblem = (policies: ReadonlyArray<string>): string =>
|
|
94
|
+
[
|
|
95
|
+
`Gemini CLI has system admin policies (${policies.join(", ")}), so it ignores the`,
|
|
96
|
+
"--admin-policy the model bridge relies on to take every tool out of the model's view.",
|
|
97
|
+
"The bridge will not run Gemini with its tools still in reach (ADR 0037); use",
|
|
98
|
+
"LLM4TS_GEMINI_BRIDGE_MODE=acp, or ask the administrator to allow this policy."
|
|
99
|
+
].join(" ")
|
|
100
|
+
|
|
101
|
+
interface GeminiTurn {
|
|
102
|
+
readonly text: string
|
|
103
|
+
readonly tokens: GeminiTokens | undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One `gemini` call over `transcript`, its system file and lock-down written for the call. */
|
|
107
|
+
const geminiTurn = (
|
|
108
|
+
config: GeminiModelCallConfig,
|
|
109
|
+
model: string,
|
|
110
|
+
system: string,
|
|
111
|
+
transcript: string
|
|
112
|
+
): Effect.Effect<GeminiTurn, LlmError> =>
|
|
113
|
+
Effect.scoped(
|
|
114
|
+
Effect.gen(function* () {
|
|
115
|
+
const systemFile = yield* config.temporaryFiles.write("gemini-system-", ".md", system)
|
|
116
|
+
const settings = yield* config.temporaryFiles.write(
|
|
117
|
+
"gemini-settings-",
|
|
118
|
+
".json",
|
|
119
|
+
modelModeSettingsJson
|
|
120
|
+
)
|
|
121
|
+
const policy = yield* config.temporaryFiles.write(
|
|
122
|
+
"gemini-deny-all-",
|
|
123
|
+
".toml",
|
|
124
|
+
denyAllToolsPolicyToml
|
|
125
|
+
)
|
|
126
|
+
const events = yield* Stream.runCollect(
|
|
127
|
+
config.executor
|
|
128
|
+
.runStreamingWithStdin(
|
|
129
|
+
[
|
|
130
|
+
"gemini",
|
|
131
|
+
// Bound to its flag, so it can never read as a flag of its own.
|
|
132
|
+
`--model=${model}`,
|
|
133
|
+
"--output-format",
|
|
134
|
+
"stream-json",
|
|
135
|
+
"-e",
|
|
136
|
+
"none",
|
|
137
|
+
"--admin-policy",
|
|
138
|
+
policy
|
|
139
|
+
],
|
|
140
|
+
config.cwd,
|
|
141
|
+
{
|
|
142
|
+
...config.envVars,
|
|
143
|
+
GEMINI_CLI_TRUST_WORKSPACE: "true",
|
|
144
|
+
GEMINI_SYSTEM_MD: systemFile,
|
|
145
|
+
GEMINI_CLI_SYSTEM_SETTINGS_PATH: settings
|
|
146
|
+
},
|
|
147
|
+
transcript
|
|
148
|
+
)
|
|
149
|
+
.pipe(Stream.map(parseGeminiCliStreamEvent))
|
|
150
|
+
)
|
|
151
|
+
let text = ""
|
|
152
|
+
let tokens: GeminiTokens | undefined
|
|
153
|
+
for (const event of events) {
|
|
154
|
+
switch (event._tag) {
|
|
155
|
+
case "Message":
|
|
156
|
+
if (event.role === "assistant" && event.content !== undefined) {
|
|
157
|
+
text += event.content
|
|
158
|
+
}
|
|
159
|
+
break
|
|
160
|
+
case "Error":
|
|
161
|
+
return yield* ProviderError.make({
|
|
162
|
+
message: `gemini: ${event.message ?? "error"}`
|
|
163
|
+
})
|
|
164
|
+
case "Result":
|
|
165
|
+
if (event.status === "error") {
|
|
166
|
+
return yield* ProviderError.make({
|
|
167
|
+
message: `gemini: ${event.errorMessage ?? "the call failed"}`
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
tokens =
|
|
171
|
+
event.stats === undefined
|
|
172
|
+
? undefined
|
|
173
|
+
: {
|
|
174
|
+
...(event.stats.inputTokens === undefined
|
|
175
|
+
? {}
|
|
176
|
+
: { inputTokens: event.stats.inputTokens }),
|
|
177
|
+
...(event.stats.outputTokens === undefined
|
|
178
|
+
? {}
|
|
179
|
+
: { outputTokens: event.stats.outputTokens }),
|
|
180
|
+
...(event.stats.cached === undefined ? {} : { cached: event.stats.cached })
|
|
181
|
+
}
|
|
182
|
+
break
|
|
183
|
+
default:
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { text, tokens }
|
|
188
|
+
})
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
const addTokens = (
|
|
192
|
+
left: GeminiTokens | undefined,
|
|
193
|
+
right: GeminiTokens | undefined
|
|
194
|
+
): GeminiTokens => ({
|
|
195
|
+
inputTokens: (left?.inputTokens ?? 0) + (right?.inputTokens ?? 0),
|
|
196
|
+
outputTokens: (left?.outputTokens ?? 0) + (right?.outputTokens ?? 0),
|
|
197
|
+
cached: (left?.cached ?? 0) + (right?.cached ?? 0)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
/** The largest request the bridge reads: a long conversation, not an unbounded upload. */
|
|
201
|
+
const maxRequestBytes = 32 * 1024 * 1024
|
|
202
|
+
|
|
203
|
+
/** What a Gemini model id looks like: `gemini-3.8-flash`, `gemini-2.5-pro-001`. */
|
|
204
|
+
const modelName = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u
|
|
205
|
+
|
|
206
|
+
export interface ChatCompletionOutcome {
|
|
207
|
+
readonly model: string
|
|
208
|
+
readonly reply: ModelReply
|
|
209
|
+
readonly usage: OpenAiUsage
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One chat completion: a `gemini` call, and a second one only when the
|
|
214
|
+
* first reply holds a tool call that cannot be used — the model then sees
|
|
215
|
+
* its reply and why. A second bad reply goes back to pi as text.
|
|
216
|
+
*/
|
|
217
|
+
export const handleChatCompletion = Effect.fn("@llm4ts/runner/NodeGeminiModelBridge.handle")(
|
|
218
|
+
function* (
|
|
219
|
+
config: GeminiModelCallConfig,
|
|
220
|
+
body: JsonValue
|
|
221
|
+
): Effect.fn.Return<ChatCompletionOutcome, LlmError | InvalidRequestError> {
|
|
222
|
+
const request = chatRequestFrom(body)
|
|
223
|
+
if (request._tag === "Invalid") {
|
|
224
|
+
return yield* InvalidRequestError.make({ message: request.reason })
|
|
225
|
+
}
|
|
226
|
+
const model = config.model ?? request.model
|
|
227
|
+
// The model reaches Gemini CLI's arguments: a name only, never a flag.
|
|
228
|
+
if (!modelName.test(model)) {
|
|
229
|
+
return yield* InvalidRequestError.make({
|
|
230
|
+
message: `"${model}" is not a model name (letters, digits, . _ : -, starting with a letter or digit)`
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
const system = systemFileFor(request)
|
|
234
|
+
const transcript = transcriptFor(request.messages)
|
|
235
|
+
const toolNames = request.tools.map((tool) => tool.name)
|
|
236
|
+
const first = yield* geminiTurn(config, model, system, transcript)
|
|
237
|
+
const reply = parseModelReply(first.text, toolNames)
|
|
238
|
+
if (reply._tag !== "Malformed") {
|
|
239
|
+
return { model, reply, usage: openAiUsage(first.tokens) }
|
|
240
|
+
}
|
|
241
|
+
const second = yield* geminiTurn(
|
|
242
|
+
config,
|
|
243
|
+
model,
|
|
244
|
+
system,
|
|
245
|
+
correctionTranscript(transcript, first.text, reply.error)
|
|
246
|
+
)
|
|
247
|
+
return {
|
|
248
|
+
model,
|
|
249
|
+
reply: parseModelReply(second.text, toolNames),
|
|
250
|
+
usage: openAiUsage(addTokens(first.tokens, second.tokens))
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
let completions = 0
|
|
256
|
+
const nextIds = (): { readonly id: string; readonly callId: string } => {
|
|
257
|
+
completions += 1
|
|
258
|
+
return { id: `chatcmpl-llm4ts-${completions}`, callId: `call_llm4ts_${completions}` }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const writeChunks = (response: ServerResponse, chunks: ReadonlyArray<JsonValue>): void => {
|
|
262
|
+
response.writeHead(200, {
|
|
263
|
+
"Content-Type": "text/event-stream",
|
|
264
|
+
"Cache-Control": "no-cache",
|
|
265
|
+
Connection: "keep-alive"
|
|
266
|
+
})
|
|
267
|
+
for (const chunk of chunks) {
|
|
268
|
+
response.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
|
269
|
+
}
|
|
270
|
+
response.write("data: [DONE]\n\n")
|
|
271
|
+
response.end()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const streamWanted = (body: JsonValue): boolean => {
|
|
275
|
+
const request = chatRequestFrom(body)
|
|
276
|
+
return request._tag === "Request" && request.stream
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export const runGeminiModelBridge = Effect.fn("@llm4ts/runner/NodeGeminiModelBridge.run")(
|
|
280
|
+
function* (
|
|
281
|
+
config: GeminiModelBridgeConfig
|
|
282
|
+
): Effect.fn.Return<RunningGeminiModelBridge, LlmError, Scope.Scope> {
|
|
283
|
+
const policies = config.systemPolicies ?? systemAdminPolicies()
|
|
284
|
+
if (policies.length > 0) {
|
|
285
|
+
return yield* ProviderError.make({ message: systemPolicyProblem(policies) })
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const listener = (request: IncomingMessage, response: ServerResponse): void => {
|
|
289
|
+
if (request.method !== "POST" || requestPath(request.url) !== "/v1/chat/completions") {
|
|
290
|
+
writeJson(response, 404, { error: { message: "not found" } })
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
// pi posts JSON from Node, with no Origin. A browser page posting to
|
|
294
|
+
// this loopback port (a cross-site form needs no preflight as
|
|
295
|
+
// text/plain) is refused before anything runs.
|
|
296
|
+
const contentType = request.headers["content-type"] ?? ""
|
|
297
|
+
// A DNS name rebound to 127.0.0.1 still sends its own Host.
|
|
298
|
+
const host = (request.headers.host ?? "").replace(/:\d+$/u, "")
|
|
299
|
+
if (
|
|
300
|
+
request.headers.origin !== undefined ||
|
|
301
|
+
!contentType.includes("application/json") ||
|
|
302
|
+
!["127.0.0.1", "localhost", "[::1]"].includes(host)
|
|
303
|
+
) {
|
|
304
|
+
writeJson(response, 403, {
|
|
305
|
+
error: { message: "the bridge answers JSON requests from local tools only" }
|
|
306
|
+
})
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
const handled = Effect.gen(function* () {
|
|
310
|
+
const body = yield* parseJsonBody(yield* readBody(request, maxRequestBytes))
|
|
311
|
+
const outcome = yield* handleChatCompletion(config, body)
|
|
312
|
+
const { id, callId } = nextIds()
|
|
313
|
+
const created = Math.floor(Date.now() / 1000)
|
|
314
|
+
if (streamWanted(body)) {
|
|
315
|
+
return yield* Effect.sync(() =>
|
|
316
|
+
writeChunks(
|
|
317
|
+
response,
|
|
318
|
+
completionChunks(id, outcome.model, outcome.reply, outcome.usage, created, callId)
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
return yield* Effect.sync(() =>
|
|
323
|
+
writeJson(
|
|
324
|
+
response,
|
|
325
|
+
200,
|
|
326
|
+
completionJson(id, outcome.model, outcome.reply, outcome.usage, created, callId)
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
}).pipe(
|
|
330
|
+
Effect.catch((error) =>
|
|
331
|
+
Effect.sync(() => {
|
|
332
|
+
if (!response.headersSent) {
|
|
333
|
+
writeJson(response, errorStatus(error), { error: { message: error.message } })
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
Effect.runPromise(handled).catch((defect: unknown) => {
|
|
339
|
+
if (!response.headersSent) {
|
|
340
|
+
writeJson(response, 500, { error: { message: String(defect) } })
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const server = yield* acquireServer(config.port, listener)
|
|
346
|
+
const address = server.address()
|
|
347
|
+
const port = address !== null && typeof address !== "string" ? address.port : config.port
|
|
348
|
+
return { port, baseUrl: `http://127.0.0.1:${port}` }
|
|
349
|
+
}
|
|
350
|
+
)
|
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
|