lex-claude 0.1.3 → 0.3.0

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: b8c5c2b51600c9ad2055444792e52f9e283c0bf5ebf32fedf04ac257f27adaed
4
- data.tar.gz: d9d22feccce327d9c0de5e785241fe655d6f722d4f4d8f251932dccea30992d4
3
+ metadata.gz: 0b422c38d51391b457e88f4760b941afe22a835be9ba5405812a89024e082bb4
4
+ data.tar.gz: 23c95a1d66de519a7f24d9bdf86d522d13cc53b47732bdce3709dcee09df20bd
5
5
  SHA512:
6
- metadata.gz: fbe826ba9a0c958abbbc84fb394016d3b96fef65938531847c9f6da1861757b7a66fa1eab2931fd36c158cd1db2a0fd002b6f095f104ec374add77e8b45c300a
7
- data.tar.gz: d69483be77a939199f0db0e018c057eb76a33d56c972e067647d66d86217608676fab4f74dbd19dc9ea49e0ff78376dc9d3d2b69360603d24c2478f577b254ca
6
+ metadata.gz: aa72850dab4628a3fcf6e6f6bd479ec5e3d4c9518710f05ab28f695c3f4b6834bb3615fbb79630b111d66317b13938a0145f281b93b6e0edab3a2e3e91f48c14
7
+ data.tar.gz: a0a9cff50d0bbd456f5eb2fd0270132721ddfad15d60d140aa63277bbb80a604c27aafb1e56ddacfdd89e85d573aec8dc755fa58a8f7fcb907f672f543eb5cec
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-03-31
4
+
5
+ ### Added
6
+ - `Helpers::Errors` — structured exception hierarchy (`ApiError`, `RateLimitError`, `OverloadedError`, `AuthenticationError`, `PermissionError`, `NotFoundError`, `InvalidRequestError`, `ServerError`, `StreamingError`); `from_response` factory; `retryable?` predicate
7
+ - `Helpers::Retry` — exponential backoff retry wrapper (`with_retry`) with configurable `max_attempts`, `base_delay`, `max_delay`
8
+ - `Helpers::Sse` — SSE event stream parser (`parse_stream`), text assembler (`collect_text`), usage merger (`collect_usage`)
9
+ - `Helpers::Response` — `handle_response` raises typed exceptions on non-2xx, parses 9 Anthropic rate limit headers, `parse_usage` extracts standard + cache token counts
10
+ - `Helpers::Client::BETA_HEADERS` — registry of 18 named beta identifiers; `client` factory accepts `betas:` array
11
+ - `Helpers::Client.streaming_client` — Faraday connection for SSE responses
12
+ - `Helpers::Tools` — `web_search` factory, `cache_control` helper, `required_betas_for` inspector
13
+ - `Helpers::Models` — registry of 11 canonical Claude model IDs with Symbol alias resolution; `adaptive_thinking?` predicate
14
+ - `Runners::Messages#create_stream` — streaming message creation with SSE event yielding
15
+ - `cache_system:` wraps system prompt in ephemeral cache_control block
16
+ - `cache_scope: :global` auto-injects `prompt-caching-scope-2026-01-05` beta
17
+ - `thinking:` for extended thinking with temperature auto-omission and beta auto-injection
18
+ - `output_config:` for structured output (JSON schema), effort control, task budgets with auto-beta
19
+ - `fast_mode: true` sends `speed: 'fast'` with `fast-mode-2026-02-01` beta
20
+ - `context_management:` with `context-management-2025-06-27` beta auto-injection
21
+ - `:usage` key in all `create` results with `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`
22
+ - All new helpers wired into main `require 'legion/extensions/claude'` tree
23
+ - Updated README with comprehensive examples for all new features
24
+
25
+ ### Changed
26
+ - All runners raise typed `Helpers::Errors::*` exceptions instead of returning raw status codes
27
+ - `Messages#create` and `#create_stream` refactored to use shared `build_message_body` and `resolve_feature_betas` helpers
28
+ - `Messages#count_tokens` now accepts `thinking:`, `cache_system:` keywords
29
+ - Added `rubocop-legion` for consistent linting
30
+
3
31
  ## [0.1.3] - 2026-03-30
4
32
 
5
33
  ### Changed
data/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  # lex-claude
2
2
 
3
- Claude Anthropic API integration for LegionIO. Provides runners for creating messages, listing models, counting tokens, and managing message batches.
3
+ Production-grade Claude Anthropic API integration for LegionIO. Provides runners for creating messages (streaming and batch), counting tokens, listing models, managing message batches, and accessing all modern Anthropic API features.
4
4
 
5
5
  ## Purpose
6
6
 
