Date: 2026-07-21 Status: GATE PASSED β hard cutover is viable.
Scope: Verify the real @earendil-works/pi-ai
API against live local models, confirm the OpenAI-compatible coverage path, and prove usage/abort/streaming parity. This document gates the hard cutover decision in #947.
Method #
- Cloned the pi mono repo into
references/pi-mono/
(sibling-harness inspection per AGENTS.md). Source inspected:packages/ai/src/{types,models,compat,stream}.ts
,packages/ai/src/providers/
,packages/coding-agent/src/extensions/llama/provider.ts
. - Built a standalone spike (
`bun add @earendil-works/pi-ai@0.79.2`
β the exact version we will depend on) that imports the real published package and calls live models. - Tested against
**LM Studio**(`localhost:1234`
, OpenAI-compatible) and**Ollama**(`localhost:11434`
, OpenAI-compatible/v1
).
Finding 1 β The API in #947 does not exist as described #
#947 referenced ModelRuntime.create()
. That symbol does not exist in the installed SDK (v0.79.2, verified against dist/index.d.ts
and dist/core/sdk.d.ts
). The real substrate is:
(`Model<TApi>`
pi-ai/types.ts
) β carriesid
,api
,provider
,baseUrl
,headers
,compat
,contextWindow
,maxTokens
,cost
. A custom OpenAI-compatible endpoint is aModel<"openai-completions">
with a custombaseUrl
+compat
flags.This is exactly the pattern piβs owncoding-agent/extensions/llama/provider.ts
uses for local llama.cpp servers.(completeSimple
/streamSimple
pi-ai/stream.ts
, re-exported from the package root) β the one-shot and streaming call primitives. Signature:(model, context, options?) => Promise<AssistantMessage> | AssistantMessageEventStream
.β must be called once before any call; registers the per-API stream functions (anthropic, openai-completions, openai-responses, google, etc.).registerBuiltInApiProviders()
/AuthStorage
CredentialStore
β pluggable auth backend.Workstream B(createAgentSession
) builds on this; A constructsModel
objects directly fromagent.yaml
and bypasses Piβs config system.
Finding 2 β Local models (LM Studio / Ollama / llama.cpp) are covered #
pi-ai
ships no native Ollama or llama.cpp provider. This was the flagged coverage risk. It is closed by the OpenAI-compatible path, which is piβs own canonical pattern (see their llama.cpp extension). All three local servers expose /v1/chat/completions
and route through Model<"openai-completions">
.
Live test results (real calls, not mocked):
| Test | Result |
|---|---|
completeSimple β LM Studio (qwen3.5-0.8b ) |
stopReason=stop , answer β68β, 2011ms |
streamSimple β LM Studio |
streamed β68β, TTFT 73ms, usage in done event |
streamSimple + AbortSignal (120ms) |
fired at 119ms β reason=aborted at 121ms (2ms cancel latency), clean loop exit |
streamSimple + AbortSignal (400ms) |
reason=aborted at 400ms, 468 chars produced, clean exit |
streamSimple β Ollama (qwen3.5:4b ) | βThe sky is usually blue at noon.β, usage input=30 output=220 total=250 |
Keyless servers work by passing a dummy apiKey
in options (LM Studio / Ollama ignore the header). The credential resolver short-circuits on any non-empty string.
Finding 3 β Usage / stopReason / abort parity is clean #
pi-ai
Usage
β Signet LlmUsage
(platform/core/src/types.ts
) is a field-for-field map:
pi-ai Usage |
Signet LlmUsage |
|---|---|
input |
inputTokens |
output |
outputTokens |
cacheRead |
cacheReadTokens |
cacheWrite |
cacheCreationTokens |
cost.total |
totalCost |
| β (tracked by adapter) | totalDurationMs |
AssistantMessage.stopReason
(stop
/ length
/ toolUse
/ error
/ aborted
) maps to adapter error handling: error
/aborted
throw; the rest return. AbortSignal
propagates through options.signal
and terminates the stream with reason=aborted
β no swallowed errors, no orphaned connections. This satisfies AGENTS.md βSilent Failure And Fallbacks.β
Finding 4 β The adapter is small #
The createPiModelProvider(): LlmProvider
adapter is ~100β150 LOC:
`generate(prompt, opts)`
β`completeSimple(model, { messages: [...] }, { apiKey, signal, maxTokens, temperature })`
β join text content β return string.generateWithUsage(prompt, opts)
β same β`{ text, usage }`
via the map above.`available()`
βtrue
(model is constructed from config; no live ping needed).responseFormat: "json"
andthink
map to pi-ai options (openai-completionsresponse_format
;reasoning
thinking level) β detail to nail in Phase 1.
The 35 LlmProvider
call sites are unchanged. LlmProvider
stays the internal seam; Pi is the single implementation behind it (plus the retained/promoted ACPX backend).
Gate verdict #
Hard cutover is viable. The flagged coverage gap (no native Ollama/llama.cpp) is closed by the OpenAI-compatible path, which is piβs own canonical pattern for local servers. Usage, streaming, and abort all behave correctly against live models. No load-bearing gap was found that would force a fallback path.
**Carry-forward to Phase 1:**
`registerBuiltInApiProviders()`
must run once at daemon init, before any provider call.- Keyless local servers need a dummy
apiKey
(not a special code path β the resolver short-circuits on it). compat
flags per model matter (LM Studio/Ollama:supportsStore:false
,maxTokensField:"max_tokens"
, etc.) β derive from the server, not hardcode.responseFormat:"json"
andthink
mapping to confirm against a real cloud provider (not testable locally) during Phase 1.
The deprecated providers (Claude Code subprocess, OpenCode, Codex CLI, generic command-line, native Ollama/llama.cpp HTTP) are folded: their capability is provided by Piβs API providers and the OpenAI-compatible local path, plus the retained ACPX backend.