tina4ruby 3.13.112 → 3.13.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c0ba91f4e37ef96e557eb291a221cf419821ad59a00bad3e4fe1dbbfaaa5a97
4
- data.tar.gz: c54f721f615114967ed5f4e6e86b1c3573da6fb85dcf4fcc9003f0840608b848
3
+ metadata.gz: 964e92e8030ced5a8f4af3934fb9bb7e49df0056d795c5025d5176aed2fa06d2
4
+ data.tar.gz: 39fa0f71a1b3219bbaa4382f136c1fb69a4f4760436d75339a50d6906ba5a8e0
5
5
  SHA512:
6
- metadata.gz: 1ada54d7fac14bbfec973040294322d70155c99024c071a4d5a9b558f0ab16d69bf2c57020e92db43ca70a1eeb00099944ddf6d6cf40268ba51f755621426e5c
7
- data.tar.gz: b95811c10bf0db5264f304e19ade1f6849f790c4eec98c4106ed2c7a4201380492082fc4a67ef32fd517e0ad58997a7c3888d49e37e79c845e2d2c92c6fa40d4
6
+ metadata.gz: 8ceab487af3f411513fb2fbd3a057e15a04560c40ff4268d7663d51ebb62c7b955ab2584d17183e82a0194967ce5baa67f7c9465d62b207a6490aa2741f0aeaf
7
+ data.tar.gz: 68564196c7aadba28370621f5d613d8700c0a0e5b137ebbed4f871cb7ce6393a4cd535dd531c2455c5db730f3079088805f16d375441d22c390e64db218fcf6d
data/CHANGELOG.md CHANGED
@@ -6,6 +6,72 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.113
10
+
11
+ Feature: streaming and multimodal AI, plus reusable `Api.stream` primitives
12
+ (ADR-0060).
13
+
14
+ ### Api.stream primitives
15
+
16
+ - `Tina4::API#stream_bytes(path, ...)` streams the response body as raw
17
+ chunks in transport order. Pass a block or take an Enumerator.
18
+ - `Tina4::API#stream_lines(path, ...)` yields one UTF-8 String per LF- or
19
+ CRLF-terminated line; a trailing line without a newline is yielded on EOF;
20
+ multibyte sequences that split across chunks are buffered.
21
+ - `Tina4::API#stream_sse(path, ...)` frames SSE events into
22
+ `{data:, event:, id:, retry:}` Hashes; blank line separates events;
23
+ `:` comment lines are dropped; multi-line `data:` fields are joined with
24
+ `\n`; the OpenAI `[DONE]` sentinel is delivered as the last event and the
25
+ iterator ends. Each primitive is layered on the one below it.
26
+ - `Tina4::APIStreamError` (with `#status`) is raised when a stream opens
27
+ with a non-2xx HTTP status (pre-stream error, no bytes yielded).
28
+
29
+ ### Ai.chat streaming: typed events (breaking)
30
+
31
+ - `Tina4::Ai.chat(messages, stream: true)` now yields typed event Hashes:
32
+ `{ type: :text_delta, text: ... }`, `{ type: :tool_call, id:, name:, args: }`,
33
+ `{ type: :done, finish_reason:, usage: nil }`, or
34
+ `{ type: :error, message:, code: nil }`. Text deltas fire per chunk;
35
+ OpenAI `tool_calls` fragments and Anthropic `input_json_delta` fragments
36
+ are aggregated and emitted as a single `tool_call` event; `done` fires
37
+ exactly once on the terminal wire signal; `error` replaces `done` on a
38
+ mid-stream failure. Malformed tool-call arguments JSON raises
39
+ `Tina4::AiParseError`.
40
+ - The stream path is implemented on top of `Tina4::API#stream_sse`; there is
41
+ one SSE reader for the framework, shared between `Api` and `Ai`.
42
+ - Migration for 3.13.101–3.13.112 callers: `for chunk in stream` becomes
43
+ `for event in stream; event[:text] if event[:type] == :text_delta; end`.
44
+ No shim (feedback_no_aliases, feedback_breaking_changes).
45
+
46
+ ### Ai.chat multimodal content
47
+
48
+ - `message[:content]` accepts a String OR an Array of parts:
49
+ `{ type: "text", text: String }` and
50
+ `{ type: "image", source: "data:<mime>;base64,<payload>" }` or
51
+ `{ type: "image", source: "https://..." }` (also with symbol keys).
52
+ - Parts are translated per provider before the request is sent:
53
+ OpenAI / local get `{ type: "image_url", image_url: { url } }`;
54
+ Anthropic gets `{ type: "image", source: { type: "base64", media_type, data } }`
55
+ for data URIs or `{ type: "image", source: { type: "url", url } }` for
56
+ https URLs.
57
+ - Malformed parts (missing `text` or `source`, unknown `type`, non-string
58
+ values, a `data:` URI without `;base64,`, a plain `http:` URL) raise
59
+ `Tina4::AiConfigError` before any request is sent.
60
+
61
+ ### Tests
62
+
63
+ - New `spec/api_stream_contract_spec.rb` with a real local TCP fixture
64
+ server exercising chunked transport, LF/CRLF line splitting, trailing
65
+ lines, multibyte-across-chunk-boundary buffering, and every SSE framing
66
+ case (single/multi-line/named/comment/blank/[DONE]/retry). Verifies the
67
+ request body reaches the server and that a transport drop raises.
68
+ - Extended `spec/ai_client_contract_spec.rb` with `/stream-openai-tools`,
69
+ `/stream-anthropic-tools`, `/stream-midstream-drop`, and
70
+ `/multimodal-echo` cases proving typed events, tool-call aggregation on
71
+ both providers, one-and-only-one `done`, mid-stream error semantics,
72
+ no-retry-after-first-event, and the OpenAI/Anthropic body shapes for
73
+ multimodal parts.
74
+
9
75
  ## 3.13.107
