ask-llm-providers 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,8 @@ module Ask
5
5
  # Cloudflare Workers AI provider. Supports both direct Workers AI and AI Gateway.
6
6
  class Cloudflare < Ask::Provider
7
7
  include Ask::LLM::SSEBuffer
8
+ include Ask::LLM::ProviderConfig
9
+
8
10
  def initialize(config = {})
9
11
  config = normalize_config(config)
10
12
  super(config)
@@ -25,15 +27,8 @@ module Ask
25
27
 
26
28
  def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
27
29
  msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
30
+ payload = build_request(msgs, model:, tools:, temperature:, stream:, schema:, **params)
28
31
  endpoint = @config.gateway_id ? "chat/completions" : "run/#{model}"
29
- payload = if @config.gateway_id
30
- { model: model, messages: msgs.map { |m| { role: (m[:role] || m["role"]).to_s, content: m[:content] || m["content"] } }, stream: stream || false }
31
- else
32
- { messages: msgs.map { |m| { role: (m[:role] || m["role"]).to_s, content: m[:content] || m["content"] } } }
33
- end
34
- payload[:temperature] = temperature if temperature
35
- payload.merge(params)
36
-
37
32
  if stream && @config.gateway_id
38
33
  chat_stream_gateway(endpoint, payload, model, &block)
39
34
  else
@@ -42,7 +37,6 @@ module Ask
42
37
  end
43
38
 
44
39
  def list_models
45
- # Workers AI lists models differently — rely on model catalog
46
40
  []
47
41
  end
48
42
 
@@ -52,18 +46,62 @@ module Ask
52
46
  end
53
47
 
54
48
  class << self
49
+ def slug; "cloudflare"; end
50
+
55
51
  def capabilities
56
52
  { chat: true, streaming: true, vision: true }
57
53
  end
54
+
58
55
  def configuration_options; %i[api_key account_id gateway_id]; end
59
56
  def configuration_requirements; %i[api_key account_id]; end
60
- def slug; "cloudflare"; end
57
+ end
58
+
59
+ # --- Config transformation contract ---
60
+
61
+ def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
62
+ if @config.gateway_id
63
+ payload = {
64
+ model:,
65
+ messages: messages.map { |m| format_message(m) },
66
+ stream: stream || false
67
+ }
68
+ else
69
+ payload = {
70
+ messages: messages.map { |m| format_message(m) }
71
+ }
72
+ end
73
+ payload[:temperature] = temperature if temperature
74
+ payload.merge(params)
75
+ end
76
+
77
+ def parse_response(body, model)
78
+ if @config.gateway_id
79
+ parse_openai_response(body, model)
80
+ else
81
+ result = body["result"] || {}
82
+ Ask::Message.new(role: :assistant, content: result["response"], metadata: { model:, raw: body })
83
+ end
84
+ end
85
+
86
+ def parse_stream(raw, stream, model, &block)
87
+ each_sse_event(raw) do |data|
88
+ parsed = JSON.parse(data) rescue next
89
+ delta = parsed.dig("choices", 0, "delta") || {}
90
+ chunk = Ask::Chunk.new(content: delta["content"])
91
+ stream.add(chunk)
92
+ yield chunk if block_given?
93
+ end
94
+ end
95
+
96
+ def format_message(msg)
97
+ { role: (msg[:role] || msg["role"]).to_s, content: msg[:content] || msg["content"] }
61
98
  end
62
99
 
63
100
  private
64
101
 
65
102
  def normalize_config(config)
66
103
  return config unless config.is_a?(Hash)
