- OllamaRequestBody.encodeNativeMultimodal + encodeOpenAICompatMultimodal
produce the OpenAI-vision data-URL block:
{type:image_url, image_url:{url:'data:<mediaType>;base64,<b64>'}}
- Both T1 (Ollama gemma4:31b) and T2 (vllm-mlx Qwen3.5 via useOpenAICompat=true)
consume the same encoder
- OllamaProvider.stream(messages:images:...) routes through the new encoders;
empty images still produce byte-identical bodies
- All 5 OllamaImageBlockEncodingTests + 147 existing tests pass
- OllamaProvider actor conforms to LLMProvider; branches between
/api/chat NDJSON (default) and /v1/chat/completions SSE on
useOpenAICompat flag.
- OpenAICompatDecoder handles atomic tool_calls and accepts arguments
as either a JSON-encoded string (OpenAI convention) or object.
Distinct state machine from NDJSONDecoder per the plan's
"zero shared decoder state" directive.
- [DONE] sentinel emits stopReason+usage+messageStop trio; mid-stream
EOF emits .stopReason(.streamTruncated).
- 8 tests for OpenAICompatDecoder + 1 conformance compile check.
- NDJSONDecoder emits .toolUseRequested whenever tool_calls is present on
any chunk; never gates on done:true (AGENT-04 transport gotcha).
- Each tool_use gets a fresh UUID id (Ollama emits no ids on /api/chat).
- done_reason mapping: stop→endTurn (or toolUse if calls seen), length→
maxTokens, tool_calls→toolUse, missing→toolUse-if-calls-else-endTurn.
- Mid-stream EOF emits .stopReason(.streamTruncated) — no
partialToolUseAtDisconnect (NDJSON is atomic per line).
- OllamaRequestBody.encodeNative drops the tools array entirely when
ToolChoice == .none (AGENT-07 cap-recovery invariant).
- ToolChoice.tool(name:) appends a best-effort system hint message
(Ollama has no first-class tool-choice.force).
- OpenAI-compat path: tool_choice serializes as "auto"/"none"/"required"
string or {type:function,function:{name}} object.
- Replaces Plan 04-01 placeholder files; tests: 19 passing.