Files
james.maes a731b5db6e feat(04-01): AnthropicProvider with URLSession SSE decoder covering all six AGENT-03 edges + 1h cache TTL header wiring (AGENT-02, AGENT-03, AGENT-07)
- AnthropicProvider actor: hand-rolled URLSession + per-request apiKeyProvider closure (S-5 fetch-per-request) + nonisolated stream(...) returning AsyncThrowingStream<LLMEvent, Error>
- AGENT-02: every request carries anthropic-version: 2023-06-01 + anthropic-beta: extended-cache-ttl-2025-04-11 (unconditional) + x-api-key + Accept: text/event-stream + Content-Type: application/json
- SSELineReader: generic over AsyncSequence<UInt8> — same code path for URLSession.AsyncBytes + test fixtures; multi-line data: concat with \n; CRLF tolerant; flushes pending frame on EOF
- SSEDecoder: state machine covering all AGENT-03 edges
  - message_stop (NOT message_delta) is canonical close
  - ping events swallowed
  - thinking_delta routes to its own LLMEvent case
  - stop_reason: refusal first-class (not warning)
  - input_json_delta chunks concatenated; one toolUseRequested on content_block_stop
  - empty partial_json chunks skipped
  - mid-stream EOF emits partialToolUseAtDisconnect, then streamTruncated, then messageStop
- RequestBody: Codable encoder shapes Anthropic JSON via typed JSONValue enum (no [String: Any]); cache_control attaches to first system block with ttl: 1h when CacheHints.extended1h; tools.input_schema embedded inline (NOT double-encoded as string); ToolChoice.none → {"type":"none"}
- Base64URL helper for Plan 04-04 turnNonce wrapping (SEC-06)
- 7 RequestBodyTests + 11 SSEDecoderTests passing
2026-04-24 17:21:04 -05:00

249 lines
9.6 KiB
Swift

