ask-llm-providers 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8ca86d4692c3bdd09134c7f3b356c62ee924e55751dfa9e5a443aceb05828c59
4
- data.tar.gz: c6267cbaa6513caf8159d2fec37906ce6eae362e4067f3524c5db1f4888d41b3
3
+ metadata.gz: 93b16f8655292838315d314b3a57dd94368182372a421f88b3911d6a9b171488
4
+ data.tar.gz: 59fe887341f72aa42c765da7bda10e93454aa8b8cc25bfd1ebb85e316d401a17
5
5
  SHA512:
6
- metadata.gz: dd36e3ba8058b2bb33d8076c2feb00a09ee8a7ffd8e41a0a28888394711c775fdecf3a261f1bfe3eebce72358e6a255e9134e030c07bedd3edd3d39dfc76cf02
7
- data.tar.gz: 77c177f586e71e71c2abf11c328e0649a5182f53c50be5fa880c66654de5a267082d8bc75d5809ea0292d41c7ee00dc2f08a6a1eefb03db15e1cb100debcf01d
6
+ metadata.gz: 8627f783b0e7848373fd9358c13e48db978fe728b043c7c5e9f23fd4425c631203c19eae980d870e1f683cdd16d803199cf7b8cba7b8bb0b5f4bfe7b09ffc1ad
7
+ data.tar.gz: 13cbee9ed69bca82917cbfd060e6f8f8a5130c717b975b886f3851203dada60da1ac081200c2613efa53b7625e744baedc639e06e2ee60bc990faa91c14bccfa
data/lib/ask/llm/http.rb CHANGED
@@ -15,10 +15,12 @@ module Ask
15
15
 
16
16
  # Map an HTTP exception or error response to the appropriate Ask::Error.
17
17
  def self.map_error(status, body, provider:)
18
+ body = JSON.parse(body) rescue body if body.is_a?(String)
18
19
  message = extract_error_message(body, status) || "HTTP #{status} from #{provider}"
19
20
 
20
21
  # Check for context length exceeded regardless of status code
21
- if body&.dig("error", "code") == "context_length_exceeded"
22
+ err_code = body.respond_to?(:dig) ? body.dig("error", "code") : nil
23
+ if err_code == "context_length_exceeded"
22
24
  return Ask::ContextLengthExceeded.new("#{provider}: #{message}")
23
25
  end
24
26
 
@@ -36,11 +38,15 @@ module Ask
36
38
  def self.extract_error_message(body, status)
37
39
  return nil unless body
38
40
 
39
- body.dig("error", "message") ||
40
- body.dig("error", "msg") ||
41
- body.dig("error", "error") ||
42
- body["message"] ||
41
+ if body.respond_to?(:dig)
42
+ body.dig("error", "message") ||
43
+ body.dig("error", "msg") ||
44
+ body.dig("error", "error") ||
45
+ body["message"] ||
46
+ body.to_s
47
+ else
43
48
  body.to_s
49
+ end
44
50
  end
45
51
  end
46
52
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module LLM
5
+ module SSEBuffer
6
+ def init_sse_buffer
7
+ @_sse_buffer = +""
8
+ end
9
+
10
+ def each_sse_event(raw)
11
+ @_sse_buffer ||= +""
12
+ @_sse_buffer << raw
13
+
14
+ while (event_end = @_sse_buffer.index("\n\n"))
15
+ event_data = @_sse_buffer.slice!(0, event_end + 2).strip
16
+ next if event_data.empty?
17
+
18
+ data_content = extract_data(event_data)
19
+ next if data_content.empty?
20
+ break if data_content == "[DONE]"
21
+
22
+ yield data_content
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def extract_data(event_data)
29
+ content = +""
30
+ event_data.each_line do |line|
31
+ line = line.strip
32
+ next if line.empty? || line.start_with?(":")
33
+ if line.start_with?("data: ")
34
+ content << line[6..]
35
+ elsif line.start_with?("data:")
36
+ content << line[5..]
37
+ end
38
+ end
39
+ content
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.1.14"
5
+ VERSION = "0.1.16"
6
6
  end
7
7
  end
@@ -4,6 +4,7 @@ module Ask
4
4
  module Providers
5
5
  # Anthropic Claude API provider.
6
6
  class Anthropic < Ask::Provider
7
+ include Ask::LLM::SSEBuffer
7
8
  def initialize(config = {})
8
9
  config = normalize_config(config)
9
10
  super(config)
@@ -179,6 +180,7 @@ module Ask
179
180
 
180
181
  def chat_stream(payload, model, &block)
181
182
  stream = Ask::Stream.new
183
+ init_sse_buffer
182
184
  response = @http.post("v1/messages") do |req|
183
185
  req.body = payload.merge(stream: true)
184
186
  req.options.on_data = proc { |data, _bytes, _env| process_anthropic_chunk(data, stream, model, &block) }
@@ -189,39 +191,24 @@ module Ask
189
191
  end
190
192
 
191
193
  def process_anthropic_chunk(raw, stream, model)
192
- raw.each_line do |line|
193
- line = line.strip
194
- next if line.empty? || line.start_with?(":")
195
- next unless line.start_with?("event:") || line.start_with?("data:")
196
-
197
- if line.start_with?("data: ")
198
- data = line[6..]
199
- begin
200
- parsed = JSON.parse(data)
201
- rescue JSON::ParserError
202
- next
203
- end
204
-
205
- case parsed["type"]
206
- when "content_block_delta"
207
- delta = parsed.dig("delta")
208
- next unless delta
209
- chunk = Ask::Chunk.new(
210
- content: delta["text"],
211
- finish_reason: delta["type"] == "thinking_delta" ? nil : nil
212
- )
194
+ each_sse_event(raw) do |data|
195
+ parsed = JSON.parse(data) rescue next
196
+
197
+ case parsed["type"]
198
+ when "content_block_delta"
199
+ delta = parsed.dig("delta")
200
+ next unless delta
201
+ chunk = Ask::Chunk.new(content: delta["text"])
202
+ stream.add(chunk)
203
+ yield chunk if block_given?
204
+ when "message_stop"
205
+ usage = parsed["usage"] || parsed["message"]&.dig("usage")
206
+ if usage
207
+ chunk = Ask::Chunk.new(finish_reason: "stop", usage: usage)
213
208
  stream.add(chunk)
214
209
  yield chunk if block_given?
215
- when "message_stop"
216
- usage = parsed["usage"] || parsed["message"]&.dig("usage")
217
- if usage
218
- chunk = Ask::Chunk.new(finish_reason: "stop", usage: usage)
219
- stream.add(chunk)
220
- yield chunk if block_given?
221
- end
222
- when "message_start"
223
- # Message started — no content yet
224
210
  end
211
+ when "message_start"
225
212
  end
226
213
  end
227
214
  end
@@ -4,6 +4,7 @@ module Ask
4
4
  module Providers
5
5
  # Cloudflare Workers AI provider. Supports both direct Workers AI and AI Gateway.
6
6
  class Cloudflare < Ask::Provider
7
+ include Ask::LLM::SSEBuffer
7
8
  def initialize(config = {})
8
9
  config = normalize_config(config)
9
10
  super(config)
@@ -96,6 +97,7 @@ module Ask
96
97
 
97
98
  def chat_stream_gateway(endpoint, payload, model, &block)
98
99
  stream = Ask::Stream.new
100
+ init_sse_buffer
99
101
  response = @http.post(endpoint) do |req|
100
102
  req.body = payload.merge(stream: true)
101
103
  req.options.on_data = proc { |data, _bytes, _env| process_stream_chunk(data, stream, model, &block) }
@@ -106,11 +108,7 @@ module Ask
106
108
  end
107
109
 
108
110
  def process_stream_chunk(raw, stream, model)
109
- raw.each_line do |line|
110
- line = line.strip
111
- next unless line.start_with?("data: ")
112
- data = line[6..]
113
- next if data == "[DONE]"
111
+ each_sse_event(raw) do |data|
114
112
  parsed = JSON.parse(data) rescue next
115
113
  delta = parsed.dig("choices", 0, "delta") || {}
116
114
  chunk = Ask::Chunk.new(content: delta["content"])
@@ -4,6 +4,7 @@ module Ask
4
4
  module Providers
5
5
  # Google Gemini API provider. Also supports Vertex AI via GCP service account auth.
6
6
  class Google < Ask::Provider
7
+ include Ask::LLM::SSEBuffer
7
8
  def initialize(config = {})
8
9
  config = normalize_config(config)
9
10
  super(config)
@@ -187,6 +188,7 @@ module Ask
187
188
 
188
189
  def chat_stream(path, payload, model, &block)
189
190
  stream = Ask::Stream.new
191
+ init_sse_buffer
190
192
  response = @http.post(path) do |req|
191
193
  req.body = payload
192
194
  req.params["key"] = @config.api_key if @config.api_key
@@ -198,10 +200,7 @@ module Ask
198
200
  end
199
201
 
200
202
  def process_google_chunk(raw, stream, model)
