@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/PiModels.ts
ADDED
|
@@ -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
|
+
}
|