lex-llm-anthropic 0.2.13 → 0.2.16

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: 7ea542d2f08da7b03e8f42e683dca5638bdaf07966f3389cdfcccfa22f8581b0
4
- data.tar.gz: b0e902241e5dd1c94c3742f9cee76d4aa2a41516829ce5c481084e70378826cc
3
+ metadata.gz: 9ef129fbcb71eb5500eff0ef7047dda892d97d57b21dab9567488be60cca0401
4
+ data.tar.gz: 3efed0ed6d287da75ac969e7dd29121b1c4e49e11f989a7fb7fed4f8c24f1ac2
5
5
  SHA512:
6
- metadata.gz: 333cff908f11b23fe522c2bb79743d879b5adf0d7ce952e0b989becb9da775b58a528ba7f52ec42b7f7ef188d4d7b3db29db9700df4b17e0435c0e4de0087b1c
7
- data.tar.gz: 57ad771fb6b041d74e1459bb46e5cd471f8f37f416b014bcc6873f97aca3fe0aea74c7a943ddfb3d8418bbf6ade69a0cda8fa6cc755134df53eabf9abeaaf1f1
6
+ metadata.gz: dfd4e546964d3994cb294860aae5d2f81476c0c067b76519b9c65506eb782e32df58fda6ede3df2f9c17095b1b7aaf7e940a2e5afc4ea34efb7d086c00e17c4f
7
+ data.tar.gz: 62d2e1ac960fd5084becf6d83beb966ad02556205f1d70b183ab48892a69cb3783004eddcbea08435986b7a8ec569a3f41f8b835a5e12aed63b8388b078471e3
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
- - "*.gemspec"
14
- - spec/**/*
15
- Metrics/MethodLength:
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
- RSpec/MultipleExpectations:
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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.16 - 2026-06-10
4
+
5
+ - **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).
6
+ - **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).
7
+ - **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.
8
+
9
+ ## 0.2.15 - 2026-06-05
10
+
11
+ - **Fix RuboCop cyclomatic complexity** — Extract `extract_hash_budget` helper to reduce `thinking_budget` cyclomatic complexity from 8 to 6, meeting the 7-line threshold.
12
+ - **Add budget_tokens support** — `extract_hash_budget` now checks `:budget_tokens` and `'budget_tokens'` keys (Anthropic API canonical) in addition to legacy `:budget`/`'budget'`.
13
+ - **Spec and RuboCop compliance** — All 28 specs passing, 0 RuboCop offenses.
14
+
15
+ ## 0.2.14 - 2026-06-05
16
+
17
+ - **Fix RuboCop cyclomatic complexity** — Extract `extract_hash_budget` helper to reduce `thinking_budget` complexity from 8 to 6, meeting the 7-line threshold.
18
+ - **Fix Style/IfUnlessModifier** — Split conditional return in `thinking_budget` to avoid modifier form exceeding max line length.
19
+
3
20
  ## 0.2.13 - 2026-06-02
4
21
 
5
22
  - **Fix invalid anthropic-version header** — Default `api_version` was `'2023-10-02'` (typo), which Anthropic rejects. Changed to `'2023-10-16'` (anthropic.rb)
data/README.md CHANGED
@@ -1,10 +1,36 @@
1
1
  # lex-llm-anthropic
2
2
 
3
- LegionIO LLM provider extension for Anthropic.
3
+ LegionIO LLM provider extension for **Anthropic Claude** models.
4
4
 
5
- This gem lives under `Legion::Extensions::Llm::Anthropic` and depends on `lex-llm >= 0.4.3` for shared provider contracts, response normalization, fleet responder helpers, and schema primitives. It does not require `legion-llm` at runtime.
5
+ This gem provides the `:anthropic` provider family for LegionIO's LLM routing layer, connecting to the [Anthropic Messages API](https://docs.anthropic.com/en/docs/api/messages). It handles chat completions, streaming, tool use, extended thinking, model discovery, and fleet request consumption.
6
6
 
