llmshim 0.1.24

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: 1ee5f94ef389e23139fc944ee4c2a4d378936629e4f3bc7cb3487308f2fd07e3
4
+ data.tar.gz: 4478f78641ed1a4104f3659524ee8da1eb1e028a68774d760b5830e1209d7d94
5
+ SHA512:
6
+ metadata.gz: e8da5ef6535d958c9e3008f1ab4b4d5d51cbd92d3984bf520d2b1a6376a0700588fc3110372e10716fd7a4c5bd81bf0f36d9a30837cc515f16d6c12c03e1d18d
7
+ data.tar.gz: 7a69fda0d58fefecf63df339895cd0e277377639cd74088feaf1af674165e5448f85574ea5ebd65d6cfeb1f549de17903d4b36d734e81200638629ffb0f2789d
data/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # llmshim (Ruby client)
2
+
3
+ A thin, dependency-free Ruby client for the [llmshim](https://github.com/sanjay920/llmshim)
4
+ multi-provider LLM proxy. Send OpenAI-style chat requests to a running llmshim
5
+ proxy and let it translate to OpenAI, Anthropic, Google Gemini, or xAI.
6
+
7
+ This gem talks to a proxy over plain HTTP — it does **not** spawn the Rust
8
+ binary. Start the proxy separately (`llmshim proxy`, default `http://localhost:3000`).
9
+
10
+ - Standard library only (`net/http`, `json`, `uri`) — no runtime gem dependencies.
11
+ - Non-streaming and SSE streaming chat, model listing, health check.
12
+ - Typed responses and a typed `Llmshim::APIError`.
13
+
14
+ ## Install
15
+
16
+ From RubyGems (once published):
17
+
18
+ ```bash
19
+ gem install llmshim
20
+ ```
21
+
22
+ Or add to your `Gemfile`:
23
+
24
+ ```ruby
25
+ gem "llmshim"
26
+ ```
27
+
28
+ Or build locally from this directory:
29
+
30
+ ```bash
31
+ gem build llmshim.gemspec
32
+ gem install ./llmshim-*.gem
33
+ ```
34
+
35
+ ## Prerequisite: run the proxy
36
+
37
+ ```bash
38
+ llmshim proxy # listens on 0.0.0.0:3000 by default
39
+ ```
40
+
41
+ Configure provider API keys for the proxy via `llmshim configure` or the
42
+ `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` / `XAI_API_KEY`
43
+ environment variables. The Ruby client never sees your keys — the proxy holds them.
44
+
45
+ ## Quickstart
46
+
47
+ ```ruby
48
+ require "llmshim"
49
+
50
+ client = Llmshim::Client.new(base_url: "http://localhost:3000")
51
+
52
+ resp = client.chat(model: "claude-sonnet-4-6", messages: "What is Rust?")
53
+ puts resp.content # => "Rust is a systems programming language..."
54
+ puts resp.provider # => "anthropic"
55
+ puts resp.usage.total_tokens
56
+ ```
57
+
58
+ `messages:` accepts a single string (treated as one user message) or an array
59
+ of message hashes:
60
+
61
+ ```ruby
62
+ resp = client.chat(
63
+ model: "openai/gpt-5.5",
64
+ messages: [
65
+ { role: "system", content: "You are a pirate." },
66
+ { role: "user", content: "Hello!" }
67
+ ],
68
+ max_tokens: 500,
69
+ temperature: 0.7,
70
+ reasoning_effort: "high"
71
+ )
72
+ ```
73
+
74
+ ### Module-level convenience
75
+
76
+ A shared default client (base URL from `LLMSHIM_BASE_URL`, else `http://localhost:3000`):
77
+
78
+ ```ruby
79
+ require "llmshim"
80
+
81
+ resp = Llmshim.chat(model: "gpt-5.5", messages: "Explain quicksort")
82
+ puts resp.content
83
+ ```
84
+
85
+ ## Streaming
86
+
87
+ `stream` yields a `Llmshim::StreamEvent` for each SSE event and stops after the
88
+ `done` event. Event types: `content`, `reasoning`, `tool_call`, `usage`,
89
+ `done`, `error`.
90
+
91
+ ```ruby
92
+ client.stream(model: "claude-sonnet-4-6", messages: "Write a haiku") do |event|
93
+ case event.type
94
+ when "reasoning" then print event.text # thinking tokens
95
+ when "content" then print event.text # answer tokens
96
+ when "tool_call" then puts "\n[tool] #{event.name}(#{event.arguments})"
97
+ when "usage" then puts "\ntokens: #{event.usage.total_tokens}"
98
+ when "error" then warn "error: #{event.message}"
99
+ end
100
+ end
101
+ ```
102
+
103
+ Predicate helpers are available too: `event.content?`, `event.reasoning?`,
104
+ `event.tool_call?`, `event.usage?`, `event.done?`, `event.error?`.
105
+
106
+ Called without a block, `stream` returns the collected array of events:
107
+
108
+ ```ruby
109
+ events = client.stream(model: "gpt-5.5", messages: "Hi")
110
+ text = events.select(&:content?).map(&:text).join
111
+ ```
112
+
113
+ ## Tools, provider passthrough, and fallback
114
+
115
+ ```ruby
116
+ resp = client.chat(
117
+ model: "anthropic/claude-sonnet-4-6",
118
+ messages: "What's the weather in SF?",
119
+ tools: [
120
+ { type: "function",
121
+ function: { name: "get_weather",
122
+ parameters: { type: "object",
123
+ properties: { city: { type: "string" } } } } }
124
+ ],
125
+ tool_choice: "auto",
126
+ # Raw provider-specific JSON merged into the underlying request:
127
+ provider_config: { thinking: { type: "adaptive" } },
128
+ # Try these models if the primary fails with a retryable error:
129
+ fallback: ["openai/gpt-5.5", "gemini/gemini-3-flash-preview"]
130
+ )
131
+
132
+ resp.message.tool_calls.each do |tc|
133
+ puts "#{tc.name} -> #{tc.arguments}"
134
+ end
135
+ ```
136
+
137
+ `tools` and `tool_choice` are folded into `provider_config` (passed straight
138
+ through to the provider). `max_tokens`, `temperature`, `top_p`, `top_k`,
139
+ `stop`, and `reasoning_effort` are folded into the request `config`.
140
+
141
+ ## Models and health
142
+
143
+ ```ruby
144
+ client.models.each { |m| puts "#{m.id} (#{m.provider})" }
145
+
146
+ h = client.health
147
+ puts h.status # => "ok"
148
+ puts h.providers # => ["anthropic", "openai", ...]
149
+ ```
150
+
151
+ ## Error handling
152
+
153
+ Any non-2xx response raises `Llmshim::APIError`, populated from the proxy's
154
+ `ErrorResponse` body:
155
+
156
+ ```ruby
157
+ begin
158
+ client.chat(model: "bogus/model", messages: "hi")
159
+ rescue Llmshim::APIError => e
160
+ warn "#{e.status} #{e.code}: #{e.message}"
161
+ end
162
+ ```
163
+
164
+ `Llmshim::APIError` exposes `#status` (Integer), `#code` (String or nil),
165
+ `#message`, and `#body` (raw response body).
166
+
167
+ ## Configuration
168
+
169
+ `Llmshim::Client.new` options:
170
+
171
+ | Option | Default | Description |
172
+ | ---------- | ------------------------ | ------------------------------------ |
173
+ | `base_url` | `http://localhost:3000` | Proxy base URL |
174
+ | `headers` | `{}` | Extra headers sent on every request |
175
+ | `timeout` | `120` | Open/read timeout in seconds |
176
+
177
+ ## Development
178
+
179
+ ```bash
180
+ bundle install # optional; only for dev tools
181
+ rake test # runs the mocked test suite (no network, no API keys)
182
+ ```
183
+
184
+ Tests use a local WEBrick mock server returning canned JSON and SSE — they
185
+ never contact a real provider and never require a running proxy.
186
+
187
+ ## License
188
+
189
+ MIT
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "errors"
8
+ require_relative "types"
9
+
10
+ module Llmshim
11
+ # A thin, dependency-free HTTP client for a running llmshim proxy.
12
+ #
13
+ # client = Llmshim::Client.new(base_url: "http://localhost:3000")
14
+ # resp = client.chat(model: "claude-sonnet-4-6", messages: "Hello!")
15
+ # puts resp.content
16
+ #
17
+ # All request/response shapes follow api/openapi.yaml. Only the Ruby
18
+ # standard library is used (net/http, json, uri).
19
+ class Client
20
+ DEFAULT_BASE_URL = "http://localhost:3000"
21
+ DEFAULT_TIMEOUT = 120
22
+
23
+ # Base URL of the proxy (without a trailing slash).
24
+ attr_reader :base_url
25
+ # Extra headers sent with every request (Hash).
26
+ attr_reader :headers
27
+ # Read/open timeout in seconds.
28
+ attr_reader :timeout
29
+
30
+ # @param base_url [String] proxy base URL (default http://localhost:3000)
31
+ # @param headers [Hash] extra headers merged into every request
32
+ # @param timeout [Numeric] read/open timeout in seconds
33
+ def initialize(base_url: DEFAULT_BASE_URL, headers: {}, timeout: DEFAULT_TIMEOUT)
34
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
35
+ @headers = headers || {}
36
+ @timeout = timeout
37
+ end
38
+
39
+ # Send a non-streaming chat completion (POST /v1/chat).
40
+ #
41
+ # @param model [String] "provider/model" or a bare model name
42
+ # @param messages [String, Array<Hash>] a single user string or message hashes
43
+ # @param opts [Hash] see #build_body (max_tokens, temperature, top_p, top_k,
44
+ # stop, reasoning_effort, config, provider_config, tools, tool_choice, fallback)
45
+ # @return [Llmshim::ChatResponse]
46
+ def chat(model:, messages:, **opts)
47
+ body = build_body(model, messages, opts)
48
+ hash = post_json("/v1/chat", body)
49
+ ChatResponse.from_hash(hash)
50
+ end
51
+
52
+ # Stream a chat completion (POST /v1/chat/stream).
53
+ #
54
+ # Yields a Llmshim::StreamEvent per SSE event. Iteration stops after a
55
+ # "done" event or on EOF. When no block is given, returns the collected
56
+ # array of events.
57
+ #
58
+ # @return [Array<Llmshim::StreamEvent>, nil]
59
+ def stream(model:, messages:, **opts)
60
+ body = build_body(model, messages, opts)
61
+ collected = block_given? ? nil : []
62
+ stream_sse("/v1/chat/stream", body) do |event|
63
+ if block_given?
64
+ yield event
65
+ else
66
+ collected << event
67
+ end
68
+ end
69
+ collected
70
+ end
71
+
72
+ # List available models (GET /v1/models).
73
+ #
74
+ # @return [Array<Llmshim::Model>]
75
+ def models
76
+ hash = get_json("/v1/models")
77
+ (hash["models"] || []).map { |m| Model.from_hash(m) }
78
+ end
79
+
80
+ # Health check (GET /health).
81
+ #
82
+ # @return [Llmshim::Health]
83
+ def health
84
+ Health.from_hash(get_json("/health"))
85
+ end
86
+
87
+ private
88
+
89
+ # Build the JSON request body from friendly keyword options.
90
+ #
91
+ # config-level opts (max_tokens, temperature, top_p, top_k, stop,
92
+ # reasoning_effort) are folded into +config+. tools / tool_choice are folded
93
+ # into +provider_config+ (the proxy passes them through to the provider).
94
+ def build_body(model, messages, opts)
95
+ msgs =
96
+ if messages.is_a?(String)
97
+ [{ "role" => "user", "content" => messages }]
98
+ else
99
+ messages
100
+ end
101
+
102
+ body = { "model" => model, "messages" => msgs }
103
+
104
+ config = stringify(opts[:config] || {})
105
+ %i[max_tokens temperature top_p top_k stop reasoning_effort].each do |key|
106
+ config[key.to_s] = opts[key] unless opts[key].nil?
107
+ end
108
+ body["config"] = config unless config.empty?
109
+
110
+ provider_config = stringify(opts[:provider_config] || {})
111
+ provider_config["tools"] = opts[:tools] unless opts[:tools].nil?
112
+ provider_config["tool_choice"] = opts[:tool_choice] unless opts[:tool_choice].nil?
113
+ body["provider_config"] = provider_config unless provider_config.empty?
114
+
115
+ body["fallback"] = opts[:fallback] unless opts[:fallback].nil?
116
+ body["stream"] = opts[:stream] unless opts[:stream].nil?
117
+
118
+ body
119
+ end
120
+
121
+ def stringify(hash)
122
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
123
+ end
124
+
125
+ def get_json(path)
126
+ req = Net::HTTP::Get.new(uri_for(path))
127
+ apply_headers(req)
128
+ parse_json_response(perform(req))
129
+ end
130
+
131
+ def post_json(path, body)
132
+ req = Net::HTTP::Post.new(uri_for(path))
133
+ req["Content-Type"] = "application/json"
134
+ apply_headers(req)
135
+ req.body = JSON.generate(body)
136
+ parse_json_response(perform(req))
137
+ end
138
+
139
+ # Perform a streaming POST and yield typed StreamEvent objects.
140
+ def stream_sse(path, body)
141
+ uri = uri_for(path)
142
+ req = Net::HTTP::Post.new(uri)
143
+ req["Content-Type"] = "application/json"
144
+ req["Accept"] = "text/event-stream"
145
+ apply_headers(req)
146
+ req.body = JSON.generate(body)
147
+
148
+ with_http(uri) do |http|
149
+ http.request(req) do |res|
150
+ unless res.is_a?(Net::HTTPSuccess)
151
+ raise APIError.from_response(res.code.to_i, res.body)
152
+ end
153
+
154
+ # State carried across chunk boundaries. We read the full (finite)
155
+ # SSE body rather than aborting mid-stream — abandoning net/http's
156
+ # read_body leaves the socket in an inconsistent state. Once a "done"
157
+ # event is seen we simply stop forwarding further events.
158
+ state = { buffer: +"", event: nil, done: false }
159
+ res.read_body do |chunk|
160
+ next if state[:done]
161
+
162
+ state[:buffer] << chunk
163
+ while (idx = state[:buffer].index("\n"))
164
+ line = state[:buffer].slice!(0..idx).chomp
165
+ dispatch_sse_line(line, state) { |ev| yield ev }
166
+ break if state[:done]
167
+ end
168
+ end
169
+ # Flush any trailing line that arrived without a newline.
170
+ unless state[:done] || state[:buffer].empty?
171
+ dispatch_sse_line(state[:buffer].chomp, state) { |ev| yield ev }
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ # Process one SSE line, updating +state+ in place and yielding a
178
+ # StreamEvent for each complete data line until a "done" event is seen.
179
+ def dispatch_sse_line(line, state)
180
+ return if state[:done]
181
+ return if line.empty?
182
+ return if line.start_with?(":") # SSE comment / heartbeat
183
+
184
+ if line.start_with?("event:")
185
+ state[:event] = line.sub(/\Aevent:\s?/, "")
186
+ elsif line.start_with?("data:")
187
+ data = line.sub(/\Adata:\s?/, "")
188
+ return if data.empty?
189
+
190
+ parsed =
191
+ begin
192
+ JSON.parse(data)
193
+ rescue JSON::ParserError
194
+ {}
195
+ end
196
+ type = state[:event] || parsed["type"] || ""
197
+ event = StreamEvent.new(type, parsed)
198
+ yield event
199
+ state[:done] = true if event.done?
200
+ end
201
+ end
202
+
203
+ def uri_for(path)
204
+ URI.parse("#{@base_url}#{path}")
205
+ end
206
+
207
+ def apply_headers(req)
208
+ @headers.each { |k, v| req[k.to_s] = v }
209
+ end
210
+
211
+ def with_http(uri)
212
+ http = Net::HTTP.new(uri.host, uri.port)
213
+ http.use_ssl = uri.scheme == "https"
214
+ http.open_timeout = @timeout
215
+ http.read_timeout = @timeout
216
+ http.start unless http.started?
217
+ begin
218
+ yield http
219
+ ensure
220
+ http.finish if http.started?
221
+ end
222
+ end
223
+
224
+ def perform(req)
225
+ uri = URI.parse("#{@base_url}#{req.path}")
226
+ with_http(uri) { |http| http.request(req) }
227
+ end
228
+
229
+ def parse_json_response(res)
230
+ unless res.is_a?(Net::HTTPSuccess)
231
+ raise APIError.from_response(res.code.to_i, res.body)
232
+ end
233
+
234
+ body = res.body.to_s
235
+ return {} if body.empty?
236
+
237
+ JSON.parse(body)
238
+ rescue JSON::ParserError => e
239
+ raise Error, "Failed to parse response JSON: #{e.message}"
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmshim
4
+ # Base class for all errors raised by this gem.
5
+ class Error < StandardError; end
6
+
7
+ # Raised when the proxy returns a non-2xx response.
8
+ #
9
+ # The proxy encodes errors as an +ErrorResponse+ object
10
+ # (+{ "error": { "code": ..., "message": ... } }+). When the body cannot
11
+ # be parsed, +code+ falls back to +nil+ and +message+ to the raw body.
12
+ class APIError < Error
13
+ # HTTP status code of the failed response (Integer).
14
+ attr_reader :status
15
+ # Machine-readable error code from the proxy (String or nil).
16
+ attr_reader :code
17
+ # Raw response body (String).
18
+ attr_reader :body
19
+
20
+ def initialize(message, status:, code: nil, body: nil)
21
+ @status = status
22
+ @code = code
23
+ @body = body
24
+ super(message)
25
+ end
26
+
27
+ # Build an APIError from an HTTP status and response body.
28
+ def self.from_response(status, body)
29
+ code = nil
30
+ message = nil
31
+ begin
32
+ parsed = JSON.parse(body.to_s)
33
+ err = parsed["error"]
34
+ if err.is_a?(Hash)
35
+ code = err["code"]
36
+ message = err["message"]
37
+ end
38
+ rescue JSON::ParserError
39
+ # fall through to raw body
40
+ end
41
+ message ||= (body.to_s.empty? ? "HTTP #{status}" : body.to_s)
42
+ new(message, status: status, code: code, body: body)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmshim
4
+ # Token accounting returned with a chat response or a usage stream event.
5
+ #
6
+ # Mirrors the +Usage+ schema in api/openapi.yaml.
7
+ Usage = Struct.new(
8
+ :input_tokens, :output_tokens, :reasoning_tokens, :total_tokens,
9
+ keyword_init: true
10
+ ) do
11
+ def self.from_hash(hash)
12
+ return nil if hash.nil?
13
+
14
+ new(
15
+ input_tokens: hash["input_tokens"],
16
+ output_tokens: hash["output_tokens"],
17
+ reasoning_tokens: hash["reasoning_tokens"],
18
+ total_tokens: hash["total_tokens"]
19
+ )
20
+ end
21
+ end
22
+
23
+ # A single tool call requested by the assistant.
24
+ #
25
+ # +function+ is a plain Hash with "name" and "arguments" (JSON-encoded string)
26
+ # to match the wire format exactly.
27
+ ToolCall = Struct.new(:id, :type, :function, keyword_init: true) do
28
+ def self.from_hash(hash)
29
+ new(id: hash["id"], type: hash["type"], function: hash["function"])
30
+ end
31
+
32
+ # Convenience accessor for the tool name.
33
+ def name
34
+ function && function["name"]
35
+ end
36
+
37
+ # Convenience accessor for the JSON-encoded arguments string.
38
+ def arguments
39
+ function && function["arguments"]
40
+ end
41
+ end
42
+
43
+ # The assistant message inside a ChatResponse.
44
+ ResponseMessage = Struct.new(:role, :content, :tool_calls, keyword_init: true) do
45
+ def self.from_hash(hash)
46
+ calls = (hash["tool_calls"] || []).map { |c| ToolCall.from_hash(c) }
47
+ new(role: hash["role"], content: hash["content"], tool_calls: calls)
48
+ end
49
+ end
50
+
51
+ # A non-streaming chat completion response (+ChatResponse+ schema).
52
+ #
53
+ # +raw+ retains the original parsed Hash for forward compatibility.
54
+ ChatResponse = Struct.new(
55
+ :id, :model, :provider, :message, :reasoning, :usage, :latency_ms, :raw,
56
+ keyword_init: true
57
+ ) do
58
+ def self.from_hash(hash)
59
+ new(
60
+ id: hash["id"],
61
+ model: hash["model"],
62
+ provider: hash["provider"],
63
+ message: ResponseMessage.from_hash(hash["message"] || {}),
64
+ reasoning: hash["reasoning"],
65
+ usage: Usage.from_hash(hash["usage"]),
66
+ latency_ms: hash["latency_ms"],
67
+ raw: hash
68
+ )
69
+ end
70
+
71
+ # Shortcut for the assistant text content.
72
+ def content
73
+ message&.content
74
+ end
75
+ end
76
+
77
+ # A single model entry from GET /v1/models.
78
+ Model = Struct.new(:id, :provider, :name, keyword_init: true) do
79
+ def self.from_hash(hash)
80
+ new(id: hash["id"], provider: hash["provider"], name: hash["name"])
81
+ end
82
+ end
83
+
84
+ # The GET /health response.
85
+ Health = Struct.new(:status, :providers, keyword_init: true) do
86
+ def self.from_hash(hash)
87
+ new(status: hash["status"], providers: hash["providers"] || [])
88
+ end
89
+ end
90
+
91
+ # A typed SSE event from POST /v1/chat/stream.
92
+ #
93
+ # +type+ is one of: "content", "reasoning", "tool_call", "usage", "done",
94
+ # "error". All variant fields are exposed through +raw+ and the helper
95
+ # accessors below; unknown fields remain reachable via +raw+.
96
+ class StreamEvent
97
+ # Event type string (String).
98
+ attr_reader :type
99
+ # Original parsed data Hash for this event.
100
+ attr_reader :raw
101
+
102
+ def initialize(type, raw)
103
+ @type = type
104
+ @raw = raw || {}
105
+ end
106
+
107
+ def content?
108
+ type == "content"
109
+ end
110
+
111
+ def reasoning?
112
+ type == "reasoning"
113
+ end
114
+
115
+ def tool_call?
116
+ type == "tool_call"
117
+ end
118
+
119
+ def usage?
120
+ type == "usage"
121
+ end
122
+
123
+ def done?
124
+ type == "done"
125
+ end
126
+
127
+ def error?
128
+ type == "error"
129
+ end
130
+
131
+ # content / reasoning events
132
+ def text
133
+ raw["text"]
134
+ end
135
+
136
+ # tool_call events
137
+ def id
138
+ raw["id"]
139
+ end
140
+
141
+ def name
142
+ raw["name"]
143
+ end
144
+
145
+ def arguments
146
+ raw["arguments"]
147
+ end
148
+
149
+ # usage events
150
+ def usage
151
+ return nil unless usage?
152
+
153
+ Usage.from_hash(raw)
154
+ end
155
+
156
+ # error events
157
+ def message
158
+ raw["message"]
159
+ end
160
+
161
+ def [](key)
162
+ raw[key.to_s]
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmshim
4
+ VERSION = "0.1.24"
5
+ end
data/lib/llmshim.rb ADDED
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "llmshim/version"
4
+ require_relative "llmshim/errors"
5
+ require_relative "llmshim/types"
6
+ require_relative "llmshim/client"
7
+
8
+ # Ruby client for the llmshim multi-provider LLM proxy.
9
+ #
10
+ # Point it at a running proxy (default http://localhost:3000) and send
11
+ # OpenAI-style chat requests; llmshim translates to the native provider API.
12
+ #
13
+ # Object API:
14
+ #
15
+ # client = Llmshim::Client.new(base_url: "http://localhost:3000")
16
+ # resp = client.chat(model: "claude-sonnet-4-6", messages: "Hello!")
17
+ # puts resp.content
18
+ #
19
+ # Module convenience API (uses a shared default client):
20
+ #
21
+ # Llmshim.chat(model: "gpt-5.5", messages: "Hello!")
22
+ # Llmshim.stream(model: "gpt-5.5", messages: "Hi") { |ev| print ev.text if ev.content? }
23
+ #
24
+ module Llmshim
25
+ class << self
26
+ # Base URL used by the module-level convenience methods.
27
+ # Defaults to the LLMSHIM_BASE_URL env var, then http://localhost:3000.
28
+ attr_writer :base_url
29
+
30
+ def base_url
31
+ @base_url ||= ENV.fetch("LLMSHIM_BASE_URL", Client::DEFAULT_BASE_URL)
32
+ end
33
+
34
+ # The shared default Client used by the convenience methods.
35
+ def default_client
36
+ @default_client ||= Client.new(base_url: base_url)
37
+ end
38
+
39
+ # Replace the shared default client (e.g. to pass custom headers/timeout).
40
+ attr_writer :default_client
41
+
42
+ # @see Client#chat
43
+ def chat(**kwargs, &block)
44
+ default_client.chat(**kwargs, &block)
45
+ end
46
+
47
+ # @see Client#stream
48
+ def stream(**kwargs, &block)
49
+ default_client.stream(**kwargs, &block)
50
+ end
51
+
52
+ # @see Client#models
53
+ def models
54
+ default_client.models
55
+ end
56
+
57
+ # @see Client#health
58
+ def health
59
+ default_client.health
60
+ end
61
+ end
62
+ end
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: llmshim
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.24
5
+ platform: ruby
6
+ authors:
7
+ - Sanjay Nadhavajhala
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: minitest
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '5.0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '5.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '13.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '13.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: webrick
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.8'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.8'
55
+ description: |
56
+ A thin, dependency-free HTTP client for a running llmshim proxy. Send
57
+ OpenAI-style chat requests and let llmshim translate to OpenAI, Anthropic,
58
+ Google Gemini, or xAI. Supports non-streaming and SSE streaming, model
59
+ listing, and health checks. Standard library only.
60
+ email:
61
+ - sanjay@f2.ai
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - README.md
67
+ - lib/llmshim.rb
68
+ - lib/llmshim/client.rb
69
+ - lib/llmshim/errors.rb
70
+ - lib/llmshim/types.rb
71
+ - lib/llmshim/version.rb
72
+ homepage: https://github.com/sanjay920/llmshim
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://github.com/sanjay920/llmshim
77
+ source_code_uri: https://github.com/sanjay920/llmshim
78
+ changelog_uri: https://github.com/sanjay920/llmshim/releases
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 2.6.0
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.5.22
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Ruby client for the llmshim multi-provider LLM proxy.
98
+ test_files: []