lex-llm-anthropic 0.2.16 → 0.2.20

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: c8d65710f4980c8851f9ff9457cadb0053493ec4817e91b0b046ea354297c9a2
4
+ data.tar.gz: d88fda72706b4035cd5ba2866c3b4354c2f36bdcad1d56bd1373684a8c82b0f9
5
5
  SHA512:
6
- metadata.gz: dfd4e546964d3994cb294860aae5d2f81476c0c067b76519b9c65506eb782e32df58fda6ede3df2f9c17095b1b7aaf7e940a2e5afc4ea34efb7d086c00e17c4f
7
- data.tar.gz: 62d2e1ac960fd5084becf6d83beb966ad02556205f1d70b183ab48892a69cb3783004eddcbea08435986b7a8ec569a3f41f8b835a5e12aed63b8388b078471e3
6
+ metadata.gz: 8a81ea1ad5a04c67d4079af690238308be289c66315f19555a4d5423a92c43f3ffc940356bf52791d6fd504a446e5fc53fc495c798458f42a7e18bc6beaa5863
7
+ data.tar.gz: d53359d7292d05f5719a7412b90d6a8761cc2982f8325a63446dc92ded3c77957c2914a1138eda5eaf52ff56e41408aeea3337cb49c62a00506de860780ad89f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.20 - 2026-06-16
4
+
5
+ - dependency updates, code quality improvements
6
+
7
+ ## 0.2.19 - 2026-06-15
8
+
9
+ - **CapabilityPolicy integration** — Streaming and tools from `:provider_envelope`; vision/thinking default false unless explicitly enabled via settings. Settings overrides at provider/instance/model level supported.
10
+
11
+ ## 0.2.18 - 2026-06-13
12
+
13
+ - **Gemfile cleanup** — Remove local path overrides; all dependencies resolve from gemspec via rubygems.
14
+ - 135 examples, 0 failures; 20 files, 0 rubocop offenses.
15
+
16
+ ## 0.2.17 - 2026-06-10
17
+
18
+ - **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).
19
+ - **Anthropic capability declarations** — `thinking: :signature_lifecycle`, `assistant_prefill: true`, `tool_calls: :native`, `system_content_blocks: true`, `supported_params` explicitly listed.
20
+ - **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).
21
+ - **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.
22
+ - **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.
23
+ - **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.
24
+ - **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.
25
+ - **Lex-llm dependency bumped to >= 0.5.0** — Requires canonical types (B1a) and conformance kit (B1b) shipped in lex-llm 0.5.0 (gemspec).
26
+ - **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.
27
+
3
28
  ## 0.2.16 - 2026-06-10
4
29
 
5
30
  - **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,13 +2,6 @@
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
11
-
12
5
  gemspec
13
6
 
14
7
  group :development do
@@ -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
@@ -36,6 +36,9 @@ module Legion
36
36
  return unless defined?(Legion::LLM::Discovery)
37
37
 
38
38
  Legion::LLM::Discovery.refresh_discovered_models!(provider: :anthropic)
39
+
40
+ Legion::LLM::Router.populate_auto_rules(Legion::LLM::Discovery.discovered_instances) if defined?(Legion::LLM::Router) && Legion::LLM::Router.respond_to?(:populate_auto_rules)
41
+ Legion::LLM::Inventory.invalidate_offerings_cache! if defined?(Legion::LLM::Inventory) && Legion::LLM::Inventory.respond_to?(:invalidate_offerings_cache!)
39
42
  rescue StandardError => e
40
43
  handle_exception(e, level: :warn, handled: true, operation: 'anthropic.actor.discovery_refresh')
41
44
  end
@@ -20,7 +20,7 @@ module Legion
20
20
  def capabilities = Capabilities
21
21
 
22
22
  def registry_publisher
23
- @registry_publisher ||= RegistryPublisher.new
23
+ @registry_publisher ||= Legion::Extensions::Llm::RegistryPublisher.new(provider_family: :anthropic)
24
24
  end
25
25
  end
26
26
 
@@ -54,6 +54,10 @@ module Legion
54
54
  def stream_url = completion_url
55
55
  def models_url = '/v1/models'
56
56
 
57
+ def translator
58
+ @translator ||= Translator.new(config)
59
+ end
60
+
57
61
  def embed(**_provider_options)
58
62
  raise NotImplementedError, 'Anthropic does not expose embeddings through this provider'
59
63
  end
@@ -76,6 +80,8 @@ module Legion
76
80
  'claude-3-haiku' => 200_000
77
81
  }.freeze
78
82
 
83
+ COMPLETION_BASE = [:completion].freeze
84
+
79
85
  private
80
86
 
81
87
  def render_payload(messages, tools:, temperature:, model:, stream:, schema:, thinking:, tool_prefs:)
@@ -186,7 +192,11 @@ module Legion
186
192
 