7
- Load it with `require 'legion/extensions/llm/anthropic'`.
7
+ **Namespace:** `Legion::Extensions::Llm::Anthropic`
8
+ **Provider family:** `:anthropic`
9
+ **Default model:** `claude-sonnet-4-6`
10
+ **Dependency:** `lex-llm >= 0.4.3` (shared provider contracts, response normalization, fleet responders)
11
+
12
+ ```ruby
13
+ require 'legion/extensions/llm/anthropic'
14
+ ```
15
+
16
+ ---
17
+
18
+ ## File Index
19
+
20
+ | File | Role |
21
+ |------|------|
22
+ | `anthropic.rb` | Entry point; namespace, `PROVIDER_FAMILY`, default settings, instance discovery |
23
+ | `anthropic/provider.rb` | `Provider` class — chat, stream, list_models, format/parse payloads |
24
+ | `anthropic/registry_event_builder.rb` | Builds registry event envelopes for model availability |
25
+ | `anthropic/registry_publisher.rb` | Publishes model events to `llm.registry` exchange (async, best-effort) |
26
+ | `anthropic/version.rb` | `VERSION` constant |
27
+ | `anthropic/actors/discovery_refresh.rb` | Periodic actor that refreshes Anthropic model list |
28
+ | `anthropic/actors/fleet_worker.rb` | Subscription actor for fleet request consumption |
29
+ | `anthropic/runners/fleet_worker.rb` | Runner entrypoint for fleet request execution |
30
+ | `anthropic/transport/exchanges/llm_registry.rb` | Topic exchange definition for `llm.registry` |
31
+ | `anthropic/transport/messages/registry_event.rb` | Transport message wrapper for registry events |
32
+
33
+ ---
8
34
 
9
35
  ## Installation
10
36
 
@@ -12,36 +38,83 @@ Load it with `require 'legion/extensions/llm/anthropic'`.
12
38
  gem 'lex-llm-anthropic', '~> 0.2'
13
39
  ```
14
40
 
15
- Anthropic credentials are discovered from `ANTHROPIC_API_KEY`, Claude config, configured provider instances, or an identity broker when one is available.
41
+ ---
16
42
 
17
- ## Provider
43
+ ## Architecture
18
44
 
19
- `Legion::Extensions::Llm::Anthropic::Provider` registers with `Legion::Extensions::Llm::Provider` as `:anthropic` and uses Anthropic's Messages API:
45
+ ### Provider (`Provider`)
20
46
 
21
- - chat and streaming: `/v1/messages`
22
- - model discovery: `/v1/models`
23
- - authentication headers: `x-api-key` and `anthropic-version`
24
- - tools: Anthropic `tools` and `tool_choice` payload fields
25
- - extended thinking: Anthropic `thinking` request field and returned thinking blocks
47
+ The `Provider` class extends the `lex-llm` base provider contract and implements:
26
48
 
27
- Anthropic embeddings are intentionally not exposed by this provider.
49
+ | Method | Description |
50
+ |--------|-------------|
51
+ | `chat(**kwargs)` | Synchronous chat completion via `/v1/messages` |
52
+ | `stream_chat(**kwargs)` | Streaming chat via `/v1/messages?stream=true` |
53
+ | `list_models` | Fetches available models from `/v1/models` |
54
+ | `format_payload(**)` | Builds Anthropic Messages API request body |
55
+ | `parse_response(response)` | Normalizes API response to Legion envelope |
56
+ | `parse_stream(response)` | Parses SSE stream into chunk events |
57
+ | `build_chunk(event, state)` | Accumulates streaming state (content, tool calls, thinking) |
28
58
 
29
- ## Configuration
59
+ **Supported capabilities:** `:completion`, `:streaming`, `:vision`, `:tools`
60
+
61
+ **API endpoints:**
62
+ - Chat & streaming: `POST /v1/messages`
63
+ - Model discovery: `GET /v1/models`
64
+
65
+ **Authentication headers:** `x-api-key`, `anthropic-version`
66
+
67
+ ### Instance Discovery (`discover_instances`)
68
+
69
+ The extension discovers and normalizes Anthropic credentials from four sources, in priority order:
70
+
71
+ 1. **Environment** — `ANTHROPIC_API_KEY`
72
+ 2. **Claude config** — `~/.claude/settings.json` under `anthropicApiKey`
73
+ 3. **Extension settings** — `Legion::Settings` at `extensions.llm.anthropic`
74
+ 4. **Identity broker** — `Legion::Identity::Broker.credential_for(:anthropic)`
75
+
76
+ Named instances are supported under `extensions.llm.anthropic.instances.<name>`. Generic keys (`api_key`, `endpoint`, `version`) are normalized to Anthropic-specific keys (`anthropic_api_key`, `anthropic_api_base`, `anthropic_version`). Duplicate credentials are deduplicated by fingerprint.
77
+
78
+ ### Default Settings
30
79
 
31
80
  ```ruby
32
- Legion::Extensions::Llm.configure do |config|
33
- config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY')
34
- config.anthropic_version = '2023-06-01'
35
- end
81
+ {
82
+ default_model: 'claude-sonnet-4-6',
83
+ endpoint: 'https://api.anthropic.com',
84
+ api_version: '2023-10-16',
85
+ default_max_tokens: 4096,
86
+ tier: :frontier,
87
+ transport: :http,
88
+ credentials: { api_key: 'env://ANTHROPIC_API_KEY' },
89
+ usage: { inference: true, embedding: false, image: false },
90
+ limits: { concurrency: 4 },
91
+ fleet: {
92
+ enabled: false,
93
+ respond_to_requests: false,
94
+ capabilities: %i[chat stream_chat],
95
+ lanes: [],
96
+ concurrency: 4,
97
+ queue_suffix: nil
98
+ }
99
+ }
36
100
  ```
37
101
 
38
- `anthropic_api_base` can override the default `https://api.anthropic.com` endpoint for tests or compatible Anthropic gateways.
102
+ ### Registry Events (Availability Publishing)
103
+
104
+ After model discovery, discovered models are published to the `llm.registry` topic exchange so other LegionIO nodes can discover Anthropic availability.
105
+
106
+ | Class | Role |
107
+ |-------|------|
108
+ | `RegistryEventBuilder` | Builds the event envelope with model offering, health, and metadata |
109
+ | `RegistryPublisher` | Schedules async publish; checks transport readiness before sending |
110
+ | `Transport::Messages::RegistryEvent` | Message wrapper targeting the `llm.registry` exchange |
111
+ | `Transport::Exchanges::LlmRegistry` | Topic exchange definition (`llm.registry`) |
39
112
 
40
- Named instances can use generic provider keys such as `api_key`, `endpoint`, and `version`; the extension normalizes them to Anthropic-specific provider options during discovery.
113
+ Publishing is **best-effort** and requires transport to be loaded. Failures are logged at `debug` level and silently absorbed.
41
114
 
42
- ## Fleet Responder
115
+ ### Fleet Responder
43
116
 
44
- Provider instances can opt in to consuming Legion LLM fleet requests. The provider-owned fleet actor only starts when at least one configured instance enables `respond_to_requests`.
117
+ Provider instances can consume Legion LLM fleet requests. The fleet actor only starts when at least one configured instance enables `respond_to_requests`.
45
118
 
46
119
  ```yaml
47
120
  extensions:
@@ -57,4 +130,103 @@ extensions:
57
130
  - stream_chat
58
131
  ```
59
132
 
