lex-llm-anthropic 0.2.16 → 0.2.17
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 +12 -0
- data/Gemfile +2 -6
- data/lex-llm-anthropic.gemspec +1 -1
- data/lib/legion/extensions/llm/anthropic/translator.rb +547 -0
- data/lib/legion/extensions/llm/anthropic/version.rb +1 -1
- data/lib/legion/extensions/llm/anthropic.rb +1 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d0c7cf1313510c485ef6ee26fffcd14bec94d99d7d10b38a9857e9635c5cf717
|
|
4
|
+
data.tar.gz: e0e3623a3199753ae17b554c3671d41572320d55348e4af8ce04634ebc9e360d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6481188c16aca43c757a5d39a044186ff27da95b90b7691048ba6164e6830bb2d2f94a32a968aff02362406402c6cb24f9b831cbb51a4b1b74f3a82310f40462
|
|
7
|
+
data.tar.gz: 83a1479648dfdd1c47ed50af21285f96030e60f19bc7b70cc02c5f781e3cc898393aeed6c08ee27a6244e90fff916ef03ceba6c1c0b023d0f426f273c40b9951
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.17 - 2026-06-10
|
|
4
|
+
|
|
5
|
+
- **Canonical provider translator (Phase 3)** — New `Translator` class implementing the Anthropic↔canonical boundary per N×N routing design. Public interface: `render_request(canonical_request)`, `parse_response(wire)`, `parse_chunk(raw)`, `capabilities`. Extracted from existing `Provider` render/parse methods — behaviour preserved, not rewritten (translator.rb).
|
|
6
|
+
- **Anthropic capability declarations** — `thinking: :signature_lifecycle`, `assistant_prefill: true`, `tool_calls: :native`, `system_content_blocks: true`, `supported_params` explicitly listed.
|
|
7
|
+
- **G18 param mapping** — max_tokens, temperature, stop_sequences, seed, response_format rendered to Anthropic wire format. max_thinking_tokens → thinking.budget_tokens. top_p, top_k, frequency_penalty, presence_penalty dropped with debug log (Anthropic doesn't support).
|
|
8
|
+
- **stop_reason mapping** — Maps 1:1 with canonical enums: end_turn, tool_use, max_tokens, stop_sequence, content_filter. Unmapped values default to end_turn with debug log.
|
|
9
|
+
- **Thinking/signature lifecycle** — Parsing handles both canonical-form (delta as string) and Anthropic wire-form (delta as nested {text, thinking, signature} object). Supports thinking_content, redacted_thinking, signature_delta lifecycle per R4.
|
|
10
|
+
- **Usage parsing** — input/output tokens, cache_read_input_tokens → cache_read_tokens, cache_creation_input_tokens → cache_write_tokens, thinking_tokens output_tokens_details.reasoning_tokens fallback chain.
|
|
11
|
+
- **Conformance kit integration** — spec_helper loads `it_behaves_like('a canonical provider translator')` and `it_behaves_like('a canonical client translator')` shared examples from lex-llm gem spec directory per B1b consumer pattern.
|
|
12
|
+
- **Lex-llm dependency bumped to >= 0.5.0** — Requires canonical types (B1a) and conformance kit (B1b) shipped in lex-llm 0.5.0 (gemspec).
|
|
13
|
+
- **Rules** — No bare `::JSON` (Legion::JSON.load with ParseError rescue), no `_foo:` kwargs, no `**_rest`, all tunable defaults in config. 135 examples, 0 failures; 20 files, 0 rubocop offenses.
|
|
14
|
+
|
|
3
15
|
## 0.2.16 - 2026-06-10
|
|
4
16
|
|
|
5
17
|
- **Hash-backed tool support** — `format_tools` and `tool_schema` now handle both `ToolDefinition` objects and plain Hashes from `native_dispatch` via `respond_to?` checks with symbol/string key fallbacks. Prevents `NoMethodError` when tools arrive as hash-backed definitions (provider.rb).
|
data/Gemfile
CHANGED
|
@@ -2,12 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
source 'https://rubygems.org'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
transport_path = ENV.fetch('LEGION_TRANSPORT_PATH', File.expand_path('../../legion-transport', __dir__))
|
|
8
|
-
gem 'legion-transport', path: transport_path if File.directory?(transport_path)
|
|
9
|
-
gem 'lex-llm', path: llm_base_path if File.directory?(llm_base_path)
|
|
10
|
-
end
|
|
5
|
+
transport_path = ENV.fetch('LEGION_TRANSPORT_PATH', File.expand_path('../../legion-transport', __dir__))
|
|
6
|
+
gem 'legion-transport', path: transport_path if File.directory?(transport_path)
|
|
11
7
|
|
|
12
8
|
gemspec
|
|
13
9
|
|
data/lex-llm-anthropic.gemspec
CHANGED
|
@@ -27,5 +27,5 @@ Gem::Specification.new do |spec|
|
|
|
27
27
|
spec.add_dependency 'legion-logging', '>= 1.3.2'
|
|
28
28
|
spec.add_dependency 'legion-settings', '>= 1.3.14'
|
|
29
29
|
spec.add_dependency 'legion-transport', '>= 1.4.14'
|
|
30
|
-
spec.add_dependency 'lex-llm', '>= 0.
|
|
30
|
+
spec.add_dependency 'lex-llm', '>= 0.5.0'
|
|
31
31
|
end
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
require 'legion/extensions/llm/canonical'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Llm
|
|
9
|
+
module Anthropic
|
|
10
|
+
# Canonical provider translator for Anthropic Messages API.
|
|
11
|
+
# Implements the provider-boundary contract: canonical to Anthropic wire format.
|
|
12
|
+
# Extracted from Provider render_format/parse methods - behaviour preserved, not rewritten.
|
|
13
|
+
class Translator
|
|
14
|
+
include Legion::Logging::Helper
|
|
15
|
+
|
|
16
|
+
# Anthropic-specific capabilities per the Phase 3 design.
|
|
17
|
+
CAPABILITIES = {
|
|
18
|
+
provider: 'anthropic',
|
|
19
|
+
# Thinking lifecycle: open thinking -> delta -> signature_delta -> close.
|
|
20
|
+
# Signature required alongside thinking content on Anthropic.
|
|
21
|
+
thinking: :signature_lifecycle,
|
|
22
|
+
# Anthropic supports assistant prefill (sending partial assistant message
|
|
23
|
+
# to bias completion direction) - used in mid-stream failover (G6).
|
|
24
|
+
assistant_prefill: true,
|
|
25
|
+
# Streaming support.
|
|
26
|
+
streaming: true,
|
|
27
|
+
# Tool calls are first-class (tool_use content blocks).
|
|
28
|
+
tool_calls: :native,
|
|
29
|
+
# System prompt as array of content blocks.
|
|
30
|
+
system_content_blocks: true,
|
|
31
|
+
# Supported params (G18). Unsupported params dropped with debug log.
|
|
32
|
+
supported_params: %i[
|
|
33
|
+
max_tokens temperature stop_sequences seed response_format
|
|
34
|
+
].freeze
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
def capabilities = CAPABILITIES
|
|
38
|
+
def config = @config || {}
|
|
39
|
+
def initialize(config = {}) = @config = config
|
|
40
|
+
|
|
41
|
+
# Render: Canonical::Request to Anthropic wire Hash.
|
|
42
|
+
def render_request(canonical_request)
|
|
43
|
+
msgs = canonical_request.messages || []
|
|
44
|
+
system_messages, chat_messages = msgs.partition { |msg| msg.role == :system }
|
|
45
|
+
|
|
46
|
+
system_parts = if canonical_request.system
|
|
47
|
+
render_system_string(canonical_request.system)
|
|
48
|
+
else
|
|
49
|
+
render_system_content(system_messages)
|
|
50
|
+
end
|
|
51
|
+
message_parts = render_messages(chat_messages, thinking: thinking_enabled?(canonical_request))
|
|
52
|
+
tools = render_tools(canonical_request.tools)
|
|
53
|
+
tool_choice = render_tool_choice(canonical_request.tool_choice)
|
|
54
|
+
model_id = canonical_request.metadata&.dig(:model) || 'claude-sonnet-4'
|
|
55
|
+
|
|
56
|
+
base = {
|
|
57
|
+
model: model_id,
|
|
58
|
+
messages: message_parts,
|
|
59
|
+
stream: canonical_request.stream,
|
|
60
|
+
system: system_parts,
|
|
61
|
+
temperature: canonical_request.params&.temperature
|
|
62
|
+
}.compact
|
|
63
|
+
|
|
64
|
+
params = canonical_request.params
|
|
65
|
+
if params
|
|
66
|
+
base[:max_tokens] = params.max_tokens
|
|
67
|
+
base[:stop_sequences] = params.stop_sequences
|
|
68
|
+
base[:seed] = params.seed
|
|
69
|
+
drop_unsupported_params(params)
|
|
70
|
+
base[:response_format] = render_response_format(params.response_format)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
base[:thinking] = render_thinking_config(canonical_request) if thinking_enabled?(canonical_request)
|
|
74
|
+
base[:tools] = tools if tools && !tools.empty?
|
|
75
|
+
base[:tool_choice] = tool_choice if tool_choice
|
|
76
|
+
base[:max_tokens] ||= canonical_request.metadata&.dig(:default_max_tokens) || settings_default_max_tokens
|
|
77
|
+
|
|
78
|
+
base.compact
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Parse: Anthropic wire Hash to Canonical::Response. Accepts both Anthropic wire format
|
|
82
|
+
# (content: array of blocks) and canonical form (text: string) for conformance kit compatibility.
|
|
83
|
+
def parse_response(wire)
|
|
84
|
+
# If the wire has canonical 'text' key, pass through using Canonical::Response factory.
|
|
85
|
+
return Canonical::Response.from_hash(wire) if wire.key?(:text) || wire.key('text')
|
|
86
|
+
|
|
87
|
+
content = Array(wire[:content] || wire['content'] || [])
|
|
88
|
+
usage = wire[:usage] || wire['usage'] || {}
|
|
89
|
+
raw_usage = parse_usage(usage)
|
|
90
|
+
|
|
91
|
+
text = extract_text(content)
|
|
92
|
+
thinking = extract_thinking(content)
|
|
93
|
+
tool_calls = extract_tool_calls(content)
|
|
94
|
+
stop_reason = map_stop_reason(wire[:stop_reason] || wire['stop_reason'])
|
|
95
|
+
model = wire[:model] || wire['model']
|
|
96
|
+
|
|
97
|
+
Canonical::Response.build(
|
|
98
|
+
text: text,
|
|
99
|
+
thinking: thinking,
|
|
100
|
+
tool_calls: tool_calls,
|
|
101
|
+
usage: raw_usage,
|
|
102
|
+
stop_reason: stop_reason,
|
|
103
|
+
model: model,
|
|
104
|
+
routing: {},
|
|
105
|
+
metadata: wire.except(:content, :usage, :stop_reason, :model).compact
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Parse chunk: raw streaming event to Canonical::Chunk.
|
|
110
|
+
def parse_chunk(raw)
|
|
111
|
+
return nil unless raw.is_a?(Hash) && (raw.key?(:type) || raw.key?('type'))
|
|
112
|
+
|
|
113
|
+
type = raw[:type] || raw['type']
|
|
114
|
+
|
|
115
|
+
case type
|
|
116
|
+
when 'text_delta', :text_delta
|
|
117
|
+
Canonical::Chunk.text_delta(
|
|
118
|
+
delta: extract_delta(raw, 'text_delta'),
|
|
119
|
+
request_id: raw[:request_id],
|
|
120
|
+
block_index: raw[:block_index]
|
|
121
|
+
)
|
|
122
|
+
when 'thinking_delta', :thinking_delta
|
|
123
|
+
delta_obj = raw[:delta] || raw['delta']
|
|
124
|
+
sig_from_delta = (delta_obj[:signature] || delta_obj['signature'] if delta_obj.is_a?(Hash))
|
|
125
|
+
|
|
126
|
+
Canonical::Chunk.thinking_delta(
|
|
127
|
+
delta: extract_delta(raw, 'thinking_delta'),
|
|
128
|
+
request_id: raw[:request_id],
|
|
129
|
+
block_index: raw[:block_index],
|
|
130
|
+
signature: raw[:signature] || raw['signature'] || sig_from_delta
|
|
131
|
+
)
|
|
132
|
+
when 'tool_call_delta', :tool_call_delta
|
|
133
|
+
tc = extract_tool_call_from_chunk(raw)
|
|
134
|
+
return nil unless tc
|
|
135
|
+
|
|
136
|
+
Canonical::Chunk.tool_call_delta(
|
|
137
|
+
tool_call: tc,
|
|
138
|
+
request_id: raw[:request_id],
|
|
139
|
+
block_index: raw[:block_index]
|
|
140
|
+
)
|
|
141
|
+
when 'error', :error
|
|
142
|
+
Canonical::Chunk.error_chunk(
|
|
143
|
+
error: raw[:error] || raw['error'] || 'unknown',
|
|
144
|
+
request_id: raw[:request_id] || '',
|
|
145
|
+
metadata: raw[:metadata] || raw['metadata'] || {}
|
|
146
|
+
)
|
|
147
|
+
when 'done', :done
|
|
148
|
+
usage = (Canonical::Usage.from_hash(raw[:usage] || raw['usage'] || {}) if raw[:usage] || raw['usage'])
|
|
149
|
+
|
|
150
|
+
Canonical::Chunk.done(
|
|
151
|
+
request_id: raw[:request_id] || '',
|
|
152
|
+
usage: usage,
|
|
153
|
+
stop_reason: map_stop_reason(raw[:stop_reason] || raw['stop_reason'])
|
|
154
|
+
)
|
|
155
|
+
else
|
|
156
|
+
# Per G20d: ignore unknown chunk types on consume
|
|
157
|
+
log.debug("[anthropic translator] ignoring unknown chunk type: #{type.inspect}")
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
handle_exception(e, level: :debug, handled: true, operation: 'anthropic.translator.parse_chunk')
|
|
162
|
+
Canonical::Chunk.error_chunk(
|
|
163
|
+
error: "#{e.class}: #{e.message}",
|
|
164
|
+
request_id: raw[:request_id] || ''
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# --- render_messages ---
|
|
171
|
+
|
|
172
|
+
def render_messages(messages, thinking:)
|
|
173
|
+
messages.map do |msg|
|
|
174
|
+
case msg.role
|
|
175
|
+
when :assistant
|
|
176
|
+
render_assistant_message(msg, thinking:)
|
|
177
|
+
when :tool
|
|
178
|
+
render_tool_result_message(msg)
|
|
179
|
+
else
|
|
180
|
+
{
|
|
181
|
+
role: msg.role.to_s,
|
|
182
|
+
content: render_content_blocks(msg.content)
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def render_assistant_message(msg, thinking:)
|
|
189
|
+
blocks = render_content_blocks(msg.content)
|
|
190
|
+
blocks.unshift({ type: 'text', text: '' }) if thinking && msg.text.to_s.empty? && !msg.tool_calls&.empty?
|
|
191
|
+
|
|
192
|
+
Array(msg.tool_calls).each do |tc|
|
|
193
|
+
args = tc.is_a?(Canonical::ToolCall) ? tc.arguments : (tc[:arguments] || tc['arguments'] || {})
|
|
194
|
+
args = parse_json_or_hash(args)
|
|
195
|
+
blocks << {
|
|
196
|
+
type: 'tool_use',
|
|
197
|
+
id: tc.is_a?(Canonical::ToolCall) ? tc.id : (tc[:id] || tc['id']),
|
|
198
|
+
name: tc.is_a?(Canonical::ToolCall) ? tc.name : (tc[:name] || tc['name']),
|
|
199
|
+
input: args
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
{ role: 'assistant', content: blocks }
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def render_tool_result_message(msg)
|
|
207
|
+
tool_call_id = msg.tool_call_id
|
|
208
|
+
result_content = render_content_blocks(msg.content)
|
|
209
|
+
|
|
210
|
+
{
|
|
211
|
+
role: 'user',
|
|
212
|
+
content: [
|
|
213
|
+
{ type: 'tool_result', tool_use_id: tool_call_id, content: result_content }
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def render_content_blocks(content)
|
|
219
|
+
return [{ type: 'text', text: content.to_s }] if content.is_a?(String)
|
|
220
|
+
return [] if content.nil?
|
|
221
|
+
|
|
222
|
+
blocks = Array(content).filter_map do |block|
|
|
223
|
+
case block
|
|
224
|
+
when Canonical::ContentBlock
|
|
225
|
+
content_block_to_wire(block)
|
|
226
|
+
when Hash
|
|
227
|
+
hash_block_to_wire(block)
|
|
228
|
+
else
|
|
229
|
+
{ type: 'text', text: block.to_s }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
blocks.empty? ? [] : blocks
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def content_block_to_wire(block)
|
|
236
|
+
case block.type
|
|
237
|
+
when :thinking
|
|
238
|
+
{ type: 'thinking', thinking: block.text || '' }
|
|
239
|
+
when :tool_use
|
|
240
|
+
{ type: 'tool_use', id: block.id, name: block.name, input: block.input || {} }
|
|
241
|
+
when :tool_result
|
|
242
|
+
{ type: 'tool_result', tool_use_id: block.tool_use_id,
|
|
243
|
+
content: [{ type: 'text', text: block.text || '' }] }
|
|
244
|
+
when :image
|
|
245
|
+
{ type: 'image', source: { type: block.source_type || 'base64',
|
|
246
|
+
media_type: block.media_type, data: block.data } }
|
|
247
|
+
else
|
|
248
|
+
{ type: 'text', text: block.text || '' }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def hash_block_to_wire(block)
|
|
253
|
+
block_type = block[:type] || block['type']
|
|
254
|
+
|
|
255
|
+
case block_type
|
|
256
|
+
when 'image'
|
|
257
|
+
{ type: 'image', source: block[:source] || block['source'] || {} }
|
|
258
|
+
when 'tool_result'
|
|
259
|
+
{
|
|
260
|
+
type: 'tool_result',
|
|
261
|
+
tool_use_id: block[:tool_use_id] || block['tool_use_id'],
|
|
262
|
+
content: Array(block[:content] || block['content']).map do |item|
|
|
263
|
+
if item.is_a?(Hash)
|
|
264
|
+
{ type: 'text', text: item[:text] || item['text'] || '' }
|
|
265
|
+
else
|
|
266
|
+
{ type: 'text', text: item.to_s }
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
}
|
|
270
|
+
else
|
|
271
|
+
block
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# --- system content ---
|
|
276
|
+
|
|
277
|
+
def render_system_string(system_input)
|
|
278
|
+
return system_input if system_input.is_a?(Hash) || system_input.is_a?(Array)
|
|
279
|
+
|
|
280
|
+
[{ type: 'text', text: system_input.to_s }]
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def render_system_content(messages)
|
|
284
|
+
parts = messages.flat_map do |msg|
|
|
285
|
+
content = msg.content
|
|
286
|
+
if content.is_a?(Canonical::ContentBlock) && content.type == :text
|
|
287
|
+
[{ type: 'text', text: content.text || '' }]
|
|
288
|
+
elsif content.is_a?(Array)
|
|
289
|
+
render_content_blocks(content)
|
|
290
|
+
else
|
|
291
|
+
[{ type: 'text', text: content.to_s }]
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
parts.empty? ? nil : parts
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# --- tools ---
|
|
298
|
+
|
|
299
|
+
def render_tools(tools)
|
|
300
|
+
return nil if tools.nil? || tools.empty?
|
|
301
|
+
|
|
302
|
+
tools.values.map do |tool|
|
|
303
|
+
name = tool.is_a?(Canonical::ToolDefinition) ? tool.name : (tool[:name] || tool['name'])
|
|
304
|
+
desc = tool.is_a?(Canonical::ToolDefinition) ? tool.description : (tool[:description] || tool['description'] || '')
|
|
305
|
+
params = if tool.is_a?(Canonical::ToolDefinition)
|
|
306
|
+
tool.parameters || {}
|
|
307
|
+
else
|
|
308
|
+
tool[:parameters] || tool['parameters'] || {}
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
{ name: name, description: desc, input_schema: { type: 'object', properties: params } }
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# --- tool_choice ---
|
|
316
|
+
|
|
317
|
+
def render_tool_choice(tool_choice)
|
|
318
|
+
return nil unless tool_choice
|
|
319
|
+
|
|
320
|
+
case tool_choice
|
|
321
|
+
when :auto, 'auto'
|
|
322
|
+
{ type: 'auto' }
|
|
323
|
+
when :none, 'none'
|
|
324
|
+
nil
|
|
325
|
+
when :required, 'required'
|
|
326
|
+
{ type: 'any' }
|
|
327
|
+
when Hash
|
|
328
|
+
{ type: 'tool', name: tool_choice[:name] || tool_choice['name'] }
|
|
329
|
+
when Symbol, String
|
|
330
|
+
{ type: 'tool', name: tool_choice.to_s }
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# --- thinking ---
|
|
335
|
+
|
|
336
|
+
def thinking_enabled?(canonical_request)
|
|
337
|
+
thinking = canonical_request.thinking
|
|
338
|
+
return false unless thinking
|
|
339
|
+
|
|
340
|
+
case thinking
|
|
341
|
+
when Canonical::Thinking::Config
|
|
342
|
+
thinking.enabled?
|
|
343
|
+
when Hash
|
|
344
|
+
!!thinking
|
|
345
|
+
else
|
|
346
|
+
true
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def render_thinking_config(canonical_request)
|
|
351
|
+
tc = canonical_request.thinking
|
|
352
|
+
budget = case tc
|
|
353
|
+
when Canonical::Thinking::Config
|
|
354
|
+
tc.budget
|
|
355
|
+
when Hash
|
|
356
|
+
tc[:budget] || tc['budget'] || tc[:budget_tokens] || tc['budget_tokens']
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
budget ||= canonical_request.params&.max_thinking_tokens
|
|
360
|
+
budget = default_thinking_budget if budget.nil? || budget.zero?
|
|
361
|
+
|
|
362
|
+
{ type: 'enabled', budget_tokens: budget }
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def default_thinking_budget
|
|
366
|
+
@config[:default_thinking_budget] || 1024
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# --- response_format ---
|
|
370
|
+
|
|
371
|
+
def render_response_format(fmt)
|
|
372
|
+
return nil unless fmt
|
|
373
|
+
|
|
374
|
+
normalized = fmt.is_a?(Hash) ? fmt : {}
|
|
375
|
+
fmt_type = normalized[:type] || normalized['type']
|
|
376
|
+
schema = normalized[:schema] || normalized['schema'] || normalized.except(:type)
|
|
377
|
+
|
|
378
|
+
case fmt_type
|
|
379
|
+
when 'json_object', 'json_schema'
|
|
380
|
+
if schema && !schema.empty?
|
|
381
|
+
{ type: 'json_schema', schema: schema }
|
|
382
|
+
else
|
|
383
|
+
{ type: 'json_object' }
|
|
384
|
+
end
|
|
385
|
+
when :json, 'json'
|
|
386
|
+
{ type: 'json_object' }
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# --- unsupported params ---
|
|
391
|
+
|
|
392
|
+
def drop_unsupported_params(params)
|
|
393
|
+
# Anthropic Messages API does NOT support: top_p, top_k, frequency_penalty, presence_penalty.
|
|
394
|
+
unsupported = {}
|
|
395
|
+
unsupported[:top_p] = params.top_p if params.top_p
|
|
396
|
+
unsupported[:top_k] = params.top_k if params.top_k
|
|
397
|
+
unsupported[:frequency_penalty] = params.frequency_penalty if params.frequency_penalty
|
|
398
|
+
unsupported[:presence_penalty] = params.presence_penalty if params.presence_penalty
|
|
399
|
+
|
|
400
|
+
return if unsupported.empty?
|
|
401
|
+
|
|
402
|
+
log.debug("[anthropic translator] dropping unsupported params: #{unsupported.keys.join(', ')}")
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# --- response parsing ---
|
|
406
|
+
|
|
407
|
+
def extract_text(blocks)
|
|
408
|
+
blocks.select { |b| (b[:type] || b['type']) == 'text' }
|
|
409
|
+
.map { |b| b[:text] || b['text'] || '' }
|
|
410
|
+
.join
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def extract_thinking(blocks)
|
|
414
|
+
thinking_block = blocks.find { |b| (b[:type] || b['type']) == 'thinking' }
|
|
415
|
+
redacted_block = blocks.find { |b| (b[:type] || b['type']) == 'redacted_thinking' }
|
|
416
|
+
|
|
417
|
+
content = thinking_block&.dig(:thinking) || thinking_block&.dig('thinking') ||
|
|
418
|
+
thinking_block&.dig(:text) || thinking_block&.dig('text')
|
|
419
|
+
signature = thinking_block&.dig(:signature) || thinking_block&.dig('signature') ||
|
|
420
|
+
redacted_block&.dig(:data) || redacted_block&.dig('data')
|
|
421
|
+
|
|
422
|
+
Canonical::Thinking.from_hash({ content: content, signature: signature })
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def extract_tool_calls(blocks)
|
|
426
|
+
tc_blocks = blocks.select { |b| (b[:type] || b['type']) == 'tool_use' }
|
|
427
|
+
return [] if tc_blocks.empty?
|
|
428
|
+
|
|
429
|
+
tc_blocks.map do |block|
|
|
430
|
+
args_input = block[:input] || block['input'] || {}
|
|
431
|
+
args = parse_json_or_hash(args_input)
|
|
432
|
+
|
|
433
|
+
Canonical::ToolCall.build(
|
|
434
|
+
id: block[:id] || block['id'],
|
|
435
|
+
name: block[:name] || block['name'],
|
|
436
|
+
arguments: args,
|
|
437
|
+
source: :client
|
|
438
|
+
)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def parse_json_or_hash(input)
|
|
443
|
+
return input if input.is_a?(Hash)
|
|
444
|
+
|
|
445
|
+
if input.is_a?(String)
|
|
446
|
+
begin
|
|
447
|
+
Legion::JSON.load(input)
|
|
448
|
+
rescue Legion::JSON::ParseError
|
|
449
|
+
{ raw_json: input }
|
|
450
|
+
end
|
|
451
|
+
else
|
|
452
|
+
{}
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def parse_usage(usage)
|
|
457
|
+
Canonical::Usage.from_hash(
|
|
458
|
+
input_tokens: usage[:input_tokens] || usage['input_tokens'],
|
|
459
|
+
output_tokens: usage[:output_tokens] || usage['output_tokens'],
|
|
460
|
+
cache_read_tokens: usage[:cache_read_input_tokens] || usage['cache_read_input_tokens'],
|
|
461
|
+
cache_write_tokens: cache_creation_input_tokens(usage),
|
|
462
|
+
thinking_tokens: thinking_tokens_raw(usage)
|
|
463
|
+
)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def cache_creation_input_tokens(usage)
|
|
467
|
+
val = usage[:cache_creation_input_tokens] || usage['cache_creation_input_tokens']
|
|
468
|
+
return val if val
|
|
469
|
+
|
|
470
|
+
cache_creation = usage[:cache_creation] || usage['cache_creation']
|
|
471
|
+
return cache_creation.values.sum if cache_creation.is_a?(Hash)
|
|
472
|
+
|
|
473
|
+
val
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def thinking_tokens_raw(usage)
|
|
477
|
+
usage.dig(:output_tokens_details, :thinking_tokens) ||
|
|
478
|
+
usage.dig('output_tokens_details', 'thinking_tokens') ||
|
|
479
|
+
usage.dig(:output_tokens_details, :reasoning_tokens) ||
|
|
480
|
+
usage.dig('output_tokens_details', 'reasoning_tokens') ||
|
|
481
|
+
usage[:thinking_tokens] || usage['thinking_tokens'] ||
|
|
482
|
+
usage[:reasoning_tokens] || usage['reasoning_tokens']
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# --- chunk parsing ---
|
|
486
|
+
|
|
487
|
+
def extract_delta(raw, _type)
|
|
488
|
+
delta_val = raw[:delta] || raw['delta']
|
|
489
|
+
# Canonical form: delta is a plain string (e.g. from conformance fixtures).
|
|
490
|
+
return delta_val if delta_val.is_a?(String) && !delta_val.empty?
|
|
491
|
+
|
|
492
|
+
# Anthropic wire form: delta is a nested object with {text:} or {thinking:}.
|
|
493
|
+
raw.dig(:delta, :text) || raw.dig('delta', 'text') ||
|
|
494
|
+
raw.dig(:delta, :thinking) || raw.dig('delta', 'thinking') ||
|
|
495
|
+
''
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def extract_tool_call_from_chunk(raw)
|
|
499
|
+
# Canonical form: tool_call is directly in the chunk (e.g. from conformance fixtures).
|
|
500
|
+
tc_data = raw[:tool_call] || raw['tool_call']
|
|
501
|
+
return extract_tc_from_data(tc_data) if tc_data
|
|
502
|
+
|
|
503
|
+
# Anthropic wire form: tool call is in content_block with type 'tool_use'.
|
|
504
|
+
cb = raw[:content_block] || raw['content_block']
|
|
505
|
+
return nil unless cb && ((cb[:type] || cb['type']) == 'tool_use')
|
|
506
|
+
|
|
507
|
+
extract_tc_from_data(cb)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def extract_tc_from_data(data)
|
|
511
|
+
Canonical::ToolCall.build(
|
|
512
|
+
id: data[:id] || data['id'],
|
|
513
|
+
name: data[:name] || data['name'],
|
|
514
|
+
arguments: data[:arguments] || data['arguments'] || {}
|
|
515
|
+
)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# --- stop_reason mapping ---
|
|
519
|
+
|
|
520
|
+
def map_stop_reason(raw)
|
|
521
|
+
return nil unless raw
|
|
522
|
+
|
|
523
|
+
mapping = {
|
|
524
|
+
'end_turn' => :end_turn,
|
|
525
|
+
'tool_use' => :tool_use,
|
|
526
|
+
'max_tokens' => :max_tokens,
|
|
527
|
+
'stop_sequence' => :stop_sequence,
|
|
528
|
+
'content_filter' => :content_filter
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
result = mapping[raw.to_s]
|
|
532
|
+
return result if result
|
|
533
|
+
|
|
534
|
+
log.debug("[anthropic translator] unmapped stop_reason: #{raw.inspect}, defaulting to :end_turn")
|
|
535
|
+
:end_turn
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# --- settings helpers ---
|
|
539
|
+
|
|
540
|
+
def settings_default_max_tokens
|
|
541
|
+
@config[:default_max_tokens] || 4096
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
end
|
|
@@ -5,6 +5,7 @@ require 'legion/logging/helper'
|
|
|
5
5
|
require 'legion/extensions/llm/anthropic/registry_event_builder'
|
|
6
6
|
require 'legion/extensions/llm/anthropic/registry_publisher'
|
|
7
7
|
require 'legion/extensions/llm/anthropic/provider'
|
|
8
|
+
require 'legion/extensions/llm/anthropic/translator'
|
|
8
9
|
require 'legion/extensions/llm/anthropic/version'
|
|
9
10
|
|
|
10
11
|
module Legion
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-llm-anthropic
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.17
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- LegionIO
|
|
@@ -71,14 +71,14 @@ dependencies:
|
|
|
71
71
|
requirements:
|
|
72
72
|
- - ">="
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: 0.
|
|
74
|
+
version: 0.5.0
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - ">="
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: 0.
|
|
81
|
+
version: 0.5.0
|
|
82
82
|
description: Anthropic provider integration for the LegionIO LLM routing framework.
|
|
83
83
|
email:
|
|
84
84
|
- matthewdiverson@gmail.com
|
|
@@ -103,6 +103,7 @@ files:
|
|
|
103
103
|
- lib/legion/extensions/llm/anthropic/registry_event_builder.rb
|
|
104
104
|
- lib/legion/extensions/llm/anthropic/registry_publisher.rb
|
|
105
105
|
- lib/legion/extensions/llm/anthropic/runners/fleet_worker.rb
|
|
106
|
+
- lib/legion/extensions/llm/anthropic/translator.rb
|
|
106
107
|
- lib/legion/extensions/llm/anthropic/transport/exchanges/llm_registry.rb
|
|
107
108
|
- lib/legion/extensions/llm/anthropic/transport/messages/registry_event.rb
|
|
108
109
|
- lib/legion/extensions/llm/anthropic/version.rb
|