10
76
 
11
77
  Feature: RBAC role and permission guards (parity across all four frameworks).
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "net/http"
5
5
  require "uri"
6
+ require "openssl"
6
7
 
7
8
  module Tina4
8
9
  class AiError < StandardError; end
@@ -21,16 +22,32 @@ module Tina4
21
22
 
22
23
  ChatResponse = Struct.new(:text, :model, :usage, :finish_reason, :raw, keyword_init: true)
23
24
 
24
- # Zero-dependency app-facing AI client (ADR-0053).
25
+ # Zero-dependency app-facing AI client. ADR-0053 defined the base contract
26
+ # (chat / complete / embed with a normalised, provider-neutral response
27
+ # shape); ADR-0060 extended it with typed streaming events and multimodal
28
+ # content parts. Both live here so the AI surface is a single file.
25
29
  class Ai
26
30
  PROVIDERS = %w[local openai anthropic].freeze
27
31
 
28
32
  class << self
29
- def chat(messages, model: nil, temperature: nil, max_tokens: nil, stream: false, timeout: nil, provider: nil)
33
+ # chat(stream: false) still returns a ChatResponse (ADR-0053).
34
+ # chat(stream: true) returns an Enumerator of typed events (ADR-0060):
35
+ #
36
+ # { type: :text_delta, text: "..." }
37
+ # { type: :tool_call, id: "...", name: "...", args: {...} }
38
+ # { type: :done, finish_reason: "...", usage: {...} | nil }
39
+ # { type: :error, message: "...", code: "..." | nil }
40
+ #
41
+ # The typed events replace the ADR-0053 string-only stream shape. That
42
+ # was a deliberately breaking change (see ADR-0060 §7) so an agent loop
43
+ # could observe tool_calls and finish_reason without hand-rolled SSE
44
+ # parsing per app.
45
+ def chat(messages, model: nil, temperature: nil, max_tokens: nil,
46
+ stream: false, timeout: nil, provider: nil)
30
47
  validate_messages(messages)
31
48
  config = resolve_config("chat", model, timeout, provider)
32
49
  body = chat_body(config, messages, temperature, max_tokens, stream)
33
- return stream_request(config, headers(config), body) if stream
50
+ return stream_events(config, headers(config), body) if stream
34
51
 
35
52
  normalize_chat(config[:provider], request_json(config, headers(config), body))
36
53
  end
@@ -67,14 +84,61 @@ module Tina4
67
84
 
68
85
  private
69
86
 
87
+ # ── validation ─────────────────────────────────────────────────────────
88
+
70
89
  def validate_messages(messages)
71
- valid = messages.is_a?(Array) && !messages.empty? && messages.all? do |message|
72
- message.is_a?(Hash) && %w[system user assistant].include?((message[:role] || message["role"]).to_s) &&
73
- (message.key?(:content) ? message[:content] : message["content"]).is_a?(String)
90
+ unless messages.is_a?(Array) && !messages.empty?
91
+ raise AiConfigError, "AI messages must be a non-empty list"
92
+ end
93
+
94
+ messages.each do |message|
95
+ raise AiConfigError, "AI messages must be objects" unless message.is_a?(Hash)
96
+
97
+ role = (message[:role] || message["role"]).to_s
98
+ unless %w[system user assistant].include?(role)
99
+ raise AiConfigError, "AI messages must contain supported roles"
100
+ end
101
+
102
+ content = message.key?(:content) ? message[:content] : message["content"]
103
+ validate_content(content)
74
104
  end
75
- raise AiConfigError, "AI messages must contain supported roles and string content" unless valid
76
105
  end
