tina4ruby 3.13.113 → 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 +46 -0
- data/lib/tina4/ai_client.rb +207 -10
- data/lib/tina4/version.rb +1 -1
- metadata +1 -1
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,52 @@ 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
|
+
|
|
9
55
|
## 3.13.113
|
|
10
56
|
|
|
11
57
|
Feature: streaming and multimodal AI, plus reusable `Api.stream` primitives
|
data/lib/tina4/ai_client.rb
CHANGED
|
@@ -42,11 +42,20 @@ module Tina4
|
|
|
42
42
|
# was a deliberately breaking change (see ADR-0060 §7) so an agent loop
|
|
43
43
|
# could observe tool_calls and finish_reason without hand-rolled SSE
|
|
44
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.
|
|
45
51
|
def chat(messages, model: nil, temperature: nil, max_tokens: nil,
|
|
46
|
-
stream: false, timeout: nil, provider: nil
|
|
52
|
+
stream: false, timeout: nil, provider: nil,
|
|
53
|
+
tools: nil, tool_choice: nil)
|
|
47
54
|
validate_messages(messages)
|
|
55
|
+
validate_tools(tools)
|
|
56
|
+
validate_tool_choice(tool_choice)
|
|
48
57
|
config = resolve_config("chat", model, timeout, provider)
|
|
49
|
-
body = chat_body(config, messages, temperature, max_tokens, stream)
|
|
58
|
+
body = chat_body(config, messages, temperature, max_tokens, stream, tools, tool_choice)
|
|
50
59
|
return stream_events(config, headers(config), body) if stream
|
|
51
60
|
|
|
52
61
|
normalize_chat(config[:provider], request_json(config, headers(config), body))
|
|
@@ -95,11 +104,29 @@ module Tina4
|
|
|
95
104
|
raise AiConfigError, "AI messages must be objects" unless message.is_a?(Hash)
|
|
96
105
|
|
|
97
106
|
role = (message[:role] || message["role"]).to_s
|
|
98
|
-
unless %w[system user assistant].include?(role)
|
|
107
|
+
unless %w[system user assistant tool].include?(role)
|
|
99
108
|
raise AiConfigError, "AI messages must contain supported roles"
|
|
100
109
|
end
|
|
101
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
|
+
|
|
102
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
|
+
|
|
103
130
|
validate_content(content)
|
|
104
131
|
end
|
|
105
132
|
end
|
|
@@ -132,9 +159,51 @@ module Tina4
|
|
|
132
159
|
elsif !source.start_with?("https://")
|
|
133
160
|
raise AiConfigError, "AI image source must be a data:<mime>;base64,<data> URI or an https:// URL"
|
|
134
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)
|
|
135
170
|
else
|
|
136
|
-
raise AiConfigError, "AI content part type must be 'text' or '
|
|
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"
|
|
137
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'}"
|
|
138
207
|
end
|
|
139
208
|
|
|
140
209
|
# ── configuration ──────────────────────────────────────────────────────
|
|
@@ -208,13 +277,9 @@ module Tina4
|
|
|
208
277
|
|
|
209
278
|
# ── request body ───────────────────────────────────────────────────────
|
|
210
279
|
|
|
211
|
-
def chat_body(config, messages, temperature, max_tokens, stream)
|
|
280
|
+
def chat_body(config, messages, temperature, max_tokens, stream, tools = nil, tool_choice = nil)
|
|
212
281
|
provider = config[:provider]
|
|
213
|
-
normalized = messages
|
|
214
|
-
role = (message[:role] || message["role"]).to_s
|
|
215
|
-
content = message.key?(:content) ? message[:content] : message["content"]
|
|
216
|
-
{ role: role, content: translate_content(provider, role, content) }
|
|
217
|
-
end
|
|
282
|
+
normalized = normalize_messages_for_provider(provider, messages)
|
|
218
283
|
body = { model: config[:model], messages: normalized, stream: stream }
|
|
219
284
|
body[:temperature] = temperature unless temperature.nil?
|
|
220
285
|
body[:max_tokens] = max_tokens unless max_tokens.nil?
|
|
@@ -224,9 +289,141 @@ module Tina4
|
|
|
224
289
|
body[:max_tokens] = max_tokens || 1024
|
|
225
290
|
body[:system] = system_texts.join("\n\n") unless system_texts.empty?
|
|
226
291
|
end
|
|
292
|
+
apply_tools(body, provider, tools, tool_choice)
|
|
293
|
+
body
|
|
294
|
+
end
|
|
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
|
+
|
|
227
383
|
body
|
|
228
384
|
end
|
|
229
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
|
+
|
|
230
427
|
# Anthropic's `system` field is a plain string. If the caller passed a
|
|
231
428
|
# parts array on a system message, concatenate the text parts (image
|
|
232
429
|
# parts on the system role are silently dropped — Anthropic rejects them
|
data/lib/tina4/version.rb
CHANGED