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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ef129fbcb71eb5500eff0ef7047dda892d97d57b21dab9567488be60cca0401
4
- data.tar.gz: 3efed0ed6d287da75ac969e7dd29121b1c4e49e11f989a7fb7fed4f8c24f1ac2
3
+ metadata.gz: d0c7cf1313510c485ef6ee26fffcd14bec94d99d7d10b38a9857e9635c5cf717
4
+ data.tar.gz: e0e3623a3199753ae17b554c3671d41572320d55348e4af8ce04634ebc9e360d
5
5
  SHA512:
6
- metadata.gz: dfd4e546964d3994cb294860aae5d2f81476c0c067b76519b9c65506eb782e32df58fda6ede3df2f9c17095b1b7aaf7e940a2e5afc4ea34efb7d086c00e17c4f
7
- data.tar.gz: 62d2e1ac960fd5084becf6d83beb966ad02556205f1d70b183ab48892a69cb3783004eddcbea08435986b7a8ec569a3f41f8b835a5e12aed63b8388b078471e3
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
- group :test do
6
- llm_base_path = ENV.fetch('LEX_LLM_PATH', File.expand_path('../lex-llm', __dir__))
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
 
@@ -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.4.3'
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
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Anthropic
7
- VERSION = '0.2.16'
7
+ VERSION = '0.2.17'
8
8
  end
9
9
  end
10
10
  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.16
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.4.3
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.4.3
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