60
- Fleet execution is delegated to `Legion::Extensions::Llm::Fleet::ProviderResponder` from `lex-llm`. The responder owns provider invocation for this gem; routing and fleet request publication remain outside this provider extension.
133
+ | Component | Role |
134
+ |-----------|------|
135
+ | `Actor::FleetWorker` | Subscription actor; checks if any instance enables fleet responding |
136
+ | `Runners::FleetWorker` | Runner entrypoint; delegates to `lex-llm` `ProviderResponder` |
137
+
138
+ Fleet execution is delegated to `Legion::Extensions::Llm::Fleet::ProviderResponder` from `lex-llm`. Routing and fleet request publication are handled outside this extension.
139
+
140
+ ### Model Discovery Refresh
141
+
142
+ A periodic actor (`Actor::DiscoveryRefresh`) refreshes the Anthropic model list every 30 minutes (configurable via `extensions.llm.anthropic.discovery_interval`). It calls `Legion::LLM::Discovery.refresh_discovered_models!(provider: :anthropic)`.
143
+
144
+ ---
145
+
146
+ ## Configuration
147
+
148
+ ```ruby
149
+ Legion::Extensions::Llm.configure do |config|
150
+ config.anthropic_api_key = ENV.fetch('ANTHROPIC_API_KEY')
151
+ config.anthropic_version = '2023-10-16'
152
+ end
153
+ ```
154
+
155
+ **Configurable options:**
156
+
157
+ | Setting | Key | Description |
158
+ |---------|-----|-------------|
159
+ | API key | `anthropic_api_key` / `ANTHROPIC_API_KEY` | Required; API authentication |
160
+ | API version | `anthropic_version` | Defaults to `2023-10-16` |
161
+ | Endpoint | `anthropic_api_base` | Override `https://api.anthropic.com` |
162
+ | Max tokens | `default_max_tokens` | Defaults to `4096` |
163
+ | Discovery interval | `discovery_interval` (seconds) | Defaults to `1800` |
164
+
165
+ ---
166
+
167
+ ## Usage Examples
168
+
169
+ ### Chat (synchronous)
170
+
171
+ ```ruby
172
+ provider = Legion::Extensions::Llm::Anthropic::Provider.new(api_key: 'sk-ant-...')
173
+ response = provider.chat(model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'Hello' }])
174
+ ```
175
+
176
+ ### Chat (streaming)
177
+
178
+ ```ruby
179
+ provider.stream_chat(model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'Hello' }]) do |chunk|
180
+ print chunk[:content] if chunk[:content]
181
+ end
182
+ ```
183
+
184
+ ### With tools
185
+
186
+ ```ruby
187
+ response = provider.chat(
188
+ model: 'claude-sonnet-4-6',
189
+ messages: [{ role: 'user', content: 'What is the weather in SF?' }],
190
+ tools: [{ name: 'get_weather', description: '...', input_schema: { ... } }]
191
+ )
192
+ ```
193
+
194
+ ### Extended thinking
195
+
196
+ ```ruby
197
+ response = provider.chat(
198
+ model: 'claude-sonnet-4-6',
199
+ messages: [{ role: 'user', content: 'Solve this math problem...' }],
200
+ thinking: { budget_tokens: 4096, enabled: true }
201
+ )
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Dependencies
207
+
208
+ | Gem | Minimum version | Role |
209
+ |-----|-----------------|------|
210
+ | `lex-llm` | `>= 0.4.3` | Base provider contract, response normalization, fleet responder, auto-registration |
211
+ | `legion-logging` | (via lex-llm) | Logging helper for diagnostics |
212
+ | `legion-settings` | (via lex-llm) | Configuration access |
213
+ | `legion-transport` | (via lex-llm) | Message exchange for registry events |
214
+
215
+ ---
216
+
217
+ ## Testing
218
+
219
+ ```bash
220
+ bundle exec rspec # 28 examples
221
+ bundle exec rubocop # 0 offenses
222
+ ```
223
+
224
+ ---
225
+
226
+ ## Design Notes
227
+
228
+ - **No embeddings** — Anthropic embeddings are intentionally not exposed.
229
+ - **No `:claude` alias** — `provider_aliases` returns `[]`; only `:anthropic` is registered.
230
+ - **Prompt caching** — When `cache_enabled?` is true, system content and tool definitions are marked as cache breakpoints; early conversation turns are cacheable, the final message is never cached.
231
+ - **Thinking budget** — Supports `Integer`, `Hash` (with `:budget_tokens` or legacy `:budget`), and objects responding to `#budget`. Defaults to `1024`.
232
+ - **Context windows** — Static `CONTEXT_WINDOWS` map covers known Claude model families; `fetch_model_detail` and `infer_context_window` provide fallback inference.
@@ -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 # rubocop:disable Style/Documentation
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 # rubocop:disable Metrics/ClassLength
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' => config.anthropic_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' => 200_000,
70
+ 'claude-opus-4' => 200_000,
71
71
  'claude-sonnet-4' => 200_000,
