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 +4 -4
- data/lib/ask/llm/http.rb +11 -5
- data/lib/ask/llm/sse_buffer.rb +43 -0
- data/lib/ask/llm/version.rb +1 -1
- data/lib/ask/provider/anthropic.rb +17 -30
- data/lib/ask/provider/cloudflare.rb +3 -5
- data/lib/ask/provider/google.rb +3 -4
- data/lib/ask/provider/ollama.rb +8 -1
- data/lib/ask/provider/openai.rb +3 -5
- data/lib/ask-llm-providers.rb +1 -0
- metadata +2 -2
- data/lib/ask/provider/openai.rb.bak +0 -201
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 93b16f8655292838315d314b3a57dd94368182372a421f88b3911d6a9b171488
|
|
4
|
+
data.tar.gz: 59fe887341f72aa42c765da7bda10e93454aa8b8cc25bfd1ebb85e316d401a17
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
40
|
-
body.dig("error", "
|
|
41
|
-
|
|
42
|
-
|
|
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
|
data/lib/ask/llm/version.rb
CHANGED
|
@@ -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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
|
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"])
|
data/lib/ask/provider/google.rb
CHANGED
|
@@ -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
|
|
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)
|
data/lib/ask/provider/ollama.rb
CHANGED
|
@@ -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
|
-
|
|
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"])
|
data/lib/ask/provider/openai.rb
CHANGED
|
@@ -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
|
|
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"] || {}
|
data/lib/ask-llm-providers.rb
CHANGED
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.
|
|
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
|