feat: B6 — first-class OSS and local model support - #297
Conversation
Adds a provider/model registry and makes Ollama, Kimi, GLM, DeepSeek, Qwen, and OpenRouter first-class. The B1 transport plumbing already existed, but nothing in the codebase *knew* about these models, so four independent tables each assumed the world was Gemini/Anthropic/OpenAI. That produced four defects, all fixed here: 1. detectProvider() returned "gemini" for unrecognised names, so `--model kimi-k3` silently built a Gemini client. 2. Context windows fell back to 100k. Hosted OSS models ship 1M windows (auto-compact fired 13x too early); local models are typically much smaller — a stock qwen2.5-coder:14b is 32768, so auto-compact never fired and the model silently truncated. The local case is worse because it is invisible. 3. resolveApiKey() was a hardcoded 3-way branch, so a Moonshot key had to live in OPENAI_API_KEY and collided with real OpenAI. 4. hasNativeThinking() only matched Gemini, so always-on-reasoning models still got a redundant `think` tool. src/providers/registry.ts is now the single source of truth for wire format, base URL, API-key env vars, context windows, and capabilities. Adding a provider is a data change, not four call-site edits. Two behaviours are specific to open-weight models, both opt-in per preset: - salvage.ts recovers tool calls emitted as bare, fenced, or partially tagged JSON in message content. Verified against qwen2.5-coder:14b on Ollama 0.32.5: the chat template asks for <tool_call> wrappers, the model emits bare JSON, Ollama's parser finds no tags, and the payload lands in content with tool_calls: null. This reproduces on both the native /api/chat and OpenAI-compatible endpoints, so it is a model instruction-following gap, not a translation bug. Without salvage the agent loop sees zero calls and does nothing. Salvage only runs when a turn produced no structured calls, and only promotes names that were actually offered — prose discussing a tool is never executed. - ollama-discovery.ts queries /api/tags for a local model's real context window and warns when the selected model cannot call tools. Always best-effort: Ollama being down degrades to static defaults. Also hardens the OpenAI client for compatible servers generally: tool calls are flushed at end-of-stream (many report finish_reason "stop" even with calls pending), and a malformed argument blob no longer aborts the whole stream. Drive-by: fixes #251 — config.temperature was loaded but never reached the client unless --temperature was passed explicitly. Verified end-to-end against local qwen2.5-coder:14b: salvage fires, the read tool executes, and the agent returns a final answer. 817 tests passing (+83). Closes #296 Part of #251 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| const timer = setTimeout(() => controller.abort(), timeoutMs); | ||
|
|
||
| try { | ||
| const res = await fetch(`${root}/api/tags`, { signal: controller.signal }); |
ReviewRead the design doc first, then the code. I agree with the design — collapsing four parallel tables into one keyed table is the right shape, and the priority argument for local-first is correct. Ran the full gate locally on The framing I most agree with is §"Context windows wrong in both directions": over-estimating a local window is the dangerous direction because nothing surfaces it — no warning, no error, just context falling off the end. Prioritising that over the hosted 1M presets is the right call. §4 is also genuinely well-evidenced: reproducing the bare-JSON emission on both Below: two places the doc claims more than the code delivers, then code findings. Nothing here is a blocker. Design doc vs. implementation1. Salvage is on for six presets, the doc says one. The §1 preset table marks 2. §4's safety claim is stronger than the guard actually is. The doc says the name-must-match check is "what makes this safe rather than reckless". That check does not cover the case where the model outputs the JSON for a real tool as an illustration. Verified against the built code: That gets promoted to a
Worth restating §4 accurately — the doc is the artifact future readers will trust, and "name check makes it safe" invites someone to later relax the start-of-content check thinking the name check has them covered. Code3.
4. Salvage buffering holds an entire legitimate JSON answer to end-of-stream. 5. 6. The one piece with no unit test is the piece that joins the other three. 7. Nit: Things I'd call out as good
HousekeepingPer Verdict: approve with the above as follow-ups. Findings 1 and 2 are doc edits I'd do before merge since they're a few lines; 3–7 are fine as issues. |
…ction warnings, bounded salvage Review: #297 (comment) Verified every finding against the built code before acting; all were reproducible as described. Finding 5 (real bug, silent + dangerous direction): contextWindowFor() searched every provider's table, so a local glm-5.2 on Ollama whose runtime discovery came back empty inherited Z.ai's 1_000_000 instead of the conservative default, and auto-compact never fired. That is the same invisible over-estimation this milestone exists to prevent, reintroduced through the cross-provider search. Agent now receives `provider` and scopes the lookup. Finding 3: provider detection was silent when it guessed. Verified `qwen2.5-coder:14b` matches the hosted `qwen` prefix and routes to DashScope; `llama3.1:8b` falls back to gemini. Both then failed with an error naming neither cause. Added providerDetectionWarning() covering the Ollama `name:tag` shape and the no-prefix-matched fallback. The fallback itself stays — what was wrong was doing it silently. Finding 4: couldBeToolCall() stays true indefinitely for content opening with `{`, so a legitimate JSON answer streamed nothing and landed as one terminal block. Bounded at MAX_SALVAGE_BUFFER (32KB), sized to still admit a `write` call carrying a file body. Finding 6: lifted resolveContextWindow() out of index.ts into cli/context-window.ts. It is where override → discovery → static precedence actually happens, including the "discovery unavailable" path that must return undefined rather than invent a number. Finding 7: drainCalls() now reports discarded malformed arguments through an injected onWarn sink (debug-gated), rather than leaving the model with "missing required parameter" and no trace of why. Injected rather than written directly, since core/ and providers/ must not touch stderr. Finding 2 (doc): §4 credited the name-match check as what makes salvage safe. It is not — verified that an illustrative call for a REAL tool (`{"name":"bash","arguments":{"command":"rm -rf /tmp/x"}}`) is promoted. The actual guards are the whole-content requirement (leading prose defeats salvage entirely), the no-structured-calls precondition, and the HITL confirmation gate. Rewritten to say so, and to name the residual risk we accept. Finding 1: pushed back. The review asked to narrow salvage to ollama+openrouter on the grounds that hosted OSS endpoints emit structured calls properly. That is plausible but unverified by either side — we have no keys for those services. Chose on which error is recoverable instead: enabled-but-unnecessary costs bounded buffering and a structurally tiny misfire risk (payload must be the entire response AND name an offered tool), while disabled-but-necessary means the agent silently does nothing — the headline bug of this PR. Kept salvage on for all OSS presets with the reasoning recorded in the registry, and pinned the policy with a test so future edits are intentional. Also updated stale --provider help text, which still listed only the three first-party providers. 842 tests passing (+25). Re-verified end-to-end against local qwen2.5-coder:14b, and confirmed all three detection-warning cases from the review's table now name the real cause. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — this was a genuinely useful review. I verified every finding against the built code before acting; all seven reproduced exactly as described. Addressed in I took six of them and pushed back on one. Taking the disagreement first. Finding 1 — I disagree, and kept salvage on all OSS presetsThe argument for narrowing is that Kimi/GLM/DeepSeek/Qwen hosted endpoints emit structured calls properly. That is plausible, but neither of us verified it — I have no keys for those services, and the review does not cite a test either. So narrowing rests on the same class of unverified assumption as leaving it on. That makes it a question of which error is recoverable:
The asymmetry favours keeping it on, especially now the cost is bounded. I have recorded that reasoning in the Your underlying point — that the doc claimed one thing and the code did another — was completely right, and that half is fixed. Finding 5 — the best catch, and worse than describedVerified: A local Finding 2 — you were right, my doc credited the wrong mechanismReproduced your case exactly: So the name-match check is not what makes this safe. §4 now credits the three things that actually do — the whole-content requirement, the no-structured-calls precondition, and the HITL gate — and explicitly names the residual risk we accept. Your reasoning for why this matters (that a future reader might relax the start-of-content check believing the name check covers them) is precisely why I wrote it as a warning rather than a footnote. Findings 3, 4, 6, 7 — taken3. All three rows of your table now name the real cause: Agreed the fallback should stay and only the silence was wrong. The 4. Bounded at 32 KB — sized to still admit a 6. Lifted to 7. Reported through an injected HousekeepingSet the 842 tests passing (+25). Re-verified end-to-end against local |
Re-review of
|
Per CLAUDE.md the _Status:_ line must not describe code already on main as pending, so it now records the merge SHA and date. §4's closing line still said salvage was opt-in for "ollama, openrouter" — a leftover from an edit toward narrowing the set. §1 says, and the code does, all six OSS/local presets. Left as-is it would have been the same doc-says-one-thing/code-does-another divergence the review round was about, one section away from the argument for it. Follow-up from #297 (comment) Part of #296 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #296. Part of #251. Design doc:
docs/design/b6-oss-models.md.Makes Ollama (local), Kimi, GLM, DeepSeek, Qwen, and OpenRouter first-class. The B1 transport plumbing already shipped and
openai.tswas already a generic chat-completions client — so these models were reachable. What was missing is that nothing knew about them.The four defects this fixes
detectProvider()returned"gemini"for unrecognised names--model kimi-k3silently built a Gemini client100_000qwen2.5-coder:14b= 32 768) → auto-compact never fired and the model silently truncatedresolveApiKey()was a hardcoded 3-way branchOPENAI_API_KEY, colliding with real OpenAIhasNativeThinking()only matched GeminithinktoolDefect 2 is the one worth pausing on: the local direction is the dangerous one, because over-estimating the window means no compaction warning ever appears — the context just quietly falls off the end.
What landed
src/providers/registry.ts— one table owning wire format, base URL, API-key env vars, context windows, and capabilities.detectProvider(),contextWindowFor(),hasNativeThinking(), andresolveApiKey()all read from it. Adding a provider is now a data change, not four call-site edits.src/providers/ollama-discovery.ts— queries/api/tagsfor a local model's real context window, and warns at startup when the selected model can't call tools. Best-effort throughout: Ollama being down degrades to static defaults rather than crashing.src/providers/salvage.ts— recovers tool calls that open-weight models emit as text.Why salvage is not optional
Verified against
qwen2.5-coder:14bon Ollama 0.32.5. The chat template instructs the model to wrap calls in<tool_call>tags. It emits bare JSON instead:{ "name": "ls", "arguments": { "path": "/tmp" } }Ollama's parser finds no tags and leaves it in
message.contentwithfinish_reason: "stop",tool_calls: null. This reproduces identically on the native/api/chatand the OpenAI-compatible endpoint — so it is a model instruction-following gap, not a bug in Ollama's translation layer or in OpenCLI. The agent loop sees zero calls and ends the turn, meaning local support would connect successfully and then do nothing.Safety rails: salvage runs only when a turn produced no structured calls, and only promotes a name that was actually offered in that request. A model discussing a tool in prose is never silently executed. Opt-in per preset, so first-party providers are untouched.
Incidental hardening
The OpenAI client is now more tolerant of compatible servers generally: tool calls are flushed at end-of-stream (many such servers report
finish_reason: "stop"even with calls pending, which previously stranded them), and one malformed argument blob no longer aborts the whole stream and discards well-formed calls alongside it.Drive-by fix
#251 —
config.temperaturewas loaded intoConfigbut never reached the client unless--temperaturewas passed explicitly, so the configured default was dead.Verification
Real agent turn against local
qwen2.5-coder:14b, no API key, no network:Salvage fires, the tool executes, the agent answers. (The count is wrong — the file has 3 lines. That is 14B-model accuracy, not an OpenCLI defect; the loop, tool execution, and result handling are all correct.)
Context-window resolution against the live instance:
817 tests passing (+83). New coverage: registry lookup/prefix ordering, Ollama discovery (including connection-refused, malformed JSON, missing fields, cache behaviour), salvage (bare / fenced / tagged / arrays / split-across-chunks, plus every must-not-promote case), and the client-level integration.
Not in scope
Architect/editor routing (B5) — depends on B4 (#63,
AgentContextas a serializable value type). This registry is a prerequisite for it, not a delivery of it.🤖 Generated with Claude Code