elelem-anthropic 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 56afa7f5f223aa55908e14a3f1338e5855e60cc14b545afea442359c91f52093
4
+ data.tar.gz: c0ff12d63238c13cb2087b402a95ca493998b4eae059e0eab84578e9f76e7a5d
5
+ SHA512:
6
+ metadata.gz: 493df59bd32c8efe6e84231b184abda1e8d58d8ad27e2f0c3433e0bf2eb969d7f8b20c7550c1c26988f11a561de44944394556c373fb9d307f381179f26375c8
7
+ data.tar.gz: 5c440bb7aead702d60d013d6fc105b217d63502b89c99d74b35093d66f4b461f47fa9b35a9c12d6df25b1bc471e42bebc2c4a990a86ded078ef1711b4fb82029
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 mo khan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ task default: %i[]
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "elelem/cli"
5
+
6
+ Signal.trap("INT") { exit 1 }
7
+
8
+ Elelem::CLI.new(ARGV, provider: "anthropic").run
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Anthropic
5
+ class Client
6
+ def initialize(endpoint:, headers:, model:, version: nil, read_timeout: 3600, open_timeout: 10)
7
+ @uri = URI.parse(endpoint)
8
+ @headers_source = headers
9
+ @model = model
10
+ @version = version
11
+ @read_timeout = read_timeout
12
+ @open_timeout = open_timeout
13
+ end
14
+
15
+ def fetch(messages, tools = [], &block)
16
+ system_prompt, normalized_messages = extract_system(messages)
17
+ tool_calls = []
18
+
19
+ stream(normalized_messages, system_prompt, tools) do |event|
20
+ handle_event(event, tool_calls, &block)
21
+ end
22
+
23
+ finalize_tool_calls(tool_calls, &block)
24
+ end
25
+
26
+ private
27
+
28
+ def headers
29
+ @headers_source.respond_to?(:call) ? @headers_source.call : @headers_source
30
+ end
31
+
32
+ def handle_event(event, tool_calls, &block)
33
+ case event["type"]
34
+ when "content_block_start"
35
+ handle_content_block_start(event, tool_calls)
36
+ when "content_block_delta"
37
+ handle_content_block_delta(event, tool_calls, &block)
38
+ end
39
+ end
40
+
41
+ def handle_content_block_start(event, tool_calls)
42
+ content_block = event["content_block"]
43
+ return unless content_block["type"] == "tool_use"
44
+
45
+ tool_calls << {
46
+ id: content_block["id"],
47
+ name: content_block["name"],
48
+ args: String.new
49
+ }
50
+ end
51
+
52
+ def handle_content_block_delta(event, tool_calls, &block)
53
+ delta = event["delta"]
54
+
55
+ case delta["type"]
56
+ when "text_delta"
57
+ block.call(type: "saying", text: delta["text"])
58
+ when "thinking_delta"
59
+ block.call(type: "thinking", text: delta["thinking"])
60
+ when "input_json_delta"
61
+ tool_calls.last[:args] << delta["partial_json"].to_s if tool_calls.any?
62
+ end
63
+ end
64
+
65
+ def finalize_tool_calls(tool_calls, &block)
66
+ tool_calls.each do |tool_call|
67
+ args = tool_call.delete(:args)
68
+ tool_call[:arguments] = args.empty? ? {} : JSON.parse(args)
69
+ block.call(type: "doing", id: tool_call[:id], name: tool_call[:name], arguments: tool_call[:arguments])
70
+ end
71
+ tool_calls
72
+ end
73
+
74
+ def stream(messages, system_prompt, tools)
75
+ body = build_request_body(messages, system_prompt, tools)
76
+
77
+ request = Net::HTTP::Post.new(@uri)
78
+ request["content-type"] = "application/json"
79
+ headers.each { |k, v| request[k] = v }
80
+ request.body = JSON.generate(body)
81
+
82
+ http = Net::HTTP.new(@uri.host, @uri.port)
83
+ http.use_ssl = @uri.scheme == "https"
84
+ http.read_timeout = @read_timeout
85
+ http.open_timeout = @open_timeout
86
+
87
+ http.start do |conn|
88
+ conn.request(request) do |response|
89
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
90
+
91
+ read_sse_stream(response) { |event| yield event }
92
+ end
93
+ end
94
+ end
95
+
96
+ def build_request_body(messages, system_prompt, tools)
97
+ body = { max_tokens: 64000, messages:, stream: true }
98
+ body[:model] = @model unless @version
99
+ body[:anthropic_version] = @version if @version
100
+ body[:system] = system_prompt if system_prompt
101
+ body[:tools] = unwrap_tools(tools) unless tools.empty?
102
+ body
103
+ end
104
+
105
+ def read_sse_stream(response)
106
+ buffer = String.new
107
+
108
+ response.read_body do |chunk|
109
+ buffer << chunk
110
+
111
+ while (index = buffer.index("\n\n"))
112
+ raw_event = buffer.slice!(0, index + 2)
113
+ event = parse_sse(raw_event)
114
+ yield event if event
115
+ end
116
+ end
117
+ end
118
+
119
+ def parse_sse(raw)
120
+ line = raw.lines.find { |l| l.start_with?("data: ") }
121
+ return nil unless line
122
+
123
+ data = line.delete_prefix("data: ").strip
124
+ return nil if data == "[DONE]"
125
+
126
+ JSON.parse(data)
127
+ end
128
+
129
+ def extract_system(messages)
130
+ system_messages, other_messages = messages.partition { |message| message[:role] == "system" }
131
+ system_content = system_messages.first&.dig(:content)
132
+ [system_content, normalize(other_messages)]
133
+ end
134
+
135
+ def normalize(messages)
136
+ messages.map { |message| normalize_message(message) }
137
+ end
138
+
139
+ def normalize_message(message)
140
+ case message[:role]
141
+ when "tool"
142
+ tool_result_message(message)
143
+ when "assistant"
144
+ message[:tool_calls]&.any? ? assistant_with_tools_message(message) : message
145
+ else
146
+ message
147
+ end
148
+ end
149
+
150
+ def tool_result_message(message)
151
+ {
152
+ role: "user",
153
+ content: [{
154
+ type: "tool_result",
155
+ tool_use_id: message[:tool_call_id],
156
+ content: message[:content]
157
+ }]
158
+ }
159
+ end
160
+
161
+ def assistant_with_tools_message(message)
162
+ text_content = build_text_content(message[:content])
163
+ tool_content = build_tool_content(message[:tool_calls])
164
+
165
+ { role: "assistant", content: text_content + tool_content }
166
+ end
167
+
168
+ def build_text_content(content)
169
+ return [] if content.to_s.empty?
170
+
171
+ [{ type: "text", text: content }]
172
+ end
173
+
174
+ def build_tool_content(tool_calls)
175
+ tool_calls.map do |tool_call|
176
+ {
177
+ type: "tool_use",
178
+ id: tool_call[:id],
179
+ name: tool_call[:name],
180
+ input: tool_call[:arguments]
181
+ }
182
+ end
183
+ end
184
+
185
+ def unwrap_tools(tools)
186
+ tools.map do |tool|
187
+ {
188
+ name: tool.dig(:function, :name),
189
+ description: tool.dig(:function, :description),
190
+ input_schema: tool.dig(:function, :parameters)
191
+ }
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Anthropic
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "elelem"
4
+ require "json"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ require_relative "anthropic/version"
9
+ require_relative "anthropic/client"
10
+
11
+ Elelem::Providers.register(:anthropic) do
12
+ api_key = ENV.fetch("ANTHROPIC_API_KEY")
13
+ Elelem::Anthropic::Client.new(
14
+ endpoint: "https://api.anthropic.com/v1/messages",
15
+ headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
16
+ model: ENV.fetch("ANTHROPIC_MODEL", "claude-opus-4-5-20250514")
17
+ )
18
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: elelem-anthropic
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - mo khan
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: elelem
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.10'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.10'
26
+ - !ruby/object:Gem::Dependency
27
+ name: json
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.21'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.21'
40
+ - !ruby/object:Gem::Dependency
41
+ name: net-http
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.9'
54
+ - !ruby/object:Gem::Dependency
55
+ name: uri
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.1'
68
+ description: An Anthropic provider plugin for elelem.
69
+ email:
70
+ - mo@mokhan.ca
71
+ executables:
72
+ - elelem-anthropic
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - LICENSE.txt
77
+ - Rakefile
78
+ - exe/elelem-anthropic
79
+ - lib/elelem/anthropic.rb
80
+ - lib/elelem/anthropic/client.rb
81
+ - lib/elelem/anthropic/version.rb
82
+ homepage: https://src.mokhan.ca/elelem/anthropic
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ allowed_push_host: https://rubygems.org
87
+ homepage_uri: https://src.mokhan.ca/elelem/anthropic
88
+ source_code_uri: https://src.mokhan.ca/elelem/anthropic
89
+ rdoc_options: []
90
+ require_paths:
91
+ - lib
92
+ required_ruby_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 4.0.0
97
+ required_rubygems_version: !ruby/object:Gem::Requirement
98
+ requirements:
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ requirements: []
103
+ rubygems_version: 4.0.20
104
+ specification_version: 4
105
+ summary: An Anthropic provider plugin for elelem.
106
+ test_files: []