7
- Wraps the Anthropic Claude REST API as named runners consumable by any LegionIO task chain. Use this extension when you need direct access to the full Anthropic API surface (including async Batches) within the LEX runner/actor lifecycle. For simple chat/embed workflows, consider `legion-llm` instead.
7
+ Wraps the Anthropic Claude REST API as named runners consumable by any LegionIO task chain. Supports streaming, prompt caching, extended thinking, structured output, web search, effort control, fast mode, and all beta API features. For simple chat/embed workflows, consider `legion-llm` instead.
8
8
 
9
9
  ## Installation
10
10
 
@@ -21,23 +21,27 @@ gem 'lex-claude'
21
21
  ## Functions
22
22
 
23
23
  ### Messages
24
- - `create` - Create a message (chat completion) with Claude
25
- - `count_tokens` - Count input tokens for a message request
24
+ - `create` Create a message (supports caching, thinking, tools, structured output)
25
+ - `create_stream` Streaming message creation with SSE event yielding
26
+ - `count_tokens` — Count input tokens (supports tools, thinking, caching)
26
27
 
27
28
  ### Models
28
- - `list` - List available Claude models
29
- - `retrieve` - Get details for a specific model
29
+ - `list` List available Claude models
30
+ - `retrieve` Get details for a specific model
30
31
 
31
32
  ### Batches
32
- - `create_batch` - Create an asynchronous message batch
33
- - `list_batches` - List message batches
34
- - `retrieve_batch` - Get details for a specific batch
35
- - `cancel_batch` - Cancel an in-progress batch
36
- - `batch_results` - Retrieve results for a completed batch
33
+ - `create_batch` Create an asynchronous message batch
34
+ - `list_batches` List message batches
35
+ - `retrieve_batch` Get details for a specific batch
36
+ - `cancel_batch` Cancel an in-progress batch
37
+ - `batch_results` Retrieve results for a completed batch
37
38
 
38
- ## Configuration
39
+ ### Helpers
40
+ - `Helpers::Tools.web_search` — Build web search tool descriptor
41
+ - `Helpers::Models.resolve` — Resolve model Symbol aliases to canonical IDs
42
+ - `Helpers::Errors` — Structured exception hierarchy
39
43
 
40
- Set your API key in your LegionIO settings:
44
+ ## Configuration
41
45
 
42
46
  ```json
43
47
  {
@@ -47,47 +51,150 @@ Set your API key in your LegionIO settings:
47
51
  }
48
52
  ```
49
53
 
50
- ## Standalone Usage
54
+ ## Usage
55
+
56
+ ### Basic message
51
57
 
52
58
  ```ruby
53
59
  require 'legion/extensions/claude/client'
54
60
 
55
61
  client = Legion::Extensions::Claude::Client.new(api_key: ENV['ANTHROPIC_API_KEY'])
56
62
 
57
- # Create a message
58
63
  result = client.create(
59
- model: 'claude-opus-4-6',
60
- messages: [{ role: 'user', content: 'Hello, Claude!' }],
64
+ model: 'claude-sonnet-4-6',
65
+ messages: [{ role: 'user', content: 'Hello!' }],
61
66
  max_tokens: 1024
62
67
  )
63
68
  puts result[:result]['content'].first['text']
69
+ puts result[:usage].inspect
70
+ ```
71
+
72
+ ### Streaming
73
+
74
+ ```ruby
75
+ client.create_stream(
76
+ model: 'claude-sonnet-4-6',
77
+ messages: [{ role: 'user', content: 'Tell me a story.' }],
78
+ max_tokens: 2048
79
+ ) do |event|
80
+ print event[:data].dig('delta', 'text') if event[:event] == 'content_block_delta'
81
+ end
82
+ ```
64
83
 
65
- # List models
66
- models = client.list
67
- puts models[:result]['data'].map { |m| m['id'] }
84
+ ### Prompt caching
85
+
86
+ ```ruby
87
+ result = client.create(
88
+ model: 'claude-sonnet-4-6',
89
+ messages: [{ role: 'user', content: 'Summarize this.' }],
90
+ system: 'You are a helpful assistant with deep context about...',
91
+ cache_system: true,
92
+ cache_scope: :global,
93
+ max_tokens: 512
94
+ )
95
+ puts result[:usage][:cache_read_tokens]
96
+ ```
68
97
 
69
- # Count tokens
70
- tokens = client.count_tokens(
98
+ ### Extended thinking
99
+
100
+ ```ruby
101
+ result = client.create(
71
102
  model: 'claude-opus-4-6',
72
- messages: [{ role: 'user', content: 'How many tokens is this?' }]
103
+ messages: [{ role: 'user', content: 'Solve this complex problem...' }],
104
+ thinking: { type: 'adaptive' },
105
+ max_tokens: 8192
106
+ )
107
+ ```
108
+
109
+ ### Structured output
110
+
111
+ ```ruby
112
+ result = client.create(
113
+ model: 'claude-sonnet-4-6',
114
+ messages: [{ role: 'user', content: 'Extract the name and age.' }],
115
+ max_tokens: 256,
116
+ output_config: {
117
+ format: {
118
+ type: 'json_schema',
119
+ json_schema: {
120
+ type: 'object',
121
+ properties: { name: { type: 'string' }, age: { type: 'integer' } },
122
+ required: %w[name age]
123
+ }
124
+ }
125
+ }
126
+ )
127
+ ```
128
+
129
+ ### Web search
130
+
131
+ ```ruby
132
+ web_tool = Legion::Extensions::Claude::Helpers::Tools.web_search(max_uses: 3)
133
+
134
+ result = client.create(
135
+ model: 'claude-sonnet-4-6',
136
+ messages: [{ role: 'user', content: 'What happened in the news today?' }],
137
+ tools: [web_tool],
138
+ betas: [:web_search],
139
+ max_tokens: 1024
140
+ )
141
+ ```
142
+
143
+ ### Effort control and fast mode
144
+
145
+ ```ruby
146
+ result = client.create(
147
+ model: 'claude-sonnet-4-6',
148
+ messages: messages,
149
+ max_tokens: 2048,
150
+ output_config: { effort: 'high' }
73
151
  )
74
- puts tokens[:result]['input_tokens']
75
-
76
- # Create an async batch
77
- batch = client.create_batch(
78
- requests: [
79
- { custom_id: 'req-1', params: { model: 'claude-opus-4-6',
80
- messages: [{ role: 'user', content: 'Hello' }],
81
- max_tokens: 100 } }
82
- ]
152
+
153
+ result = client.create(
154
+ model: 'claude-sonnet-4-6',
155
+ messages: messages,
156
+ max_tokens: 512,
157
+ fast_mode: true
83
158
  )
84
- puts batch[:result]['id']
159
+ ```
160
+
161
+ ### Beta headers
162
+
163
+ ```ruby
164
+ result = client.create(
165
+ model: 'claude-sonnet-4-6',
166
+ messages: messages,
167
+ max_tokens: 1024,
168
+ betas: [:token_efficient_tools, :advanced_tool_use]
169
+ )
170
+ ```
171
+
172
+ ### Error handling
173
+
174
+ ```ruby
175
+ begin
176
+ result = client.create(model: 'claude-sonnet-4-6', messages: messages, max_tokens: 512)
177
+ rescue Legion::Extensions::Claude::Helpers::Errors::RateLimitError => e
178
+ puts "Rate limited (#{e.status}): #{e.message}"
179
+ rescue Legion::Extensions::Claude::Helpers::Errors::AuthenticationError
180
+ puts 'Check your API key'
181
+ rescue Legion::Extensions::Claude::Helpers::Errors::ApiError => e
182
+ puts "API error #{e.status}: #{e.message}"
183
+ end
184
+ ```
185
+
186
+ ### Auto-retry
187
+
188
+ ```ruby
189
+ result = Legion::Extensions::Claude::Helpers::Retry.with_retry(max_attempts: 3) do
190
+ client.create(model: 'claude-sonnet-4-6', messages: messages, max_tokens: 512)
191
+ end
85
192
  ```
86
193
 
87
194
  ## Dependencies
88
195
 
89
- - `faraday` >= 2.0 - HTTP client
90
- - `multi_json` - JSON parser abstraction
196
+ - `faraday` >= 2.0 HTTP client
197
+ - `multi_json` JSON parser abstraction
91
198
 
92
199
  ## Requirements
93
200
 
@@ -97,8 +204,8 @@ puts batch[:result]['id']
97
204
 
98
205
  ## Related
99
206
 
100
- - `lex-bedrock` — Access Claude models via AWS Bedrock instead of Anthropic directly
101
- - `legion-llm` — High-level LLM interface including Anthropic via ruby_llm
207
+ - `lex-bedrock` — Access Claude models via AWS Bedrock
208
+ - `legion-llm` — High-level LLM interface
102
209
  - `extensions-ai/CLAUDE.md` — Architecture patterns shared across all AI extensions
103
210
 
104
211
  ## License
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/claude/helpers/client'
4
+ require 'legion/extensions/claude/helpers/models'
4
5
  require 'legion/extensions/claude/runners/messages'
5
6
  require 'legion/extensions/claude/runners/models'
6
7
  require 'legion/extensions/claude/runners/batches'
@@ -9,19 +9,64 @@ module Legion
9
9
  module Helpers
10
10
  module Client
11
11
  DEFAULT_HOST = 'https://api.anthropic.com'
12
- API_VERSION = '2023-06-01'
12
+ API_VERSION = '2023-06-01'
13
+
14
+ BETA_HEADERS = {
15
+ interleaved_thinking: 'interleaved-thinking-2025-05-14',
16
+ context_1m: 'context-1m-2025-08-07',
17
+ context_management: 'context-management-2025-06-27',
18
+ structured_outputs: 'structured-outputs-2025-12-15',
19
+ web_search: 'web-search-2025-03-05',
20
+ advanced_tool_use: 'advanced-tool-use-2025-11-20',
21
+ effort: 'effort-2025-11-24',
22
+ task_budgets: 'task-budgets-2026-03-13',
23
+ prompt_caching_scope: 'prompt-caching-scope-2026-01-05',
24
+ fast_mode: 'fast-mode-2026-02-01',
25
+ redact_thinking: 'redact-thinking-2026-02-12',
26
+ token_efficient_tools: 'token-efficient-tools-2026-03-28',
27
+ summarize_connector: 'summarize-connector-text-2026-03-13',
28
+ afk_mode: 'afk-mode-2026-01-31',
29
+ advisor: 'advisor-tool-2026-03-01',
30
+ files_api: 'files-api-2025-04-14',
31
+ claude_code: 'claude-code-20250219',
32
+ tool_search: 'tool-search-tool-2025-10-19'
33
+ }.freeze
13
34
 
14
35
  module_function
15
36
 
16
- def client(api_key:, host: DEFAULT_HOST, **_opts)
37
+ def client(api_key:, host: DEFAULT_HOST, betas: nil, **_opts)
38
+ beta_list = resolve_betas(betas)
39
+
17
40
  Faraday.new(url: host) do |conn|
18
41
  conn.request :json
19
42
  conn.response :json, content_type: /\bjson$/
20
43
  conn.headers['x-api-key'] = api_key
21
- conn.headers['anthropic-version'] = API_VERSION
22
- conn.headers['Content-Type'] = 'application/json'
44
+ conn.headers['anthropic-version'] = API_VERSION
45
+ conn.headers['Content-Type'] = 'application/json'
46
+ conn.headers['anthropic-beta'] = beta_list.join(',') if beta_list.any?
23
47
  end
24
48
  end
49
+
50
+ def streaming_client(api_key:, host: DEFAULT_HOST, betas: nil, **_opts)
51
+ beta_list = resolve_betas(betas)
52
+
53
+ Faraday.new(url: host) do |conn|
54
+ conn.headers['x-api-key'] = api_key
55
+ conn.headers['anthropic-version'] = API_VERSION
56
+ conn.headers['Content-Type'] = 'application/json'
57
+ conn.headers['Accept'] = 'text/event-stream'
58
+ conn.headers['anthropic-beta'] = beta_list.join(',') if beta_list.any?
59
+ conn.adapter Faraday.default_adapter
60
+ end
61
+ end
62
+
63
+ def resolve_betas(betas)
64
+ return [] if betas.nil? || betas.empty?
65
+
66
+ betas.filter_map do |b|
67
+ b.is_a?(Symbol) ? BETA_HEADERS[b] : b.to_s
68
+ end.uniq
69
+ end
25
70
  end
26
71
  end
27
72
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Claude
6
+ module Helpers
7
+ module Errors
8
+ class ApiError < StandardError
9
+ attr_reader :status, :error_type, :body
10
+
11
+ def initialize(message = nil, status: nil, error_type: nil, body: nil)
12
+ super(message)
13
+ @status = status
14
+ @error_type = error_type
15
+ @body = body
16
+ end
17
+ end
18
+
19
+ class AuthenticationError < ApiError; end
20
+ class PermissionError < ApiError; end
21
+ class NotFoundError < ApiError; end
22
+ class RateLimitError < ApiError; end
23
+ class OverloadedError < ApiError; end
24
+ class InvalidRequestError < ApiError; end
25
+ class ServerError < ApiError; end
26
+ class StreamingError < ApiError; end
27
+
28
+ STATUS_MAP = {
29
+ 401 => AuthenticationError,
30
+ 403 => PermissionError,
31
+ 404 => NotFoundError,
32
+ 429 => RateLimitError,
33
+ 529 => OverloadedError
34
+ }.freeze
35
+
36
+ TYPE_MAP = {
37
+ 'authentication_error' => AuthenticationError,
38
+ 'permission_error' => PermissionError,
39
+ 'not_found_error' => NotFoundError,
40
+ 'rate_limit_error' => RateLimitError,
41
+ 'overloaded_error' => OverloadedError,
42
+ 'invalid_request_error' => InvalidRequestError,
43
+ 'server_error' => ServerError,
44
+ 'streaming_error' => StreamingError
45
+ }.freeze
46
+
47
+ RETRYABLE = [RateLimitError, OverloadedError].freeze
48
+
49
+ module_function
50
+
51
+ def from_response(status:, body:)
52
+ error_hash = body.is_a?(Hash) ? (body[:error] || body['error']) : nil # rubocop:disable Legion/Framework/ApiStringKeys
53
+ error_type = error_hash.is_a?(Hash) ? (error_hash[:type] || error_hash['type']) : nil
54
+ message = error_hash.is_a?(Hash) ? (error_hash[:message] || error_hash['message']) : nil
55
+ message ||= body.to_s
56
+
57
+ klass = TYPE_MAP[error_type] ||
58
+ STATUS_MAP[status] ||
59
+ (status >= 500 ? ServerError : InvalidRequestError)
60
+
61
+ klass.new(message, status: status, error_type: error_type, body: body)
62
+ end
63
+
64
+ def retryable?(error)
65
+ RETRYABLE.any? { |klass| error.is_a?(klass) }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Claude
6
+ module Helpers
7
+ module Models
8
+ # rubocop:disable Naming/VariableNumber
9
+ MODELS = {
10
+ haiku_3_5: 'claude-3-5-haiku-20241022',
11
+ haiku_4_5: 'claude-haiku-4-5-20251001',
12
+ sonnet_3_5: 'claude-3-5-sonnet-20241022',
13
+ sonnet_3_7: 'claude-3-7-sonnet-20250219',
14
+ sonnet_4: 'claude-sonnet-4-20250514',
15
+ sonnet_4_5: 'claude-sonnet-4-5-20250929',
16
+ sonnet_4_6: 'claude-sonnet-4-6',
17
+ opus_4: 'claude-opus-4-20250514',
18
+ opus_4_1: 'claude-opus-4-1-20250805',
19
+ opus_4_5: 'claude-opus-4-5-20251101',
20
+ opus_4_6: 'claude-opus-4-6'
21
+ }.freeze
22
+ # rubocop:enable Naming/VariableNumber
23
+
24
+ ADAPTIVE_THINKING_MODELS = %w[
25
+ claude-sonnet-4-20250514
26
+ claude-sonnet-4-5-20250929
27
+ claude-sonnet-4-6
28
+ claude-opus-4-20250514
29
+ claude-opus-4-1-20250805
30
+ claude-opus-4-5-20251101
31
+ claude-opus-4-6
32
+ ].freeze
33
+
34
+ module_function
35
+
36
+ def resolve(model)
37
+ key = model.is_a?(Symbol) ? model : model.to_s.to_sym
38
+ MODELS.fetch(key, model.to_s)
39
+ end
40
+
41
+ def adaptive_thinking?(model_id)
42
+ ADAPTIVE_THINKING_MODELS.include?(model_id.to_s)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/claude/helpers/errors'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Claude
8
+ module Helpers
9
+ module Response
10
+ RATE_LIMIT_HEADERS = {
11
+ 'anthropic-ratelimit-unified-status' => :status,
12
+ 'anthropic-ratelimit-unified-reset' => :reset,
13
+ 'anthropic-ratelimit-unified-fallback' => :fallback,
14
+ 'anthropic-ratelimit-unified-5h-utilization' => :utilization_5h,
15
+ 'anthropic-ratelimit-unified-5h-reset' => :reset_5h,
16
+ 'anthropic-ratelimit-unified-7d-utilization' => :utilization_7d,
17
+ 'anthropic-ratelimit-unified-7d-reset' => :reset_7d,
18
+ 'anthropic-ratelimit-unified-overage-status' => :overage_status,
19
+ 'anthropic-ratelimit-unified-overage-reset' => :overage_reset
20
+ }.freeze
21
+
22
+ FLOAT_KEYS = %i[utilization_5h utilization_7d].freeze
23
+
24
+ module_function
25
+
26
+ def handle_response(response)
27
+ raise Errors.from_response(status: response.status, body: response.body) unless response.status >= 200 && response.status < 300
28
+
29
+ result = { result: response.body, status: response.status }
30
+ rate_info = parse_rate_limit_headers(response.headers)
31
+ result[:rate_limit] = rate_info unless rate_info.empty?
32
+ result
33
+ end
34
+
35
+ def parse_rate_limit_headers(headers)
36
+ return {} if headers.nil? || headers.empty?
37
+
38
+ parsed = {}
39
+ RATE_LIMIT_HEADERS.each do |header_name, key|
40
+ value = headers[header_name]
41
+ next if value.nil?
42
+
43
+ parsed[key] = FLOAT_KEYS.include?(key) ? value.to_f : value
44
+ end
45
+ parsed
46
+ end
47
+
48
+ def parse_usage(body)
49
+ usage = body.is_a?(Hash) ? (body[:usage] || body['usage'] || {}) : {} # rubocop:disable Legion/Framework/ApiStringKeys
50
+ {
51
+ input_tokens: (usage[:input_tokens] || usage['input_tokens'] || 0).to_i,
52
+ output_tokens: (usage[:output_tokens] || usage['output_tokens'] || 0).to_i,
53
+ cache_read_tokens: (usage[:cache_read_input_tokens] || usage['cache_read_input_tokens'] || 0).to_i,
54
+ cache_write_tokens: (usage[:cache_creation_input_tokens] || usage['cache_creation_input_tokens'] || 0).to_i
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/claude/helpers/errors'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Claude
8
+ module Helpers
9
+ module Retry
10
+ DEFAULT_MAX_ATTEMPTS = 3
11
+ DEFAULT_BASE_DELAY = 1.0
12
+ DEFAULT_MAX_DELAY = 60.0
13
+
14
+ module_function
15
+
16
+ def with_retry(max_attempts: DEFAULT_MAX_ATTEMPTS, base_delay: DEFAULT_BASE_DELAY,
17
+ max_delay: DEFAULT_MAX_DELAY)
18
+ attempt = 0
19
+ begin
20
+ yield
21
+ rescue Errors::ApiError => e
22
+ raise unless Errors.retryable?(e)
23
+
24
+ attempt += 1
25
+ raise if attempt >= max_attempts
26
+
27
+ delay = backoff_seconds(attempt: attempt - 1, base_delay: base_delay, max_delay: max_delay)
28
+ sleep(delay) if delay.positive?
29
+ retry
30
+ end
31
+ end
32
+
33
+ def backoff_seconds(attempt:, base_delay: DEFAULT_BASE_DELAY, max_delay: DEFAULT_MAX_DELAY)
34
+ raw = base_delay * (2**attempt)
35
+ [raw, max_delay].min.to_f
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'multi_json'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Claude
8
+ module Helpers
9
+ module Sse
10
+ module_function
11
+
12
+ def parse_stream(raw, include_pings: false)
13
+ events = []
14
+ current_event = nil
15
+
16
+ raw.each_line do |line|
17
+ line = line.chomp
18
+ if line.start_with?('event:')
19
+ current_event = line.sub(/^event:\s*/, '').strip
20
+ elsif line.start_with?('data:')
21
+ next if current_event == 'ping' && !include_pings
22
+
23
+ json_str = line.sub(/^data:\s*/, '').strip
24
+ next if json_str.empty?
25
+
26
+ begin
27
+ data = MultiJson.load(json_str)
28
+ events << { event: current_event, data: data }
29
+ rescue MultiJson::ParseError => e
30
+ log.warn("SSE parse error: #{e.message}")
31
+ next
32
+ end
33
+ current_event = nil
34
+ end
35
+ end
36
+
37
+ events
38
+ end
39
+
40
+ def collect_text(events)
41
+ events
42
+ .select { |e| e[:event] == 'content_block_delta' && e[:data].dig('delta', 'type') == 'text_delta' }
43
+ .map { |e| e[:data].dig('delta', 'text').to_s }
44
+ .join
45
+ end
46
+
47
+ def collect_usage(events)
48
+ input_tokens = 0
49
+ output_tokens = 0
50
+
51
+ events.each do |e|
52
+ case e[:event]
53
+ when 'message_start'
54
+ usage = e[:data].dig('message', 'usage') || {}
55
+ input_tokens += usage.fetch('input_tokens', 0).to_i
56
+ output_tokens += usage.fetch('output_tokens', 0).to_i
57
+ when 'message_delta'
58
+ usage = e[:data].fetch('usage', {})
59
+ output_tokens += usage.fetch('output_tokens', 0).to_i
60
+ end
61
+ end
62
+
63
+ { input_tokens: input_tokens, output_tokens: output_tokens }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Claude
6
+ module Helpers
7
+ module Tools
8
+ module_function
9
+
10
+ def web_search(max_uses: 5, allowed_domains: nil, blocked_domains: nil)
11
+ tool = { type: 'web_search_20250305', max_uses: max_uses }
12
+ tool[:allowed_domains] = allowed_domains if allowed_domains
13
+ tool[:blocked_domains] = blocked_domains if blocked_domains
14
+ tool
15
+ end
16
+
17
+ def cache_control
18
+ { type: 'ephemeral' }
19
+ end
20
+
21
+ def required_betas_for(tools)
22
+ return [] if tools.nil? || tools.empty?
23
+
24
+ betas = []
25
+ betas << :web_search if tools.any? { |t| t.is_a?(Hash) && t[:type].to_s.start_with?('web_search') }
26
+ betas
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/claude/helpers/client'
4
+ require 'legion/extensions/claude/helpers/response'
4
5
 
5
6
  module Legion
6
7
  module Extensions
@@ -12,7 +13,7 @@ module Legion
12
13
  def create_batch(api_key:, requests:, **)
13
14
  body = { requests: requests }
14
15
  response = client(api_key: api_key, **).post('/v1/messages/batches', body)
15
- { result: response.body, status: response.status }
16
+ Helpers::Response.handle_response(response)
16
17
  end
17
18
 
18
19
  def list_batches(api_key:, limit: 20, before_id: nil, after_id: nil, **)
@@ -21,22 +22,22 @@ module Legion
21
22
  params[:after_id] = after_id if after_id
22
23
 
23
24
  response = client(api_key: api_key, **).get('/v1/messages/batches', params)
24
- { result: response.body, status: response.status }
25
+ Helpers::Response.handle_response(response)
25
26
  end
26
27
 
27
28
  def retrieve_batch(api_key:, batch_id:, **)
28
29
  response = client(api_key: api_key, **).get("/v1/messages/batches/#{batch_id}")
29
- { result: response.body, status: response.status }
30
+ Helpers::Response.handle_response(response)
30
31
  end
31
32
 
32
33
  def cancel_batch(api_key:, batch_id:, **)
33
34
  response = client(api_key: api_key, **).post("/v1/messages/batches/#{batch_id}/cancel")
34
- { result: response.body, status: response.status }
35
+ Helpers::Response.handle_response(response)
35
36
  end
36
37
 
37
38
  def batch_results(api_key:, batch_id:, **)
38
39
  response = client(api_key: api_key, **).get("/v1/messages/batches/#{batch_id}/results")
39
- { result: response.body, status: response.status }
40
+ Helpers::Response.handle_response(response)
40
41
  end
41
42
 
42
43
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/claude/helpers/client'
4
+ require 'legion/extensions/claude/helpers/response'
5
+ require 'legion/extensions/claude/helpers/sse'
4
6
 
5
7
  module Legion
6
8
  module Extensions
@@ -9,35 +11,105 @@ module Legion
9
11
  module Messages
10
12
  extend Legion::Extensions::Claude::Helpers::Client
11
13
 
12
- def create(api_key:, model:, messages:, max_tokens: 1024, system: nil, temperature: nil, # rubocop:disable Metrics/ParameterLists
13
- top_p: nil, top_k: nil, stop_sequences: nil, metadata: nil, tools: nil,
14
- tool_choice: nil, stream: false, **)
15
- body = {
16
- model: model,
17
- messages: messages,
18
- max_tokens: max_tokens,
19
- stream: stream
14
+ def create(api_key:, model:, messages:, max_tokens: 1024, stream: false, betas: nil, **opts)
15
+ body = build_message_body(model: model, messages: messages, max_tokens: max_tokens, stream: stream, **opts)
16
+ resolved_betas = resolve_feature_betas(betas, opts)
17
+
18
+ response = client(api_key: api_key, betas: resolved_betas, **opts).post('/v1/messages', body)
19
+ result = Helpers::Response.handle_response(response)
20
+ result[:usage] = Helpers::Response.parse_usage(response.body) if response.body.is_a?(Hash)
21
+ result
22
+ end
23
+
24
+ def create_stream(api_key:, model:, messages:, max_tokens: 1024, betas: nil, **opts, &block)
25
+ body = build_message_body(model: model, messages: messages, max_tokens: max_tokens, stream: true, **opts)
26
+ resolved_betas = resolve_feature_betas(betas, opts)
27
+
28
+ raw_body = +''
29
+ conn = Helpers::Client.streaming_client(api_key: api_key, betas: resolved_betas)
30
+ response = conn.post('/v1/messages', MultiJson.dump(body)) do |req|
31
+ req.options.on_data = proc { |chunk, _bytes| raw_body << chunk }
32
+ end
33
+
34
+ raise Helpers::Errors.from_response(status: response.status, body: {}) unless response.status == 200
35
+
36
+ raw_body = response.body if raw_body.empty? && response.body.is_a?(String)
37
+
38
+ events = Helpers::Sse.parse_stream(raw_body)
39
+ events.each(&block) if block
40
+
41
+ {
42
+ result: Helpers::Sse.collect_text(events),
43
+ events: events,
44
+ usage: Helpers::Sse.collect_usage(events),
45
+ status: 200
20
46
  }
21
- body[:system] = system if system
22
- body[:temperature] = temperature if temperature
23
- body[:top_p] = top_p if top_p
24
- body[:top_k] = top_k if top_k
25
- body[:stop_sequences] = stop_sequences if stop_sequences
26
- body[:metadata] = metadata if metadata
27
- body[:tools] = tools if tools
28
- body[:tool_choice] = tool_choice if tool_choice
29
-
30
- response = client(api_key: api_key, **).post('/v1/messages', body)
31
- { result: response.body, status: response.status }
32
47
  end
33
48
 
34
- def count_tokens(api_key:, model:, messages:, system: nil, tools: nil, **)
49
+ def count_tokens(api_key:, model:, messages:, betas: nil, **opts)
50
+ system = opts[:system]
51
+ tools = opts[:tools]
52
+ thinking = opts[:thinking]
53
+ cache_system = opts.fetch(:cache_system, false)
54
+
35
55
  body = { model: model, messages: messages }
36
- body[:system] = system if system
37
- body[:tools] = tools if tools
56
+ body[:system] = build_system(system, cache_system) if system
57
+ body[:tools] = tools if tools
58
+ body[:thinking] = thinking if thinking
59
+
60
+ resolved_betas = Array(betas).dup
61
+ resolved_betas << :interleaved_thinking if thinking && !resolved_betas.include?(:interleaved_thinking)
62
+
63
+ response = client(api_key: api_key, betas: resolved_betas).post('/v1/messages/count_tokens', body)
64
+ Helpers::Response.handle_response(response)
65
+ end
66
+
67
+ private
68
+
69
+ def build_message_body(model:, messages:, max_tokens:, stream:, system: nil, temperature: nil, # rubocop:disable Metrics/ParameterLists
70
+ top_p: nil, top_k: nil, stop_sequences: nil, metadata: nil, tools: nil,
71
+ tool_choice: nil, cache_system: false, thinking: nil, output_config: nil,
72
+ fast_mode: false, context_management: nil, **)
73
+ body = { model: model, messages: messages, max_tokens: max_tokens, stream: stream }
74
+
75
+ body[:system] = build_system(system, cache_system) if system
76
+ body[:top_p] = top_p if top_p
77
+ body[:top_k] = top_k if top_k
78
+ body[:stop_sequences] = stop_sequences if stop_sequences
79
+ body[:metadata] = metadata if metadata
80
+ body[:tools] = tools if tools
81
+ body[:tool_choice] = tool_choice if tool_choice
82
+ body[:output_config] = output_config if output_config
83
+ body[:speed] = 'fast' if fast_mode
84
+ body[:context_management] = context_management if context_management
85
+
86
+ if thinking
87
+ body[:thinking] = thinking
88
+ elsif temperature
89
+ body[:temperature] = temperature
90
+ end
91
+
92
+ body
93
+ end
94
+
95
+ def resolve_feature_betas(betas, opts)
96
+ resolved = Array(betas).dup
97
+ resolved << :prompt_caching_scope if opts[:cache_scope] == :global
98
+ resolved << :interleaved_thinking if opts[:thinking] && !resolved.include?(:interleaved_thinking)
99
+ resolved << :structured_outputs if opts[:output_config]&.key?(:format)
100
+ resolved << :effort if opts[:output_config]&.key?(:effort)
101
+ resolved << :task_budgets if opts[:output_config]&.key?(:task_budget)
102
+ resolved << :fast_mode if opts[:fast_mode]
103
+ resolved << :context_management if opts[:context_management]
104
+ resolved
105
+ end
38
106
 
39
- response = client(api_key: api_key, **).post('/v1/messages/count_tokens', body)
40
- { result: response.body, status: response.status }
107
+ def build_system(system, cache_system)
108
+ if cache_system
109
+ [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }]
110
+ else
111
+ system
112
+ end
41
113
  end
42
114
 
43
115
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/extensions/claude/helpers/client'
4
+ require 'legion/extensions/claude/helpers/response'
4
5
 
5
6
  module Legion
6
7
  module Extensions
@@ -15,12 +16,12 @@ module Legion
15
16
  params[:after_id] = after_id if after_id
16
17
 
17
18
  response = client(api_key: api_key, **).get('/v1/models', params)
18
- { result: response.body, status: response.status }
19
+ Helpers::Response.handle_response(response)
19
20
  end
20
21
 
21
22
  def retrieve(api_key:, model_id:, **)
22
23
  response = client(api_key: api_key, **).get("/v1/models/#{model_id}")
23
- { result: response.body, status: response.status }
24
+ Helpers::Response.handle_response(response)
24
25
  end
25
26
 
26
27
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Claude
6
- VERSION = '0.1.3'
6
+ VERSION = '0.3.0'
7
7
  end
8
8
  end
9
9
  end
@@ -2,6 +2,12 @@
2
2
 
3
3
  require 'legion/extensions/claude/version'
4
4
  require 'legion/extensions/claude/helpers/client'
5
+ require 'legion/extensions/claude/helpers/errors'
6
+ require 'legion/extensions/claude/helpers/retry'
7
+ require 'legion/extensions/claude/helpers/sse'
8
+ require 'legion/extensions/claude/helpers/response'
9
+ require 'legion/extensions/claude/helpers/tools'
10
+ require 'legion/extensions/claude/helpers/models'
5
11
  require 'legion/extensions/claude/runners/messages'
6
12
  require 'legion/extensions/claude/runners/models'
7
13
  require 'legion/extensions/claude/runners/batches'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-claude
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -158,6 +158,12 @@ files:
158
158
  - lib/legion/extensions/claude.rb
159
159
  - lib/legion/extensions/claude/client.rb
160
160
  - lib/legion/extensions/claude/helpers/client.rb
161
+ - lib/legion/extensions/claude/helpers/errors.rb
162
+ - lib/legion/extensions/claude/helpers/models.rb
163
+ - lib/legion/extensions/claude/helpers/response.rb
164
+ - lib/legion/extensions/claude/helpers/retry.rb
165
+ - lib/legion/extensions/claude/helpers/sse.rb
166
+ - lib/legion/extensions/claude/helpers/tools.rb
161
167
  - lib/legion/extensions/claude/runners/batches.rb
162
168
  - lib/legion/extensions/claude/runners/messages.rb
163
169
  - lib/legion/extensions/claude/runners/models.rb