77
106
 
107
+ def validate_content(content)
108
+ return if content.is_a?(String)
109
+
110
+ unless content.is_a?(Array) && !content.empty?
111
+ raise AiConfigError, "AI message content must be a string or a non-empty list of parts"
112
+ end
113
+
114
+ content.each { |part| validate_content_part(part) }
115
+ end
116
+
117
+ def validate_content_part(part)
118
+ raise AiConfigError, "AI content parts must be objects" unless part.is_a?(Hash)
119
+
120
+ type = (part[:type] || part["type"]).to_s
121
+ case type
122
+ when "text"
123
+ text = part.key?(:text) ? part[:text] : part["text"]
124
+ raise AiConfigError, "AI text content part must have a string text field" unless text.is_a?(String)
125
+ when "image"
126
+ source = part.key?(:source) ? part[:source] : part["source"]
127
+ raise AiConfigError, "AI image content part must have a string source field" unless source.is_a?(String)
128
+
129
+ if source.start_with?("data:")
130
+ # data:<media_type>;base64,<payload> — enforce the base64 marker
131
+ raise AiConfigError, "AI image data URI must be base64-encoded" unless source.include?(";base64,")
132
+ elsif !source.start_with?("https://")
133
+ raise AiConfigError, "AI image source must be a data:<mime>;base64,<data> URI or an https:// URL"
134
+ end
135
+ else
136
+ raise AiConfigError, "AI content part type must be 'text' or 'image'"
137
+ end
138
+ end
139
+
140
+ # ── configuration ──────────────────────────────────────────────────────
141
+
78
142
  def number(name, default, minimum)
79
143
  value = Float(ENV.fetch(name, default.to_s))
80
144
  raise AiConfigError, "#{name} must be at least #{minimum}" if value < minimum
@@ -142,20 +206,80 @@ module Tina4
142
206
  result
143
207
  end
144
208
 
209
+ # ── request body ───────────────────────────────────────────────────────
210
+
145
211
  def chat_body(config, messages, temperature, max_tokens, stream)
146
- normalized = messages.map { |message| { role: (message[:role] || message["role"]).to_s, content: message.key?(:content) ? message[:content] : message["content"] } }
212
+ provider = config[:provider]
213
+ normalized = messages.map do |message|
214
+ role = (message[:role] || message["role"]).to_s
215
+ content = message.key?(:content) ? message[:content] : message["content"]
216
+ { role: role, content: translate_content(provider, role, content) }
217
+ end
147
218
  body = { model: config[:model], messages: normalized, stream: stream }
148
219
  body[:temperature] = temperature unless temperature.nil?
149
220
  body[:max_tokens] = max_tokens unless max_tokens.nil?
150
- if config[:provider] == "anthropic"
151
- system = normalized.select { |message| message[:role] == "system" }.map { |message| message[:content] }
221
+ if provider == "anthropic"
222
+ system_texts = normalized.select { |message| message[:role] == "system" }.map { |message| system_text(message[:content]) }
152
223
  body[:messages] = normalized.reject { |message| message[:role] == "system" }
153
224
  body[:max_tokens] = max_tokens || 1024
154
- body[:system] = system.join("\n\n") unless system.empty?
225
+ body[:system] = system_texts.join("\n\n") unless system_texts.empty?
155
226
  end
156
227
  body
157
228
  end
158
229
 
230
+ # Anthropic's `system` field is a plain string. If the caller passed a
231
+ # parts array on a system message, concatenate the text parts (image
232
+ # parts on the system role are silently dropped — Anthropic rejects them
233
+ # outright, and validation already ran, so we don't raise a second time).
234
+ def system_text(content)
235
+ return content.to_s unless content.is_a?(Array)
236
+
237
+ content.select { |part| (part[:type] || part["type"]).to_s == "text" }
238
+ .map { |part| part[:text] || part["text"] }.join("\n\n")
239
+ end
240
+
241
+ # Translate parts to each provider's native shape. Strings pass through
242
+ # unchanged. See ADR-0060 §Public surface.
243
+ def translate_content(provider, role, content)
244
+ return content if content.is_a?(String)
245
+ return system_text(content) if provider == "anthropic" && role == "system"
246
+
247
+ content.map do |part|
248
+ type = (part[:type] || part["type"]).to_s
249
+ case type
250
+ when "text"
251
+ { type: "text", text: part[:text] || part["text"] }
252
+ when "image"
253
+ translate_image_part(provider, part[:source] || part["source"])
254
+ end
255
+ end
256
+ end
257
+
258
+ def translate_image_part(provider, source)
259
+ if provider == "anthropic"
260
+ if source.start_with?("data:")
261
+ media_type, payload = split_data_uri(source)
262
+ { type: "image", source: { type: "base64", media_type: media_type, data: payload } }
263
+ else
264
+ { type: "image", source: { type: "url", url: source } }
265
+ end
266
+ else
267
+ # openai and local both accept the OpenAI image_url shape
268
+ { type: "image_url", image_url: { url: source } }
269
+ end
270
+ end
271
+
272
+ def split_data_uri(source)
273
+ # "data:image/png;base64,<payload>"
274
+ header, payload = source.split(",", 2)
275
+ meta = header.to_s.sub(/\Adata:/, "")
276
+ media_type = meta.split(";").first.to_s
277
+ media_type = "application/octet-stream" if media_type.empty?
278
+ [media_type, payload.to_s]
279
+ end
280
+
281
+ # ── non-streaming request path ─────────────────────────────────────────
282
+
159
283
  def http_request(config, deadline, request_headers, body)