72
- 'claude-haiku-4' => 200_000,
73
- 'claude-3-5' => 200_000,
74
- 'claude-3-opus' => 200_000,
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' => 200_000
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:) # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
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: 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),
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: anthropic_role(message.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: 'image',
170
+ type: 'image',
171
171
  source: {
172
- type: 'base64',
172
+ type: 'base64',
173
173
  media_type: attachment.mime_type,
174
- data: attachment.encoded
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: 'tool_use',
196
- id: tool_call.id,
197
- name: tool_call.name,
198
- input: tool_call.arguments,
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: 'user',
207
+ role: 'user',
208
208
  content: [
209
209
  {
210
- type: 'tool_result',
211
- tool_use_id: message.tool_call_id,
212
- content: content_blocks(message.content, cache:),
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
  ]
@@ -231,12 +231,17 @@ module Legion
231
231
 
232
232
  def thinking_budget(thinking)
233
233
  return thinking if thinking.is_a?(Integer)
234
- return thinking[:budget] || thinking['budget'] if thinking.is_a?(Hash)
234
+ return extract_hash_budget(thinking) if thinking.is_a?(Hash)
235
235
  return thinking.budget if thinking.respond_to?(:budget) && thinking.budget
236
236
 
237
237
  1024
238
238
  end
239
239
 
240
+ # Anthropic API uses :budget_tokens, but legacy config may use :budget
241
+ def extract_hash_budget(thinking)
242
+ thinking[:budget_tokens] || thinking['budget_tokens'] || thinking[:budget] || thinking['budget']
243
+ end
244
+
240
245
  def thinking_block(thinking)
241
246
  return nil unless thinking
242
247
 
@@ -251,9 +256,12 @@ module Legion
251
256
  return nil if tools.empty?
252
257
 
253
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'] || '')
254
262
  {
255
- name: tool.name,
256
- description: tool.description,
263
+ name: tool_name,
264
+ description: tool_desc,
257
265
  input_schema: tool_schema(tool)
258
266
  }
259
267
  end
@@ -266,7 +274,8 @@ module Legion
266
274
  def tool_schema(tool)
267
275
  return tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
268
276
 
269
- { type: 'object', properties: {}, required: [] }
277
+ { type: 'object',
278
+ properties: tool.respond_to?(:parameters) ? tool.parameters : (tool[:parameters] || tool['parameters'] || {}), required: [] }
270
279
  end
271
280
 
272
281
  def tool_choice(tool_prefs)
@@ -313,17 +322,17 @@ module Legion
313
322
  usage = body['usage'] || {}
314
323
 
315
324
  Legion::Extensions::Llm::Message.new(
316
- role: :assistant,
317
- content: text_from(content_blocks),
318
- model_id: body['model'],
319
- thinking: thinking_from(content_blocks),
320
- tool_calls: parse_tool_calls(content_blocks),
321
- input_tokens: usage['input_tokens'],
322
- output_tokens: usage['output_tokens'],
323
- cached_tokens: usage['cache_read_input_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'],
324
333
  cache_creation_tokens: cache_creation_tokens(usage),
325
- thinking_tokens: thinking_tokens(usage),
326
- raw: body
334
+ thinking_tokens: thinking_tokens(usage),
335
+ raw: body
327
336
  )
328
337
  end
329
338
 
@@ -336,7 +345,7 @@ module Legion
336
345
  redacted_block = blocks.find { |block| block['type'] == 'redacted_thinking' }
337
346
 
338
347
  Legion::Extensions::Llm::Thinking.build(
339
- text: thinking_block&.dig('thinking') || thinking_block&.dig('text'),
348
+ text: thinking_block&.dig('thinking') || thinking_block&.dig('text'),
340
349
  signature: thinking_block&.dig('signature') || redacted_block&.dig('data')
341
350
  )
342
351
  end
@@ -359,16 +368,16 @@ module Legion
359
368
  delta_type = data.dig('delta', 'type')
360
369
 
361
370
  Legion::Extensions::Llm::Chunk.new(
362
- role: :assistant,
363
- content: delta_type == 'text_delta' ? data.dig('delta', 'text') : nil,
364
- model_id: data.dig('message', 'model'),
365
- thinking: Legion::Extensions::Llm::Thinking.build(
366
- text: delta_type == 'thinking_delta' ? data.dig('delta', 'thinking') : nil,
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,
367
376
  signature: delta_type == 'signature_delta' ? data.dig('delta', 'signature') : nil
368
377
  ),
369
- input_tokens: data.dig('message', 'usage', 'input_tokens'),
378
+ input_tokens: data.dig('message', 'usage', 'input_tokens'),
370
379
  output_tokens: data.dig('message', 'usage', 'output_tokens') || data.dig('usage', 'output_tokens'),
371
- tool_calls: extract_streaming_tool_calls(data, delta_type)
380
+ tool_calls: extract_streaming_tool_calls(data, delta_type)
372
381
  )
373
382
  end
374
383
 
@@ -389,8 +398,8 @@ module Legion
389
398
  [
390
399
  block['id'],
391
400
  Legion::Extensions::Llm::ToolCall.new(
392
- id: block['id'],
393
- name: block['name'],
401
+ id: block['id'],
402
+ name: block['name'],
394
403
  arguments: block['input'] || {}
395
404
  )
396
405
  ]
@@ -403,12 +412,12 @@ module Legion
403
412
  detail = model_detail(model_id)
404
413
  ctx = detail&.dig(:context_window) || infer_context_window(model_id)
405
414
  Legion::Extensions::Llm::Model::Info.new(
406
- id: model_id,
407
- name: model['display_name'] || model_id,
408
- provider: provider,
409
- capabilities: %i[completion streaming tools],
415
+ id: model_id,
416
+ name: model['display_name'] || model_id,
417
+ provider: provider,
418
+ capabilities: %i[completion streaming tools],
410
419
  context_length: ctx,
411
- metadata: model.merge('created_at' => model['created_at']).compact
420
+ metadata: model.merge('created_at' => model['created_at']).compact
412
421
  )
413
422
  end
414
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: runtime_metadata,
17
- health: model_health(readiness),
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: :anthropic,
26
+ provider_family: :anthropic,
27
27
  provider_instance: provider_instance,
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
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: model.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: payload,
18
- provider_family: Anthropic::PROVIDER_FAMILY,
19
- provider_class: Anthropic::Provider,
17
+ payload: payload,
18
+ provider_family: Anthropic::PROVIDER_FAMILY,
19
+ provider_class: Anthropic::Provider,
20
20
  provider_instances: -> { Anthropic.discover_instances },
21
- delivery: delivery,
22
- properties: properties
21
+ delivery: delivery,
22
+ properties: properties
23
23
  )
24
24
  end
25
25
  end
@@ -30,7 +30,7 @@ module Legion
30
30
  @options[:app_id] || RegistryPublisher::APP_ID
31
31
  end
32
32
 
33
- def persistent # rubocop:disable Naming/PredicateMethod
33
+ def persistent
34
34
  false
35
35
  end
36
36
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Anthropic
7
- VERSION = '0.2.13'
7
+ VERSION = '0.2.16'
8
8
  end
9
9
  end
10
10
  end
@@ -11,7 +11,7 @@ module Legion
11
11
  module Extensions
12
12
  module Llm
13
13
  # Anthropic provider extension namespace.
14
- module Anthropic # rubocop:disable Metrics/ModuleLength
14
+ module Anthropic
15
15
  extend ::Legion::Extensions::Core if ::Legion::Extensions.const_defined?(:Core, false)
16
16
  extend Legion::Logging::Helper
17
17
  extend Legion::Extensions::Llm::AutoRegistration
@@ -20,24 +20,24 @@ module Legion
20
20
 
21
21
  def self.default_settings
22
22
  ::Legion::Extensions::Llm.provider_settings(
23
- family: PROVIDER_FAMILY,
23
+ family: PROVIDER_FAMILY,
24
24
  instance: {
25
- default_model: 'claude-sonnet-4-6',
26
- endpoint: 'https://api.anthropic.com',
27
- api_version: '2023-10-16',
25
+ default_model: 'claude-sonnet-4-6',
26
+ endpoint: 'https://api.anthropic.com',
27
+ api_version: '2023-10-16',
28
28
  default_max_tokens: 4096,
29
- tier: :frontier,
30
- transport: :http,
31
- credentials: { api_key: 'env://ANTHROPIC_API_KEY' },
32
- usage: { inference: true, embedding: false, image: false },
33
- limits: { concurrency: 4 },
34
- fleet: {
35
- enabled: false,
29
+ tier: :frontier,
30
+ transport: :http,
31
+ credentials: { api_key: 'env://ANTHROPIC_API_KEY' },
32
+ usage: { inference: true, embedding: false, image: false },
33
+ limits: { concurrency: 4 },
34
+ fleet: {
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],
38
+ lanes: [],
39
+ concurrency: 4,
40
+ queue_suffix: nil
41
41
  }
42
42
  }
43
43
  )
@@ -51,16 +51,16 @@ module Legion
51
51
  []
52
52
  end
53
53
 
54
- def self.discover_instances # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
54
+ def self.discover_instances
55
55
  candidates = {}
56
56
 
57
57
  env_key = CredentialSources.env('ANTHROPIC_API_KEY')
58
58
  if env_key
59
59
  candidates[:env] = {
60
- api_key: env_key,
61
- anthropic_api_key: env_key,
62
- tier: :frontier,
63
- source: CredentialSources.source_tag(:env, 'ANTHROPIC_API_KEY'),
60
+ api_key: env_key,
61
+ anthropic_api_key: env_key,
62
+ tier: :frontier,
63
+ source: CredentialSources.source_tag(:env, 'ANTHROPIC_API_KEY'),
64
64
  credential_fingerprint: CredentialSources.credential_fingerprint(env_key)
65
65
  }
66
66
  end
@@ -68,10 +68,10 @@ module Legion
68
68
  claude_key = CredentialSources.claude_config_value(:anthropicApiKey)
69
69
  if claude_key
70
70
  candidates[:claude] = {
71
- api_key: claude_key,
72
- anthropic_api_key: claude_key,
73
- tier: :frontier,
74
- source: CredentialSources.source_tag(:file, '~/.claude/settings.json', 'anthropicApiKey'),
71
+ api_key: claude_key,
72
+ anthropic_api_key: claude_key,
73
+ tier: :frontier,
74
+ source: CredentialSources.source_tag(:file, '~/.claude/settings.json', 'anthropicApiKey'),
75
75
  credential_fingerprint: CredentialSources.credential_fingerprint(claude_key)
76
76
  }
77
77
  end
@@ -81,10 +81,10 @@ module Legion
81
81
  settings_key = settings_config[:api_key] || settings_config['api_key']
82
82
  if settings_key
83
83
  candidates[:settings] = normalize_instance_config(settings_config).merge(
84
- api_key: settings_key,
85
- anthropic_api_key: settings_key,
86
- tier: :frontier,
87
- source: CredentialSources.source_tag(:settings, 'extensions.llm.anthropic'),
84
+ api_key: settings_key,
85
+ anthropic_api_key: settings_key,
86
+ tier: :frontier,
87
+ source: CredentialSources.source_tag(:settings, 'extensions.llm.anthropic'),
88
88
  credential_fingerprint: CredentialSources.credential_fingerprint(settings_key)
89
89
  )
90
90
  end
@@ -108,10 +108,10 @@ module Legion
108
108
  broker_cred = Legion::Identity::Broker.credential_for(:anthropic)
109
109
  if broker_cred
110
110
  candidates[:broker] = {
111
- api_key: broker_cred,
112
- anthropic_api_key: broker_cred,
113
- tier: :frontier,
114
- source: CredentialSources.source_tag(:broker, 'identity', 'anthropic'),
111
+ api_key: broker_cred,
112
+ anthropic_api_key: broker_cred,
113
+ tier: :frontier,
114
+ source: CredentialSources.source_tag(:broker, 'identity', 'anthropic'),
115
115
  credential_fingerprint: CredentialSources.credential_fingerprint(broker_cred)
116
116
  }
117
117
  end
@@ -130,7 +130,7 @@ module Legion
130
130
  instances.is_a?(Hash) ? instances : {}
131
131
  end
132
132
 
133
- def self.normalize_instance_config(config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
133
+ def self.normalize_instance_config(config)
134
134
  normalized = config.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
135
135
  normalized[:anthropic_api_key] ||= normalized.delete(:api_key)
136
136
  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.13
4
+ version: 0.2.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO