Files
james.maes cbc5efcc02 feat(04-02): OllamaProvider actor + OpenAI-compat SSE decoder behind feature flag
- 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.
2026-04-24 17:41:03 -05:00

159 lines
6.5 KiB
Swift

import Foundation
import AgentCore
/// SSE state machine for Ollama's OpenAI-compatible endpoint
/// (`/v1/chat/completions`).
///
/// Distinct from the Anthropic SSE decoder because:
/// - Frame shape is `data: {...}\n\n` with no `event:` field.
/// - Tool calls arrive **atomically** (`choices[0].delta.tool_calls` is a
/// complete object), unlike Anthropic which splits args across deltas.
/// - The terminator is the literal `data: [DONE]` sentinel, not a typed event.
/// - `arguments` may be a JSON-encoded **string** (OpenAI convention) rather
/// than an object (Ollama native). Decoder accepts both.
struct OpenAICompatDecoder {
private(set) var emittedMessageStart = false
private(set) var messageStopEmitted = false
private(set) var capturedStopReason: StopReason?
/// Buffer per call_id for any future split arguments — OpenAI rarely
/// splits but the spec allows it.
private(set) var toolCallArgBuffers: [String: Data] = [:]
private(set) var toolCallNames: [String: String] = [:]
/// Insertion order of call_ids so we emit toolUseRequested deterministically.
private(set) var toolCallOrder: [String] = []
/// Dispatch one SSE line (raw, including the `data: ` prefix when present).
mutating func dispatch(
line: String,
into yield: (LLMEvent) -> Void
) {
if line.isEmpty { return }
// Comments / heartbeats start with ":". Ignore.
if line.hasPrefix(":") { return }
// Per SSE spec other field types could appear; we only handle `data:`.
guard line.hasPrefix("data:") else { return }
// Strip "data:" and any single leading space.
var payload = String(line.dropFirst(5))
if payload.first == " " { payload.removeFirst() }
if payload == "[DONE]" {
// Flush any buffered tool calls before stop.
flushPendingToolCalls(into: yield)
if !emittedMessageStart {
yield(.messageStart(LLMMessageStart(
messageId: UUID().uuidString, model: "", usagePrefix: nil
)))
emittedMessageStart = true
}
let reason = capturedStopReason ?? .endTurn
yield(.stopReason(reason))
yield(.usage(.zero))
yield(.messageStop)
messageStopEmitted = true
return
}
// Parse JSON payload.
guard let raw = try? JSONSerialization.jsonObject(with: Data(payload.utf8)),
let obj = raw as? [String: Any] else {
yield(.providerError(.decode(reason: "OpenAI-compat SSE payload not JSON: \(payload)")))
return
}
// Synthesize messageStart on first JSON frame.
if !emittedMessageStart {
let modelStr = (obj["model"] as? String) ?? ""
yield(.messageStart(LLMMessageStart(
messageId: UUID().uuidString,
model: modelStr,
usagePrefix: nil
)))
emittedMessageStart = true
}
guard let choices = obj["choices"] as? [[String: Any]],
let first = choices.first else { return }
// delta.content
if let delta = first["delta"] as? [String: Any] {
if let content = delta["content"] as? String, !content.isEmpty {
yield(.textDelta(content))
}
// delta.tool_calls
if let calls = delta["tool_calls"] as? [[String: Any]] {
for call in calls {
let id = (call["id"] as? String) ?? UUID().uuidString
if toolCallOrder.contains(id) == false {
toolCallOrder.append(id)
}
if let function = call["function"] as? [String: Any] {
if let name = function["name"] as? String {
toolCallNames[id] = name
}
if let args = function["arguments"] {
// OpenAI emits arguments as a JSON-encoded string.
// Ollama's compat layer may emit either string or object.
let chunk: Data
if let str = args as? String {
chunk = Data(str.utf8)
} else {
chunk = (try? JSONSerialization.data(
withJSONObject: args,
options: [.sortedKeys]
)) ?? Data()
}
toolCallArgBuffers[id, default: Data()].append(chunk)
}
}
}
}
}
// finish_reason
if let reason = first["finish_reason"] as? String {
switch reason {
case "stop": capturedStopReason = .endTurn
case "length": capturedStopReason = .maxTokens
case "tool_calls": capturedStopReason = .toolUse
default: capturedStopReason = .endTurn
}
// Flush accumulated tool calls now that finish_reason is known.
flushPendingToolCalls(into: yield)
}
}
/// Called when the byte stream ends without a `[DONE]` sentinel.
mutating func flushOnEOF(into yield: (LLMEvent) -> Void) {
if messageStopEmitted { return }
flushPendingToolCalls(into: yield)
if !emittedMessageStart {
yield(.messageStart(LLMMessageStart(
messageId: UUID().uuidString, model: "", usagePrefix: nil
)))
emittedMessageStart = true
}
yield(.stopReason(.streamTruncated))
yield(.usage(.zero))
yield(.messageStop)
messageStopEmitted = true
}
/// Emit toolUseBuffering + toolUseRequested for any buffered calls and
/// clear the buffers (so we don't re-emit on `[DONE]`).
private mutating func flushPendingToolCalls(into yield: (LLMEvent) -> Void) {
guard !toolCallOrder.isEmpty else { return }
for id in toolCallOrder {
guard let name = toolCallNames[id] else { continue }
let args = toolCallArgBuffers[id] ?? Data("{}".utf8)
yield(.toolUseBuffering(toolUseId: id))
yield(.toolUseRequested(ToolUseRequest(
id: id, name: name, argsJSON: args
)))
}
toolCallOrder.removeAll()
toolCallNames.removeAll()
toolCallArgBuffers.removeAll()
}
}