tina4ruby 3.13.112 → 3.13.114
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 +112 -0
- data/lib/tina4/ai_client.rb +529 -70
- 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: a76c2791a9673d8d16cdeece741b3a234561cd1b244b36bcb25fcf87979a35af
|
|
4
|
+
data.tar.gz: 8c344ec70e43ab6a95ea8622390a50e8473b38857db4113377f1de884bd6d1d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0f98f2ca7ecd95280f0290d7b91b712e56fd9186c58732a67d897395bb3be9206308c262b274fe284d79b54aad41ad1d21776b0a7ca7c19ba44b68641c574bdc
|
|
7
|
+
data.tar.gz: 1acf8361982384c67c1f872838855e1415316f2b8ef396cb3efa2bc9471fb32fa05165d471299f2933e41f31157a1725e6ed4be661632c460d5eb2b246bcad96
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,118 @@ 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.114
|
|
10
|
+
|
|
11
|
+
Feature: the tool loop closes — `Tina4::Ai.chat` accepts `tools:` and
|
|
12
|
+
`tool_choice:`, and validates tool-result turns in either OpenAI or Anthropic
|
|
13
|
+
form (ADR-0061).
|
|
14
|
+
|
|
15
|
+
### Send-side additions
|
|
16
|
+
|
|
17
|
+
- `Tina4::Ai.chat(messages, tools: [...])` translates the neutral tool shape
|
|
18
|
+
(`{name:, description:, parameters: <JSON-Schema Hash>}`) into each
|
|
19
|
+
provider's outbound body: OpenAI/local as
|
|
20
|
+
`[{type: "function", function: {name, description, parameters}}]`;
|
|
21
|
+
Anthropic as `[{name, description, input_schema}]` (`parameters` renamed).
|
|
22
|
+
Missing `tools:` omits the field entirely.
|
|
23
|
+
- `Tina4::Ai.chat(..., tool_choice: ...)` accepts `'auto'`, `'none'`,
|
|
24
|
+
`'required'`, or `{name: 'x'}` (String or Symbol; String or Symbol keys on
|
|
25
|
+
the Hash). Translation per ADR-0061: `'auto'` → OpenAI `"auto"` / Anthropic
|
|
26
|
+
`{type: "auto"}`; `'none'` → OpenAI `"none"` / Anthropic OMITS `tools`;
|
|
27
|
+
`'required'` → OpenAI `"required"` / Anthropic `{type: "any"}`;
|
|
28
|
+
`{name: 'x'}` → OpenAI `{type: "function", function: {name: "x"}}` /
|
|
29
|
+
Anthropic `{type: "tool", name: "x"}`.
|
|
30
|
+
- `validate_messages` accepts the two tool-result forms: OpenAI
|
|
31
|
+
`{role: "tool", tool_call_id:, content:}` and Anthropic
|
|
32
|
+
`{role: "user", content: [{type: "tool_result", tool_use_id:, content:}]}`.
|
|
33
|
+
Malformed shapes raise `Tina4::AiConfigError` before any bytes hit the
|
|
34
|
+
wire.
|
|
35
|
+
- The client normalises whichever form the caller sent to whichever form the
|
|
36
|
+
configured provider expects, so an agent loop written against `Ai.chat` is
|
|
37
|
+
provider-neutral. An assistant message with `tool_calls:` and `nil` content
|
|
38
|
+
now validates too (the OpenAI wire form for a model's tool invocation).
|
|
39
|
+
|
|
40
|
+
### Tests
|
|
41
|
+
|
|
42
|
+
`spec/ai_client_contract_spec.rb` grows by 13 fixture-mapped cases across four
|
|
43
|
+
groups: `ai-tools-openai-body-shape`, `ai-tools-anthropic-body-shape`,
|
|
44
|
+
`ai-tools-parameters-passthrough-jsonschema`, `ai-tool-choice-auto`,
|
|
45
|
+
`ai-tool-choice-none`, `ai-tool-choice-required`, `ai-tool-choice-named`,
|
|
46
|
+
`ai-tool-result-openai-form-passthrough`,
|
|
47
|
+
`ai-tool-result-anthropic-form-passthrough`,
|
|
48
|
+
`ai-tool-result-openai-to-anthropic-translation`,
|
|
49
|
+
`ai-tool-result-anthropic-to-openai-translation`,
|
|
50
|
+
`ai-agent-loop-openai-round-trip`, `ai-agent-loop-anthropic-round-trip`. The
|
|
51
|
+
inline `AiContractServer` gains `/tool-echo-openai`, `/tool-echo-anthropic`,
|
|
52
|
+
`/agent-openai`, and `/agent-anthropic` endpoints so the full loop is proven
|
|
53
|
+
against real sockets, no mocks.
|
|
54
|
+
|
|
55
|
+
## 3.13.113
|
|
56
|
+
|
|
57
|
+
Feature: streaming and multimodal AI, plus reusable `Api.stream` primitives
|
|
58
|
+
(ADR-0060).
|
|
59
|
+
|
|
60
|
+
### Api.stream primitives
|
|
61
|
+
|
|
62
|
+
- `Tina4::API#stream_bytes(path, ...)` streams the response body as raw
|
|
63
|
+
chunks in transport order. Pass a block or take an Enumerator.
|
|
64
|
+
- `Tina4::API#stream_lines(path, ...)` yields one UTF-8 String per LF- or
|
|
65
|
+
CRLF-terminated line; a trailing line without a newline is yielded on EOF;
|
|
66
|
+
multibyte sequences that split across chunks are buffered.
|
|
67
|
+
- `Tina4::API#stream_sse(path, ...)` frames SSE events into
|
|
68
|
+
`{data:, event:, id:, retry:}` Hashes; blank line separates events;
|
|
69
|
+
`:` comment lines are dropped; multi-line `data:` fields are joined with
|
|
70
|
+
`\n`; the OpenAI `[DONE]` sentinel is delivered as the last event and the
|
|
71
|
+
iterator ends. Each primitive is layered on the one below it.
|
|
72
|
+
- `Tina4::APIStreamError` (with `#status`) is raised when a stream opens
|
|
73
|
+
with a non-2xx HTTP status (pre-stream error, no bytes yielded).
|
|
74
|
+
|
|
75
|
+
### Ai.chat streaming: typed events (breaking)
|
|
76
|
+
|
|
77
|
+
- `Tina4::Ai.chat(messages, stream: true)` now yields typed event Hashes:
|
|
78
|
+
`{ type: :text_delta, text: ... }`, `{ type: :tool_call, id:, name:, args: }`,
|
|
79
|
+
`{ type: :done, finish_reason:, usage: nil }`, or
|
|
80
|
+
`{ type: :error, message:, code: nil }`. Text deltas fire per chunk;
|
|
81
|
+
OpenAI `tool_calls` fragments and Anthropic `input_json_delta` fragments
|
|
82
|
+
are aggregated and emitted as a single `tool_call` event; `done` fires
|
|
83
|
+
exactly once on the terminal wire signal; `error` replaces `done` on a
|
|
84
|
+
mid-stream failure. Malformed tool-call arguments JSON raises
|
|
85
|
+
`Tina4::AiParseError`.
|
|
86
|
+
- The stream path is implemented on top of `Tina4::API#stream_sse`; there is
|
|
87
|
+
one SSE reader for the framework, shared between `Api` and `Ai`.
|
|
88
|
+
- Migration for 3.13.101–3.13.112 callers: `for chunk in stream` becomes
|
|
89
|
+
`for event in stream; event[:text] if event[:type] == :text_delta; end`.
|
|
90
|
+
No shim (feedback_no_aliases, feedback_breaking_changes).
|
|
91
|
+
|
|
92
|
+
### Ai.chat multimodal content
|
|
93
|
+
|
|
94
|
+
- `message[:content]` accepts a String OR an Array of parts:
|
|
95
|
+
`{ type: "text", text: String }` and
|
|
96
|
+
`{ type: "image", source: "data:<mime>;base64,<payload>" }` or
|
|
97
|
+
`{ type: "image", source: "https://..." }` (also with symbol keys).
|
|
98
|
+
- Parts are translated per provider before the request is sent:
|
|
99
|
+
OpenAI / local get `{ type: "image_url", image_url: { url } }`;
|
|
100
|
+
Anthropic gets `{ type: "image", source: { type: "base64", media_type, data } }`
|
|
101
|
+
for data URIs or `{ type: "image", source: { type: "url", url } }` for
|
|
102
|
+
https URLs.
|
|
103
|
+
- Malformed parts (missing `text` or `source`, unknown `type`, non-string
|
|
104
|
+
values, a `data:` URI without `;base64,`, a plain `http:` URL) raise
|
|
105
|
+
`Tina4::AiConfigError` before any request is sent.
|
|
106
|
+
|
|
107
|
+
### Tests
|
|
108
|
+
|
|
109
|
+
- New `spec/api_stream_contract_spec.rb` with a real local TCP fixture
|
|
110
|
+
server exercising chunked transport, LF/CRLF line splitting, trailing
|
|
111
|
+
lines, multibyte-across-chunk-boundary buffering, and every SSE framing
|
|
112
|
+
case (single/multi-line/named/comment/blank/[DONE]/retry). Verifies the
|
|
113
|
+
request body reaches the server and that a transport drop raises.
|
|
114
|
+
- Extended `spec/ai_client_contract_spec.rb` with `/stream-openai-tools`,
|
|
115
|
+
`/stream-anthropic-tools`, `/stream-midstream-drop`, and
|
|
116
|
+
`/multimodal-echo` cases proving typed events, tool-call aggregation on
|
|
117
|
+
both providers, one-and-only-one `done`, mid-stream error semantics,
|
|
118
|
+
no-retry-after-first-event, and the OpenAI/Anthropic body shapes for
|
|
119
|
+
multimodal parts.
|
|
120
|
+
|
|
9
121
|
## 3.13.107
|
|
10
122
|
|
|
11
123
|
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,41 @@ 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
|
+
#
|
|
46
|
+
# ADR-0061 adds the SEND half of the agent loop:
|
|
47
|
+
# - tools: [{name:, description:, parameters:}] (parameters is JSON-Schema)
|
|
48
|
+
# - tool_choice: 'auto' | 'none' | 'required' | {name: '...'}
|
|
49
|
+
# - tool-result turns accepted in either OpenAI or Anthropic form; the
|
|
50
|
+
# client normalises to whichever the current provider expects.
|
|
51
|
+
def chat(messages, model: nil, temperature: nil, max_tokens: nil,
|
|
52
|
+
stream: false, timeout: nil, provider: nil,
|
|
53
|
+
tools: nil, tool_choice: nil)
|
|
30
54
|
validate_messages(messages)
|
|
55
|
+
validate_tools(tools)
|
|
56
|
+
validate_tool_choice(tool_choice)
|
|
31
57
|
config = resolve_config("chat", model, timeout, provider)
|
|
32
|
-
body = chat_body(config, messages, temperature, max_tokens, stream)
|
|
33
|
-
return
|
|
58
|
+
body = chat_body(config, messages, temperature, max_tokens, stream, tools, tool_choice)
|
|
59
|
+
return stream_events(config, headers(config), body) if stream
|
|
34
60
|
|
|
35
61
|
normalize_chat(config[:provider], request_json(config, headers(config), body))
|
|
36
62
|
end
|
|
@@ -67,14 +93,121 @@ module Tina4
|
|
|
67
93
|
|
|
68
94
|
private
|
|
69
95
|
|
|
96
|
+
# ── validation ─────────────────────────────────────────────────────────
|
|
97
|
+
|
|
70
98
|
def validate_messages(messages)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
99
|
+
unless messages.is_a?(Array) && !messages.empty?
|
|
100
|
+
raise AiConfigError, "AI messages must be a non-empty list"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
messages.each do |message|
|
|
104
|
+
raise AiConfigError, "AI messages must be objects" unless message.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
role = (message[:role] || message["role"]).to_s
|
|
107
|
+
unless %w[system user assistant tool].include?(role)
|
|
108
|
+
raise AiConfigError, "AI messages must contain supported roles"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if role == "tool"
|
|
112
|
+
# OpenAI-form tool-result message (ADR-0061).
|
|
113
|
+
tool_call_id = message[:tool_call_id] || message["tool_call_id"]
|
|
114
|
+
unless tool_call_id.is_a?(String) && !tool_call_id.empty?
|
|
115
|
+
raise AiConfigError, "AI tool message must have a string tool_call_id"
|
|
116
|
+
end
|
|
117
|
+
content = message.key?(:content) ? message[:content] : message["content"]
|
|
118
|
+
raise AiConfigError, "AI tool message must have a string content" unless content.is_a?(String)
|
|
119
|
+
next
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
content = message.key?(:content) ? message[:content] : message["content"]
|
|
123
|
+
# An assistant message carrying tool_calls may have nil content
|
|
124
|
+
# (that is how the OpenAI wire form encodes the model's tool
|
|
125
|
+
# invocation). Callers append this before the tool result.
|
|
126
|
+
if content.nil? && role == "assistant" && (message[:tool_calls] || message["tool_calls"])
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
validate_content(content)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_content(content)
|
|
135
|
+
return if content.is_a?(String)
|
|
136
|
+
|
|
137
|
+
unless content.is_a?(Array) && !content.empty?
|
|
138
|
+
raise AiConfigError, "AI message content must be a string or a non-empty list of parts"
|
|
74
139
|
end
|
|
75
|
-
|
|
140
|
+
|
|
141
|
+
content.each { |part| validate_content_part(part) }
|
|
76
142
|
end
|
|
77
143
|
|
|
144
|
+
def validate_content_part(part)
|
|
145
|
+
raise AiConfigError, "AI content parts must be objects" unless part.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
type = (part[:type] || part["type"]).to_s
|
|
148
|
+
case type
|
|
149
|
+
when "text"
|
|
150
|
+
text = part.key?(:text) ? part[:text] : part["text"]
|
|
151
|
+
raise AiConfigError, "AI text content part must have a string text field" unless text.is_a?(String)
|
|
152
|
+
when "image"
|
|
153
|
+
source = part.key?(:source) ? part[:source] : part["source"]
|
|
154
|
+
raise AiConfigError, "AI image content part must have a string source field" unless source.is_a?(String)
|
|
155
|
+
|
|
156
|
+
if source.start_with?("data:")
|
|
157
|
+
# data:<media_type>;base64,<payload> — enforce the base64 marker
|
|
158
|
+
raise AiConfigError, "AI image data URI must be base64-encoded" unless source.include?(";base64,")
|
|
159
|
+
elsif !source.start_with?("https://")
|
|
160
|
+
raise AiConfigError, "AI image source must be a data:<mime>;base64,<data> URI or an https:// URL"
|
|
161
|
+
end
|
|
162
|
+
when "tool_result"
|
|
163
|
+
# Anthropic-form tool-result part (ADR-0061).
|
|
164
|
+
tool_use_id = part[:tool_use_id] || part["tool_use_id"]
|
|
165
|
+
unless tool_use_id.is_a?(String) && !tool_use_id.empty?
|
|
166
|
+
raise AiConfigError, "AI tool_result part must have a string tool_use_id"
|
|
167
|
+
end
|
|
168
|
+
content = part.key?(:content) ? part[:content] : part["content"]
|
|
169
|
+
raise AiConfigError, "AI tool_result part must have a string content" unless content.is_a?(String)
|
|
170
|
+
else
|
|
171
|
+
raise AiConfigError, "AI content part type must be 'text', 'image', or 'tool_result'"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ── tools + tool_choice validation (ADR-0061) ──────────────────────────
|
|
176
|
+
|
|
177
|
+
def validate_tools(tools)
|
|
178
|
+
return if tools.nil?
|
|
179
|
+
|
|
180
|
+
raise AiConfigError, "AI tools must be an array" unless tools.is_a?(Array)
|
|
181
|
+
|
|
182
|
+
tools.each do |tool|
|
|
183
|
+
raise AiConfigError, "Each AI tool must be an object" unless tool.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
name = tool[:name] || tool["name"]
|
|
186
|
+
raise AiConfigError, "AI tool must have a string name" unless name.is_a?(String) && !name.empty?
|
|
187
|
+
|
|
188
|
+
params = tool[:parameters] || tool["parameters"]
|
|
189
|
+
raise AiConfigError, "AI tool must have a JSON-Schema parameters object" unless params.is_a?(Hash)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def validate_tool_choice(tool_choice)
|
|
194
|
+
return if tool_choice.nil?
|
|
195
|
+
|
|
196
|
+
if tool_choice.is_a?(Hash)
|
|
197
|
+
name = tool_choice[:name] || tool_choice["name"]
|
|
198
|
+
return if name.is_a?(String) && !name.empty?
|
|
199
|
+
|
|
200
|
+
raise AiConfigError, "AI tool_choice {name:} must have a non-empty string name"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
value = tool_choice.to_s
|
|
204
|
+
return if %w[auto none required].include?(value)
|
|
205
|
+
|
|
206
|
+
raise AiConfigError, "AI tool_choice must be 'auto', 'none', 'required', or {name: 'x'}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ── configuration ──────────────────────────────────────────────────────
|
|
210
|
+
|
|
78
211
|
def number(name, default, minimum)
|
|
79
212
|
value = Float(ENV.fetch(name, default.to_s))
|
|
80
213
|
raise AiConfigError, "#{name} must be at least #{minimum}" if value < minimum
|
|
@@ -142,20 +275,208 @@ module Tina4
|
|
|
142
275
|
result
|
|
143
276
|
end
|
|
144
277
|
|
|
145
|
-
|
|
146
|
-
|
|
278
|
+
# ── request body ───────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
def chat_body(config, messages, temperature, max_tokens, stream, tools = nil, tool_choice = nil)
|
|
281
|
+
provider = config[:provider]
|
|
282
|
+
normalized = normalize_messages_for_provider(provider, messages)
|
|
147
283
|
body = { model: config[:model], messages: normalized, stream: stream }
|
|
148
284
|
body[:temperature] = temperature unless temperature.nil?
|
|
149
285
|
body[:max_tokens] = max_tokens unless max_tokens.nil?
|
|
150
|
-
if
|
|
151
|
-
|
|
286
|
+
if provider == "anthropic"
|
|
287
|
+
system_texts = normalized.select { |message| message[:role] == "system" }.map { |message| system_text(message[:content]) }
|
|
152
288
|
body[:messages] = normalized.reject { |message| message[:role] == "system" }
|
|
153
289
|
body[:max_tokens] = max_tokens || 1024
|
|
154
|
-
body[:system] =
|
|
290
|
+
body[:system] = system_texts.join("\n\n") unless system_texts.empty?
|
|
155
291
|
end
|
|
292
|
+
apply_tools(body, provider, tools, tool_choice)
|
|
156
293
|
body
|
|
157
294
|
end
|
|
158
295
|
|
|
296
|
+
# Normalise the caller's message list to the provider-native shape.
|
|
297
|
+
# Tool-result turns are translated between OpenAI form
|
|
298
|
+
# ({role: 'tool', tool_call_id, content}) and Anthropic form
|
|
299
|
+
# ({role: 'user', content: [{type: 'tool_result', tool_use_id, content}]})
|
|
300
|
+
# so the caller's agent loop never has to fork on TINA4_AI_PROVIDER
|
|
301
|
+
# (ADR-0061). Anthropic-form user messages carrying multiple
|
|
302
|
+
# tool_result parts fan out to N OpenAI tool messages on the wire.
|
|
303
|
+
def normalize_messages_for_provider(provider, messages)
|
|
304
|
+
messages.flat_map { |message| normalize_one_message(provider, message) }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def normalize_one_message(provider, message)
|
|
308
|
+
role = (message[:role] || message["role"]).to_s
|
|
309
|
+
content = message.key?(:content) ? message[:content] : message["content"]
|
|
310
|
+
|
|
311
|
+
if role == "tool"
|
|
312
|
+
tool_call_id = message[:tool_call_id] || message["tool_call_id"]
|
|
313
|
+
return translate_openai_tool_message(provider, tool_call_id, content)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
if role == "assistant" && content.nil?
|
|
317
|
+
tool_calls = message[:tool_calls] || message["tool_calls"]
|
|
318
|
+
return [{ role: "assistant", content: nil, tool_calls: tool_calls }]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
if role == "user" && content.is_a?(Array)
|
|
322
|
+
tool_result_parts = content.select { |part| part.is_a?(Hash) && content_part_type(part) == "tool_result" }
|
|
323
|
+
unless tool_result_parts.empty?
|
|
324
|
+
if tool_result_parts.length != content.length
|
|
325
|
+
raise AiConfigError, "AI tool_result parts cannot be mixed with other content parts in one message"
|
|
326
|
+
end
|
|
327
|
+
return translate_anthropic_tool_results(provider, tool_result_parts)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
[{ role: role, content: translate_content(provider, role, content) }]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def content_part_type(part)
|
|
335
|
+
(part[:type] || part["type"]).to_s
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def translate_openai_tool_message(provider, tool_call_id, content)
|
|
339
|
+
if provider == "anthropic"
|
|
340
|
+
[{ role: "user",
|
|
341
|
+
content: [{ type: "tool_result", tool_use_id: tool_call_id, content: content }] }]
|
|
342
|
+
else
|
|
343
|
+
[{ role: "tool", tool_call_id: tool_call_id, content: content }]
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def translate_anthropic_tool_results(provider, parts)
|
|
348
|
+
if provider == "anthropic"
|
|
349
|
+
[{ role: "user",
|
|
350
|
+
content: parts.map do |part|
|
|
351
|
+
{ type: "tool_result",
|
|
352
|
+
tool_use_id: part[:tool_use_id] || part["tool_use_id"],
|
|
353
|
+
content: part[:content] || part["content"] }
|
|
354
|
+
end }]
|
|
355
|
+
else
|
|
356
|
+
parts.map do |part|
|
|
357
|
+
{ role: "tool",
|
|
358
|
+
tool_call_id: part[:tool_use_id] || part["tool_use_id"],
|
|
359
|
+
content: part[:content] || part["content"] }
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# ── tools translation (ADR-0061) ───────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
def apply_tools(body, provider, tools, tool_choice)
|
|
367
|
+
return body if tools.nil? && tool_choice.nil?
|
|
368
|
+
|
|
369
|
+
# Anthropic has no "none" — the equivalent is to omit tools entirely,
|
|
370
|
+
# AND we do not emit a tool_choice field either.
|
|
371
|
+
if provider == "anthropic" && tool_choice_is_string?(tool_choice, "none")
|
|
372
|
+
return body
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
if tools.is_a?(Array) && !tools.empty?
|
|
376
|
+
body[:tools] = translate_tools(provider, tools)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
unless tool_choice.nil?
|
|
380
|
+
body[:tool_choice] = translate_tool_choice(provider, tool_choice)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
body
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def tool_choice_is_string?(tool_choice, value)
|
|
387
|
+
return false if tool_choice.nil? || tool_choice.is_a?(Hash)
|
|
388
|
+
|
|
389
|
+
tool_choice.to_s == value
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def translate_tools(provider, tools)
|
|
393
|
+
if provider == "anthropic"
|
|
394
|
+
tools.map do |tool|
|
|
395
|
+
{ name: tool[:name] || tool["name"],
|
|
396
|
+
description: tool[:description] || tool["description"],
|
|
397
|
+
input_schema: tool[:parameters] || tool["parameters"] }
|
|
398
|
+
end
|
|
399
|
+
else
|
|
400
|
+
tools.map do |tool|
|
|
401
|
+
{ type: "function",
|
|
402
|
+
function: { name: tool[:name] || tool["name"],
|
|
403
|
+
description: tool[:description] || tool["description"],
|
|
404
|
+
parameters: tool[:parameters] || tool["parameters"] } }
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def translate_tool_choice(provider, tool_choice)
|
|
410
|
+
if tool_choice.is_a?(Hash)
|
|
411
|
+
name = tool_choice[:name] || tool_choice["name"]
|
|
412
|
+
return provider == "anthropic" ? { type: "tool", name: name } : { type: "function", function: { name: name } }
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
value = tool_choice.to_s
|
|
416
|
+
if provider == "anthropic"
|
|
417
|
+
case value
|
|
418
|
+
when "auto" then { type: "auto" }
|
|
419
|
+
when "required" then { type: "any" }
|
|
420
|
+
# "none" was already handled by apply_tools (returns before us).
|
|
421
|
+
end
|
|
422
|
+
else
|
|
423
|
+
value
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Anthropic's `system` field is a plain string. If the caller passed a
|
|
428
|
+
# parts array on a system message, concatenate the text parts (image
|
|
429
|
+
# parts on the system role are silently dropped — Anthropic rejects them
|
|
430
|
+
# outright, and validation already ran, so we don't raise a second time).
|
|
431
|
+
def system_text(content)
|
|
432
|
+
return content.to_s unless content.is_a?(Array)
|
|
433
|
+
|
|
434
|
+
content.select { |part| (part[:type] || part["type"]).to_s == "text" }
|
|
435
|
+
.map { |part| part[:text] || part["text"] }.join("\n\n")
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Translate parts to each provider's native shape. Strings pass through
|
|
439
|
+
# unchanged. See ADR-0060 §Public surface.
|
|
440
|
+
def translate_content(provider, role, content)
|
|
441
|
+
return content if content.is_a?(String)
|
|
442
|
+
return system_text(content) if provider == "anthropic" && role == "system"
|
|
443
|
+
|
|
444
|
+
content.map do |part|
|
|
445
|
+
type = (part[:type] || part["type"]).to_s
|
|
446
|
+
case type
|
|
447
|
+
when "text"
|
|
448
|
+
{ type: "text", text: part[:text] || part["text"] }
|
|
449
|
+
when "image"
|
|
450
|
+
translate_image_part(provider, part[:source] || part["source"])
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def translate_image_part(provider, source)
|
|
456
|
+
if provider == "anthropic"
|
|
457
|
+
if source.start_with?("data:")
|
|
458
|
+
media_type, payload = split_data_uri(source)
|
|
459
|
+
{ type: "image", source: { type: "base64", media_type: media_type, data: payload } }
|
|
460
|
+
else
|
|
461
|
+
{ type: "image", source: { type: "url", url: source } }
|
|
462
|
+
end
|
|
463
|
+
else
|
|
464
|
+
# openai and local both accept the OpenAI image_url shape
|
|
465
|
+
{ type: "image_url", image_url: { url: source } }
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def split_data_uri(source)
|
|
470
|
+
# "data:image/png;base64,<payload>"
|
|
471
|
+
header, payload = source.split(",", 2)
|
|
472
|
+
meta = header.to_s.sub(/\Adata:/, "")
|
|
473
|
+
media_type = meta.split(";").first.to_s
|
|
474
|
+
media_type = "application/octet-stream" if media_type.empty?
|
|
475
|
+
[media_type, payload.to_s]
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# ── non-streaming request path ─────────────────────────────────────────
|
|
479
|
+
|
|
159
480
|
def http_request(config, deadline, request_headers, body)
|
|
160
481
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
161
482
|
raise AiTimeoutError, "AI total request timeout expired" unless remaining.positive?
|
|
@@ -213,10 +534,13 @@ module Tina4
|
|
|
213
534
|
sleep(delay) if delay.positive?
|
|
214
535
|
end
|
|
215
536
|
|
|
537
|
+
# ── response normalisation ─────────────────────────────────────────────
|
|
538
|
+
|
|
216
539
|
def normalize_chat(provider, raw)
|
|
217
540
|
if provider == "anthropic"
|
|
218
541
|
parts = raw.fetch("content").select { |item| item.fetch("type", "text") == "text" }.map { |item| item.fetch("text") }
|
|
219
542
|
raise KeyError if parts.empty?
|
|
543
|
+
|
|
220
544
|
prompt = raw.fetch("usage", {}).fetch("input_tokens", 0).to_i
|
|
221
545
|
completion = raw.fetch("usage", {}).fetch("output_tokens", 0).to_i
|
|
222
546
|
return ChatResponse.new(text: parts.join, model: raw.fetch("model", "").to_s,
|
|
@@ -226,6 +550,7 @@ module Tina4
|
|
|
226
550
|
choice = raw.fetch("choices").fetch(0)
|
|
227
551
|
text = choice.fetch("message").fetch("content")
|
|
228
552
|
raise TypeError unless text.is_a?(String)
|
|
553
|
+
|
|
229
554
|
usage = raw.fetch("usage", {})
|
|
230
555
|
ChatResponse.new(text: text, model: raw.fetch("model", "").to_s,
|
|
231
556
|
usage: { prompt_tokens: usage.fetch("prompt_tokens", 0).to_i,
|
|
@@ -236,74 +561,208 @@ module Tina4
|
|
|
236
561
|
raise AiParseError, "AI provider returned a malformed chat response"
|
|
237
562
|
end
|
|
238
563
|
|
|
239
|
-
|
|
240
|
-
return [true, nil] if data == "[DONE]"
|
|
564
|
+
# ── streaming (ADR-0060 typed events, built on Api#stream_sse) ─────────
|
|
241
565
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
566
|
+
# Split the resolved provider URL into (origin, request_uri) so we can
|
|
567
|
+
# hand an Api client the origin and a path -- Api#build_uri concatenates
|
|
568
|
+
# them without further munging.
|
|
569
|
+
def split_url(url)
|
|
570
|
+
uri = URI.parse(url)
|
|
571
|
+
origin = "#{uri.scheme}://#{uri.host}"
|
|
572
|
+
origin += ":#{uri.port}" if uri.port
|
|
573
|
+
[origin, uri.request_uri]
|
|
574
|
+
end
|
|
249
575
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
576
|
+
def stream_events(config, request_headers, body)
|
|
577
|
+
Enumerator.new do |yielder|
|
|
578
|
+
stream_pump(config, request_headers, body, yielder)
|
|
579
|
+
end
|
|
253
580
|
end
|
|
254
581
|
|
|
255
|
-
def
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
582
|
+
def stream_pump(config, request_headers, body, yielder)
|
|
583
|
+
provider = config[:provider]
|
|
584
|
+
origin, request_path = split_url(config[:url])
|
|
585
|
+
api = Tina4::API.new(origin, timeout: config[:total_timeout])
|
|
586
|
+
stream_headers = request_headers.merge("Accept" => "text/event-stream")
|
|
587
|
+
request_body_json = JSON.generate(body)
|
|
588
|
+
|
|
589
|
+
state = {
|
|
590
|
+
text_delta_seen: false,
|
|
591
|
+
done_emitted: false,
|
|
592
|
+
error_emitted: false,
|
|
593
|
+
finish_reason: nil,
|
|
594
|
+
openai_tool_calls: {}, # index -> { id:, name:, args_fragments: [] }
|
|
595
|
+
anthropic_tool_blocks: {} # index -> { id:, name:, args_fragments: [] }
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
begin
|
|
599
|
+
catch(:tina4_ai_stream_end) do
|
|
600
|
+
api.stream_sse(
|
|
601
|
+
request_path,
|
|
602
|
+
method: "POST",
|
|
603
|
+
body: request_body_json,
|
|
604
|
+
headers: stream_headers,
|
|
605
|
+
content_type: "application/json",
|
|
606
|
+
timeout: config[:total_timeout],
|
|
607
|
+
connect_timeout: config[:connect_timeout]
|
|
608
|
+
) do |sse|
|
|
609
|
+
handle_sse_event(provider, sse, yielder, state)
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# No terminal done/error was emitted -- treat as mid-stream failure.
|
|
614
|
+
emit_error(yielder, state, "AI provider stream ended before terminal event") unless state[:done_emitted] || state[:error_emitted]
|
|
615
|
+
rescue Tina4::APIStreamError => e
|
|
616
|
+
# Pre-stream HTTP error (status arrived before any bytes). Contract:
|
|
617
|
+
# raise, not error-event. Body is not surfaced (never leak secrets).
|
|
618
|
+
raise AiHTTPError.new("AI provider returned HTTP #{e.status}", e.status)
|
|
619
|
+
rescue Net::OpenTimeout
|
|
620
|
+
raise AiTimeoutError, "AI connection timeout expired"
|
|
621
|
+
rescue Net::ReadTimeout, Timeout::Error
|
|
622
|
+
if state[:text_delta_seen]
|
|
623
|
+
emit_error(yielder, state, "AI total request timeout expired")
|
|
624
|
+
else
|
|
625
|
+
raise AiTimeoutError, "AI total request timeout expired"
|
|
626
|
+
end
|
|
627
|
+
rescue AiParseError
|
|
628
|
+
raise
|
|
629
|
+
rescue SocketError, EOFError, IOError, SystemCallError => e
|
|
630
|
+
if state[:text_delta_seen]
|
|
631
|
+
emit_error(yielder, state, "AI transport failed (#{e.class.name})")
|
|
632
|
+
else
|
|
633
|
+
raise AiHTTPError, "AI transport failed (#{e.class.name})"
|
|
263
634
|
end
|
|
264
635
|
end
|
|
265
636
|
end
|
|
266
637
|
|
|
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
|
-
|
|
638
|
+
def handle_sse_event(provider, sse, yielder, state)
|
|
639
|
+
data = sse[:data]
|
|
640
|
+
return if data.nil?
|
|
641
|
+
|
|
642
|
+
if data == "[DONE]"
|
|
643
|
+
# OpenAI sentinel -- flush any pending tool_calls, then emit :done.
|
|
644
|
+
flush_openai_tool_calls(yielder, state)
|
|
645
|
+
emit_done(yielder, state)
|
|
646
|
+
throw :tina4_ai_stream_end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
return if data.empty?
|
|
650
|
+
|
|
651
|
+
parsed = begin
|
|
652
|
+
JSON.parse(data)
|
|
653
|
+
rescue JSON::ParserError
|
|
654
|
+
raise AiParseError, "AI provider returned malformed stream data"
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
if provider == "anthropic"
|
|
658
|
+
handle_anthropic_event(parsed, yielder, state)
|
|
659
|
+
else
|
|
660
|
+
handle_openai_event(parsed, yielder, state)
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
def handle_openai_event(parsed, yielder, state)
|
|
665
|
+
choice = (parsed["choices"] || []).first
|
|
666
|
+
return unless choice
|
|
667
|
+
|
|
668
|
+
delta = choice["delta"] || {}
|
|
669
|
+
content = delta["content"]
|
|
670
|
+
if content.is_a?(String) && !content.empty?
|
|
671
|
+
yielder << { type: :text_delta, text: content }
|
|
672
|
+
state[:text_delta_seen] = true
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
(delta["tool_calls"] || []).each do |tool_call|
|
|
676
|
+
index = tool_call["index"] || 0
|
|
677
|
+
entry = state[:openai_tool_calls][index] ||= { id: nil, name: nil, args_fragments: [] }
|
|
678
|
+
entry[:id] = tool_call["id"] if tool_call["id"]
|
|
679
|
+
function = tool_call["function"] || {}
|
|
680
|
+
entry[:name] = function["name"] if function["name"]
|
|
681
|
+
entry[:args_fragments] << function["arguments"] if function["arguments"].is_a?(String)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
reason = choice["finish_reason"]
|
|
685
|
+
return unless reason
|
|
686
|
+
|
|
687
|
+
state[:finish_reason] = reason
|
|
688
|
+
# The definitive trigger to emit aggregated tool_calls. Some providers
|
|
689
|
+
# send finish_reason=stop even when tool_calls were streamed, so flush
|
|
690
|
+
# on any finish_reason -- the flush is a no-op when no calls buffered.
|
|
691
|
+
flush_openai_tool_calls(yielder, state)
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def handle_anthropic_event(parsed, yielder, state)
|
|
695
|
+
case parsed["type"]
|
|
696
|
+
when "content_block_start"
|
|
697
|
+
block = parsed["content_block"] || {}
|
|
698
|
+
if block["type"] == "tool_use"
|
|
699
|
+
state[:anthropic_tool_blocks][parsed["index"]] = {
|
|
700
|
+
id: block["id"], name: block["name"], args_fragments: []
|
|
701
|
+
}
|
|
702
|
+
end
|
|
703
|
+
when "content_block_delta"
|
|
704
|
+
delta = parsed["delta"] || {}
|
|
705
|
+
case delta["type"]
|
|
706
|
+
when "text_delta"
|
|
707
|
+
text = delta["text"]
|
|
708
|
+
if text.is_a?(String) && !text.empty?
|
|
709
|
+
yielder << { type: :text_delta, text: text }
|
|
710
|
+
state[:text_delta_seen] = true
|
|
304
711
|
end
|
|
712
|
+
when "input_json_delta"
|
|
713
|
+
entry = state[:anthropic_tool_blocks][parsed["index"]]
|
|
714
|
+
entry[:args_fragments] << (delta["partial_json"] || "") if entry
|
|
305
715
|
end
|
|
716
|
+
when "content_block_stop"
|
|
717
|
+
entry = state[:anthropic_tool_blocks].delete(parsed["index"])
|
|
718
|
+
emit_anthropic_tool_call(yielder, entry) if entry
|
|
719
|
+
when "message_delta"
|
|
720
|
+
reason = (parsed["delta"] || {})["stop_reason"]
|
|
721
|
+
state[:finish_reason] = reason if reason
|
|
722
|
+
when "message_stop"
|
|
723
|
+
emit_done(yielder, state)
|
|
724
|
+
throw :tina4_ai_stream_end
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def flush_openai_tool_calls(yielder, state)
|
|
729
|
+
state[:openai_tool_calls].keys.sort.each do |index|
|
|
730
|
+
entry = state[:openai_tool_calls][index]
|
|
731
|
+
joined = entry[:args_fragments].join
|
|
732
|
+
args = parse_tool_args(joined)
|
|
733
|
+
yielder << { type: :tool_call, id: entry[:id].to_s, name: entry[:name].to_s, args: args }
|
|
306
734
|
end
|
|
735
|
+
state[:openai_tool_calls].clear
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def emit_anthropic_tool_call(yielder, entry)
|
|
739
|
+
joined = entry[:args_fragments].join
|
|
740
|
+
args = parse_tool_args(joined)
|
|
741
|
+
yielder << { type: :tool_call, id: entry[:id].to_s, name: entry[:name].to_s, args: args }
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
def parse_tool_args(joined)
|
|
745
|
+
return {} if joined.nil? || joined.empty?
|
|
746
|
+
|
|
747
|
+
JSON.parse(joined)
|
|
748
|
+
rescue JSON::ParserError
|
|
749
|
+
raise AiParseError, "AI provider returned malformed tool_call arguments JSON"
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def emit_done(yielder, state)
|
|
753
|
+
return if state[:done_emitted] || state[:error_emitted]
|
|
754
|
+
|
|
755
|
+
yielder << { type: :done, finish_reason: state[:finish_reason] || "stop" }
|
|
756
|
+
state[:done_emitted] = true
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def emit_error(yielder, state, message, code: nil)
|
|
760
|
+
return if state[:done_emitted] || state[:error_emitted]
|
|
761
|
+
|
|
762
|
+
payload = { type: :error, message: message }
|
|
763
|
+
payload[:code] = code if code
|
|
764
|
+
yielder << payload
|
|
765
|
+
state[:error_emitted] = true
|
|
307
766
|
end
|
|
308
767
|
end
|
|
309
768
|
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.114
|
|
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
|