187
193
  def format_tool_call_message(message, thinking:, cache:)
188
194
  blocks = content_blocks(message.content, thinking:, message:, cache:)
189
- message.tool_calls.each_value { |tool_call| blocks << tool_use_block(tool_call, cache:) }
195
+ # tool_calls is an Array of ToolCall since the adapter stopped
196
+ # name-keying them (name-keyed hashes silently dropped parallel
197
+ # same-name calls); tolerate the legacy Hash shape from old callers.
198
+ calls = message.tool_calls.is_a?(Hash) ? message.tool_calls.values : Array(message.tool_calls)
199
+ calls.each { |tool_call| blocks << tool_use_block(tool_call, cache:) }
190
200
  { role: 'assistant', content: blocks }
191
201
  end
192
202
 
@@ -274,8 +284,8 @@ module Legion
274
284
  def tool_schema(tool)
275
285
  return tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
276
286
 
277
- { type: 'object',
278
- properties: tool.respond_to?(:parameters) ? tool.parameters : (tool[:parameters] || tool['parameters'] || {}), required: [] }
287
+ raw = tool.respond_to?(:parameters) ? tool.parameters : (tool[:parameters] || tool['parameters'])
288
+ Legion::Extensions::Llm::Canonical::ToolDefinition.normalize_parameters(raw)
279
289
  end
280
290
 
281
291
  def tool_choice(tool_prefs)
@@ -318,116 +328,124 @@ module Legion
318
328
 
319
329
  def parse_completion_response(response)
320
330
  body = response.body
321
- content_blocks = body['content'] || []
322
- usage = body['usage'] || {}
323
-
324
- Legion::Extensions::Llm::Message.new(
325
- role: :assistant,
326
- content: text_from(content_blocks),
327
- model_id: body['model'],
328
- thinking: thinking_from(content_blocks),
329
- tool_calls: parse_tool_calls(content_blocks),
330
- input_tokens: usage['input_tokens'],
331
- output_tokens: usage['output_tokens'],
332
- cached_tokens: usage['cache_read_input_tokens'],
333
- cache_creation_tokens: cache_creation_tokens(usage),
334
- thinking_tokens: thinking_tokens(usage),
335
- raw: body
336
- )
331
+ canonical = translator.parse_response(body)
332
+ to_legacy_message(canonical, body)
337
333
  end
338
334
 
339
- def text_from(blocks)
340
- blocks.select { |block| block['type'] == 'text' }.map { |block| block['text'] }.join
341
- end
342
-
343
- def thinking_from(blocks)
344
- thinking_block = blocks.find { |block| block['type'] == 'thinking' }
345
- redacted_block = blocks.find { |block| block['type'] == 'redacted_thinking' }
346
-
347
- Legion::Extensions::Llm::Thinking.build(
348
- text: thinking_block&.dig('thinking') || thinking_block&.dig('text'),
349
- signature: thinking_block&.dig('signature') || redacted_block&.dig('data')
350
- )
351
- end
352
-
353
- def cache_creation_tokens(usage)
354
- cache_creation = usage['cache_creation']
355
- cache_creation_values = cache_creation.values if cache_creation
335
+ def build_chunk(data)
336
+ canonical_chunk = translator.parse_chunk(data)
337
+ return nil if canonical_chunk.nil?
356
338
 
357
- usage['cache_creation_input_tokens'] || cache_creation_values&.compact&.sum
339
+ to_legacy_chunk(canonical_chunk, data)
358
340
  end
359
341
 
360
- def thinking_tokens(usage)
361
- usage.dig('output_tokens_details', 'thinking_tokens') ||
362
- usage.dig('output_tokens_details', 'reasoning_tokens') ||
363
- usage['thinking_tokens'] ||
364
- usage['reasoning_tokens']
342
+ def to_legacy_message(canonical, raw_body)
343
+ usage = canonical.usage
344
+ Legion::Extensions::Llm::Message.new(
345
+ role: :assistant,
346
+ content: canonical.text,
347
+ model_id: canonical.model,
348
+ thinking: if canonical.thinking
349
+ Legion::Extensions::Llm::Thinking.build(
350
+ text: canonical.thinking.content,
351
+ signature: canonical.thinking.signature
352
+ )
353
+ end,
354
+ tool_calls: legacy_tool_calls(canonical.tool_calls),
355
+ input_tokens: usage&.input_tokens,
356
+ output_tokens: usage&.output_tokens,
357
+ cached_tokens: usage&.cache_read_tokens,
358
+ cache_creation_tokens: usage&.cache_write_tokens,
359
+ thinking_tokens: usage&.thinking_tokens,
360
+ raw: raw_body
361
+ )
365
362
  end
366
363
 
367
- def build_chunk(data)
368
- delta_type = data.dig('delta', 'type')
369
-
364
+ def to_legacy_chunk(canonical_chunk, raw_data)
370
365
  Legion::Extensions::Llm::Chunk.new(
371
366
  role: :assistant,
372
- content: delta_type == 'text_delta' ? data.dig('delta', 'text') : nil,
373
- model_id: data.dig('message', 'model'),
374
- thinking: Legion::Extensions::Llm::Thinking.build(
375
- text: delta_type == 'thinking_delta' ? data.dig('delta', 'thinking') : nil,
376
- signature: delta_type == 'signature_delta' ? data.dig('delta', 'signature') : nil
377
- ),
378
- input_tokens: data.dig('message', 'usage', 'input_tokens'),
379
- output_tokens: data.dig('message', 'usage', 'output_tokens') || data.dig('usage', 'output_tokens'),
380
- tool_calls: extract_streaming_tool_calls(data, delta_type)
367
+ content: canonical_chunk.text_delta? ? canonical_chunk.delta : nil,
368
+ model_id: raw_data.dig('message', 'model'),
369
+ thinking: if canonical_chunk.thinking_delta?
370
+ Legion::Extensions::Llm::Thinking.build(
371
+ text: canonical_chunk.delta,
372
+ signature: canonical_chunk.signature
373
+ )
374
+ end,
375
+ input_tokens: canonical_chunk.usage&.input_tokens,
376
+ output_tokens: canonical_chunk.usage&.output_tokens,
377
+ tool_calls: legacy_streaming_tool_calls(canonical_chunk)
381
378
  )
382
379
  end
383
380
 
384
- def extract_streaming_tool_calls(data, _delta_type)
385
- content_block = data['content_block']
386
- return nil unless content_block && content_block['type'] == 'tool_use'
381
+ def legacy_tool_calls(canonical_tool_calls)
382
+ return nil if canonical_tool_calls.nil? || canonical_tool_calls.empty?
387
383
 
388
- { content_block['id'] => Legion::Extensions::Llm::ToolCall.new(
389
- id: content_block['id'], name: content_block['name'], arguments: ''
390
- ) }
391
- end
392
-
393
- def parse_tool_calls(content_blocks)
394
- blocks = Array(content_blocks).select { |block| block && block['type'] == 'tool_use' }
395
- return nil if blocks.empty?
396
-
397
- blocks.to_h do |block|
384
+ canonical_tool_calls.to_h do |tc|
398
385
  [
399
- block['id'],
386
+ tc.id,
400
387
  Legion::Extensions::Llm::ToolCall.new(
401
- id: block['id'],
402
- name: block['name'],
403
- arguments: block['input'] || {}
388
+ id: tc.id, name: tc.name, arguments: tc.arguments || {}
404
389
  )
405
390
  ]
406
391
  end
407
392
  end
408
393
 
394
+ def legacy_streaming_tool_calls(canonical_chunk)
395
+ return nil unless canonical_chunk.tool_call_delta?
396
+
397
+ tc = canonical_chunk.tool_call
398
+ return nil unless tc
399
+
400
+ { tc.id => Legion::Extensions::Llm::ToolCall.new(
401
+ id: tc.id, name: tc.name, arguments: tc.arguments || ''
402
+ ) }
403
+ end
404
+
409
405
  def parse_list_models_response(response, provider, _capabilities)
410
406
  Array(response.body['data']).map do |model|
411
407
  model_id = model.fetch('id')
412
408
  detail = model_detail(model_id)
413
409
  ctx = detail&.dig(:context_window) || infer_context_window(model_id)
410
+ resolved = resolve_model_capabilities(model_id)
414
411
  Legion::Extensions::Llm::Model::Info.new(
415
412
  id: model_id,
416
413
  name: model['display_name'] || model_id,
417
414
  provider: provider,
418
- capabilities: %i[completion streaming tools],
415
+ capabilities: COMPLETION_BASE + resolved[:capabilities],
419
416
  context_length: ctx,
420
417
  metadata: model.merge('created_at' => model['created_at']).compact
421
418
  )
422
419
  end
423
420
  end
424
421
 
425
- def infer_context_window(model_id)
426
- CONTEXT_WINDOWS.find { |prefix, _| model_id.start_with?(prefix) }&.last
422
+ def resolve_model_capabilities(model_id)
423
+ provider_settings = CredentialSources.setting(:extensions, :llm, :anthropic)
424
+ provider_cfg = provider_settings.is_a?(Hash) ? provider_settings.except(:instances) : {}
425
+ model_cfg = model_config_for(model_id, provider_settings)
426
+
427
+ Legion::Extensions::Llm::CapabilityPolicy.resolve(
428
+ real: {},
429
+ provider_catalog: {},
430
+ probe: {},
431
+ provider_envelope: { streaming: true, tools: true },
432
+ provider_config: provider_cfg,
433
+ instance_config: config.respond_to?(:to_h) ? config.to_h : {},
434
+ model_config: model_cfg
435
+ )
427
436
  end
428
437
 
429
- def model_detail(model_name)
430
- fetch_model_detail(model_name)
438
+ def model_config_for(model_id, provider_settings)
439
+ return {} unless provider_settings.is_a?(Hash)
440
+
441
+ models = provider_settings[:models] || provider_settings['models']
442
+ return {} unless models.is_a?(Hash)
443
+
444
+ models[model_id.to_sym] || models[model_id] || {}
445
+ end
446
+
447
+ def infer_context_window(model_id)
448
+ CONTEXT_WINDOWS.find { |prefix, _| model_id.start_with?(prefix) }&.last
431
449
  end
432
450
 
433
451
  def fetch_model_detail(model_name)
@@ -0,0 +1,651 @@
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 Anthropic SSE event to Canonical::Chunk.
110
+ # Real Anthropic events use top-level types like content_block_delta, message_delta,
111
+ # content_block_start; the delta kind is nested inside delta.type.
112
+ def parse_chunk(raw)
113
+ return nil unless raw.is_a?(Hash) && (raw.key?(:type) || raw.key?('type'))
114
+
115
+ type = (raw[:type] || raw['type']).to_s
116
+ delta = raw[:delta] || raw['delta'] || {}
117
+ delta = {} unless delta.is_a?(Hash)
118
+ delta_type = (delta[:type] || delta['type']).to_s
119
+
120
+ case type
121
+ when 'content_block_delta'
122
+ parse_content_block_delta(raw, delta, delta_type)
123
+ when 'content_block_start'
124
+ parse_content_block_start(raw)
125
+ when 'message_start'
126
+ parse_message_start(raw)
127
+ when 'message_delta'
128
+ parse_message_delta(raw, delta)
129
+ when 'message_stop'
130
+ Canonical::Chunk.done(
131
+ request_id: raw[:request_id] || '',
132
+ stop_reason: map_stop_reason(delta[:stop_reason] || delta['stop_reason'])
133
+ )
134
+ when 'text_delta'
135
+ Canonical::Chunk.text_delta(
136
+ delta: extract_delta(raw, 'text_delta'),
137
+ request_id: raw[:request_id],
138
+ block_index: raw[:block_index] || raw['index']
139
+ )
140
+ when 'thinking_delta'
141
+ sig_from_delta = (delta[:signature] || delta['signature'] if delta.any?)
142
+ Canonical::Chunk.thinking_delta(
143
+ delta: extract_delta(raw, 'thinking_delta'),
144
+ request_id: raw[:request_id],
145
+ block_index: raw[:block_index] || raw['index'],
146
+ signature: raw[:signature] || raw['signature'] || sig_from_delta
147
+ )
148
+ when 'tool_call_delta'
149
+ tc = extract_tool_call_from_chunk(raw)
150
+ return nil unless tc
151
+
152
+ Canonical::Chunk.tool_call_delta(
153
+ tool_call: tc,
154
+ request_id: raw[:request_id],
155
+ block_index: raw[:block_index] || raw['index']
156
+ )
157
+ when 'error'
158
+ Canonical::Chunk.error_chunk(
159
+ error: raw[:error] || raw['error'] || 'unknown',
160
+ request_id: raw[:request_id] || '',
161
+ metadata: raw[:metadata] || raw['metadata'] || {}
162
+ )
163
+ when 'done'
164
+ usage = (Canonical::Usage.from_hash(raw[:usage] || raw['usage'] || {}) if raw[:usage] || raw['usage'])
165
+ Canonical::Chunk.done(
166
+ request_id: raw[:request_id] || '',
167
+ usage: usage,
168
+ stop_reason: map_stop_reason(raw[:stop_reason] || raw['stop_reason'])
169
+ )
170
+ else
171
+ log.debug("[anthropic translator] ignoring unknown chunk type: #{type.inspect}")
172
+ nil
173
+ end
174
+ rescue StandardError => e
175
+ handle_exception(e, level: :debug, handled: true, operation: 'anthropic.translator.parse_chunk')
176
+ Canonical::Chunk.error_chunk(
177
+ error: "#{e.class}: #{e.message}",
178
+ request_id: raw[:request_id] || ''
179
+ )
180
+ end
181
+
182
+ private
183
+
184
+ # --- render_messages ---
185
+
186
+ def render_messages(messages, thinking:)
187
+ messages.map do |msg|
188
+ case msg.role
189
+ when :assistant
190
+ render_assistant_message(msg, thinking:)
191
+ when :tool
192
+ render_tool_result_message(msg)
193
+ else
194
+ {
195
+ role: msg.role.to_s,
196
+ content: render_content_blocks(msg.content)
197
+ }
198
+ end
199
+ end
200
+ end
201
+
202
+ def render_assistant_message(msg, thinking:)
203
+ blocks = render_content_blocks(msg.content)
204
+ blocks.unshift({ type: 'text', text: '' }) if thinking && msg.text.to_s.empty? && !msg.tool_calls&.empty?
205
+
206
+ Array(msg.tool_calls).each do |tc|
207
+ args = tc.is_a?(Canonical::ToolCall) ? tc.arguments : (tc[:arguments] || tc['arguments'] || {})
208
+ args = parse_json_or_hash(args)
209
+ blocks << {
210
+ type: 'tool_use',
211
+ id: tc.is_a?(Canonical::ToolCall) ? tc.id : (tc[:id] || tc['id']),
212
+ name: tc.is_a?(Canonical::ToolCall) ? tc.name : (tc[:name] || tc['name']),
213
+ input: args
214
+ }
215
+ end
216
+
217
+ { role: 'assistant', content: blocks }
218
+ end
219
+
220
+ def render_tool_result_message(msg)
221
+ tool_call_id = msg.tool_call_id
222
+ result_content = render_content_blocks(msg.content)
223
+
224
+ {
225
+ role: 'user',
226
+ content: [
227
+ { type: 'tool_result', tool_use_id: tool_call_id, content: result_content }
228
+ ]
229
+ }
230
+ end
231
+
232
+ def render_content_blocks(content)
233
+ return [{ type: 'text', text: content.to_s }] if content.is_a?(String)
234
+ return [] if content.nil?
235
+
236
+ blocks = Array(content).filter_map do |block|
237
+ case block
238
+ when Canonical::ContentBlock
239
+ content_block_to_wire(block)
240
+ when Hash
241
+ hash_block_to_wire(block)
242
+ else
243
+ { type: 'text', text: block.to_s }
244
+ end
245
+ end
246
+ blocks.empty? ? [] : blocks
247
+ end
248
+
249
+ def content_block_to_wire(block)
250
+ wire = case block.type
251
+ when :thinking
252
+ { type: 'thinking', thinking: block.text || '' }
253
+ when :tool_use
254
+ { type: 'tool_use', id: block.id, name: block.name, input: block.input || {} }
255
+ when :tool_result
256
+ { type: 'tool_result', tool_use_id: block.tool_use_id,
257
+ content: [{ type: 'text', text: block.text || '' }] }
258
+ when :image
259
+ { type: 'image', source: { type: block.source_type || 'base64',
260
+ media_type: block.media_type, data: block.data } }
261
+ else
262
+ { type: 'text', text: block.text || '' }
263
+ end
264
+ wire[:cache_control] = block.cache_control if block.cache_control
265
+ wire
266
+ end
267
+
268
+ def hash_block_to_wire(block)
269
+ block_type = block[:type] || block['type']
270
+ cc = block[:cache_control] || block['cache_control']
271
+
272
+ wire = case block_type
273
+ when 'image'
274
+ { type: 'image', source: block[:source] || block['source'] || {} }
275
+ when 'tool_result'
276
+ {
277
+ type: 'tool_result',
278
+ tool_use_id: block[:tool_use_id] || block['tool_use_id'],
279
+ content: Array(block[:content] || block['content']).map do |item|
280
+ if item.is_a?(Hash)
281
+ { type: 'text', text: item[:text] || item['text'] || '' }
282
+ else
283
+ { type: 'text', text: item.to_s }
284
+ end
285
+ end
286
+ }
287
+ else
288
+ return block
289
+ end
290
+ wire[:cache_control] = cc if cc
291
+ wire
292
+ end
293
+
294
+ # --- system content ---
295
+
296
+ def render_system_string(system_input)
297
+ return system_input if system_input.is_a?(Hash) || system_input.is_a?(Array)
298
+
299
+ [{ type: 'text', text: system_input.to_s }]
300
+ end
301
+
302
+ def render_system_content(messages)
303
+ parts = messages.flat_map do |msg|
304
+ content = msg.content
305
+ if content.is_a?(Canonical::ContentBlock) && content.type == :text
306
+ [{ type: 'text', text: content.text || '' }]
307
+ elsif content.is_a?(Array)
308
+ render_content_blocks(content)
309
+ else
310
+ [{ type: 'text', text: content.to_s }]
311
+ end
312
+ end
313
+ parts.empty? ? nil : parts
314
+ end
315
+
316
+ # --- tools ---
317
+
318
+ def render_tools(tools)
319
+ return nil if tools.nil? || tools.empty?
320
+
321
+ tools.values.map do |tool|
322
+ name = tool.is_a?(Canonical::ToolDefinition) ? tool.name : (tool[:name] || tool['name'])
323
+ desc = tool.is_a?(Canonical::ToolDefinition) ? tool.description : (tool[:description] || tool['description'] || '')
324
+ params = if tool.is_a?(Canonical::ToolDefinition)
325
+ tool.parameters
326
+ else
327
+ Canonical::ToolDefinition.normalize_parameters(tool[:parameters] || tool['parameters'])
328
+ end
329
+
330
+ { name: name, description: desc, input_schema: params }
331
+ end
332
+ end
333
+
334
+ # --- tool_choice ---
335
+
336
+ def render_tool_choice(tool_choice)
337
+ return nil unless tool_choice
338
+
339
+ case tool_choice
340
+ when :auto, 'auto'
341
+ { type: 'auto' }
342
+ when :none, 'none'
343
+ nil
344
+ when :required, 'required'
345
+ { type: 'any' }
346
+ when Hash
347
+ { type: 'tool', name: tool_choice[:name] || tool_choice['name'] }
348
+ when Symbol, String
349
+ { type: 'tool', name: tool_choice.to_s }
350
+ end
351
+ end
352
+
353
+ # --- thinking ---
354
+
355
+ def thinking_enabled?(canonical_request)
356
+ thinking = canonical_request.thinking
357
+ return false unless thinking
358
+
359
+ case thinking
360
+ when Canonical::Thinking::Config
361
+ thinking.enabled?
362
+ when Hash
363
+ !!thinking
364
+ else
365
+ true
366
+ end
367
+ end
368
+
369
+ def render_thinking_config(canonical_request)
370
+ tc = canonical_request.thinking
371
+ budget = case tc
372
+ when Canonical::Thinking::Config
373
+ tc.budget
374
+ when Hash
375
+ tc[:budget] || tc['budget'] || tc[:budget_tokens] || tc['budget_tokens']
376
+ end
377
+
378
+ budget ||= canonical_request.params&.max_thinking_tokens
379
+ budget = default_thinking_budget if budget.nil? || budget.zero?
380
+
381
+ { type: 'enabled', budget_tokens: budget }
382
+ end
383
+
384
+ def default_thinking_budget
385
+ @config[:default_thinking_budget] || 1024
386
+ end
387
+
388
+ # --- response_format ---
389
+
390
+ def render_response_format(fmt)
391
+ return nil unless fmt
392
+
393
+ normalized = fmt.is_a?(Hash) ? fmt : {}
394
+ fmt_type = normalized[:type] || normalized['type']
395
+ schema = normalized[:schema] || normalized['schema'] || normalized.except(:type)
396
+
397
+ case fmt_type
398
+ when 'json_object', 'json_schema'
399
+ if schema && !schema.empty?
400
+ { type: 'json_schema', schema: schema }
401
+ else
402
+ { type: 'json_object' }
403
+ end
404
+ when :json, 'json'
405
+ { type: 'json_object' }
406
+ end
407
+ end
408
+
409
+ # --- unsupported params ---
410
+
411
+ def drop_unsupported_params(params)
412
+ # Anthropic Messages API does NOT support: top_p, top_k, frequency_penalty, presence_penalty.
413
+ unsupported = {}
414
+ unsupported[:top_p] = params.top_p if params.top_p
415
+ unsupported[:top_k] = params.top_k if params.top_k
416
+ unsupported[:frequency_penalty] = params.frequency_penalty if params.frequency_penalty
417
+ unsupported[:presence_penalty] = params.presence_penalty if params.presence_penalty
418
+
419
+ return if unsupported.empty?
420
+
421
+ log.debug("[anthropic translator] dropping unsupported params: #{unsupported.keys.join(', ')}")
422
+ end
423
+
424
+ # --- response parsing ---
425
+
426
+ def extract_text(blocks)
427
+ blocks.select { |b| (b[:type] || b['type']) == 'text' }
428
+ .map { |b| b[:text] || b['text'] || '' }
429
+ .join
430
+ end
431
+
432
+ def extract_thinking(blocks)
433
+ thinking_block = blocks.find { |b| (b[:type] || b['type']) == 'thinking' }
434
+ redacted_block = blocks.find { |b| (b[:type] || b['type']) == 'redacted_thinking' }
435
+
436
+ content = thinking_block&.dig(:thinking) || thinking_block&.dig('thinking') ||
437
+ thinking_block&.dig(:text) || thinking_block&.dig('text')
438
+ signature = thinking_block&.dig(:signature) || thinking_block&.dig('signature') ||
439
+ redacted_block&.dig(:data) || redacted_block&.dig('data')
440
+
441
+ Canonical::Thinking.from_hash({ content: content, signature: signature })
442
+ end
443
+
444
+ def extract_tool_calls(blocks)
445
+ tc_blocks = blocks.select { |b| (b[:type] || b['type']) == 'tool_use' }
446
+ return [] if tc_blocks.empty?
447
+
448
+ tc_blocks.map do |block|
449
+ args_input = block[:input] || block['input'] || {}
450
+ args = parse_json_or_hash(args_input)
451
+
452
+ Canonical::ToolCall.build(
453
+ id: block[:id] || block['id'],
454
+ name: block[:name] || block['name'],
455
+ arguments: args,
456
+ source: :client
457
+ )
458
+ end
459
+ end
460
+
461
+ def parse_json_or_hash(input)
462
+ return input if input.is_a?(Hash)
463
+
464
+ if input.is_a?(String)
465
+ begin
466
+ Legion::JSON.load(input)
467
+ rescue Legion::JSON::ParseError
468
+ { raw_json: input }
469
+ end
470
+ else
471
+ {}
472
+ end
473
+ end
474
+
475
+ def parse_usage(usage)
476
+ Canonical::Usage.from_hash(
477
+ input_tokens: usage[:input_tokens] || usage['input_tokens'],
478
+ output_tokens: usage[:output_tokens] || usage['output_tokens'],
479
+ cache_read_tokens: usage[:cache_read_input_tokens] || usage['cache_read_input_tokens'],
480
+ cache_write_tokens: cache_creation_input_tokens(usage),
481
+ thinking_tokens: thinking_tokens_raw(usage)
482
+ )
483
+ end
484
+
485
+ def cache_creation_input_tokens(usage)
486
+ val = usage[:cache_creation_input_tokens] || usage['cache_creation_input_tokens']
487
+ return val if val
488
+
489
+ cache_creation = usage[:cache_creation] || usage['cache_creation']
490
+ return cache_creation.values.sum if cache_creation.is_a?(Hash)
491
+
492
+ val
493
+ end
494
+
495
+ def thinking_tokens_raw(usage)
496
+ usage.dig(:output_tokens_details, :thinking_tokens) ||
497
+ usage.dig('output_tokens_details', 'thinking_tokens') ||
498
+ usage.dig(:output_tokens_details, :reasoning_tokens) ||
499
+ usage.dig('output_tokens_details', 'reasoning_tokens') ||
500
+ usage[:thinking_tokens] || usage['thinking_tokens'] ||
501
+ usage[:reasoning_tokens] || usage['reasoning_tokens']
502
+ end
503
+
504
+ # --- chunk parsing ---
505
+
506
+ # --- Anthropic wire-format SSE event parsers ---
507
+
508
+ def parse_content_block_delta(raw, delta, delta_type)
509
+ index = raw[:index] || raw['index']
510
+ case delta_type
511
+ when 'text_delta'
512
+ Canonical::Chunk.text_delta(
513
+ delta: delta[:text] || delta['text'] || '',
514
+ request_id: raw[:request_id],
515
+ block_index: index
516
+ )
517
+ when 'thinking_delta'
518
+ Canonical::Chunk.thinking_delta(
519
+ delta: delta[:thinking] || delta['thinking'] || '',
520
+ request_id: raw[:request_id],
521
+ block_index: index
522
+ )
523
+ when 'signature_delta'
524
+ Canonical::Chunk.thinking_delta(
525
+ delta: '',
526
+ request_id: raw[:request_id],
527
+ block_index: index,
528
+ signature: delta[:signature] || delta['signature']
529
+ )
530
+ when 'input_json_delta'
531
+ tc = Canonical::ToolCall.new(
532
+ id: nil, exchange_id: nil, name: nil, source: nil,
533
+ arguments: delta[:partial_json] || delta['partial_json'] || '',
534
+ status: nil, duration_ms: nil, result: nil, error: nil,
535
+ started_at: nil, finished_at: nil, category: nil,
536
+ data_handling_classification: nil, policy_decision: nil
537
+ )
538
+ Canonical::Chunk.tool_call_delta(
539
+ tool_call: tc,
540
+ request_id: raw[:request_id],
541
+ block_index: index
542
+ )
543
+ else
544
+ log.debug("[anthropic translator] ignoring content_block_delta delta_type: #{delta_type}")
545
+ nil
546
+ end
547
+ end
548
+
549
+ def parse_content_block_start(raw)
550
+ content_block = raw[:content_block] || raw['content_block'] || {}
551
+ return nil unless content_block.is_a?(Hash)
552
+
553
+ block_type = content_block[:type] || content_block['type']
554
+ return nil unless block_type == 'tool_use'
555
+
556
+ tc = Canonical::ToolCall.build(
557
+ id: content_block[:id] || content_block['id'],
558
+ name: content_block[:name] || content_block['name']
559
+ )
560
+ Canonical::Chunk.tool_call_delta(
561
+ tool_call: tc,
562
+ request_id: raw[:request_id],
563
+ block_index: raw[:index] || raw['index']
564
+ )
565
+ end
566
+
567
+ def parse_message_start(raw)
568
+ message = raw[:message] || raw['message'] || {}
569
+ message = {} unless message.is_a?(Hash)
570
+ usage_raw = message[:usage] || message['usage']
571
+ usage = Canonical::Usage.from_hash(usage_raw) if usage_raw.is_a?(Hash) && usage_raw.any?
572
+
573
+ Canonical::Chunk.usage_chunk(
574
+ usage: usage,
575
+ request_id: raw[:request_id] || ''
576
+ )
577
+ end
578
+
579
+ def parse_message_delta(raw, delta)
580
+ usage_raw = raw[:usage] || raw['usage']
581
+ usage = Canonical::Usage.from_hash(usage_raw) if usage_raw.is_a?(Hash) && usage_raw.any?
582
+ stop_reason = delta[:stop_reason] || delta['stop_reason']
583
+
584
+ Canonical::Chunk.done(
585
+ request_id: raw[:request_id] || '',
586
+ usage: usage,
587
+ stop_reason: map_stop_reason(stop_reason)
588
+ )
589
+ end
590
+
591
+ def extract_delta(raw, _type)
592
+ delta_val = raw[:delta] || raw['delta']
593
+ # Canonical form: delta is a plain string (e.g. from conformance fixtures).
594
+ return delta_val if delta_val.is_a?(String) && !delta_val.empty?
595
+
596
+ # Anthropic wire form: delta is a nested object with {text:} or {thinking:}.
597
+ raw.dig(:delta, :text) || raw.dig('delta', 'text') ||
598
+ raw.dig(:delta, :thinking) || raw.dig('delta', 'thinking') ||
599
+ ''
600
+ end
601
+
602
+ def extract_tool_call_from_chunk(raw)
603
+ # Canonical form: tool_call is directly in the chunk (e.g. from conformance fixtures).
604
+ tc_data = raw[:tool_call] || raw['tool_call']
605
+ return extract_tc_from_data(tc_data) if tc_data
606
+
607
+ # Anthropic wire form: tool call is in content_block with type 'tool_use'.
608
+ cb = raw[:content_block] || raw['content_block']
609
+ return nil unless cb && ((cb[:type] || cb['type']) == 'tool_use')
610
+
611
+ extract_tc_from_data(cb)
612
+ end
613
+
614
+ def extract_tc_from_data(data)
615
+ Canonical::ToolCall.build(
616
+ id: data[:id] || data['id'],
617
+ name: data[:name] || data['name'],
618
+ arguments: data[:arguments] || data['arguments'] || {}
619
+ )
620
+ end
621
+
622
+ # --- stop_reason mapping ---
623
+
624
+ def map_stop_reason(raw)
625
+ return nil unless raw
626
+
627
+ mapping = {
628
+ 'end_turn' => :end_turn,
629
+ 'tool_use' => :tool_use,
630
+ 'max_tokens' => :max_tokens,
631
+ 'stop_sequence' => :stop_sequence,
632
+ 'content_filter' => :content_filter
633
+ }
634
+
635
+ result = mapping[raw.to_s]
636
+ return result if result
637
+
638
+ log.debug("[anthropic translator] unmapped stop_reason: #{raw.inspect}, defaulting to :end_turn")
639
+ :end_turn
640
+ end
641
+
642
+ # --- settings helpers ---
643
+
644
+ def settings_default_max_tokens
645
+ @config[:default_max_tokens] || 4096
646
+ end
647
+ end
648
+ end
649
+ end
650
+ end
651
+ 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.20'
8
8
  end
