@llm4ts/runner 2.2.1 → 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 +10 -1
- package/dist/Doctor.d.ts.map +1 -1
- package/dist/Doctor.js +99 -6
- 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 +112 -0
- package/dist/NodeGeminiAcpBridge.d.ts.map +1 -0
- package/dist/NodeGeminiAcpBridge.js +377 -0
- package/dist/NodeGeminiAcpBridge.js.map +1 -0
- 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 +5 -3
- package/src/Doctor.ts +126 -6
- package/src/FlowRunner.ts +85 -3
- package/src/NodeGeminiAcpBridge.ts +576 -0
- package/src/PiModels.ts +222 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/runner",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Node runner, CLI, terminal, and MCP composition for llm4ts",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./FlowRunner": "./dist/FlowRunner.js",
|
|
23
23
|
"./Kits": "./dist/Kits.js",
|
|
24
24
|
"./McpStdio": "./dist/McpStdio.js",
|
|
25
|
+
"./NodeGeminiAcpBridge": "./dist/NodeGeminiAcpBridge.js",
|
|
25
26
|
"./NodeHttpClient": "./dist/NodeHttpClient.js",
|
|
26
27
|
"./NodeGeminiCliExecutor": "./dist/NodeGeminiCliExecutor.js",
|
|
27
28
|
"./NodePlainFileStore": "./dist/NodePlainFileStore.js",
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
"./NodeWorkspace": "./dist/NodeWorkspace.js",
|
|
31
32
|
"./NodeRuntime": "./dist/NodeRuntime.js",
|
|
32
33
|
"./Packs": "./dist/Packs.js",
|
|
34
|
+
"./PiModels": "./dist/PiModels.js",
|
|
33
35
|
"./ReviewFingerprint": "./dist/ReviewFingerprint.js",
|
|
34
36
|
"./Terminal": "./dist/Terminal.js",
|
|
35
37
|
"./TerminalInteraction": "./dist/TerminalInteraction.js",
|
|
@@ -59,8 +61,8 @@
|
|
|
59
61
|
"typescript"
|
|
60
62
|
],
|
|
61
63
|
"dependencies": {
|
|
62
|
-
"@llm4ts/core": "2.
|
|
63
|
-
"@llm4ts/flow": "2.
|
|
64
|
+
"@llm4ts/core": "2.3.1",
|
|
65
|
+
"@llm4ts/flow": "2.3.1"
|
|
64
66
|
},
|
|
65
67
|
"peerDependencies": {
|
|
66
68
|
"effect": "4.0.0-rc.115"
|
package/src/Doctor.ts
CHANGED
|
@@ -3,6 +3,11 @@ import type { HealthStatus } from "@llm4ts/core/Models"
|
|
|
3
3
|
import { ConnectorIds } from "@llm4ts/core/Models"
|
|
4
4
|
import type { ConnectorRegistryShape } from "@llm4ts/core/ConnectorRegistry"
|
|
5
5
|
import { nodeFlowRunnerDependencies } from "./FlowRunner.ts"
|
|
6
|
+
import {
|
|
7
|
+
defaultReadPiModelsJson as readPiModels,
|
|
8
|
+
geminiBridgePort as bridgePort,
|
|
9
|
+
piModelsConfig as parsePiModels
|
|
10
|
+
} from "./PiModels.ts"
|
|
6
11
|
|
|
7
12
|
const credentialKeys = [
|
|
8
13
|
"OPENAI_API_KEY",
|
|
@@ -28,6 +33,8 @@ export interface PrerequisiteReport {
|
|
|
28
33
|
readonly satisfied: boolean
|
|
29
34
|
readonly summary: string
|
|
30
35
|
readonly hint?: string
|
|
36
|
+
/** Extra lines rendered under the summary, already indented relative to it. */
|
|
37
|
+
readonly detail?: ReadonlyArray<string>
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
/**
|
|
@@ -98,9 +105,111 @@ const wrap = (text: string, width: number, indent: string): ReadonlyArray<string
|
|
|
98
105
|
const selectedCoder = (environment: Readonly<Record<string, string | undefined>>): string =>
|
|
99
106
|
environment.LLM4TS_CODER ?? "claude (default)"
|
|
100
107
|
|
|
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"
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether pi's custom-provider config points at this machine's Gemini ACP
|
|
127
|
+
* bridge — detect-and-report only, never auto-fixed (ADR 0016). `undefined`
|
|
128
|
+
* means the check doesn't apply: nothing asked for the bridge.
|
|
129
|
+
*/
|
|
130
|
+
export const geminiBridgePrerequisites = (
|
|
131
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
132
|
+
modelsJson: string | undefined
|
|
133
|
+
): PrerequisiteReport | undefined => {
|
|
134
|
+
if (!truthy(environment.LLM4TS_GEMINI_BRIDGE)) {
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
const port = bridgePort(environment)
|
|
138
|
+
const needle = `127.0.0.1:${port}`
|
|
139
|
+
const hint =
|
|
140
|
+
`add a custom provider to ~/.pi/agent/models.json with baseUrl ` +
|
|
141
|
+
`"http://${needle}" so pi draws inference from the gemini-cli bridge instead ` +
|
|
142
|
+
`of a model API key (ADR 0016) — llm4ts never writes this file for you.`
|
|
143
|
+
if (modelsJson === undefined) {
|
|
144
|
+
return { satisfied: false, summary: "~/.pi/agent/models.json not found", hint }
|
|
145
|
+
}
|
|
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
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
101
209
|
export const makeDoctorProgram = (
|
|
102
210
|
registry: ConnectorRegistryShape = nodeFlowRunnerDependencies().registry,
|
|
103
|
-
environment: Readonly<Record<string, string | undefined>> = process.env
|
|
211
|
+
environment: Readonly<Record<string, string | undefined>> = process.env,
|
|
212
|
+
readPiModelsJson: () => string | undefined = readPiModels
|
|
104
213
|
): Effect.Effect<string> =>
|
|
105
214
|
Effect.map(registry.healthCheckAll, (statuses) => {
|
|
106
215
|
const lines: Array<string> = []
|
|
@@ -128,13 +237,24 @@ export const makeDoctorProgram = (
|
|
|
128
237
|
(id) =>
|
|
129
238
|
id.value === ConnectorIds.GeminiCli.value || id.value === ConnectorIds.GeminiApi.value
|
|
130
239
|
)
|
|
131
|
-
|
|
132
|
-
|
|
240
|
+
const bridge = geminiBridgePrerequisites(environment, readPiModelsJson())
|
|
241
|
+
if (geminiRelevant || bridge !== undefined) {
|
|
133
242
|
lines.push("")
|
|
134
243
|
lines.push("prerequisites:")
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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}`)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (geminiRelevant) {
|
|
254
|
+
pushReport("gemini", geminiPrerequisites(environment))
|
|
255
|
+
}
|
|
256
|
+
if (bridge !== undefined) {
|
|
257
|
+
pushReport("pi-gemini-bridge", bridge)
|
|
138
258
|
}
|
|
139
259
|
}
|
|
140
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
|
})
|