201
- raw.each_line do |line|
202
- next unless line.start_with?("data: ")
203
- data = line[6..]
204
- next if data.strip == "[DONE]"
203
+ each_sse_event(raw) do |data|
205
204
  parsed = JSON.parse(data) rescue next
206
205
  candidate = parsed.dig("candidates", 0) or next
207
206
  part = candidate.dig("content", "parts", 0)
@@ -79,6 +79,7 @@ module Ask
79
79
 
80
80
  def chat_stream(payload, model, &block)
81
81
  stream = Ask::Stream.new
82
+ @_sse_buffer = +""
82
83
  response = @http.post("api/chat") do |req|
83
84
  req.body = payload.merge(stream: true)
84
85
  req.options.on_data = proc { |data, _bytes, _env| process_ollama_chunk(data, stream, model, &block) }
@@ -89,7 +90,13 @@ module Ask
89
90
  end
90
91
 
91
92
  def process_ollama_chunk(raw, stream, model)
92
- raw.each_line do |line|
93
+ @_sse_buffer ||= +""
94
+ @_sse_buffer << raw
95
+
96
+ while (line_end = @_sse_buffer.index("\n"))
97
+ line = @_sse_buffer.slice!(0, line_end + 1).strip
98
+ next if line.empty?
99
+
93
100
  parsed = JSON.parse(line) rescue next
94
101
  msg = parsed["message"] || {}
95
102
  chunk = Ask::Chunk.new(content: msg["content"])
@@ -6,6 +6,7 @@ module Ask
6
6
  # (OpenRouter, DeepSeek, Azure, XAI, Perplexity, GPUStack, etc.) via
7
7
  # +base_url+ override.
8
8
  class OpenAI < Ask::Provider
9
+ include Ask::LLM::SSEBuffer
9
10
  def initialize(config = {})
10
11
  @provider_keys = extract_provider_keys(config)
11
12
  config = normalize_config(config)
@@ -148,6 +149,7 @@ module Ask
148
149
 
149
150
  def chat_stream(payload, model, &block)
150
151
  stream = Ask::Stream.new
152
+ init_sse_buffer
151
153
  @http.post("chat/completions") do |req|
152
154
  req.body = payload.merge(stream: true)
153
155
  req.options.on_data = proc { |data, _bytes, _env| process_chunk(data, stream, model, &block) }
@@ -167,12 +169,8 @@ module Ask
167
169
  stream
168
170
  end
169
171
 
170
-
171
172
  def process_chunk(raw, stream, model)
172
- raw.each_line do |line|
173
- line = line.strip
174
- next if line.empty? || line.start_with?(":") || !line.start_with?("data: ")
175
- data = line[6..]; next if data == "[DONE]"
173
+ each_sse_event(raw) do |data|
176
174
  parsed = JSON.parse(data) rescue next
177
175
  choice = parsed.dig("choices", 0) or next
178
176
  delta = choice["delta"] || {}
@@ -10,6 +10,7 @@ require "base64"
10
10
  # Common infrastructure
11
11
  require_relative "ask/llm/config"
12
12
  require_relative "ask/llm/http"
13
+ require_relative "ask/llm/sse_buffer"
13
14
  require_relative "ask/llm/models/openai"
14
15
 
15
16
  # Load providers
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-llm-providers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.14
4
+ version: 0.1.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -178,6 +178,7 @@ files:
178
178
  - lib/ask/llm/config.rb
179
179
  - lib/ask/llm/http.rb
180
180
  - lib/ask/llm/models/openai.rb
181
+ - lib/ask/llm/sse_buffer.rb
181
182
  - lib/ask/llm/version.rb
182
183
  - lib/ask/provider/anthropic.rb
183
184
  - lib/ask/provider/bedrock.rb
@@ -188,7 +189,6 @@ files:
188
189
  - lib/ask/provider/mistral.rb
189
190
  - lib/ask/provider/ollama.rb
190
191
  - lib/ask/provider/openai.rb
191
- - lib/ask/provider/openai.rb.bak
192
192
  - lib/ask/provider/opencode.rb
193
193
  - lib/ask/provider/opencode_go.rb
194
194
  - lib/ask/provider/openrouter.rb