160
284
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
161
285
  raise AiTimeoutError, "AI total request timeout expired" unless remaining.positive?
@@ -213,10 +337,13 @@ module Tina4
213
337
  sleep(delay) if delay.positive?
214
338
  end
215
339
 
340
+ # ── response normalisation ─────────────────────────────────────────────
341
+
216
342
  def normalize_chat(provider, raw)
217
343
  if provider == "anthropic"
218
344
  parts = raw.fetch("content").select { |item| item.fetch("type", "text") == "text" }.map { |item| item.fetch("text") }
219
345
  raise KeyError if parts.empty?
346
+
220
347
  prompt = raw.fetch("usage", {}).fetch("input_tokens", 0).to_i
221
348
  completion = raw.fetch("usage", {}).fetch("output_tokens", 0).to_i
222
349
  return ChatResponse.new(text: parts.join, model: raw.fetch("model", "").to_s,
@@ -226,6 +353,7 @@ module Tina4
226
353
  choice = raw.fetch("choices").fetch(0)
227
354
  text = choice.fetch("message").fetch("content")
228
355
  raise TypeError unless text.is_a?(String)
356
+
229
357
  usage = raw.fetch("usage", {})
230
358
  ChatResponse.new(text: text, model: raw.fetch("model", "").to_s,
231
359
  usage: { prompt_tokens: usage.fetch("prompt_tokens", 0).to_i,
@@ -236,75 +364,209 @@ module Tina4
236
364
  raise AiParseError, "AI provider returned a malformed chat response"
237
365
  end
238
366
 
239
- def stream_delta(provider, data)
240
- return [true, nil] if data == "[DONE]"
367
+ # ── streaming (ADR-0060 typed events, built on Api#stream_sse) ─────────
241
368
 
242
- event = JSON.parse(data)
243
- text = if provider == "anthropic"
244
- event["type"] == "content_block_delta" ? event.dig("delta", "text") : nil
245
- else
246
- event.dig("choices", 0, "delta", "content")
247
- end
248
- raise AiParseError, "AI provider returned malformed stream data" unless text.nil? || text.is_a?(String)
369
+ # Split the resolved provider URL into (origin, request_uri) so we can
370
+ # hand an Api client the origin and a path -- Api#build_uri concatenates
371
+ # them without further munging.
372
+ def split_url(url)
373
+ uri = URI.parse(url)
374
+ origin = "#{uri.scheme}://#{uri.host}"
375
+ origin += ":#{uri.port}" if uri.port
376
+ [origin, uri.request_uri]
377
+ end
249
378
 
250
- [false, text]
251
- rescue JSON::ParserError
252
- raise AiParseError, "AI provider returned malformed stream data"
379
+ def stream_events(config, request_headers, body)
380
+ Enumerator.new do |yielder|
381
+ stream_pump(config, request_headers, body, yielder)
382
+ end
253
383
  end
254
384
 
255
- def each_stream_data(response, deadline)
256
- buffer = +""
257
- response.read_body do |chunk|
258
- raise AiTimeoutError, "AI total request timeout expired" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
259
- buffer << chunk
260
- while (index = buffer.index("\n"))
261
- line = buffer.slice!(0..index).strip
262
- yield line.delete_prefix("data:").strip if line.start_with?("data:")
385
+ def stream_pump(config, request_headers, body, yielder)
386
+ provider = config[:provider]
387
+ origin, request_path = split_url(config[:url])
388
+ api = Tina4::API.new(origin, timeout: config[:total_timeout])
389
+ stream_headers = request_headers.merge("Accept" => "text/event-stream")
390
+ request_body_json = JSON.generate(body)
391
+
392
+ state = {
393
+ text_delta_seen: false,
394
+ done_emitted: false,
395
+ error_emitted: false,
396
+ finish_reason: nil,
397
+ openai_tool_calls: {}, # index -> { id:, name:, args_fragments: [] }
398
+ anthropic_tool_blocks: {} # index -> { id:, name:, args_fragments: [] }
399
+ }
400
+
401
+ begin
402
+ catch(:tina4_ai_stream_end) do
403
+ api.stream_sse(
404
+ request_path,
405
+ method: "POST",
406
+ body: request_body_json,
407
+ headers: stream_headers,
408
+ content_type: "application/json",
409
+ timeout: config[:total_timeout],
410
+ connect_timeout: config[:connect_timeout]
411
+ ) do |sse|
412
+ handle_sse_event(provider, sse, yielder, state)
413
+ end
414
+ end
415
+
416
+ # No terminal done/error was emitted -- treat as mid-stream failure.
417
+ emit_error(yielder, state, "AI provider stream ended before terminal event") unless state[:done_emitted] || state[:error_emitted]
418
+ rescue Tina4::APIStreamError => e
419
+ # Pre-stream HTTP error (status arrived before any bytes). Contract:
420
+ # raise, not error-event. Body is not surfaced (never leak secrets).
421
+ raise AiHTTPError.new("AI provider returned HTTP #{e.status}", e.status)
422
+ rescue Net::OpenTimeout
423
+ raise AiTimeoutError, "AI connection timeout expired"
424
+ rescue Net::ReadTimeout, Timeout::Error
425
+ if state[:text_delta_seen]
426
+ emit_error(yielder, state, "AI total request timeout expired")
427
+ else
428
+ raise AiTimeoutError, "AI total request timeout expired"
429
+ end
430
+ rescue AiParseError
431
+ raise
432
+ rescue SocketError, EOFError, IOError, SystemCallError => e
433
+ if state[:text_delta_seen]
434
+ emit_error(yielder, state, "AI transport failed (#{e.class.name})")
435
+ else
436
+ raise AiHTTPError, "AI transport failed (#{e.class.name})"
263
437
  end
264
438
  end
265
439
  end
266
440
 
267
- def stream_request(config, request_headers, body)
268
- Enumerator.new do |yielder|
269
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:total_timeout]
270
- yielded = false
271
- (config[:max_retries] + 1).times do |attempt|
272
- begin
273
- http, request = http_request(config, deadline, request_headers.merge("Accept" => "text/event-stream"), body)
274
- retry_response = false
275
- completed = false
276
- http.request(request) do |response|
277
- status = response.code.to_i
278
- unless status.between?(200, 299)
279
- response.read_body { |_chunk| nil }
280
- if (status == 429 || status >= 500) && attempt < config[:max_retries]
281
- retry_delay(response, deadline)
282
- retry_response = true
283
- next
284
- end
285
- raise AiHTTPError.new("AI provider returned HTTP #{status}", status)
286
- end
287
- each_stream_data(response, deadline) do |data|
288
- completed, text = stream_delta(config[:provider], data)
289
- break if completed
290
- next if text.nil?
291
- yielded = true
292
- yielder << text
293
- end
294
- end
295
- next if retry_response
296
- raise AiParseError, "AI provider stream ended before [DONE]" unless completed
297
- break
298
- rescue Net::OpenTimeout
299
- raise AiTimeoutError, "AI connection timeout expired" if yielded || attempt >= config[:max_retries]
300
- rescue Net::ReadTimeout, Timeout::Error
301
- raise AiTimeoutError, "AI total request timeout expired" if yielded || attempt >= config[:max_retries]
302
- rescue SocketError, EOFError, IOError, SystemCallError => e
303
- raise AiHTTPError, "AI transport failed (#{e.class.name})" if yielded || attempt >= config[:max_retries]
441
+ def handle_sse_event(provider, sse, yielder, state)
442
+ data = sse[:data]
443
+ return if data.nil?
444
+
445
+ if data == "[DONE]"
446
+ # OpenAI sentinel -- flush any pending tool_calls, then emit :done.
447
+ flush_openai_tool_calls(yielder, state)
448
+ emit_done(yielder, state)
449
+ throw :tina4_ai_stream_end
450
+ end
451
+
452
+ return if data.empty?
453
+
454
+ parsed = begin
455
+ JSON.parse(data)
456
+ rescue JSON::ParserError
457
+ raise AiParseError, "AI provider returned malformed stream data"
458
+ end
459
+
460
+ if provider == "anthropic"
461
+ handle_anthropic_event(parsed, yielder, state)
462
+ else
463
+ handle_openai_event(parsed, yielder, state)
464
+ end
465
+ end
466
+
467
+ def handle_openai_event(parsed, yielder, state)
468
+ choice = (parsed["choices"] || []).first
469
+ return unless choice
470
+
471
+ delta = choice["delta"] || {}
472
+ content = delta["content"]
473
+ if content.is_a?(String) && !content.empty?
474
+ yielder << { type: :text_delta, text: content }
475
+ state[:text_delta_seen] = true
476
+ end
477
+
478
+ (delta["tool_calls"] || []).each do |tool_call|
479
+ index = tool_call["index"] || 0
480
+ entry = state[:openai_tool_calls][index] ||= { id: nil, name: nil, args_fragments: [] }
481
+ entry[:id] = tool_call["id"] if tool_call["id"]
482
+ function = tool_call["function"] || {}
483
+ entry[:name] = function["name"] if function["name"]
484
+ entry[:args_fragments] << function["arguments"] if function["arguments"].is_a?(String)
485
+ end
486
+
487
+ reason = choice["finish_reason"]
488
+ return unless reason
489
+
490
+ state[:finish_reason] = reason
491
+ # The definitive trigger to emit aggregated tool_calls. Some providers
492
+ # send finish_reason=stop even when tool_calls were streamed, so flush
493
+ # on any finish_reason -- the flush is a no-op when no calls buffered.
494
+ flush_openai_tool_calls(yielder, state)
495
+ end
496
+
497
+ def handle_anthropic_event(parsed, yielder, state)
498
+ case parsed["type"]
499
+ when "content_block_start"
500
+ block = parsed["content_block"] || {}
501
+ if block["type"] == "tool_use"
502
+ state[:anthropic_tool_blocks][parsed["index"]] = {
503
+ id: block["id"], name: block["name"], args_fragments: []
504
+ }
505
+ end
506
+ when "content_block_delta"
507
+ delta = parsed["delta"] || {}
508
+ case delta["type"]
509
+ when "text_delta"
510
+ text = delta["text"]
511
+ if text.is_a?(String) && !text.empty?
512
+ yielder << { type: :text_delta, text: text }
513
+ state[:text_delta_seen] = true
304
514
  end
515
+ when "input_json_delta"
516
+ entry = state[:anthropic_tool_blocks][parsed["index"]]
517
+ entry[:args_fragments] << (delta["partial_json"] || "") if entry
305
518
  end
519
+ when "content_block_stop"
520
+ entry = state[:anthropic_tool_blocks].delete(parsed["index"])
521
+ emit_anthropic_tool_call(yielder, entry) if entry
522
+ when "message_delta"
523
+ reason = (parsed["delta"] || {})["stop_reason"]
524
+ state[:finish_reason] = reason if reason
525
+ when "message_stop"
526
+ emit_done(yielder, state)
527
+ throw :tina4_ai_stream_end
306
528
  end
307
529
  end
530
+
531
+ def flush_openai_tool_calls(yielder, state)
532
+ state[:openai_tool_calls].keys.sort.each do |index|
533
+ entry = state[:openai_tool_calls][index]
534
+ joined = entry[:args_fragments].join
535
+ args = parse_tool_args(joined)
536
+ yielder << { type: :tool_call, id: entry[:id].to_s, name: entry[:name].to_s, args: args }
537
+ end
538
+ state[:openai_tool_calls].clear
539
+ end
540
+
541
+ def emit_anthropic_tool_call(yielder, entry)
542
+ joined = entry[:args_fragments].join
543
+ args = parse_tool_args(joined)
544
+ yielder << { type: :tool_call, id: entry[:id].to_s, name: entry[:name].to_s, args: args }
545
+ end
546
+
547
+ def parse_tool_args(joined)
548
+ return {} if joined.nil? || joined.empty?
549
+
550
+ JSON.parse(joined)
551
+ rescue JSON::ParserError
552
+ raise AiParseError, "AI provider returned malformed tool_call arguments JSON"
553
+ end
554
+
555
+ def emit_done(yielder, state)
556
+ return if state[:done_emitted] || state[:error_emitted]
557
+
558
+ yielder << { type: :done, finish_reason: state[:finish_reason] || "stop" }
559
+ state[:done_emitted] = true
560
+ end
561
+
562
+ def emit_error(yielder, state, message, code: nil)
563
+ return if state[:done_emitted] || state[:error_emitted]
564
+
565
+ payload = { type: :error, message: message }
566
+ payload[:code] = code if code
567
+ yielder << payload
568
+ state[:error_emitted] = true
569
+ end
308
570
  end
309
571
  end
310
572
  end
data/lib/tina4/api.rb CHANGED
@@ -302,8 +302,182 @@ module Tina4
302
302
  end
303
303
  end
304
304
 
305
+ # ── Streaming primitives (ADR-0060 / 3.13.113) ─────────────────────────────
306
+ #
307
+ # Three cooperating primitives, each layered on the one below:
308
+ # * stream_bytes — raw response body chunks in the order the transport
309
+ # delivers them (no buffering, no framing).
310
+ # * stream_lines — one String per LF- or CRLF-terminated line, decoded as
311
+ # UTF-8. A trailing line without a newline is yielded on
312
+ # EOF. An incomplete UTF-8 sequence at a chunk boundary
313
+ # is buffered across chunks.
314
+ # * stream_sse — SSE-framed events {data:, event:, id:, retry:}. Blank
315
+ # line separates events; ":" comment lines are dropped;
316
+ # multi-line data: fields are joined with "\n"; the
317
+ # OpenAI [DONE] sentinel arrives as the last event
318
+ # (data == "[DONE]") and the iterator ends on the next
319
+ # EOF (the caller decides how to treat it).
320
+ #
321
+ # Ruby idiom: pass a block, OR call without a block to get an Enumerator.
322
+ # Every keyword arg (method:, body:, headers:, content_type:, timeout:,
323
+ # connect_timeout:) is optional and matches the send_request defaults.
324
+ # Aborting the iterator (break, StopIteration on Enumerator#next, GC) closes
325
+ # the underlying socket cleanly — Net::HTTP's block form releases it on any
326
+ # exit from the block.
327
+ #
328
+ # These are the SAME primitives Tina4::Ai.chat(stream: true) uses under the
329
+ # hood (ADR-0060 rule 5). Application code that streams HTTP anywhere (LLMs,
330
+ # log tails, event feeds, chunked downloads) reaches for these instead of
331
+ # hand-rolling a Net::HTTP + line-buffer + SSE-frame reader per app.
332
+
333
+ def stream_bytes(path, method: "GET", body: nil, headers: {},
334
+ content_type: nil, timeout: nil, connect_timeout: nil, &block)
335
+ unless block_given?
336
+ return Enumerator.new do |y|
337
+ stream_bytes(path, method: method, body: body, headers: headers,
338
+ content_type: content_type, timeout: timeout,
339
+ connect_timeout: connect_timeout) { |chunk| y << chunk }
340
+ end
341
+ end
342
+ open_stream(path, method, body, headers, content_type, timeout, connect_timeout, &block)
343
+ end
344
+
345
+ def stream_lines(path, method: "GET", body: nil, headers: {},
346
+ content_type: nil, timeout: nil, connect_timeout: nil, &block)
347
+ unless block_given?
348
+ return Enumerator.new do |y|
349
+ stream_lines(path, method: method, body: body, headers: headers,
350
+ content_type: content_type, timeout: timeout,
351
+ connect_timeout: connect_timeout) { |line| y << line }
352
+ end
353
+ end
354
+ buffer = String.new(encoding: Encoding::BINARY)
355
+ stream_bytes(path, method: method, body: body, headers: headers,
356
+ content_type: content_type, timeout: timeout,
357
+ connect_timeout: connect_timeout) do |chunk|
358
+ buffer << chunk.b
359
+ while (index = buffer.index("\n".b))
360
+ raw = buffer.byteslice(0, index)
361
+ raw = raw.byteslice(0, raw.bytesize - 1) if raw.bytesize.positive? && raw.getbyte(raw.bytesize - 1) == 13
362
+ buffer = buffer.byteslice(index + 1, buffer.bytesize - index - 1) || String.new(encoding: Encoding::BINARY)
363
+ block.call(decode_utf8(raw))
364
+ end
365
+ end
366
+ block.call(decode_utf8(buffer)) unless buffer.empty?
367
+ end
368
+
369
+ def stream_sse(path, method: "GET", body: nil, headers: {},
370
+ content_type: nil, timeout: nil, connect_timeout: nil, &block)
371
+ unless block_given?
372
+ return Enumerator.new do |y|
373
+ stream_sse(path, method: method, body: body, headers: headers,
374
+ content_type: content_type, timeout: timeout,
375
+ connect_timeout: connect_timeout) { |event| y << event }
376
+ end
377
+ end
378
+ data_parts = []
379
+ event_name = nil
380
+ event_id = nil
381
+ retry_ms = nil
382
+ dispatch = lambda do
383
+ return if data_parts.empty? && event_name.nil? && event_id.nil? && retry_ms.nil?
384
+
385
+ payload = { data: data_parts.join("\n") }
386
+ payload[:event] = event_name if event_name
387
+ payload[:id] = event_id if event_id
388
+ payload[:retry] = retry_ms if retry_ms
389
+ block.call(payload)
390
+ data_parts = []
391
+ event_name = nil
392
+ event_id = nil
393
+ retry_ms = nil
394
+ end
395
+ stream_lines(path, method: method, body: body, headers: headers,
396
+ content_type: content_type, timeout: timeout,
397
+ connect_timeout: connect_timeout) do |line|
398
+ if line.empty?
399
+ dispatch.call
400
+ elsif line.start_with?(":")
401
+ # comment — ignored per the SSE spec
402
+ else
403
+ field, sep, value = line.partition(":")
404
+ field = line if sep.empty?
405
+ value = "" if sep.empty?
406
+ value = value[1..] if value.start_with?(" ")
407
+ case field
408
+ when "data" then data_parts << value
409
+ when "event" then event_name = value
410
+ when "id" then event_id = value
411
+ when "retry"
412
+ begin
413
+ retry_ms = Integer(value)
414
+ rescue ArgumentError, TypeError
415
+ retry_ms = nil
416
+ end
417
+ end
418
+ end
419
+ end
420
+ dispatch.call
421
+ end
422
+
305
423
  private
306
424
 
425
+ # Force UTF-8 on a raw line, tolerating a mid-multibyte tail (which
426
+ # stream_lines already buffered across chunks — the trailing-EOF flush is
427
+ # the only place an incomplete sequence can slip through, and we mark it
428
+ # replaceable rather than crash the whole stream).
429
+ def decode_utf8(bytes)
430
+ string = bytes.dup.force_encoding(Encoding::UTF_8)
431
+ string.valid_encoding? ? string : string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
432
+ end
433
+
434
+ # Open a single streaming HTTP request and yield body chunks to the block.
435
+ # Non-2xx responses raise APIStreamError (with the status code); a transport
436
+ # failure (DNS, connection refused, mid-stream drop) also raises. Net::HTTP's
437
+ # block form releases the socket when this method returns for any reason —
438
+ # including a break/StopIteration propagated up from the caller.
439
+ def open_stream(path, method, body, headers, content_type, timeout, connect_timeout)
440
+ uri = build_uri(path)
441
+ request_class = case method.to_s.upcase
442
+ when "GET" then Net::HTTP::Get
443
+ when "POST" then Net::HTTP::Post
444
+ when "PUT" then Net::HTTP::Put
445
+ when "PATCH" then Net::HTTP::Patch
446
+ when "DELETE" then Net::HTTP::Delete
447
+ when "HEAD" then Net::HTTP::Head
448
+ else raise ArgumentError, "unsupported stream method: #{method}"
449
+ end
450
+ request = request_class.new(uri)
451
+ apply_headers(request, headers || {})
452
+ cookie = cookie_header
453
+ request["Cookie"] = cookie if @cookies_enabled && cookie
454
+ if body
455
+ request.body = body.is_a?(String) ? body : JSON.generate(body)
456
+ request["Content-Type"] = content_type if content_type
457
+ elsif content_type
458
+ request["Content-Type"] = content_type
459
+ end
460
+
461
+ http = Net::HTTP.new(uri.host, uri.port)
462
+ http.use_ssl = uri.scheme == "https"
463
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
464
+ http.open_timeout = connect_timeout || @timeout
465
+ http.read_timeout = timeout || @timeout
466
+ http.write_timeout = (timeout || @timeout) if http.respond_to?(:write_timeout=)
467
+
468
+ http.start do |conn|
469
+ conn.request(request) do |response|
470
+ status = response.code.to_i
471
+ unless (200..299).cover?(status)
472
+ response.read_body { |_chunk| } # drain the error body
473
+ raise APIStreamError.new("stream returned HTTP #{status}", status)
474
+ end
475
+ store_cookies(response.get_fields("Set-Cookie"))
476
+ response.read_body { |chunk| yield chunk }
477
+ end
478
+ end
479
+ end
480
+
307
481
  def build_uri(path, params = {})
308
482
  url = "#{@base_url}#{path}"
309
483
  uri = URI.parse(url)
@@ -614,6 +788,21 @@ module Tina4
614
788
  end
615
789
  end
616
790
 
791
+ # Raised when a streaming request (stream_bytes / stream_lines / stream_sse)
792
+ # opens successfully but the HTTP status is not 2xx. The buffered `execute`
793
+ # path folds an error status into an APIResponse; a streaming caller has no
794
+ # response object to inspect, so the error is raised at the moment the status
795
+ # is known -- before any bytes are yielded (the pre-stream error contract of
796
+ # ADR-0060). Carries the status code for the caller to switch on.
797
+ class APIStreamError < StandardError
798
+ attr_reader :status
799
+
800
+ def initialize(message, status = nil)
801
+ super(message)
802
+ @status = status
803
+ end
804
+ end
805
+
617
806
  class APIResponse
618
807
  attr_reader :status, :body, :headers, :error, :path
619
808
 
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.112"
4
+ VERSION = "3.13.113"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.112
4
+ version: 3.13.113
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-21 00:00:00.000000000 Z
11
+ date: 2026-08-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack