@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.
@@ -0,0 +1,222 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+
5
+ /**
6
+ * Reading `pi`'s own model config (ADR 0016). Separate from `Doctor.ts` so
7
+ * the flow runner can consult it without importing the doctor — which
8
+ * imports the runner — and so the parsing has no dependency on either.
9
+ *
10
+ * pi owns this file; llm4ts only ever reads it.
11
+ */
12
+
13
+ const isSet = (value: string | undefined): value is string =>
14
+ value !== undefined && value.trim().length > 0
15
+
16
+ export const defaultGeminiBridgePort = "8731"
17
+
18
+ /**
19
+ * `~/.pi/agent/models.json`. Read only — llm4ts never writes it. Missing is
20
+ * not an error: the file simply doesn't exist yet.
21
+ */
22
+ export const defaultReadPiModelsJson = (): string | undefined => {
23
+ try {
24
+ return readFileSync(join(homedir(), ".pi", "agent", "models.json"), "utf8")
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ export const geminiBridgePort = (
31
+ environment: Readonly<Record<string, string | undefined>>
32
+ ): string =>
33
+ isSet(environment.LLM4TS_GEMINI_BRIDGE_PORT)
34
+ ? environment.LLM4TS_GEMINI_BRIDGE_PORT.trim()
35
+ : defaultGeminiBridgePort
36
+
37
+ /**
38
+ * One value pi accepts for `--model`: `<provider>/<model>`, where the
39
+ * provider is a key under `providers` in `~/.pi/agent/models.json` and the
40
+ * model is a `models` entry's `id`. `bridged` marks the pairs whose provider
41
+ * points at this machine's ACP bridge — the ones that draw inference from
42
+ * gemini's OAuth session instead of a model API key.
43
+ *
44
+ * `authConfigured` tracks pi's availability rule rather than its schema: a
45
+ * provider with no `apiKey` still loads, but its models stay hidden from
46
+ * `--model` and `--list-models` until `/login`, `auth.json`, or `--api-key`
47
+ * supplies one. The bridge needs no real credential, so a placeholder is
48
+ * enough — but something must be there.
49
+ */
50
+ export interface PiModelRef {
51
+ readonly ref: string
52
+ readonly bridged: boolean
53
+ readonly authConfigured: boolean
54
+ }
55
+
56
+ /**
57
+ * What pi makes of `~/.pi/agent/models.json`. `problems` mirrors pi's own
58
+ * schema validation, and a non-empty list means pi loads *nothing* from the
59
+ * file — it reports "errors loading models.json" and falls back to built-in
60
+ * providers, so `refs` is what the file intended, not what pi will accept.
61
+ */
62
+ export interface PiModelsConfig {
63
+ readonly refs: ReadonlyArray<PiModelRef>
64
+ readonly problems: ReadonlyArray<string>
65
+ }
66
+
67
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
68
+ typeof value === "object" && value !== null && !Array.isArray(value)
69
+
70
+ /** Annotated rather than asserted, matching `NodeGeminiAcpBridge.parseJson`. */
71
+ const parseJson = (raw: string): unknown => {
72
+ try {
73
+ return JSON.parse(raw)
74
+ } catch {
75
+ return undefined
76
+ }
77
+ }
78
+
79
+ /**
80
+ * pi's schema requires each `models` entry to be an object carrying a string
81
+ * `id`; a bare `"model-name"` string fails validation with
82
+ * `models.<i>: must be object` and takes the whole file down with it.
83
+ */
84
+ const modelId = (entry: unknown): string | undefined => {
85
+ if (!isRecord(entry)) {
86
+ return undefined
87
+ }
88
+ const id = entry.id
89
+ return typeof id === "string" && id.length > 0 ? id : undefined
90
+ }
91
+
92
+ /**
93
+ * Read pi's config the way pi reads it, so a report can say why a model is
94
+ * missing rather than only that it is. Deliberately total: an unreadable or
95
+ * malformed file yields problems, never a raised error, because this runs
96
+ * only to explain a failure and must never become one.
97
+ */
98
+ export const piModelsConfig = (modelsJson: string | undefined, port: string): PiModelsConfig => {
99
+ if (modelsJson === undefined) {
100
+ return { refs: [], problems: [] }
101
+ }
102
+ const parsed = parseJson(modelsJson)
103
+ if (parsed === undefined) {
104
+ return { refs: [], problems: ["models.json is not valid JSON"] }
105
+ }
106
+ const providers = isRecord(parsed) ? parsed.providers : undefined
107
+ if (!isRecord(providers)) {
108
+ return { refs: [], problems: ["providers: must be an object"] }
109
+ }
110
+ const refs: Array<PiModelRef> = []
111
+ const problems: Array<string> = []
112
+ for (const [provider, entry] of Object.entries(providers)) {
113
+ if (!isRecord(entry)) {
114
+ problems.push(`providers.${provider}: must be an object`)
115
+ continue
116
+ }
117
+ const baseUrl = entry.baseUrl
118
+ const bridged = typeof baseUrl === "string" && baseUrl.includes(`127.0.0.1:${port}`)
119
+ // pi resolves `apiKey` from a literal, `$ENV`, or a `!command`; any of
120
+ // them counts as configured here, because presence is the availability
121
+ // rule and llm4ts must never resolve the value itself.
122
+ const authConfigured = typeof entry.apiKey === "string" && entry.apiKey.length > 0
123
+ const models = entry.models
124
+ if (models === undefined) {
125
+ continue
126
+ }
127
+ if (!Array.isArray(models)) {
128
+ problems.push(`providers.${provider}.models: must be an array`)
129
+ continue
130
+ }
131
+ if (typeof baseUrl !== "string" || baseUrl.length === 0) {
132
+ problems.push(`providers.${provider}.baseUrl: required for a provider with models`)
133
+ }
134
+ models.forEach((model, index) => {
135
+ const id = modelId(model)
136
+ if (id === undefined) {
137
+ problems.push(`providers.${provider}.models.${index}: must be an object with a string id`)
138
+ return
139
+ }
140
+ refs.push({ ref: `${provider}/${id}`, bridged, authConfigured })
141
+ })
142
+ }
143
+ return { refs, problems }
144
+ }
145
+
146
+ /** Back-compat shape for callers that only want the pairs. */
147
+ export const piModelRefs = (
148
+ modelsJson: string | undefined,
149
+ port: string
150
+ ): ReadonlyArray<PiModelRef> => piModelsConfig(modelsJson, port).refs
151
+
152
+ /** The `provider/model` values that reach gemini through the bridge. */
153
+ export const bridgeModelRefs = (
154
+ modelsJson: string | undefined,
155
+ port: string
156
+ ): ReadonlyArray<string> =>
157
+ piModelRefs(modelsJson, port)
158
+ .filter((entry) => entry.bridged)
159
+ .map((entry) => entry.ref)
160
+
161
+ /**
162
+ * Which bridge-backed model a run should hand pi, decided from pi's own
163
+ * config rather than a guessed default. Shared by the flow runner and the
164
+ * ADR 0016 smoke test so both fail the same way for the same reason.
165
+ */
166
+ export type BridgeModelResolution =
167
+ | { readonly _tag: "Resolved"; readonly model: string }
168
+ | { readonly _tag: "Ambiguous"; readonly candidates: ReadonlyArray<string> }
169
+ | { readonly _tag: "Unavailable"; readonly problems: ReadonlyArray<string> }
170
+
171
+ export const resolveBridgeModel = (
172
+ modelsJson: string | undefined,
173
+ port: string,
174
+ configured: string | undefined
175
+ ): BridgeModelResolution => {
176
+ if (isSet(configured)) {
177
+ return { _tag: "Resolved", model: configured.trim() }
178
+ }
179
+ const { refs, problems } = piModelsConfig(modelsJson, port)
180
+ // pi discards the whole file on any schema error, so a bridge entry that
181
+ // parses here is still invisible to pi. Never offer one.
182
+ if (problems.length > 0) {
183
+ return { _tag: "Unavailable", problems }
184
+ }
185
+ const candidates = refs
186
+ .filter((entry) => entry.bridged && entry.authConfigured)
187
+ .map((entry) => entry.ref)
188
+ const [only] = candidates
189
+ if (only !== undefined && candidates.length === 1) {
190
+ return { _tag: "Resolved", model: only }
191
+ }
192
+ return candidates.length === 0
193
+ ? { _tag: "Unavailable", problems: [] }
194
+ : { _tag: "Ambiguous", candidates }
195
+ }
196
+
197
+ /** Why no bridge model could be chosen, phrased for a run that must stop. */
198
+ export const bridgeModelProblem = (
199
+ resolution: BridgeModelResolution,
200
+ port: string
201
+ ): string | undefined => {
202
+ if (resolution._tag === "Resolved") {
203
+ return undefined
204
+ }
205
+ if (resolution._tag === "Ambiguous") {
206
+ return (
207
+ `Several bridge models are available; set LLM4TS_GEMINI_BRIDGE_MODEL to one of:\n` +
208
+ resolution.candidates.map((ref) => ` ${ref}`).join("\n")
209
+ )
210
+ }
211
+ const detail =
212
+ resolution.problems.length > 0
213
+ ? ` It currently fails pi's schema:\n${resolution.problems.map((p) => ` ${p}`).join("\n")}`
214
+ : ""
215
+ return (
216
+ `No usable bridge model in ~/.pi/agent/models.json for port ${port}. Add a provider ` +
217
+ `with baseUrl "http://127.0.0.1:${port}", any placeholder apiKey, and a models entry ` +
218
+ `shaped { "id": "..." } (see docs/configuration.md).${detail}\n` +
219
+ `\`pi --list-models\` is the final word on what pi accepts; ` +
220
+ `\`LLM4TS_GEMINI_BRIDGE=1 llm4ts doctor\` explains what it makes of the file.`
221
+ )
222
+ }