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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c0ba91f4e37ef96e557eb291a221cf419821ad59a00bad3e4fe1dbbfaaa5a97
4
- data.tar.gz: c54f721f615114967ed5f4e6e86b1c3573da6fb85dcf4fcc9003f0840608b848
3
+ metadata.gz: a76c2791a9673d8d16cdeece741b3a234561cd1b244b36bcb25fcf87979a35af
4
+ data.tar.gz: 8c344ec70e43ab6a95ea8622390a50e8473b38857db4113377f1de884bd6d1d6
5
5
  SHA512:
6
- metadata.gz: 1ada54d7fac14bbfec973040294322d70155c99024c071a4d5a9b558f0ab16d69bf2c57020e92db43ca70a1eeb00099944ddf6d6cf40268ba51f755621426e5c
7
- data.tar.gz: b95811c10bf0db5264f304e19ade1f6849f790c4eec98c4106ed2c7a4201380492082fc4a67ef32fd517e0ad58997a7c3888d49e37e79c845e2d2c92c6fa40d4
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).
@@ -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 (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
+ #
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 stream_request(config, headers(config), body) if stream
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
- 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)
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
- raise AiConfigError, "AI messages must contain supported roles and string content" unless valid
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
- 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"] } }
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 config[:provider] == "anthropic"
151
- system = normalized.select { |message| message[:role] == "system" }.map { |message| message[:content] }
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] = system.join("\n\n") unless system.empty?
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
- def stream_delta(provider, data)
240
- return [true, nil] if data == "[DONE]"
564
+ # ── streaming (ADR-0060 typed events, built on Api#stream_sse) ─────────
241
565
 
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)
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
- [false, text]
251
- rescue JSON::ParserError
252
- raise AiParseError, "AI provider returned malformed stream data"
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 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:")
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 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]
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
@@ -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.114"
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.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-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