9
9
  end
10
10
  end
@@ -2,10 +2,10 @@
2
2
 
3
3
  require 'legion/extensions/llm'
4
4
  require 'legion/logging/helper'
5
- require 'legion/extensions/llm/anthropic/registry_event_builder'
6
- require 'legion/extensions/llm/anthropic/registry_publisher'
7
5
  require 'legion/extensions/llm/anthropic/provider'
6
+ require 'legion/extensions/llm/anthropic/translator'
8
7
  require 'legion/extensions/llm/anthropic/version'
8
+ require_relative 'anthropic/actors/discovery_refresh'
9
9
 
10
10
  module Legion
11
11
  module Extensions
@@ -34,10 +34,7 @@ module Legion
34
34
  fleet: {
35
35
  enabled: false,
36
36
  respond_to_requests: false,
37
- capabilities: %i[chat stream_chat],
38
- lanes: [],
39
- concurrency: 4,
40
- queue_suffix: nil
37
+ capabilities: %i[chat stream_chat]
41
38
  }
42
39
  }
43
40
  )
@@ -144,8 +141,7 @@ module Legion
144
141
  config.except(:api_key)
145
142
  end
146
143
 
147
- Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options) if
148
- Legion::Extensions::Llm::Configuration.respond_to?(:register_provider_options)
144
+ Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options)
149
145
  end
150
146
  end
151
147
  end
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.20
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