104
+
67
105
  Ask::LLM::Config.new(
68
106
  api_key: config[:api_key] || config["api_key"] || config[:cloudflare_api_key],
69
107
  account_id: config[:account_id] || config["account_id"] || config[:cf_account_id],
@@ -72,27 +110,22 @@ module Ask
72
110
  end
73
111
 
74
112
  def build_http
75
- LLM::HTTP.connection(api_base, headers: headers, request: { open_timeout: 30, timeout: 120 })
76
- end
77
-
78
- def chat_nonstream(endpoint, payload, model)
79
- response = @http.post(endpoint) { |r| r.body = payload }
80
- raise LLM::HTTP.map_error(response.status, response.body, provider: "Cloudflare") unless response.success?
81
-
82
- body = response.body
83
- if @config.gateway_id
84
- parse_openai_response(body, model)
85
- else
86
- result = body["result"] || {}
87
- Ask::Message.new(role: :assistant, content: result["response"], metadata: { model: model, raw: body })
88
- end
113
+ LLM::HTTP.connection(api_base, headers:, request: { open_timeout: 30, timeout: 120 })
89
114
  end
90
115
 
91
116
  def parse_openai_response(body, model)
92
117
  choice = body.dig("choices", 0)
93
118
  return Ask::Message.new(role: :assistant, content: nil) unless choice
119
+
94
120
  msg = choice["message"]
95
- Ask::Message.new(role: :assistant, content: msg["content"], metadata: { model: model, finish_reason: choice["finish_reason"], raw: body })
121
+ Ask::Message.new(role: :assistant, content: msg["content"], metadata: { model:, finish_reason: choice["finish_reason"], raw: body })
122
+ end
123
+
124
+ def chat_nonstream(endpoint, payload, model)
125
+ response = @http.post(endpoint) { |r| r.body = payload }
126
+ raise LLM::HTTP.map_error(response.status, response.body, provider: "Cloudflare") unless response.success?
127
+
128
+ parse_response(response.body, model)
96
129
  end
97
130
 
98
131
  def chat_stream_gateway(endpoint, payload, model, &block)
@@ -100,22 +133,13 @@ module Ask
100
133
  init_sse_buffer
101
134
  response = @http.post(endpoint) do |req|
102
135
  req.body = payload.merge(stream: true)
103
- req.options.on_data = proc { |data, _bytes, _env| process_stream_chunk(data, stream, model, &block) }
136
+ req.options.on_data = proc { |data, _bytes, _env| parse_stream(data, stream, model, &block) }
104
137
  end
105
138
  raise LLM::HTTP.map_error(response.status, JSON.parse(response.body), provider: "Cloudflare") unless response.success?
139
+
106
140
  stream.finish!
107
141
  stream
108
142
  end
109
-
110
- def process_stream_chunk(raw, stream, model)
111
- each_sse_event(raw) do |data|
112
- parsed = JSON.parse(data) rescue next
113
- delta = parsed.dig("choices", 0, "delta") || {}
114
- chunk = Ask::Chunk.new(content: delta["content"])
115
- stream.add(chunk)
116
- yield chunk if block_given?
117
- end
118
- end
119
143
  end
120
144
  end
121
145
  end
@@ -5,6 +5,8 @@ module Ask
5
5
  # Google Gemini API provider. Also supports Vertex AI via GCP service account auth.
6
6
  class Google < Ask::Provider
7
7
  include Ask::LLM::SSEBuffer
8
+ include Ask::LLM::ProviderConfig
9
+
8
10
  def initialize(config = {})
9
11
  config = normalize_config(config)
10
12
  super(config)
@@ -30,7 +32,7 @@ module Ask
30
32
 
31
33
  def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
32
34
  msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
33
- payload = build_chat_payload(msgs, model, tools, temperature, stream, schema, **params)
35
+ payload = build_request(msgs, model:, tools:, temperature:, stream:, schema:, **params)
34
36
  path = chat_path(model)
35
37
  if stream
36
38
  chat_stream(path, payload, model, &block)
@@ -41,8 +43,11 @@ module Ask
41
43
 
42
44
  def embed(texts, model:)
43
45
  texts = Array(texts)
44
- response = @http.post("models/#{model}:batchEmbedContents") { |r| r.body = { requests: texts.map { |t| { model: "models/#{model}", content: { parts: [{ text: t }] } } } } }
46
+ response = @http.post("models/#{model}:batchEmbedContents") { |r|
47
+ r.body = { requests: texts.map { |t| { model: "models/#{model}", content: { parts: [{ text: t }] } } } }
48
+ }
45
49
  raise LLM::HTTP.map_error(response.status, response.body, provider: "Google") unless response.success?
50
+
46
51
  embeddings = response.body.dig("embeddings") || []
47
52
  Ask::Result.success(embeddings.map { |e| e["values"] })
48
53
  end
@@ -50,7 +55,10 @@ module Ask
50
55
  def list_models
51
56
  response = @http.get("models") { |r| r.params["key"] = @config.api_key if @config.api_key }
52
57
  return [] unless response.success?
53
- (response.body["models"] || []).map { |m| Ask::ModelInfo.new(id: m["name"].sub("models/", ""), provider: slug) }
58
+
59
+ (response.body["models"] || []).map { |m|
60
+ Ask::ModelInfo.new(id: m["name"].sub("models/", ""), provider: slug)
61
+ }
54
62
  end
55
63
 
56
64
  def parse_error(response)
@@ -59,40 +67,23 @@ module Ask
59
67
  end
60
68
 
61
69
  class << self
70
+ def slug; "gemini"; end
71
+
62
72
  def capabilities
63
- { chat: true, streaming: true, tool_calls: true, vision: true, structured_output: true, embed: true, file_upload: true }
73
+ {
74
+ chat: true, streaming: true, tool_calls: true, vision: true,
75
+ structured_output: true, embed: true, file_upload: true
76
+ }
64
77
  end
78
+
65
79
  def configuration_options; %i[api_key access_token vertex_token project_id api_base]; end
66
80
  def configuration_requirements; %i[api_key]; end
67
- def slug; "gemini"; end
68
- end
69
-
70
- private
71
-
72
- def normalize_config(config)
73
- return config unless config.is_a?(Hash)
74
- key = config[:api_key] || config["api_key"] || config[:gemini_api_key]
75
- Ask::LLM::Config.new(
76
- api_key: key,
77
- access_token: config[:access_token] || config["access_token"],
78
- vertex_token: config[:vertex_token] || config["vertex_token"],
79
- project_id: config[:project_id] || config["project_id"],
80
- api_base: config[:api_base] || config["api_base"]
81
- )
82
- end
83
-
84
- def build_http
85
- LLM::HTTP.connection(api_base, headers: headers, request: { open_timeout: 30, timeout: 120 })
86
81
  end
87
82
 
88
- def chat_path(model)
89
- model_id = model.respond_to?(:id) ? model.id : model.to_s
90
- "models/#{model_id}:generateContent"
91
- end
83
+ # --- Config transformation contract ---
92
84
 
93
- def build_chat_payload(messages, model, tools, temperature, stream, schema, **params)
94
- contents = format_contents(messages)
95
- payload = { contents: contents, systemInstruction: format_system(messages) }
85
+ def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
86
+ payload = { contents: format_contents(messages), systemInstruction: format_system(messages) }
96
87
 
97
88
  if tools&.any?
98
89
  payload[:tools] = [{ functionDeclarations: tools.map { |t| format_tool(t) } }]
@@ -107,19 +98,46 @@ module Ask
107
98
  payload.merge(params)
108
99
  end
109
100
 
110
- def format_contents(messages)
111
- messages.reject { |m| (m[:role] || m["role"]).to_s == "system" }.map { |m| format_content(m) }
101
+ def parse_response(body, model)
102
+ candidate = body.dig("candidates", 0)
103
+ return Ask::Message.new(role: :assistant, content: nil) unless candidate
104
+
105
+ content = candidate.dig("content", "parts")&.map { |p| p["text"] }&.compact&.join
106
+ fc = candidate.dig("content", "parts")&.select { |p| p["functionCall"] } || []
107
+ tool_calls = fc.map do |p|
108
+ f = p["functionCall"]
109
+ { id: SecureRandom.hex(8), type: "function", name: f["name"], arguments: JSON.generate(f["args"] || {}) }
110
+ end
111
+
112
+ usage = body["usageMetadata"] || {}
113
+ Ask::Message.new(
114
+ role: :assistant,
115
+ content:,
116
+ tool_calls: tool_calls.empty? ? nil : tool_calls,
117
+ metadata: {
118
+ model:,
119
+ finish_reason: candidate["finishReason"],
120
+ input_tokens: usage["promptTokenCount"],
121
+ output_tokens: usage["candidatesTokenCount"],
122
+ raw: body
123
+ }
124
+ )
112
125
  end
113
126
 
114
- def format_system(messages)
115
- sys = messages.select { |m| (m[:role] || m["role"]).to_s == "system" }
116
- return nil if sys.empty?
117
- texts = sys.map { |m| m[:content] || m["content"] }.compact
118
- return nil if texts.empty?
119
- { parts: texts.map { |t| { text: t } } }
127
+ def parse_stream(raw, stream, model, &block)
128
+ each_sse_event(raw) do |data|
129
+ parsed = JSON.parse(data) rescue next
130
+ candidate = parsed.dig("candidates", 0) or next
131
+ part = candidate.dig("content", "parts", 0)
132
+ next unless part
133
+
134
+ chunk = Ask::Chunk.new(content: part["text"])
135
+ stream.add(chunk)
136
+ yield chunk if block_given?
137
+ end
120
138
  end
121
139
 
122
- def format_content(msg)
140
+ def format_message(msg)
123
141
  role = (msg[:role] || msg["role"]).to_s
124
142
  content = msg[:content] || msg["content"]
125
143
  google_role = role == "assistant" ? "model" : role
@@ -127,7 +145,6 @@ module Ask
127
145
  parts = []
128
146
  parts << { text: content } if content
129
147
 
130
- # Handle tool calls
131
148
  if msg[:tool_calls] || msg["tool_calls"]
132
149
  (msg[:tool_calls] || msg["tool_calls"]).each do |tc|
133
150
  parts << {
@@ -139,7 +156,6 @@ module Ask
139
156
  end
140
157
  end
141
158
 
142
- # Handle tool results
143
159
  if msg[:tool_call_id] || msg["tool_call_id"]
144
160
  parts << {
145
161
  functionResponse: {
@@ -149,11 +165,59 @@ module Ask
149
165
  }
150
166
  end
151
167
 
152
- { role: google_role, parts: parts }
168
+ { role: google_role, parts: }
169
+ end
170
+
171
+ def format_tools(tools)
172
+ tools.map { |t|
173
+ {
174
+ name: t.respond_to?(:name) ? t.name : t[:name],
175
+ description: t.respond_to?(:description) ? t.description : t[:description],
176
+ parameters: t.respond_to?(:parameters) ? t.parameters : (t[:parameters] || {})
177
+ }
178
+ }
179
+ end
180
+
181
+ private
182
+
183
+ def normalize_config(config)
184
+ return config unless config.is_a?(Hash)
185
+
186
+ key = config[:api_key] || config["api_key"] || config[:gemini_api_key]
187
+ Ask::LLM::Config.new(
188
+ api_key: key,
189
+ access_token: config[:access_token] || config["access_token"],
190
+ vertex_token: config[:vertex_token] || config["vertex_token"],
191
+ project_id: config[:project_id] || config["project_id"],
192
+ api_base: config[:api_base] || config["api_base"]
193
+ )
194
+ end
195
+
196
+ def build_http
197
+ LLM::HTTP.connection(api_base, headers:, request: { open_timeout: 30, timeout: 120 })
198
+ end
199
+
200
+ def chat_path(model)
201
+ model_id = model.respond_to?(:id) ? model.id : model.to_s
202
+ "models/#{model_id}:generateContent"
203
+ end
204
+
205
+ def format_contents(messages)
206
+ messages.reject { |m| (m[:role] || m["role"]).to_s == "system" }.map { |m| format_message(m) }
207
+ end
208
+
209
+ def format_system(messages)
210
+ sys = messages.select { |m| (m[:role] || m["role"]).to_s == "system" }
211
+ return nil if sys.empty?
212
+
213
+ texts = sys.map { |m| m[:content] || m["content"] }.compact
214
+ return nil if texts.empty?
215
+
216
+ { parts: texts.map { |t| { text: t } } }
153
217
  end
154
218
 
155
219
  def format_tool(t)
156
- { 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] || {}) }
220
+ format_tools([t]).first
157
221
  end
158
222
 
159
223
  def parse_json(str)
@@ -168,22 +232,8 @@ module Ask
168
232
  req.params["key"] = @config.api_key if @config.api_key
169
233
  end
170
234
  raise LLM::HTTP.map_error(response.status, response.body, provider: "Google") unless response.success?
171
- parse_response(response.body, model)
172
- end
173
-
174
- def parse_response(body, model)
175
- candidate = body.dig("candidates", 0)
176
- return Ask::Message.new(role: :assistant, content: nil) unless candidate
177
235
 
178
- content = candidate.dig("content", "parts")&.map { |p| p["text"] }&.compact&.join
179
- fc = candidate.dig("content", "parts")&.select { |p| p["functionCall"] } || []
180
- tool_calls = fc.map do |p|
181
- f = p["functionCall"]
182
- { id: SecureRandom.hex(8), type: "function", name: f["name"], arguments: JSON.generate(f["args"] || {}) }
183
- end
184
-
185
- usage = body["usageMetadata"] || {}
186
- Ask::Message.new(role: :assistant, content: content, tool_calls: tool_calls.empty? ? nil : tool_calls, metadata: { model: model, finish_reason: candidate["finishReason"], input_tokens: usage["promptTokenCount"], output_tokens: usage["candidatesTokenCount"], raw: body })
236
+ parse_response(response.body, model)
187
237
  end
188
238
 
189
239
  def chat_stream(path, payload, model, &block)
@@ -192,24 +242,13 @@ module Ask
192
242
  response = @http.post(path) do |req|
193
243
  req.body = payload
194
244
  req.params["key"] = @config.api_key if @config.api_key
195
- req.options.on_data = proc { |data, _bytes, _env| process_google_chunk(data, stream, model, &block) }
245
+ req.options.on_data = proc { |data, _bytes, _env| parse_stream(data, stream, model, &block) }
196
246
  end
197
247
  raise LLM::HTTP.map_error(response.status, JSON.parse(response.body), provider: "Google") unless response.success?
248
+
198
249
  stream.finish!
199
250
  stream
200
251
  end
201
-
202
- def process_google_chunk(raw, stream, model)
203
- each_sse_event(raw) do |data|
204
- parsed = JSON.parse(data) rescue next
205
- candidate = parsed.dig("candidates", 0) or next
206
- part = candidate.dig("content", "parts", 0)
207
- next unless part
208
- chunk = Ask::Chunk.new(content: part["text"])
209
- stream.add(chunk)
210
- yield chunk if block_given?
211
- end
212
- end
213
252
  end
214
253
  end
215
254
  end
@@ -4,6 +4,8 @@ module Ask
4
4
  module Providers
5
5
  # Mistral AI provider. Uses OpenAI-compatible wire format.
6
6
  class Mistral < Ask::Provider
7
+ include Ask::LLM::ProviderConfig
8
+
7
9
  def initialize(config = {})
8
10
  config = normalize_config(config)
9
11
  super(config)
@@ -19,15 +21,20 @@ module Ask
19
21
  end
20
22
 
21
23
  def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
22
- # Reuse OpenAI provider's logic since Mistral is OpenAI-compatible
23
- openai = Providers::OpenAI.new(api_key: @config.api_key, base_url: api_base)
24
- openai.chat(messages, model: model, tools: tools, temperature: temperature, stream: stream, schema: schema, **params, &block)
24
+ msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
25
+ payload = build_request(msgs, model:, tools:, temperature:, stream:, schema:, **params)
26
+ if stream
27
+ chat_stream(payload, model, &block)
28
+ else
29
+ chat_nonstream(payload, model)
30
+ end
25
31
  end
26
32
 
27
33
  def embed(texts, model:)
28
34
  texts = Array(texts)
29
- response = @http.post("embeddings") { |r| r.body = { model: model, input: texts } }
35
+ response = @http.post("embeddings") { |r| r.body = { model:, input: texts } }
30
36
  raise LLM::HTTP.map_error(response.status, response.body, provider: "Mistral") unless response.success?
37
+
31
38
  embeddings = response.body["data"].map { |d| d["embedding"] }
32
39
  Ask::Result.success(embeddings.one? ? embeddings.first : embeddings)
33
40
  end
@@ -35,6 +42,7 @@ module Ask
35
42
  def list_models
36
43
  response = @http.get("models")
37
44
  return [] unless response.success?
45
+
38
46
  response.body["data"].map { |m| Ask::ModelInfo.new(id: m["id"], provider: slug) }
39
47
  end
40
48
 
@@ -44,18 +52,94 @@ module Ask
44
52
  end
45
53
 
46
54
  class << self
55
+ def slug; "mistral"; end
56
+
47
57
  def capabilities
48
58
  { chat: true, streaming: true, tool_calls: true, structured_output: true, embed: true }
49
59
  end
60
+
50
61
  def configuration_options; %i[api_key api_base]; end
51
62
  def configuration_requirements; %i[api_key]; end
52
- def slug; "mistral"; end
63
+ end
64
+
65
+ # --- Config transformation contract ---
66
+
67
+ def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
68
+ payload = {
69
+ model:,
70
+ messages: messages.map { |m| format_message(m) },
71
+ stream: stream || false
72
+ }
73
+ payload[:temperature] = temperature if temperature
74
+ tool_defs = format_tools(tools) if tools&.any?
75
+ payload[:tools] = tool_defs if tool_defs
76
+ if schema
77
+ payload[:response_format] = {
78
+ type: "json_schema",
79
+ json_schema: { name: "response", schema:, strict: true }
80
+ }
81
+ end
82
+ payload.merge(params)
83
+ end
84
+
85
+ def parse_response(body, model)
86
+ choice = body.dig("choices", 0)
87
+ return Ask::Message.new(role: :assistant, content: nil) unless choice
88
+
89
+ msg = choice["message"]
90
+ usage = body["usage"] || {}
91
+ Ask::Message.new(
92
+ role: :assistant,
93
+ content: msg["content"],
94
+ tool_calls: parse_tool_calls(msg["tool_calls"]),
95
+ metadata: {
96
+ model: body["model"] || model,
97
+ finish_reason: choice["finish_reason"],
98
+ input_tokens: usage["prompt_tokens"],
99
+ output_tokens: usage["completion_tokens"],
100
+ raw: body
101
+ }
102
+ )
103
+ end
104
+
105
+ def parse_stream(raw, stream, model, &block)
106
+ each_sse_event(raw) do |data|
107
+ parsed = JSON.parse(data) rescue next
108
+ choice = parsed.dig("choices", 0) or next
109
+ delta = choice["delta"] || {}
110
+ chunk = Ask::Chunk.new(
111
+ content: delta["content"],
112
+ tool_calls: parse_stream_tool_calls(delta["tool_calls"]),
113
+ finish_reason: choice["finish_reason"],
114
+ usage: parsed["usage"]
115
+ )
116
+ stream.add(chunk)
117
+ yield chunk if block_given?
118
+ end
119
+ end
120
+
121
+ def format_message(msg)
122
+ { role: (msg[:role] || msg["role"]).to_s, content: msg[:content] || msg["content"] }
123
+ end
124
+
125
+ def format_tools(tools)
126
+ tools.map do |t|
127
+ {
128
+ type: "function",
129
+ function: {
130
+ name: t.respond_to?(:name) ? t.name : t[:name],
131
+ description: t.respond_to?(:description) ? t.description : t[:description],
132
+ parameters: t.respond_to?(:parameters) ? t.parameters : t[:parameters]
133
+ }
134
+ }
135
+ end
53
136
  end
54
137
 
55
138
  private
56
139
 
57
140
  def normalize_config(config)
58
141
  return config unless config.is_a?(Hash)
142
+
59
143
  Ask::LLM::Config.new(
60
144
  api_key: config[:api_key] || config["api_key"] || config[:mistral_api_key],
61
145
  api_base: config[:api_base] || config["api_base"]
@@ -63,7 +147,73 @@ module Ask
63
147
  end
64
148
 
65
149
  def build_http
66
- LLM::HTTP.connection(api_base, headers: headers, request: { open_timeout: 30, timeout: 120 })
150
+ LLM::HTTP.connection(api_base, headers:, request: { open_timeout: 30, timeout: 120 })
151
+ end
152
+
153
+ def parse_tool_calls(calls)
154
+ return nil unless calls&.any?
155
+
156
+ calls.map { |tc|
157
+ { id: tc["id"], type: "function", name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments") }
158
+ }
159
+ end
160
+
161
+ def parse_stream_tool_calls(calls)
162
+ return nil unless calls&.any?
163
+
164
+ calls.map { |tc|
165
+ { id: tc["id"], name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments"), index: tc["index"] }
166
+ }
167
+ end
168
+
169
+ def each_sse_event(raw)
170
+ @_sse_buffer ||= +""
171
+ @_sse_buffer << raw
172
+
173
+ while (event_end = @_sse_buffer.index("\n\n"))
174
+ event_data = @_sse_buffer.slice!(0, event_end + 2).strip
175
+ next if event_data.empty?
176
+
177
+ data_content = extract_data(event_data)
178
+ next if data_content.empty?
179
+ break if data_content == "[DONE]"
180
+
181
+ yield data_content
182
+ end
183
+ end
184
+
185
+ def extract_data(event_data)
186
+ content = +""
187
+ event_data.each_line do |line|
188
+ line = line.strip
189
+ next if line.empty? || line.start_with?(":")
190
+ if line.start_with?("data: ")
191
+ content << line[6..]
192
+ elsif line.start_with?("data:")
193
+ content << line[5..]
194
+ end
195
+ end
196
+ content
197
+ end
198
+
199
+ def chat_nonstream(payload, model)
200
+ response = @http.post("chat/completions") { |r| r.body = payload }
201
+ raise LLM::HTTP.map_error(response.status, response.body, provider: "Mistral") unless response.success?
202
+
203
+ parse_response(response.body, model)
204
+ end
205
+
206
+ def chat_stream(payload, model, &block)
207
+ stream = Ask::Stream.new
208
+ @_sse_buffer = +""
209
+ response = @http.post("chat/completions") do |req|
210
+ req.body = payload.merge(stream: true)
211
+ req.options.on_data = proc { |data, _bytes, _env| parse_stream(data, stream, model, &block) }
212
+ end
213
+ raise LLM::HTTP.map_error(response.status, response.body, provider: "Mistral") unless response.success?
214
+
215
+ stream.finish!
216
+ stream
67
217
  end
68
218
  end
69
219
  end