@@ -1,201 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Providers
5
- # OpenAI API provider. Also handles all OpenAI-compatible providers
6
- # (OpenRouter, DeepSeek, Azure, XAI, Perplexity, GPUStack, etc.) via
7
- # +base_url+ override.
8
- class OpenAI < Ask::Provider
9
- def initialize(config = {})
10
- @provider_keys = extract_provider_keys(config)
11
- config = normalize_config(config)
12
- super(config)
13
- @http = build_http
14
- end
15
-
16
- def api_base
17
- @config.base_url || "https://api.openai.com/v1"
18
- end
19
-
20
- def headers
21
- key = @config.api_key || @config.openai_api_key
22
- h = { "Content-Type" => "application/json" }
23
- h["Authorization"] = "Bearer #{key}" if key
24
- h["OpenAI-Organization"] = @config.organization_id if @config.organization_id
25
- h["OpenAI-Project"] = @config.project_id if @config.project_id
26
- h
27
- end
28
-
29
- def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
30
- msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
31
- payload = build_chat_payload(msgs, model, tools, temperature, stream, schema, **params)
32
- stream ? chat_stream(payload, model, &block) : chat_nonstream(payload, model)
33
- end
34
-
35
- def embed(texts, model:)
36
- texts = Array(texts)
37
- response = @http.post("embeddings") { |r| r.body = { model: model, input: texts } }
38
- raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success?
39
- embeddings = response.body["data"].map { |d| d["embedding"] }
40
- Ask::Result.success(embeddings.one? ? embeddings.first : embeddings)
41
- end
42
-
43
- def list_models
44
- response = @http.get("models")
45
- return [] unless response.success?
46
- response.body["data"].map { |m| Ask::ModelInfo.new(id: m["id"], provider: slug, metadata: { owned_by: m["owned_by"] }) }
47
- end
48
-
49
- def parse_error(response)
50
- body = response.body rescue nil
51
- body&.dig("error", "message") || body&.dig("error", "code")
52
- end
53
-
54
- class << self
55
- def slug; "openai"; end
56
- def capabilities
57
- { chat: true, streaming: true, tool_calls: true, vision: true, thinking: true, structured_output: true, embed: true, transcribe: true, paint: true, moderate: true }
58
- end
59
- def configuration_options; %i[api_key base_url organization_id project_id]; end
60
- def configuration_requirements; %i[api_key]; end
61
- def assume_models_exist?; false; end
62
- end
63
-
64
- private
65
-
66
- # Extract and store any provider-specific config keys (e.g., opencode_api_key).
67
- # These are not part of the standard OpenAI config but are used by subclasses.
68
- def extract_provider_keys(config)
69
- return {} unless config.is_a?(Hash)
70
- known = %i[api_key base_url organization_id project_id openai_api_key]
71
- config.reject { |k, _| known.include?(k.to_sym) }
72
- end
73
-
74
- # Resolve provider config from passed options, env vars, and ask-auth chain.
75
- def normalize_config(config)
76
- return config if !config.is_a?(Hash)
77
-
78
- slug = self.class.slug
79
- env_key = ENV["#{slug.upcase}_API_KEY"]
80
- auth_key = Ask::Auth.resolve(:"#{slug}_api_key") rescue nil
81
-
82
- merged = {
83
- api_key: config[:api_key] || config["api_key"] ||
84
- config[:"#{slug}_api_key"] || config[:openai_api_key] ||
85
- env_key || auth_key,
86
- base_url: config[:base_url] || config["base_url"] ||
87
- ENV["#{slug.upcase}_API_BASE"],
88
- organization_id: config[:organization_id] || config["organization_id"],
89
- project_id: config[:project_id] || config["project_id"]
90
- }.merge(config.reject { |k, _| %i[api_key base_url organization_id project_id openai_api_key].include?(k.to_sym) })
91
-
92
- Ask::LLM::Config.new(merged)
93
- end
94
-
95
- def build_http
96
- LLM::HTTP.connection(api_base, headers: headers, request: { open_timeout: 30, timeout: 120 })
97
- end
98
-
99
- def build_chat_payload(messages, model, tools, temperature, stream, schema, **params)
100
- payload = { model: model, messages: format_messages(messages), stream: stream || false }
101
- payload[:temperature] = temperature if temperature
102
- payload[:tools] = format_tools(tools) if tools&.any?
103
- payload[:response_format] = { type: "json_schema", json_schema: { name: "response", schema: schema, strict: true } } if schema
104
- payload.merge(params)
105
- end
106
-
107
- def format_messages(messages)
108
- messages.map do |msg|
109
- role = msg[:role] || msg["role"] || :user
110
- { role: role.to_s, content: msg[:content] || msg["content"] }.tap do |fm|
111
- if (tc = msg[:tool_calls] || msg["tool_calls"])
112
- calls = tc.is_a?(Hash) ? tc.values : tc
113
- fm[:tool_calls] = calls.map { |t|
114
- id = t.respond_to?(:id) ? t.id : (t[:id] || t["id"])
115
- name = t.respond_to?(:name) ? t.name : (t.dig(:function, :name) || t.dig("function", "name") || t[:name])
116
- raw_args = t.respond_to?(:arguments) ? t.arguments : (t.dig(:function, :arguments) || t.dig("function", "arguments") || t[:arguments])
117
- args = raw_args.is_a?(String) ? raw_args : JSON.generate(raw_args)
118
- { id: id, type: "function", function: { name: name, arguments: args } }
119
- }
120
- end
121
- fm[:tool_call_id] = msg[:tool_call_id] || msg["tool_call_id"] if msg[:tool_call_id] || msg["tool_call_id"]
122
- end.compact
123
- end
124
- end
125
-
126
- def format_tools(tools)
127
- tools.map { |t| { type: "function", function: { name: t.respond_to?(:name) ? t.name : t[:name], description: t.respond_to?(:description) ? t.description : t[:description], parameters: t.respond_to?(:parameters) ? t.parameters : t[:parameters] } } }
128
- end
129
-
130
- def chat_nonstream(payload, model)
131
- response = @http.post("chat/completions") { |r| r.body = payload }
132
- raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success?
133
- parse_response(response.body, model)
134
- end
135
-
136
- def parse_response(body, model)
137
- choice = body.dig("choices", 0)
138
- return Ask::Message.new(role: :assistant, content: nil) unless choice
139
- msg = choice["message"]
140
- usage = body["usage"] || {}
141
- Ask::Message.new(role: :assistant, content: msg["content"], tool_calls: parse_tool_calls(msg["tool_calls"]), metadata: { model: body["model"] || model, finish_reason: choice["finish_reason"], input_tokens: usage["prompt_tokens"], output_tokens: usage["completion_tokens"], raw: body })
142
- end
143
-
144
- def parse_tool_calls(calls)
145
- return nil unless calls&.any?
146
- calls.map { |tc| { id: tc["id"], type: "function", name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments") } }
147
- end
148
-
149
- def chat_stream(payload, model, &block)
150
- stream = Ask::Stream.new
151
- @http.post("chat/completions") do |req|
152
- req.body = payload.merge(stream: true)
153
- req.options.on_data = proc { |data, _bytes, _env| process_chunk(data, stream, model, &block) }
154
- end.tap { |resp|
155
- unless resp.success?
156
- err_body = case resp.body
157
- when Hash then resp.body
158
- when String then (JSON.parse(resp.body) rescue { "error" => { "message" => "HTTP #{resp.status}: #{resp.body[0..200]}" } })
159
- else { "error" => { "message" => "HTTP #{resp.status}: empty response body" } }
160
- end
161
- err_body["error"] ||= {}
162
- err_body["error"]["_status"] = resp.status
163
- raise LLM::HTTP.map_error(resp.status, err_body, provider: "OpenAI")
164
- end
165
- }
166
- stream.finish!
167
- stream
168
- end
169
-
170
-
171
- def process_chunk(raw, stream, model)
172
- raw.each_line do |line|
173
- line = line.strip
174
- next if line.empty? || line.start_with?(":") || !line.start_with?("data: ")
175
- data = line[6..]; next if data == "[DONE]"
176
- parsed = JSON.parse(data) rescue next
177
- choice = parsed.dig("choices", 0) or next
178
- delta = choice["delta"] || {}
179
- thinking = extract_thinking(parsed, delta)
180
- chunk = Ask::Chunk.new(content: delta["content"], tool_calls: parse_stream_tool_calls(delta["tool_calls"]), finish_reason: choice["finish_reason"], usage: parsed["usage"], thinking: thinking)
181
- stream.add(chunk)
182
- yield chunk if block_given?
183
- end
184
- end
185
-
186
- # Extract thinking/reasoning content from provider response.
187
- # Some providers (Anthropic, DeepSeek) send thinking in a separate field.
188
- def extract_thinking(parsed, delta)
189
- delta["reasoning_content"] || delta["thinking"] ||
190
- parsed.dig("choices", 0, "delta", "reasoning_content") ||
191
- parsed.dig("choices", 0, "delta", "thinking") ||
192
- parsed.dig("choices", 0, "reasoning_content")
193
- end
194
-
195
- def parse_stream_tool_calls(calls)
196
- return nil unless calls&.any?
197
- calls.map { |tc| { id: tc["id"], name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments"), index: tc["index"] } }
198
- end
199
- end
200
- end
201
- end