import Foundation
import AgentCore
import os.log
/// Hand-rolled Anthropic SSE state machine. Covers every AGENT-03 edge case:
/// - `message_stop` (NOT `message_delta`) is the canonical close.
/// - `ping` events are swallowed (zero LLMEvents emitted).
/// - `thinking_delta` routes to its own LLMEvent case.
/// - `stop_reason: "refusal"` maps to `.refusal` (first-class, not a warning).
/// - `input_json_delta` chunks are concatenated; one `.toolUseRequested` is
/// emitted on `content_block_stop` with the assembled JSON.
/// - Empty `partial_json` chunks are skipped (never appended).
/// - Mid-stream EOF mid-tool-args emits `.partialToolUseAtDisconnect`,
/// `.stopReason(.streamTruncated)`, `.messageStop` in that order.
struct SSEDecoder: Sendable {
/// Mutable state held across frames within one stream.
struct State {
var currentToolUseId: String? = nil
var currentToolUseName: String? = nil
var currentToolUseBuffer: Data = Data()
var capturedStopReason: StopReason? = nil
var capturedUsage: TurnUsage? = nil
var messageId: String? = nil
var model: String? = nil
var messageStopEmitted: Bool = false
}
private static let log = os.Logger(
subsystem: "com.koftwentytwo.jarvis.AgentCore",
category: "AnthropicSSE"
)
/// Dispatch one SSE frame. Returns `true` if `message_stop` was emitted
/// (signaling the caller can stop reading the stream).
static func dispatch(
frame: SSEFrame,
state: inout State,
emit: (LLMEvent) -> Void
) {
switch frame.event {
case "message_start":
handleMessageStart(data: frame.data, state: &state, emit: emit)
case "content_block_start":
handleContentBlockStart(data: frame.data, state: &state, emit: emit)
case "content_block_delta":
handleContentBlockDelta(data: frame.data, state: &state, emit: emit)
case "content_block_stop":
handleContentBlockStop(state: &state, emit: emit)
case "message_delta":
// Captures stop_reason + output_tokens. Does NOT close.
handleMessageDelta(data: frame.data, state: &state)
case "message_stop":
handleMessageStop(state: &state, emit: emit)
case "ping":
// Swallow — zero LLMEvents emitted.
return
case "error":
handleErrorEvent(data: frame.data, emit: emit)
default:
// Unknown event name — log warning, continue. Don't throw.
Self.log.warning("SSEDecoder: unknown event '\(frame.event, privacy: .public)'")
}
}
/// Called when the underlying byte stream ends without seeing
/// `message_stop`. If we were buffering a tool-use, emit
/// `.partialToolUseAtDisconnect` first; then emit a synthesized
/// `.stopReason(.streamTruncated)` + `.messageStop`.
static func flushOnEOF(state: inout State, emit: (LLMEvent) -> Void) {
guard !state.messageStopEmitted else { return }
if let id = state.currentToolUseId {
let req = ToolUseRequest(
id: id,
name: state.currentToolUseName ?? "",
argsJSON: state.currentToolUseBuffer
)
emit(.partialToolUseAtDisconnect(req))
state.currentToolUseId = nil
state.currentToolUseName = nil
state.currentToolUseBuffer = Data()
}
emit(.stopReason(.streamTruncated))
emit(.usage(state.capturedUsage ?? .zero))
emit(.messageStop)
state.messageStopEmitted = true
}
// MARK: - Per-event handlers
private static func handleMessageStart(
data: Data,
state: inout State,
emit: (LLMEvent) -> Void
) {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let message = json["message"] as? [String: Any] else {
log.warning("SSEDecoder: malformed message_start payload")
return
}
let messageId = (message["id"] as? String) ?? ""
let model = (message["model"] as? String) ?? ""
state.messageId = messageId
state.model = model
var prefix: TurnUsage? = nil
if let usage = message["usage"] as? [String: Any] {
prefix = TurnUsage(
inputTokens: (usage["input_tokens"] as? Int) ?? 0,
outputTokens: (usage["output_tokens"] as? Int) ?? 0,
cacheCreationInputTokens: (usage["cache_creation_input_tokens"] as? Int) ?? 0,
cacheReadInputTokens: (usage["cache_read_input_tokens"] as? Int) ?? 0
)
state.capturedUsage = prefix
}
emit(.messageStart(LLMMessageStart(
messageId: messageId,
model: model,
usagePrefix: prefix
)))
}
private static func handleContentBlockStart(
data: Data,
state: inout State,
emit: (LLMEvent) -> Void
) {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let block = json["content_block"] as? [String: Any] else {
log.warning("SSEDecoder: malformed content_block_start payload")
return
}
let blockType = block["type"] as? String ?? ""
switch blockType {
case "tool_use":
let id = (block["id"] as? String) ?? ""
let name = (block["name"] as? String) ?? ""
state.currentToolUseId = id
state.currentToolUseName = name
state.currentToolUseBuffer = Data()
emit(.toolUseBuffering(toolUseId: id))
case "text", "thinking":
// No-op — deltas will follow.
return
default:
log.warning("SSEDecoder: unknown content_block type '\(blockType, privacy: .public)'")
}
}
private static func handleContentBlockDelta(
data: Data,
state: inout State,
emit: (LLMEvent) -> Void
) {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let delta = json["delta"] as? [String: Any] else {
log.warning("SSEDecoder: malformed content_block_delta payload")
return
}
let deltaType = delta["type"] as? String ?? ""
switch deltaType {
case "text_delta":
if let text = delta["text"] as? String {
emit(.textDelta(text))
}
case "input_json_delta":
// Skip empty partial_json chunks — never append "" to the buffer.
if let partial = delta["partial_json"] as? String, !partial.isEmpty {
state.currentToolUseBuffer.append(Data(partial.utf8))
}
case "thinking_delta":
if let thinking = delta["thinking"] as? String {
emit(.thinkingDelta(thinking))
}
default:
log.warning("SSEDecoder: unknown delta type '\(deltaType, privacy: .public)'")
}
}
private static func handleContentBlockStop(
state: inout State,
emit: (LLMEvent) -> Void
) {
// If we were buffering a tool_use block, emit the assembled request
// exactly once and reset state.
if let id = state.currentToolUseId {
let req = ToolUseRequest(
id: id,
name: state.currentToolUseName ?? "",
argsJSON: state.currentToolUseBuffer
)
emit(.toolUseRequested(req))
state.currentToolUseId = nil
state.currentToolUseName = nil
state.currentToolUseBuffer = Data()
}
}
private static func handleMessageDelta(data: Data, state: inout State) {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return }
if let delta = json["delta"] as? [String: Any],
let stopRaw = delta["stop_reason"] as? String {
state.capturedStopReason = mapStopReason(stopRaw)
}
if let usage = json["usage"] as? [String: Any] {
// message_delta carries final output_tokens; merge with what
// message_start already set (input/cache tokens are in prefix).
let outputTokens = (usage["output_tokens"] as? Int) ?? 0
let prior = state.capturedUsage ?? .zero
state.capturedUsage = TurnUsage(
inputTokens: prior.inputTokens,
outputTokens: outputTokens,
cacheCreationInputTokens: prior.cacheCreationInputTokens,
cacheReadInputTokens: prior.cacheReadInputTokens
)
}
}
private static func handleMessageStop(
state: inout State,
emit: (LLMEvent) -> Void
) {
emit(.stopReason(state.capturedStopReason ?? .endTurn))
emit(.usage(state.capturedUsage ?? .zero))
emit(.messageStop)
state.messageStopEmitted = true
}
private static func handleErrorEvent(data: Data, emit: (LLMEvent) -> Void) {
let body = String(data: data, encoding: .utf8) ?? "<non-utf8 error body>"
emit(.providerError(.api(statusCode: -1, body: body)))
}
/// Map Anthropic `stop_reason` strings. Unknown values log a warning
/// and map to `.endTurn` rather than throwing.
static func mapStopReason(_ raw: String) -> StopReason {
switch raw {
case "end_turn": return .endTurn
case "tool_use": return .toolUse
case "max_tokens": return .maxTokens
case "refusal": return .refusal
default:
log.warning("SSEDecoder: unknown stop_reason '\(raw, privacy: .public)'")
return .endTurn
}
}
}