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 +4 -4
- data/CHANGELOG.md +66 -0
- data/lib/tina4/ai_client.rb +330 -68
- data/lib/tina4/api.rb +189 -0
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 964e92e8030ced5a8f4af3934fb9bb7e49df0056d795c5025d5176aed2fa06d2
|
|
4
|
+
data.tar.gz: 39fa0f71a1b3219bbaa4382f136c1fb69a4f4760436d75339a50d6906ba5a8e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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).
|
data/lib/tina4/ai_client.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
|
|
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] =
|
|
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
|
-
|
|
240
|
-
return [true, nil] if data == "[DONE]"
|
|
367
|
+
# ── streaming (ADR-0060 typed events, built on Api#stream_sse) ─────────
|
|
241
368
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
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.
|
|
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-
|
|
11
|
+
date: 2026-08-22 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|