lex-llm-anthropic 0.2.15 → 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/.rubocop.yml +40 -6
- data/CHANGELOG.md +18 -0
- data/Gemfile +2 -6
- data/lex-llm-anthropic.gemspec +1 -1
- data/lib/legion/extensions/llm/anthropic/actors/discovery_refresh.rb +1 -1
- data/lib/legion/extensions/llm/anthropic/provider.rb +61 -57
- data/lib/legion/extensions/llm/anthropic/registry_event_builder.rb +10 -10
- data/lib/legion/extensions/llm/anthropic/runners/fleet_worker.rb +5 -5
- data/lib/legion/extensions/llm/anthropic/translator.rb +547 -0
- data/lib/legion/extensions/llm/anthropic/transport/messages/registry_event.rb +1 -1
- data/lib/legion/extensions/llm/anthropic/version.rb +1 -1
- data/lib/legion/extensions/llm/anthropic.rb +35 -34
- 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/.rubocop.yml
CHANGED
|
@@ -1,18 +1,52 @@
|
|
|
1
1
|
plugins:
|
|
2
2
|
- rubocop-performance
|
|
3
3
|
- rubocop-rake
|
|
4
|
-
- rubocop-rspec
|
|
5
4
|
|
|
6
5
|
AllCops:
|
|
7
|
-
NewCops: enable
|
|
8
6
|
TargetRubyVersion: 3.4
|
|
7
|
+
NewCops: enable
|
|
9
8
|
SuggestExtensions: false
|
|
10
9
|
|
|
10
|
+
Layout/LineLength:
|
|
11
|
+
Max: 195
|
|
12
|
+
Layout/SpaceAroundEqualsInParameterDefault:
|
|
13
|
+
EnforcedStyle: space
|
|
14
|
+
Layout/HashAlignment:
|
|
15
|
+
EnforcedHashRocketStyle: table
|
|
16
|
+
EnforcedColonStyle: table
|
|
17
|
+
Metrics/MethodLength:
|
|
18
|
+
Max: 150
|
|
19
|
+
Metrics/ClassLength:
|
|
20
|
+
Max: 1500
|
|
21
|
+
Metrics/ModuleLength:
|
|
22
|
+
Max: 1500
|
|
11
23
|
Metrics/BlockLength:
|
|
24
|
+
Max: 150
|
|
12
25
|
Exclude:
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
Metrics/
|
|
26
|
+
- 'spec/**/*'
|
|
27
|
+
|
|
28
|
+
Metrics/AbcSize:
|
|
29
|
+
Max: 110
|
|
30
|
+
Metrics/BlockNesting:
|
|
31
|
+
Max: 4
|
|
32
|
+
Metrics/CyclomaticComplexity:
|
|
33
|
+
Max: 50
|
|
34
|
+
|
|
35
|
+
Metrics/PerceivedComplexity:
|
|
36
|
+
Max: 50
|
|
37
|
+
Style/Documentation:
|
|
16
38
|
Enabled: false
|
|
17
|
-
|
|
39
|
+
Style/SymbolArray:
|
|
40
|
+
Enabled: true
|
|
41
|
+
Style/FrozenStringLiteralComment:
|
|
42
|
+
Enabled: true
|
|
43
|
+
EnforcedStyle: always
|
|
44
|
+
Naming/FileName:
|
|
18
45
|
Enabled: false
|
|
46
|
+
Naming/PredicateMethod:
|
|
47
|
+
Enabled: false
|
|
48
|
+
Metrics/ParameterLists:
|
|
49
|
+
Max: 9
|
|
50
|
+
Style/RedundantConstantBase:
|
|
51
|
+
Exclude:
|
|
52
|
+
- 'spec/**/*'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
15
|
+
## 0.2.16 - 2026-06-10
|
|
16
|
+
|
|
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).
|
|
18
|
+
- **RuboCop configuration overhaul** — Relaxed metrics to match project scale: LineLength 195, MethodLength 150, ClassLength 1500, AbcSize 110, BlockNesting 4, CyclomaticComplexity/PerceivedComplexity 50. Added `Layout/HashAlignment` (table style), `Layout/SpaceAroundEqualsInParameterDefault`, `Naming/PredicateMethod` disable, `Style/RedundantConstantBase` spec exclusion. Removed `rubocop-rspec` plugin (no longer needed). All 28 specs passing, 0 offenses (.rubocop.yml).
|
|
19
|
+
- **Hash alignment formatting** — Applied consistent table-style hash alignment across provider.rb, anthropic.rb, registry_event_builder.rb, fleet_worker.rb, and transport messages for readability.
|
|
20
|
+
|
|
3
21
|
## 0.2.15 - 2026-06-05
|
|
4
22
|
|
|
5
23
|
- **Fix RuboCop cyclomatic complexity** — Extract `extract_hash_budget` helper to reduce `thinking_budget` cyclomatic complexity from 8 to 6, meeting the 7-line threshold.
|
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
|
|
@@ -13,7 +13,7 @@ module Legion
|
|
|
13
13
|
module Llm
|
|
14
14
|
module Anthropic
|
|
15
15
|
module Actor
|
|
16
|
-
class DiscoveryRefresh < Legion::Extensions::Actors::Every
|
|
16
|
+
class DiscoveryRefresh < Legion::Extensions::Actors::Every
|
|
17
17
|
include Legion::Logging::Helper
|
|
18
18
|
|
|
19
19
|
REFRESH_INTERVAL = 1800
|
|
@@ -8,7 +8,7 @@ module Legion
|
|
|
8
8
|
module Llm
|
|
9
9
|
module Anthropic
|
|
10
10
|
# Anthropic Messages API provider implementation for the Legion::Extensions::Llm contract.
|
|
11
|
-
class Provider < Legion::Extensions::Llm::Provider
|
|
11
|
+
class Provider < Legion::Extensions::Llm::Provider
|
|
12
12
|
include Legion::Logging::Helper
|
|
13
13
|
|
|
14
14
|
class << self
|
|
@@ -45,7 +45,7 @@ module Legion
|
|
|
45
45
|
|
|
46
46
|
def headers
|
|
47
47
|
identity_headers.merge({
|
|
48
|
-
'x-api-key'
|
|
48
|
+
'x-api-key' => config.anthropic_api_key,
|
|
49
49
|
'anthropic-version' => config.anthropic_version || settings[:api_version] || '2023-06-01'
|
|
50
50
|
}.compact)
|
|
51
51
|
end
|
|
@@ -67,18 +67,18 @@ module Legion
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
CONTEXT_WINDOWS = {
|
|
70
|
-
'claude-opus-4'
|
|
70
|
+
'claude-opus-4' => 200_000,
|
|
71
71
|
'claude-sonnet-4' => 200_000,
|
|
72
|
-
'claude-haiku-4'
|
|
73
|
-
'claude-3-5'
|
|
74
|
-
'claude-3-opus'
|
|
72
|
+
'claude-haiku-4' => 200_000,
|
|
73
|
+
'claude-3-5' => 200_000,
|
|
74
|
+
'claude-3-opus' => 200_000,
|
|
75
75
|
'claude-3-sonnet' => 200_000,
|
|
76
|
-
'claude-3-haiku'
|
|
76
|
+
'claude-3-haiku' => 200_000
|
|
77
77
|
}.freeze
|
|
78
78
|
|
|
79
79
|
private
|
|
80
80
|
|
|
81
|
-
def render_payload(messages, tools:, temperature:, model:, stream:, schema:, thinking:, tool_prefs:)
|
|
81
|
+
def render_payload(messages, tools:, temperature:, model:, stream:, schema:, thinking:, tool_prefs:)
|
|
82
82
|
log_render_payload(messages:, tools:, model:, stream:, schema:)
|
|
83
83
|
system_messages, chat_messages = messages.partition { |message| message.role == :system }
|
|
84
84
|
|
|
@@ -87,15 +87,15 @@ module Legion
|
|
|
87
87
|
cacheable_count = caching ? [chat_messages.size - exclude_count, 0].max : 0
|
|
88
88
|
|
|
89
89
|
{
|
|
90
|
-
model:
|
|
91
|
-
messages:
|
|
92
|
-
stream:
|
|
93
|
-
max_tokens:
|
|
94
|
-
system:
|
|
95
|
-
thinking:
|
|
96
|
-
temperature:
|
|
97
|
-
tools:
|
|
98
|
-
tool_choice:
|
|
90
|
+
model: model.id,
|
|
91
|
+
messages: format_messages(chat_messages, thinking: thinking_enabled?(thinking), cacheable_count:),
|
|
92
|
+
stream: stream,
|
|
93
|
+
max_tokens: model.max_tokens || settings[:default_max_tokens] || 4096,
|
|
94
|
+
system: system_content(system_messages, cache: caching),
|
|
95
|
+
thinking: thinking_payload(thinking),
|
|
96
|
+
temperature: temperature,
|
|
97
|
+
tools: format_tools(tools, cache: caching),
|
|
98
|
+
tool_choice: tool_choice(tool_prefs),
|
|
99
99
|
output_config: output_config(schema)
|
|
100
100
|
}.compact
|
|
101
101
|
end
|
|
@@ -123,7 +123,7 @@ module Legion
|
|
|
123
123
|
format_tool_result_message(message, cache:)
|
|
124
124
|
else
|
|
125
125
|
{
|
|
126
|
-
role:
|
|
126
|
+
role: anthropic_role(message.role),
|
|
127
127
|
content: content_blocks(message.content, thinking:, message:, cache:)
|
|
128
128
|
}
|
|
129
129
|
end
|
|
@@ -167,11 +167,11 @@ module Legion
|
|
|
167
167
|
next unless attachment.image?
|
|
168
168
|
|
|
169
169
|
{
|
|
170
|
-
type:
|
|
170
|
+
type: 'image',
|
|
171
171
|
source: {
|
|
172
|
-
type:
|
|
172
|
+
type: 'base64',
|
|
173
173
|
media_type: attachment.mime_type,
|
|
174
|
-
data:
|
|
174
|
+
data: attachment.encoded
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
end
|
|
@@ -192,10 +192,10 @@ module Legion
|
|
|
192
192
|
|
|
193
193
|
def tool_use_block(tool_call, cache: false)
|
|
194
194
|
{
|
|
195
|
-
type:
|
|
196
|
-
id:
|
|
197
|
-
name:
|
|
198
|
-
input:
|
|
195
|
+
type: 'tool_use',
|
|
196
|
+
id: tool_call.id,
|
|
197
|
+
name: tool_call.name,
|
|
198
|
+
input: tool_call.arguments,
|
|
199
199
|
cache_control: { type: 'ephemeral' }
|
|
200
200
|
}.tap do |block|
|
|
201
201
|
block.delete(:cache_control) unless cache
|
|
@@ -204,12 +204,12 @@ module Legion
|
|
|
204
204
|
|
|
205
205
|
def format_tool_result_message(message, cache: false)
|
|
206
206
|
{
|
|
207
|
-
role:
|
|
207
|
+
role: 'user',
|
|
208
208
|
content: [
|
|
209
209
|
{
|
|
210
|
-
type:
|
|
211
|
-
tool_use_id:
|
|
212
|
-
content:
|
|
210
|
+
type: 'tool_result',
|
|
211
|
+
tool_use_id: message.tool_call_id,
|
|
212
|
+
content: content_blocks(message.content, cache:),
|
|
213
213
|
cache_control: { type: 'ephemeral' }
|
|
214
214
|
}.tap { |block| block.delete(:cache_control) unless cache }
|
|
215
215
|
]
|
|
@@ -256,9 +256,12 @@ module Legion
|
|
|
256
256
|
return nil if tools.empty?
|
|
257
257
|
|
|
258
258
|
tool_array = tools.values.map do |tool|
|
|
259
|
+
# Tools can be ToolDefinition objects or plain Hashes from native_dispatch.
|
|
260
|
+
tool_name = tool.respond_to?(:name) ? tool.name : (tool[:name] || tool['name'])
|
|
261
|
+
tool_desc = tool.respond_to?(:description) ? tool.description : (tool[:description] || tool['description'] || '')
|
|
259
262
|
{
|
|
260
|
-
name:
|
|
261
|
-
description:
|
|
263
|
+
name: tool_name,
|
|
264
|
+
description: tool_desc,
|
|
262
265
|
input_schema: tool_schema(tool)
|
|
263
266
|
}
|
|
264
267
|
end
|
|
@@ -271,7 +274,8 @@ module Legion
|
|
|
271
274
|
def tool_schema(tool)
|
|
272
275
|
return tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
|
|
273
276
|
|
|
274
|
-
{ type: 'object',
|
|
277
|
+
{ type: 'object',
|
|
278
|
+
properties: tool.respond_to?(:parameters) ? tool.parameters : (tool[:parameters] || tool['parameters'] || {}), required: [] }
|
|
275
279
|
end
|
|
276
280
|
|
|
277
281
|
def tool_choice(tool_prefs)
|
|
@@ -318,17 +322,17 @@ module Legion
|
|
|
318
322
|
usage = body['usage'] || {}
|
|
319
323
|
|
|
320
324
|
Legion::Extensions::Llm::Message.new(
|
|
321
|
-
role:
|
|
322
|
-
content:
|
|
323
|
-
model_id:
|
|
324
|
-
thinking:
|
|
325
|
-
tool_calls:
|
|
326
|
-
input_tokens:
|
|
327
|
-
output_tokens:
|
|
328
|
-
cached_tokens:
|
|
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'],
|
|
329
333
|
cache_creation_tokens: cache_creation_tokens(usage),
|
|
330
|
-
thinking_tokens:
|
|
331
|
-
raw:
|
|
334
|
+
thinking_tokens: thinking_tokens(usage),
|
|
335
|
+
raw: body
|
|
332
336
|
)
|
|
333
337
|
end
|
|
334
338
|
|
|
@@ -341,7 +345,7 @@ module Legion
|
|
|
341
345
|
redacted_block = blocks.find { |block| block['type'] == 'redacted_thinking' }
|
|
342
346
|
|
|
343
347
|
Legion::Extensions::Llm::Thinking.build(
|
|
344
|
-
text:
|
|
348
|
+
text: thinking_block&.dig('thinking') || thinking_block&.dig('text'),
|
|
345
349
|
signature: thinking_block&.dig('signature') || redacted_block&.dig('data')
|
|
346
350
|
)
|
|
347
351
|
end
|
|
@@ -364,16 +368,16 @@ module Legion
|
|
|
364
368
|
delta_type = data.dig('delta', 'type')
|
|
365
369
|
|
|
366
370
|
Legion::Extensions::Llm::Chunk.new(
|
|
367
|
-
role:
|
|
368
|
-
content:
|
|
369
|
-
model_id:
|
|
370
|
-
thinking:
|
|
371
|
-
text:
|
|
371
|
+
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,
|
|
372
376
|
signature: delta_type == 'signature_delta' ? data.dig('delta', 'signature') : nil
|
|
373
377
|
),
|
|
374
|
-
input_tokens:
|
|
378
|
+
input_tokens: data.dig('message', 'usage', 'input_tokens'),
|
|
375
379
|
output_tokens: data.dig('message', 'usage', 'output_tokens') || data.dig('usage', 'output_tokens'),
|
|
376
|
-
tool_calls:
|
|
380
|
+
tool_calls: extract_streaming_tool_calls(data, delta_type)
|
|
377
381
|
)
|
|
378
382
|
end
|
|
379
383
|
|
|
@@ -394,8 +398,8 @@ module Legion
|
|
|
394
398
|
[
|
|
395
399
|
block['id'],
|
|
396
400
|
Legion::Extensions::Llm::ToolCall.new(
|
|
397
|
-
id:
|
|
398
|
-
name:
|
|
401
|
+
id: block['id'],
|
|
402
|
+
name: block['name'],
|
|
399
403
|
arguments: block['input'] || {}
|
|
400
404
|
)
|
|
401
405
|
]
|
|
@@ -408,12 +412,12 @@ module Legion
|
|
|
408
412
|
detail = model_detail(model_id)
|
|
409
413
|
ctx = detail&.dig(:context_window) || infer_context_window(model_id)
|
|
410
414
|
Legion::Extensions::Llm::Model::Info.new(
|
|
411
|
-
id:
|
|
412
|
-
name:
|
|
413
|
-
provider:
|
|
414
|
-
capabilities:
|
|
415
|
+
id: model_id,
|
|
416
|
+
name: model['display_name'] || model_id,
|
|
417
|
+
provider: provider,
|
|
418
|
+
capabilities: %i[completion streaming tools],
|
|
415
419
|
context_length: ctx,
|
|
416
|
-
metadata:
|
|
420
|
+
metadata: model.merge('created_at' => model['created_at']).compact
|
|
417
421
|
)
|
|
418
422
|
end
|
|
419
423
|
end
|
|
@@ -13,8 +13,8 @@ module Legion
|
|
|
13
13
|
def model_available(model, readiness:)
|
|
14
14
|
registry_event_class.available(
|
|
15
15
|
model_offering(model),
|
|
16
|
-
runtime:
|
|
17
|
-
health:
|
|
16
|
+
runtime: runtime_metadata,
|
|
17
|
+
health: model_health(readiness),
|
|
18
18
|
metadata: model_metadata(model)
|
|
19
19
|
)
|
|
20
20
|
end
|
|
@@ -23,14 +23,14 @@ module Legion
|
|
|
23
23
|
|
|
24
24
|
def model_offering(model)
|
|
25
25
|
{
|
|
26
|
-
provider_family:
|
|
26
|
+
provider_family: :anthropic,
|
|
27
27
|
provider_instance: provider_instance,
|
|
28
|
-
transport:
|
|
29
|
-
model:
|
|
30
|
-
usage_type:
|
|
31
|
-
capabilities:
|
|
32
|
-
limits:
|
|
33
|
-
metadata:
|
|
28
|
+
transport: :http,
|
|
29
|
+
model: model.id,
|
|
30
|
+
usage_type: :inference,
|
|
31
|
+
capabilities: Array(model.capabilities).map(&:to_sym),
|
|
32
|
+
limits: model_limits(model),
|
|
33
|
+
metadata: { lex: :llm_anthropic, model_name: model.name }.compact
|
|
34
34
|
}
|
|
35
35
|
end
|
|
36
36
|
|
|
@@ -49,7 +49,7 @@ module Legion
|
|
|
49
49
|
|
|
50
50
|
def model_limits(model)
|
|
51
51
|
{
|
|
52
|
-
context_window:
|
|
52
|
+
context_window: model.context_window,
|
|
53
53
|
max_output_tokens: model.max_output_tokens
|
|
54
54
|
}.compact
|
|
55
55
|
end
|
|
@@ -14,12 +14,12 @@ module Legion
|
|
|
14
14
|
|
|
15
15
|
def handle_fleet_request(payload, delivery: nil, properties: nil)
|
|
16
16
|
Legion::Extensions::Llm::Fleet::ProviderResponder.call(
|
|
17
|
-
payload:
|
|
18
|
-
provider_family:
|
|
19
|
-
provider_class:
|
|
17
|
+
payload: payload,
|
|
18
|
+
provider_family: Anthropic::PROVIDER_FAMILY,
|
|
19
|
+
provider_class: Anthropic::Provider,
|
|
20
20
|
provider_instances: -> { Anthropic.discover_instances },
|
|
21
|
-
delivery:
|
|
22
|
-
properties:
|
|
21
|
+
delivery: delivery,
|
|
22
|
+
properties: properties
|
|
23
23
|
)
|
|
24
24
|
end
|
|
25
25
|
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,13 +5,14 @@ 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
|
|
11
12
|
module Extensions
|
|
12
13
|
module Llm
|
|
13
14
|
# Anthropic provider extension namespace.
|
|
14
|
-
module Anthropic
|
|
15
|
+
module Anthropic
|
|
15
16
|
extend ::Legion::Extensions::Core if ::Legion::Extensions.const_defined?(:Core, false)
|
|
16
17
|
extend Legion::Logging::Helper
|
|
17
18
|
extend Legion::Extensions::Llm::AutoRegistration
|
|
@@ -20,24 +21,24 @@ module Legion
|
|
|
20
21
|
|
|
21
22
|
def self.default_settings
|
|
22
23
|
::Legion::Extensions::Llm.provider_settings(
|
|
23
|
-
family:
|
|
24
|
+
family: PROVIDER_FAMILY,
|
|
24
25
|
instance: {
|
|
25
|
-
default_model:
|
|
26
|
-
endpoint:
|
|
27
|
-
api_version:
|
|
26
|
+
default_model: 'claude-sonnet-4-6',
|
|
27
|
+
endpoint: 'https://api.anthropic.com',
|
|
28
|
+
api_version: '2023-10-16',
|
|
28
29
|
default_max_tokens: 4096,
|
|
29
|
-
tier:
|
|
30
|
-
transport:
|
|
31
|
-
credentials:
|
|
32
|
-
usage:
|
|
33
|
-
limits:
|
|
34
|
-
fleet:
|
|
35
|
-
enabled:
|
|
30
|
+
tier: :frontier,
|
|
31
|
+
transport: :http,
|
|
32
|
+
credentials: { api_key: 'env://ANTHROPIC_API_KEY' },
|
|
33
|
+
usage: { inference: true, embedding: false, image: false },
|
|
34
|
+
limits: { concurrency: 4 },
|
|
35
|
+
fleet: {
|
|
36
|
+
enabled: false,
|
|
36
37
|
respond_to_requests: false,
|
|
37
|
-
capabilities:
|
|
38
|
-
lanes:
|
|
39
|
-
concurrency:
|
|
40
|
-
queue_suffix:
|
|
38
|
+
capabilities: %i[chat stream_chat],
|
|
39
|
+
lanes: [],
|
|
40
|
+
concurrency: 4,
|
|
41
|
+
queue_suffix: nil
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
)
|
|
@@ -51,16 +52,16 @@ module Legion
|
|
|
51
52
|
[]
|
|
52
53
|
end
|
|
53
54
|
|
|
54
|
-
def self.discover_instances
|
|
55
|
+
def self.discover_instances
|
|
55
56
|
candidates = {}
|
|
56
57
|
|
|
57
58
|
env_key = CredentialSources.env('ANTHROPIC_API_KEY')
|
|
58
59
|
if env_key
|
|
59
60
|
candidates[:env] = {
|
|
60
|
-
api_key:
|
|
61
|
-
anthropic_api_key:
|
|
62
|
-
tier:
|
|
63
|
-
source:
|
|
61
|
+
api_key: env_key,
|
|
62
|
+
anthropic_api_key: env_key,
|
|
63
|
+
tier: :frontier,
|
|
64
|
+
source: CredentialSources.source_tag(:env, 'ANTHROPIC_API_KEY'),
|
|
64
65
|
credential_fingerprint: CredentialSources.credential_fingerprint(env_key)
|
|
65
66
|
}
|
|
66
67
|
end
|
|
@@ -68,10 +69,10 @@ module Legion
|
|
|
68
69
|
claude_key = CredentialSources.claude_config_value(:anthropicApiKey)
|
|
69
70
|
if claude_key
|
|
70
71
|
candidates[:claude] = {
|
|
71
|
-
api_key:
|
|
72
|
-
anthropic_api_key:
|
|
73
|
-
tier:
|
|
74
|
-
source:
|
|
72
|
+
api_key: claude_key,
|
|
73
|
+
anthropic_api_key: claude_key,
|
|
74
|
+
tier: :frontier,
|
|
75
|
+
source: CredentialSources.source_tag(:file, '~/.claude/settings.json', 'anthropicApiKey'),
|
|
75
76
|
credential_fingerprint: CredentialSources.credential_fingerprint(claude_key)
|
|
76
77
|
}
|
|
77
78
|
end
|
|
@@ -81,10 +82,10 @@ module Legion
|
|
|
81
82
|
settings_key = settings_config[:api_key] || settings_config['api_key']
|
|
82
83
|
if settings_key
|
|
83
84
|
candidates[:settings] = normalize_instance_config(settings_config).merge(
|
|
84
|
-
api_key:
|
|
85
|
-
anthropic_api_key:
|
|
86
|
-
tier:
|
|
87
|
-
source:
|
|
85
|
+
api_key: settings_key,
|
|
86
|
+
anthropic_api_key: settings_key,
|
|
87
|
+
tier: :frontier,
|
|
88
|
+
source: CredentialSources.source_tag(:settings, 'extensions.llm.anthropic'),
|
|
88
89
|
credential_fingerprint: CredentialSources.credential_fingerprint(settings_key)
|
|
89
90
|
)
|
|
90
91
|
end
|
|
@@ -108,10 +109,10 @@ module Legion
|
|
|
108
109
|
broker_cred = Legion::Identity::Broker.credential_for(:anthropic)
|
|
109
110
|
if broker_cred
|
|
110
111
|
candidates[:broker] = {
|
|
111
|
-
api_key:
|
|
112
|
-
anthropic_api_key:
|
|
113
|
-
tier:
|
|
114
|
-
source:
|
|
112
|
+
api_key: broker_cred,
|
|
113
|
+
anthropic_api_key: broker_cred,
|
|
114
|
+
tier: :frontier,
|
|
115
|
+
source: CredentialSources.source_tag(:broker, 'identity', 'anthropic'),
|
|
115
116
|
credential_fingerprint: CredentialSources.credential_fingerprint(broker_cred)
|
|
116
117
|
}
|
|
117
118
|
end
|
|
@@ -130,7 +131,7 @@ module Legion
|
|
|
130
131
|
instances.is_a?(Hash) ? instances : {}
|
|
131
132
|
end
|
|
132
133
|
|
|
133
|
-
def self.normalize_instance_config(config)
|
|
134
|
+
def self.normalize_instance_config(config)
|
|
134
135
|
normalized = config.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
|
|
135
136
|
normalized[:anthropic_api_key] ||= normalized.delete(:api_key)
|
|
136
137
|
normalized[:anthropic_api_base] ||= normalized.delete(:base_url)
|
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
|