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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/lib/ask/llm/catalog.rb +1 -0
- data/lib/ask/llm/provider_config.rb +65 -0
- data/lib/ask/llm/version.rb +1 -1
- data/lib/ask/provider/anthropic.rb +104 -88
- data/lib/ask/provider/bedrock.rb +84 -52
- data/lib/ask/provider/cloudflare.rb +60 -36
- data/lib/ask/provider/google.rb +110 -71
- data/lib/ask/provider/mistral.rb +156 -6
- data/lib/ask/provider/ollama.rb +82 -34
- data/lib/ask/provider/openai.rb +122 -71
- data/lib/ask-llm-providers.rb +3 -0
- metadata +2 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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|
|
|
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
|
data/lib/ask/provider/google.rb
CHANGED
|
@@ -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 =
|
|
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|
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
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
|
|
111
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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
|
data/lib/ask/provider/mistral.rb
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|