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 +7 -0
- data/README.md +189 -0
- data/lib/llmshim/client.rb +242 -0
- data/lib/llmshim/errors.rb +45 -0
- data/lib/llmshim/types.rb +165 -0
- data/lib/llmshim/version.rb +5 -0
- data/lib/llmshim.rb +62 -0
- metadata +98 -0
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
|
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: []
|