@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llm4ts/runner",
3
- "version": "2.2.1",
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.2.1",
63
- "@llm4ts/flow": "2.2.1"
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
- if (geminiRelevant) {
132
- const gemini = geminiPrerequisites(environment)
240
+ const bridge = geminiBridgePrerequisites(environment, readPiModelsJson())
241
+ if (geminiRelevant || bridge !== undefined) {
133
242
  lines.push("")
134
243
  lines.push("prerequisites:")
135
- lines.push(` ${gemini.satisfied ? "✔" : "?"} gemini: ${gemini.summary}`)
136
- if (gemini.hint !== undefined) {
137
- lines.push(...wrap(gemini.hint, 78, " "))
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(prepareConnector(reasoning, workDir, environment))
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
  })