@llm4ts/runner 2.3.0 → 2.3.1
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 +3 -7
- package/dist/Doctor.d.ts.map +1 -1
- package/dist/Doctor.js +74 -37
- 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 +53 -3
- package/dist/FlowRunner.js.map +1 -1
- package/dist/NodeGeminiAcpBridge.d.ts +41 -2
- package/dist/NodeGeminiAcpBridge.d.ts.map +1 -1
- package/dist/NodeGeminiAcpBridge.js +155 -9
- package/dist/NodeGeminiAcpBridge.js.map +1 -1
- package/dist/PiModels.d.ts +65 -0
- package/dist/PiModels.d.ts.map +1 -0
- package/dist/PiModels.js +148 -0
- package/dist/PiModels.js.map +1 -0
- package/package.json +4 -3
- package/src/Doctor.ts +97 -40
- package/src/FlowRunner.ts +85 -3
- package/src/NodeGeminiAcpBridge.ts +195 -14
- package/src/PiModels.ts +222 -0
package/src/Doctor.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs"
|
|
2
|
-
import { homedir } from "node:os"
|
|
3
|
-
import { join } from "node:path"
|
|
4
1
|
import * as Effect from "effect/Effect"
|
|
5
2
|
import type { HealthStatus } from "@llm4ts/core/Models"
|
|
6
3
|
import { ConnectorIds } from "@llm4ts/core/Models"
|
|
7
4
|
import type { ConnectorRegistryShape } from "@llm4ts/core/ConnectorRegistry"
|
|
8
5
|
import { nodeFlowRunnerDependencies } from "./FlowRunner.ts"
|
|
6
|
+
import {
|
|
7
|
+
defaultReadPiModelsJson as readPiModels,
|
|
8
|
+
geminiBridgePort as bridgePort,
|
|
9
|
+
piModelsConfig as parsePiModels
|
|
10
|
+
} from "./PiModels.ts"
|
|
9
11
|
|
|
10
12
|
const credentialKeys = [
|
|
11
13
|
"OPENAI_API_KEY",
|
|
@@ -31,6 +33,8 @@ export interface PrerequisiteReport {
|
|
|
31
33
|
readonly satisfied: boolean
|
|
32
34
|
readonly summary: string
|
|
33
35
|
readonly hint?: string
|
|
36
|
+
/** Extra lines rendered under the summary, already indented relative to it. */
|
|
37
|
+
readonly detail?: ReadonlyArray<string>
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
@@ -101,20 +105,22 @@ const wrap = (text: string, width: number, indent: string): ReadonlyArray<string
|
|
|
101
105
|
const selectedCoder = (environment: Readonly<Record<string, string | undefined>>): string =>
|
|
102
106
|
environment.LLM4TS_CODER ?? "claude (default)"
|
|
103
107
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
108
|
+
// Re-exported so `@llm4ts/runner/Doctor` stays the one place callers look
|
|
109
|
+
// for pi-config questions, while the parsing itself has no dependency on
|
|
110
|
+
// this module (the flow runner needs it, and Doctor imports the runner).
|
|
111
|
+
export {
|
|
112
|
+
bridgeModelProblem,
|
|
113
|
+
bridgeModelRefs,
|
|
114
|
+
defaultGeminiBridgePort,
|
|
115
|
+
defaultReadPiModelsJson,
|
|
116
|
+
geminiBridgePort,
|
|
117
|
+
piModelRefs,
|
|
118
|
+
piModelsConfig,
|
|
119
|
+
resolveBridgeModel,
|
|
120
|
+
type BridgeModelResolution,
|
|
121
|
+
type PiModelRef,
|
|
122
|
+
type PiModelsConfig
|
|
123
|
+
} from "./PiModels.ts"
|
|
118
124
|
|
|
119
125
|
/**
|
|
120
126
|
* Whether pi's custom-provider config points at this machine's Gemini ACP
|
|
@@ -128,9 +134,7 @@ export const geminiBridgePrerequisites = (
|
|
|
128
134
|
if (!truthy(environment.LLM4TS_GEMINI_BRIDGE)) {
|
|
129
135
|
return undefined
|
|
130
136
|
}
|
|
131
|
-
const port =
|
|
132
|
-
? environment.LLM4TS_GEMINI_BRIDGE_PORT.trim()
|
|
133
|
-
: defaultGeminiBridgePort
|
|
137
|
+
const port = bridgePort(environment)
|
|
134
138
|
const needle = `127.0.0.1:${port}`
|
|
135
139
|
const hint =
|
|
136
140
|
`add a custom provider to ~/.pi/agent/models.json with baseUrl ` +
|
|
@@ -139,22 +143,73 @@ export const geminiBridgePrerequisites = (
|
|
|
139
143
|
if (modelsJson === undefined) {
|
|
140
144
|
return { satisfied: false, summary: "~/.pi/agent/models.json not found", hint }
|
|
141
145
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
const { refs, problems } = parsePiModels(modelsJson, port)
|
|
147
|
+
// pi rejects the whole file on any schema error, so a well-formed bridge
|
|
148
|
+
// entry sitting beside a bad one is still not loaded. Report that first:
|
|
149
|
+
// every model below would otherwise look available while pi sees none.
|
|
150
|
+
if (problems.length > 0) {
|
|
151
|
+
return {
|
|
152
|
+
satisfied: false,
|
|
153
|
+
summary: "~/.pi/agent/models.json fails pi's schema, so pi loads none of it",
|
|
154
|
+
hint:
|
|
155
|
+
`fix the errors below and re-check with \`pi --list-models\`. Each models ` +
|
|
156
|
+
`entry must be an object with a string id ({ "id": "gemini-2.5-pro" }), not ` +
|
|
157
|
+
`a bare string.`,
|
|
158
|
+
detail: problems.map((problem) => ` ${problem}`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const bridged = refs.filter((entry) => entry.bridged)
|
|
162
|
+
const usable = bridged.filter((entry) => entry.authConfigured).map((entry) => entry.ref)
|
|
163
|
+
if (usable.length > 0) {
|
|
164
|
+
return {
|
|
165
|
+
satisfied: true,
|
|
166
|
+
summary: `a provider in ~/.pi/agent/models.json points at ${needle}`,
|
|
167
|
+
detail: [
|
|
168
|
+
"bridge models — pass one as LLM4TS_GEMINI_BRIDGE_MODEL (pi's --model):",
|
|
169
|
+
...usable.map((ref) => ` ${ref}`)
|
|
170
|
+
]
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Loaded but hidden: pi keeps a keyless provider's models out of `--model`
|
|
174
|
+
// and `--list-models`. The bridge authenticates as gemini, not as pi, so
|
|
175
|
+
// any placeholder satisfies this.
|
|
176
|
+
if (bridged.length > 0) {
|
|
177
|
+
return {
|
|
178
|
+
satisfied: false,
|
|
179
|
+
summary: `the provider pointing at ${needle} configures no apiKey, so pi hides its models`,
|
|
180
|
+
hint:
|
|
181
|
+
`give that provider any placeholder apiKey (the bridge never checks it — ` +
|
|
182
|
+
`gemini's own OAuth session supplies the credential), or run \`pi /login\` ` +
|
|
183
|
+
`for it.`,
|
|
184
|
+
detail: bridged.map((entry) => ` ${entry.ref}`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// A provider can point at the bridge and still list no models: pi resolves
|
|
188
|
+
// `--model` against that list, so the run fails with "Model not found"
|
|
189
|
+
// while a check that only greps for the port reports success.
|
|
190
|
+
const pointed = modelsJson.includes(needle)
|
|
191
|
+
const others = refs.map((entry) => entry.ref)
|
|
192
|
+
return {
|
|
193
|
+
satisfied: false,
|
|
194
|
+
summary: pointed
|
|
195
|
+
? `a provider in ~/.pi/agent/models.json points at ${needle} but lists no models`
|
|
196
|
+
: `no provider in ~/.pi/agent/models.json points at ${needle}`,
|
|
197
|
+
hint,
|
|
198
|
+
...(others.length === 0
|
|
199
|
+
? {}
|
|
200
|
+
: {
|
|
201
|
+
detail: [
|
|
202
|
+
"models pi can resolve today, none of them bridged:",
|
|
203
|
+
...others.map((ref) => ` ${ref}`)
|
|
204
|
+
]
|
|
205
|
+
})
|
|
206
|
+
}
|
|
152
207
|
}
|
|
153
208
|
|
|
154
209
|
export const makeDoctorProgram = (
|
|
155
210
|
registry: ConnectorRegistryShape = nodeFlowRunnerDependencies().registry,
|
|
156
211
|
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
157
|
-
readPiModelsJson: () => string | undefined =
|
|
212
|
+
readPiModelsJson: () => string | undefined = readPiModels
|
|
158
213
|
): Effect.Effect<string> =>
|
|
159
214
|
Effect.map(registry.healthCheckAll, (statuses) => {
|
|
160
215
|
const lines: Array<string> = []
|
|
@@ -186,18 +241,20 @@ export const makeDoctorProgram = (
|
|
|
186
241
|
if (geminiRelevant || bridge !== undefined) {
|
|
187
242
|
lines.push("")
|
|
188
243
|
lines.push("prerequisites:")
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
244
|
+
const pushReport = (label: string, report: PrerequisiteReport): void => {
|
|
245
|
+
lines.push(` ${report.satisfied ? "✔" : "?"} ${label}: ${report.summary}`)
|
|
246
|
+
if (report.hint !== undefined) {
|
|
247
|
+
lines.push(...wrap(report.hint, 78, " "))
|
|
248
|
+
}
|
|
249
|
+
for (const line of report.detail ?? []) {
|
|
250
|
+
lines.push(` ${line}`)
|
|
194
251
|
}
|
|
195
252
|
}
|
|
253
|
+
if (geminiRelevant) {
|
|
254
|
+
pushReport("gemini", geminiPrerequisites(environment))
|
|
255
|
+
}
|
|
196
256
|
if (bridge !== undefined) {
|
|
197
|
-
|
|
198
|
-
if (bridge.hint !== undefined) {
|
|
199
|
-
lines.push(...wrap(bridge.hint, 78, " "))
|
|
200
|
-
}
|
|
257
|
+
pushReport("pi-gemini-bridge", bridge)
|
|
201
258
|
}
|
|
202
259
|
}
|
|
203
260
|
|
package/src/FlowRunner.ts
CHANGED
|
@@ -11,7 +11,16 @@ import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
|
|
|
11
11
|
import type { TemporaryFilesShape } from "@llm4ts/core/TemporaryFiles"
|
|
12
12
|
import type { GeminiCliExecutorShape } from "@llm4ts/core/providers/GeminiCliProvider"
|
|
13
13
|
import { createConnectorRegistry } from "@llm4ts/core/providers/ConnectorFactories"
|
|
14
|
+
import { ConnectorIds } from "@llm4ts/core/Models"
|
|
15
|
+
import { ProviderError } from "@llm4ts/core/Errors"
|
|
14
16
|
import { makeCostTracker, type CostTracker } from "@llm4ts/flow/CostTracker"
|
|
17
|
+
import { runGeminiAcpBridge } from "./NodeGeminiAcpBridge.ts"
|
|
18
|
+
import {
|
|
19
|
+
bridgeModelProblem,
|
|
20
|
+
defaultReadPiModelsJson,
|
|
21
|
+
geminiBridgePort,
|
|
22
|
+
resolveBridgeModel
|
|
23
|
+
} from "./PiModels.ts"
|
|
15
24
|
import { checkCostBudget, makeCostRecord, type CostBudget } from "@llm4ts/flow/CostLedger"
|
|
16
25
|
import { FlowLlmError, describeFlowError, type FlowError } from "@llm4ts/flow/FlowError"
|
|
17
26
|
import {
|
|
@@ -50,6 +59,8 @@ export interface FlowRunnerDependencies {
|
|
|
50
59
|
readonly registry: ConnectorRegistryShape
|
|
51
60
|
readonly process: ProcessExecutorShape
|
|
52
61
|
readonly files: PlainFileStoreShape
|
|
62
|
+
/** pi's own model config (ADR 0016), injectable so a test never reads $HOME. */
|
|
63
|
+
readonly readPiModelsJson?: () => string | undefined
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
export interface NodeConnectorDependencies {
|
|
@@ -99,6 +110,68 @@ export interface FlowRunnerBundle {
|
|
|
99
110
|
readonly tracker: CostTracker
|
|
100
111
|
}
|
|
101
112
|
|
|
113
|
+
const truthy = (value: string | undefined): boolean =>
|
|
114
|
+
value !== undefined && ["1", "true", "yes"].includes(value.trim().toLowerCase())
|
|
115
|
+
|
|
116
|
+
const isPiSeat = (config: ConnectorConfig | undefined): config is CliConnectorConfig =>
|
|
117
|
+
config instanceof CliConnectorConfig && config.connectorId.value === ConnectorIds.Pi.value
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Start the ADR 0016 bridge for a `pi`-coder run, scoped to that run, and
|
|
121
|
+
* return the mapping that points pi's seats at it.
|
|
122
|
+
*
|
|
123
|
+
* Opting in with `LLM4TS_GEMINI_BRIDGE` but leaving pi unable to reach the
|
|
124
|
+
* bridge is the failure this whole feature exists to avoid, so an
|
|
125
|
+
* unresolvable model fails the run here rather than surfacing as pi's own
|
|
126
|
+
* "No API key found" several layers down. A seat that already names a model
|
|
127
|
+
* is left alone: an explicit choice outranks the default.
|
|
128
|
+
*/
|
|
129
|
+
const geminiBridgeSeats = Effect.fn("@llm4ts/runner/FlowRunner.geminiBridgeSeats")(function* (
|
|
130
|
+
options: FlowRunnerOptions,
|
|
131
|
+
dependencies: FlowRunnerDependencies,
|
|
132
|
+
environment: Readonly<Record<string, string | undefined>>
|
|
133
|
+
): Effect.fn.Return<(config: ConnectorConfig) => ConnectorConfig, FlowError, Scope.Scope> {
|
|
134
|
+
const identity = (config: ConnectorConfig): ConnectorConfig => config
|
|
135
|
+
if (!truthy(environment.LLM4TS_GEMINI_BRIDGE)) {
|
|
136
|
+
return identity
|
|
137
|
+
}
|
|
138
|
+
// The reasoning seat defaults to the coder, so a pi coder makes this a pi
|
|
139
|
+
// run even when nothing else names pi.
|
|
140
|
+
const seats = [options.coder, options.reasoning, ...(options.reviewers ?? [])]
|
|
141
|
+
if (!seats.some(isPiSeat)) {
|
|
142
|
+
return identity
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const port = geminiBridgePort(environment)
|
|
146
|
+
const readPiModelsJson = dependencies.readPiModelsJson ?? defaultReadPiModelsJson
|
|
147
|
+
const resolution = resolveBridgeModel(
|
|
148
|
+
readPiModelsJson(),
|
|
149
|
+
port,
|
|
150
|
+
environment.LLM4TS_GEMINI_BRIDGE_MODEL
|
|
151
|
+
)
|
|
152
|
+
const problem = bridgeModelProblem(resolution, port)
|
|
153
|
+
if (problem !== undefined || resolution._tag !== "Resolved") {
|
|
154
|
+
return yield* FlowLlmError.from(
|
|
155
|
+
ProviderError.make({ message: problem ?? "no bridge model available" })
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
const model = resolution.model
|
|
159
|
+
|
|
160
|
+
yield* runGeminiAcpBridge({
|
|
161
|
+
port: Number(port),
|
|
162
|
+
cwd: options.workDir,
|
|
163
|
+
executor: dependencies.process,
|
|
164
|
+
...(environment.LLM4TS_GEMINI_MODEL === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { model: environment.LLM4TS_GEMINI_MODEL })
|
|
167
|
+
}).pipe(Effect.mapError(FlowLlmError.from))
|
|
168
|
+
|
|
169
|
+
return (config: ConnectorConfig): ConnectorConfig =>
|
|
170
|
+
isPiSeat(config) && config.model === undefined
|
|
171
|
+
? CliConnectorConfig.make({ ...config, model })
|
|
172
|
+
: config
|
|
173
|
+
})
|
|
174
|
+
|
|
102
175
|
export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeContext")(function* (
|
|
103
176
|
options: FlowRunnerOptions,
|
|
104
177
|
dependencies: FlowRunnerDependencies
|
|
@@ -107,6 +180,13 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
|
|
|
107
180
|
const tracker = yield* makeCostTracker()
|
|
108
181
|
yield* tracker.consume(events)
|
|
109
182
|
const reasoning = defaultReasoningConfig(options.coder, options.reasoning)
|
|
183
|
+
// Bound to this run's scope: the bridge subprocess and its server go away
|
|
184
|
+
// with the run, matching every other long-lived subprocess here (ADR 0016).
|
|
185
|
+
const bridged = yield* geminiBridgeSeats(
|
|
186
|
+
options,
|
|
187
|
+
dependencies,
|
|
188
|
+
options.environment ?? process.env
|
|
189
|
+
)
|
|
110
190
|
// Every seat retries transient provider failures and flaky streams (an
|
|
111
191
|
// empty response or malformed tool call), announcing each attempt on the
|
|
112
192
|
// run's events. Without the wrapper one hiccup from a CLI agent failed the
|
|
@@ -157,10 +237,12 @@ export const makeFlowRunnerContext = Effect.fn("@llm4ts/runner/FlowRunner.makeCo
|
|
|
157
237
|
Scope.Scope
|
|
158
238
|
> {
|
|
159
239
|
const environment = options.environment ?? process.env
|
|
160
|
-
const coder = yield* resolveSeat(prepareConnector(options.coder, workDir, environment))
|
|
161
|
-
const reasoningService = yield* resolveSeat(
|
|
240
|
+
const coder = yield* resolveSeat(prepareConnector(bridged(options.coder), workDir, environment))
|
|
241
|
+
const reasoningService = yield* resolveSeat(
|
|
242
|
+
prepareConnector(bridged(reasoning), workDir, environment)
|
|
243
|
+
)
|
|
162
244
|
const reviewers = yield* Effect.forEach(options.reviewers ?? [], (configuration) =>
|
|
163
|
-
resolveSeat(prepareConnector(configuration, workDir, environment))
|
|
245
|
+
resolveSeat(prepareConnector(bridged(configuration), workDir, environment))
|
|
164
246
|
)
|
|
165
247
|
return { coder, reasoning: reasoningService, reviewers }
|
|
166
248
|
})
|
|
@@ -195,6 +195,17 @@ const toolResultInLastMessage = (
|
|
|
195
195
|
const userTextInLastMessage = (body: JsonValue): string =>
|
|
196
196
|
contentBlocksText(jsonField(lastMessage(body), "content"))
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The route to match, without the query string. `request.url` carries it,
|
|
200
|
+
* and pi's bundled Anthropic SDK posts to `/v1/messages?beta=true` for
|
|
201
|
+
* tool-enabled requests — routing on the raw value 404s exactly the request
|
|
202
|
+
* this bridge exists to serve.
|
|
203
|
+
*/
|
|
204
|
+
export const requestPath = (url: string | undefined): string => {
|
|
205
|
+
const path = (url ?? "/").split(/[?#]/)[0]
|
|
206
|
+
return path === undefined || path.length === 0 ? "/" : path
|
|
207
|
+
}
|
|
208
|
+
|
|
198
209
|
const anthropicResponse = (model: string, outcome: TurnOutcome): JsonRecord => ({
|
|
199
210
|
id: `msg_${Date.now()}`,
|
|
200
211
|
type: "message",
|
|
@@ -211,9 +222,15 @@ const anthropicResponse = (model: string, outcome: TurnOutcome): JsonRecord => (
|
|
|
211
222
|
usage: { input_tokens: 0, output_tokens: 0 }
|
|
212
223
|
})
|
|
213
224
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Drive one turn to its outcome. Split from the response rendering because a
|
|
227
|
+
* streaming request must write `message_start` before this resolves — pi
|
|
228
|
+
* hardcodes `stream: true`, and gemini can take a while to answer.
|
|
229
|
+
*/
|
|
230
|
+
export const resolveTurn = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.resolveTurn")(function* (
|
|
231
|
+
state: BridgeState,
|
|
232
|
+
body: JsonValue
|
|
233
|
+
): Effect.fn.Return<TurnOutcome, LlmError> {
|
|
217
234
|
const continuation = toolResultInLastMessage(body)
|
|
218
235
|
let pendingToResolve: PendingToolCall | undefined
|
|
219
236
|
if (continuation !== undefined) {
|
|
@@ -264,7 +281,123 @@ export const handleMessagesRequest = Effect.fn(
|
|
|
264
281
|
const outcome = yield* Deferred.await(outcomeDeferred)
|
|
265
282
|
yield* Ref.set(state.turnOutcome, Option.none())
|
|
266
283
|
|
|
267
|
-
return
|
|
284
|
+
return outcome
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
export const requestModel = (body: JsonValue): string => jsonStringField(body, "model") ?? "gemini"
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* One Anthropic Messages streaming event. pi's bundled SDK hardcodes
|
|
291
|
+
* `stream: true` on every request and there is no compatibility flag to turn
|
|
292
|
+
* it off, so this transport — not the single-JSON one — is the live path.
|
|
293
|
+
*
|
|
294
|
+
* ACP hands this bridge a finished turn rather than a token stream, so each
|
|
295
|
+
* block is emitted as one delta. That is a well-formed event sequence the
|
|
296
|
+
* client reassembles correctly; it simply arrives in a single chunk instead
|
|
297
|
+
* of token by token.
|
|
298
|
+
*/
|
|
299
|
+
export interface StreamEvent {
|
|
300
|
+
readonly event: string
|
|
301
|
+
readonly data: JsonRecord
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const emptyUsage: JsonRecord = { input_tokens: 0, output_tokens: 0 }
|
|
305
|
+
|
|
306
|
+
/** Sent before the turn resolves, so the client sees a live response. */
|
|
307
|
+
export const turnStartEvents = (id: string, model: string): ReadonlyArray<StreamEvent> => [
|
|
308
|
+
{
|
|
309
|
+
event: "message_start",
|
|
310
|
+
data: {
|
|
311
|
+
type: "message_start",
|
|
312
|
+
message: {
|
|
313
|
+
id,
|
|
314
|
+
type: "message",
|
|
315
|
+
role: "assistant",
|
|
316
|
+
model,
|
|
317
|
+
content: [],
|
|
318
|
+
stop_reason: null,
|
|
319
|
+
stop_sequence: null,
|
|
320
|
+
usage: emptyUsage
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
]
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The blocks and stop events for a resolved turn. A `tool_use` block carries
|
|
328
|
+
* an empty `input` and supplies the real arguments through `input_json_delta`,
|
|
329
|
+
* which is the shape the Anthropic client expects to accumulate.
|
|
330
|
+
*/
|
|
331
|
+
export const turnOutcomeEvents = (outcome: TurnOutcome): ReadonlyArray<StreamEvent> => {
|
|
332
|
+
const blocks: ReadonlyArray<StreamEvent> =
|
|
333
|
+
outcome._tag === "Text"
|
|
334
|
+
? [
|
|
335
|
+
{
|
|
336
|
+
event: "content_block_start",
|
|
337
|
+
data: {
|
|
338
|
+
type: "content_block_start",
|
|
339
|
+
index: 0,
|
|
340
|
+
content_block: { type: "text", text: "" }
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
event: "content_block_delta",
|
|
345
|
+
data: {
|
|
346
|
+
type: "content_block_delta",
|
|
347
|
+
index: 0,
|
|
348
|
+
delta: { type: "text_delta", text: outcome.text }
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
]
|
|
352
|
+
: [
|
|
353
|
+
{
|
|
354
|
+
event: "content_block_start",
|
|
355
|
+
data: {
|
|
356
|
+
type: "content_block_start",
|
|
357
|
+
index: 0,
|
|
358
|
+
content_block: { type: "tool_use", id: outcome.id, name: outcome.name, input: {} }
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
event: "content_block_delta",
|
|
363
|
+
data: {
|
|
364
|
+
type: "content_block_delta",
|
|
365
|
+
index: 0,
|
|
366
|
+
delta: { type: "input_json_delta", partial_json: JSON.stringify(outcome.input) }
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
]
|
|
370
|
+
return [
|
|
371
|
+
...blocks,
|
|
372
|
+
{ event: "content_block_stop", data: { type: "content_block_stop", index: 0 } },
|
|
373
|
+
{
|
|
374
|
+
event: "message_delta",
|
|
375
|
+
data: {
|
|
376
|
+
type: "message_delta",
|
|
377
|
+
delta: {
|
|
378
|
+
stop_reason: outcome._tag === "Text" ? "end_turn" : "tool_use",
|
|
379
|
+
stop_sequence: null
|
|
380
|
+
},
|
|
381
|
+
usage: { output_tokens: 0 }
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
{ event: "message_stop", data: { type: "message_stop" } }
|
|
385
|
+
]
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** A failure after `message_start` can only be reported in-band. */
|
|
389
|
+
export const turnErrorEvent = (message: string): StreamEvent => ({
|
|
390
|
+
event: "error",
|
|
391
|
+
data: { type: "error", error: { type: "api_error", message } }
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
export const messageId = (): string => `msg_${Date.now()}`
|
|
395
|
+
|
|
396
|
+
export const handleMessagesRequest = Effect.fn(
|
|
397
|
+
"@llm4ts/runner/NodeGeminiAcpBridge.handleMessagesRequest"
|
|
398
|
+
)(function* (state: BridgeState, body: JsonValue): Effect.fn.Return<JsonRecord, LlmError> {
|
|
399
|
+
const outcome = yield* resolveTurn(state, body)
|
|
400
|
+
return anthropicResponse(requestModel(body), outcome)
|
|
268
401
|
})
|
|
269
402
|
|
|
270
403
|
const readBody = (request: IncomingMessage): Effect.Effect<string, ProviderError> =>
|
|
@@ -285,6 +418,47 @@ const parseJsonBody = (raw: string): Effect.Effect<JsonValue, InvalidRequestErro
|
|
|
285
418
|
catch: () => InvalidRequestError.make({ message: "request body is not valid JSON" })
|
|
286
419
|
})
|
|
287
420
|
|
|
421
|
+
const writeStreamEvent = (response: ServerResponse, event: StreamEvent): void => {
|
|
422
|
+
response.write(`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Stream one turn. The head goes out before the turn resolves so the client
|
|
427
|
+
* has a live response while gemini works; after that point a failure can only
|
|
428
|
+
* be reported as an in-band `error` event, never as a status code.
|
|
429
|
+
*/
|
|
430
|
+
const respondStreaming = (
|
|
431
|
+
response: ServerResponse,
|
|
432
|
+
state: BridgeState,
|
|
433
|
+
body: JsonValue
|
|
434
|
+
): Effect.Effect<void> =>
|
|
435
|
+
Effect.sync(() => {
|
|
436
|
+
response.writeHead(200, {
|
|
437
|
+
"Content-Type": "text/event-stream",
|
|
438
|
+
"Cache-Control": "no-cache",
|
|
439
|
+
Connection: "keep-alive"
|
|
440
|
+
})
|
|
441
|
+
for (const event of turnStartEvents(messageId(), requestModel(body))) {
|
|
442
|
+
writeStreamEvent(response, event)
|
|
443
|
+
}
|
|
444
|
+
}).pipe(
|
|
445
|
+
Effect.andThen(resolveTurn(state, body)),
|
|
446
|
+
Effect.matchEffect({
|
|
447
|
+
onSuccess: (outcome) =>
|
|
448
|
+
Effect.sync(() => {
|
|
449
|
+
for (const event of turnOutcomeEvents(outcome)) {
|
|
450
|
+
writeStreamEvent(response, event)
|
|
451
|
+
}
|
|
452
|
+
response.end()
|
|
453
|
+
}),
|
|
454
|
+
onFailure: (error) =>
|
|
455
|
+
Effect.sync(() => {
|
|
456
|
+
writeStreamEvent(response, turnErrorEvent(error.message))
|
|
457
|
+
response.end()
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
)
|
|
461
|
+
|
|
288
462
|
const writeJson = (response: ServerResponse, status: number, body: JsonValue): void => {
|
|
289
463
|
const text = JSON.stringify(body)
|
|
290
464
|
response.writeHead(status, { "Content-Type": "application/json" })
|
|
@@ -356,7 +530,7 @@ export const runGeminiAcpBridge = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.
|
|
|
356
530
|
const state = yield* makeBridgeState(session, config.cwd, mcpServerUrl, forkBackground)
|
|
357
531
|
|
|
358
532
|
const requestListener = (request: IncomingMessage, response: ServerResponse): void => {
|
|
359
|
-
const path = request.url
|
|
533
|
+
const path = requestPath(request.url)
|
|
360
534
|
if (request.method !== "POST" || (path !== "/v1/messages" && path !== "/mcp")) {
|
|
361
535
|
writeJson(response, 404, { error: { message: "not found" } })
|
|
362
536
|
return
|
|
@@ -369,23 +543,30 @@ export const runGeminiAcpBridge = Effect.fn("@llm4ts/runner/NodeGeminiAcpBridge.
|
|
|
369
543
|
const tools = yield* Ref.get(state.tools)
|
|
370
544
|
const mcpTools = tools.map((tool) => anthropicToolToMcpTool(state, tool))
|
|
371
545
|
const reply = yield* handleMcpRequest(body, mcpTools)
|
|
372
|
-
return
|
|
546
|
+
return yield* Effect.sync(() => writeJson(response, 200, reply ?? {}))
|
|
547
|
+
}
|
|
548
|
+
if (jsonBooleanField(body, "stream") === true) {
|
|
549
|
+
return yield* respondStreaming(response, state, body)
|
|
373
550
|
}
|
|
374
551
|
const reply = yield* handleMessagesRequest(state, body)
|
|
375
|
-
return
|
|
552
|
+
return yield* Effect.sync(() => writeJson(response, 200, reply))
|
|
376
553
|
}).pipe(
|
|
554
|
+
// Only a failure before the head went out can still choose a status;
|
|
555
|
+
// `respondStreaming` owns everything after that and never fails.
|
|
377
556
|
Effect.catch((error) =>
|
|
378
|
-
Effect.
|
|
379
|
-
|
|
380
|
-
|
|
557
|
+
Effect.sync(() => {
|
|
558
|
+
if (!response.headersSent) {
|
|
559
|
+
writeJson(response, errorStatus(error), { error: { message: error.message } })
|
|
560
|
+
}
|
|
381
561
|
})
|
|
382
562
|
)
|
|
383
563
|
)
|
|
384
564
|
|
|
385
|
-
Effect.runPromise(handled).
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
565
|
+
Effect.runPromise(handled).catch((defect: unknown) => {
|
|
566
|
+
if (!response.headersSent) {
|
|
567
|
+
writeJson(response, 500, { error: { message: String(defect) } })
|
|
568
|
+
}
|
|
569
|
+
})
|
|
389
570
|
}
|
|
390
571
|
|
|
391
572
|
const server = yield* acquireServer(config.port, requestListener)
|