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
  # Ollama provider for local LLM inference.
6
6
  # Connects to a local Ollama server (default: http://localhost:11434).
7
7
  class Ollama < Ask::Provider
8
+ include Ask::LLM::ProviderConfig
9
+
8
10
  def initialize(config = {})
9
11
  config = normalize_config(config)
10
12
  super(config)
@@ -21,13 +23,7 @@ module Ask
21
23
 
22
24
  def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
23
25
  msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
24
- payload = { model: model, messages: msgs.map { |m| { role: (m[:role] || m["role"]).to_s, content: m[:content] || m["content"] } }, stream: stream || false, options: {} }
25
- payload[:options][:temperature] = temperature if temperature
26
- if tools&.any?
27
- payload[:tools] = 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] || {}) } } }
28
- end
29
- payload.merge(params)
30
-
26
+ payload = build_request(msgs, model:, tools:, temperature:, stream:, schema:, **params)
31
27
  if stream
32
28
  chat_stream(payload, model, &block)
33
29
  else
@@ -37,44 +33,116 @@ module Ask
37
33
 
38
34
  def embed(texts, model:)
39
35
  texts = Array(texts)
40
- response = @http.post("api/embeddings") { |r| r.body = { model: model, prompt: texts.first } }
36
+ response = @http.post("api/embeddings") { |r| r.body = { model:, prompt: texts.first } }
41
37
  raise LLM::HTTP.map_error(response.status, response.body, provider: "Ollama") unless response.success?
38
+
42
39
  Ask::Result.success(response.body["embedding"])
43
40
  end
44
41
 
45
42
  def list_models
46
43
  response = @http.get("api/tags")
47
44
  return [] unless response.success?
45
+
48
46
  response.body["models"].map { |m| Ask::ModelInfo.new(id: m["name"], provider: slug) }
49
47
  end
50
48
 
51
49
  class << self
50
+ def slug; "ollama"; end
51
+
52
52
  def capabilities
53
53
  { chat: true, streaming: true, tool_calls: true, embed: true, local: true }
54
54
  end
55
+
55
56
  def configuration_options; %i[api_base]; end
56
57
  def configuration_requirements; %i[]; end
57
- def slug; "ollama"; end
58
58
  def local?; true; end
59
59
  def assume_models_exist?; true; end
60
60
  end
61
61
 
62
+ # --- Config transformation contract ---
63
+
64
+ def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
65
+ payload = {
66
+ model:,
67
+ messages: messages.map { |m| format_message(m) },
68
+ stream: stream || false,
69
+ options: {}
70
+ }
71
+ payload[:options][:temperature] = temperature if temperature
72
+ tool_defs = format_tools(tools) if tools&.any?
73
+ payload[:tools] = tool_defs if tool_defs
74
+ payload.merge(params)
75
+ end
76
+
77
+ def parse_response(body, model)
78
+ msg = body["message"] || {}
79
+ Ask::Message.new(
80
+ role: :assistant,
81
+ content: msg["content"],
82
+ metadata: {
83
+ model: body["model"] || model,
84
+ done: body["done"],
85
+ total_duration: body["total_duration"],
86
+ raw: body
87
+ }
88
+ )
89
+ end
90
+
91
+ def parse_stream(raw, stream, model, &block)
92
+ @_sse_buffer ||= +""
93
+ @_sse_buffer << raw
94
+
95
+ while (line_end = @_sse_buffer.index("\n"))
96
+ line = @_sse_buffer.slice!(0, line_end + 1).strip
97
+ next if line.empty?
98
+
99
+ parsed = JSON.parse(line) rescue next
100
+ msg = parsed["message"] || {}
101
+ chunk = Ask::Chunk.new(content: msg["content"])
102
+ stream.add(chunk)
103
+ yield chunk if block_given?
104
+ if parsed["done"]
105
+ chunk = Ask::Chunk.new(finish_reason: "stop", usage: { total_duration: parsed["total_duration"] })
106
+ stream.add(chunk)
107
+ yield chunk if block_given?
108
+ end
109
+ end
110
+ end
111
+
112
+ def format_message(msg)
113
+ { role: (msg[:role] || msg["role"]).to_s, content: msg[:content] || msg["content"] }
114
+ end
115
+
116
+ def format_tools(tools)
117
+ tools.map do |t|
118
+ {
119
+ type: "function",
120
+ function: {
121
+ name: t.respond_to?(:name) ? t.name : t[:name],
122
+ description: t.respond_to?(:description) ? t.description : t[:description],
123
+ parameters: t.respond_to?(:parameters) ? t.parameters : (t[:parameters] || {})
124
+ }
125
+ }
126
+ end
127
+ end
128
+
62
129
  private
63
130
 
64
131
  def normalize_config(config)
65
132
  return config unless config.is_a?(Hash)
133
+
66
134
  Ask::LLM::Config.new(api_base: config[:api_base] || config["api_base"])
67
135
  end
68
136
 
69
137
  def build_http
70
- LLM::HTTP.connection(api_base, headers: headers, request: { open_timeout: 5, timeout: 600 })
138
+ LLM::HTTP.connection(api_base, headers:, request: { open_timeout: 5, timeout: 600 })
71
139
  end
72
140
 
73
141
  def chat_nonstream(payload, model)
74
142
  response = @http.post("api/chat") { |r| r.body = payload }
75
143
  raise LLM::HTTP.map_error(response.status, response.body, provider: "Ollama") unless response.success?
76
- msg = response.body["message"] || {}
77
- Ask::Message.new(role: :assistant, content: msg["content"], metadata: { model: response.body["model"] || model, done: response.body["done"], total_duration: response.body["total_duration"], raw: response.body })
144
+
145
+ parse_response(response.body, model)
78
146
  end
79
147
 
80
148
  def chat_stream(payload, model, &block)
@@ -82,33 +150,13 @@ module Ask
82
150
  @_sse_buffer = +""
83
151
  response = @http.post("api/chat") do |req|
84
152
  req.body = payload.merge(stream: true)
85
- req.options.on_data = proc { |data, _bytes, _env| process_ollama_chunk(data, stream, model, &block) }
153
+ req.options.on_data = proc { |data, _bytes, _env| parse_stream(data, stream, model, &block) }
86
154
  end
87
155
  raise LLM::HTTP.map_error(response.status, response.body, provider: "Ollama") unless response.success?
156
+
88
157
  stream.finish!
89
158
  stream
90
159
  end
91
-
92
- def process_ollama_chunk(raw, stream, model)
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
-
100
- parsed = JSON.parse(line) rescue next
101
- msg = parsed["message"] || {}
102
- chunk = Ask::Chunk.new(content: msg["content"])
103
- stream.add(chunk)
104
- yield chunk if block_given?
105
- if parsed["done"]
106
- chunk = Ask::Chunk.new(finish_reason: "stop", usage: { total_duration: parsed["total_duration"] })
107
- stream.add(chunk)
108
- yield chunk if block_given?
109
- end
110
- end
111
- end
112
160
  end
113
161
  end
114
162
  end
@@ -7,6 +7,8 @@ module Ask
7
7
  # +base_url+ override.
8
8
  class OpenAI < Ask::Provider
9
9
  include Ask::LLM::SSEBuffer
10
+ include Ask::LLM::ProviderConfig
11
+
10
12
  def initialize(config = {})
11
13
  @provider_keys = extract_provider_keys(config)
12
14
  config = normalize_config(config)
@@ -29,14 +31,15 @@ module Ask
29
31
 
30
32
  def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
31
33
  msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
32
- payload = build_chat_payload(msgs, model, tools, temperature, stream, schema, **params)
34
+ payload = build_request(msgs, model:, tools:, temperature:, stream:, schema:, **params)
33
35
  stream ? chat_stream(payload, model, &block) : chat_nonstream(payload, model)
34
36
  end
35
37
 
36
38
  def embed(texts, model:)
37
39
  texts = Array(texts)
38
- response = @http.post("embeddings") { |r| r.body = { model: model, input: texts } }
40
+ response = @http.post("embeddings") { |r| r.body = { model:, input: texts } }
39
41
  raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success?
42
+
40
43
  embeddings = response.body["data"].map { |d| d["embedding"] }
41
44
  Ask::Result.success(embeddings.one? ? embeddings.first : embeddings)
42
45
  end
@@ -44,7 +47,10 @@ module Ask
44
47
  def list_models
45
48
  response = @http.get("models")
46
49
  return [] unless response.success?
47
- response.body["data"].map { |m| Ask::ModelInfo.new(id: m["id"], provider: slug, metadata: { owned_by: m["owned_by"] }) }
50
+
51
+ response.body["data"].map { |m|
52
+ Ask::ModelInfo.new(id: m["id"], provider: slug, metadata: { owned_by: m["owned_by"] })
53
+ }
48
54
  end
49
55
 
50
56
  def parse_error(response)
@@ -54,25 +60,112 @@ module Ask
54
60
 
55
61
  class << self
56
62
  def slug; "openai"; end
63
+
57
64
  def capabilities
58
- { chat: true, streaming: true, tool_calls: true, vision: true, thinking: true, structured_output: true, embed: true, transcribe: true, paint: true, moderate: true }
65
+ {
66
+ chat: true, streaming: true, tool_calls: true, vision: true,
67
+ thinking: true, structured_output: true, embed: true,
68
+ transcribe: true, paint: true, moderate: true
69
+ }
59
70
  end
71
+
60
72
  def configuration_options; %i[api_key base_url organization_id project_id]; end
61
73
  def configuration_requirements; %i[api_key]; end
62
74
  def assume_models_exist?; false; end
63
75
  end
64
76
 
77
+ # --- Config transformation contract ---
78
+
79
+ def build_request(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params)
80
+ payload = { model:, messages: format_messages(messages), stream: stream || false }
81
+ payload[:temperature] = temperature if temperature
82
+ payload[:tools] = format_tools(tools) if tools&.any?
83
+ if schema
84
+ payload[:response_format] = {
85
+ type: "json_schema",
86
+ json_schema: { name: "response", schema:, strict: true }
87
+ }
88
+ end
89
+ payload.merge(params)
90
+ end
91
+
92
+ def parse_response(body, model)
93
+ choice = body.dig("choices", 0)
94
+ return Ask::Message.new(role: :assistant, content: nil) unless choice
95
+
96
+ msg = choice["message"]
97
+ usage = body["usage"] || {}
98
+ Ask::Message.new(
99
+ role: :assistant,
100
+ content: msg["content"],
101
+ tool_calls: parse_tool_calls(msg["tool_calls"]),
102
+ metadata: {
103
+ model: body["model"] || model,
104
+ finish_reason: choice["finish_reason"],
105
+ input_tokens: usage["prompt_tokens"],
106
+ output_tokens: usage["completion_tokens"],
107
+ raw: body
108
+ }
109
+ )
110
+ end
111
+
112
+ def parse_stream(raw, stream, model, &block)
113
+ each_sse_event(raw) do |data|
114
+ parsed = JSON.parse(data) rescue next
115
+ choice = parsed.dig("choices", 0) or next
116
+ delta = choice["delta"] || {}
117
+ thinking = extract_thinking(parsed, delta)
118
+ chunk = Ask::Chunk.new(
119
+ content: delta["content"],
120
+ tool_calls: parse_stream_tool_calls(delta["tool_calls"]),
121
+ finish_reason: choice["finish_reason"],
122
+ usage: parsed["usage"],
123
+ thinking:
124
+ )
125
+ stream.add(chunk)
126
+ yield chunk if block_given?
127
+ end
128
+ end
129
+
130
+ def format_tools(tools)
131
+ tools.map do |t|
132
+ {
133
+ type: "function",
134
+ function: {
135
+ name: t.respond_to?(:name) ? t.name : t[:name],
136
+ description: t.respond_to?(:description) ? t.description : t[:description],
137
+ parameters: t.respond_to?(:parameters) ? t.parameters : t[:parameters]
138
+ }
139
+ }
140
+ end
141
+ end
142
+
143
+ def format_message(msg)
144
+ role = msg[:role] || msg["role"] || :user
145
+ { role: role.to_s, content: msg[:content] || msg["content"] }.tap do |fm|
146
+ if (tc = msg[:tool_calls] || msg["tool_calls"])
147
+ calls = tc.is_a?(Hash) ? tc.values : tc
148
+ fm[:tool_calls] = calls.map { |t|
149
+ id = t.respond_to?(:id) ? t.id : (t[:id] || t["id"])
150
+ name = t.respond_to?(:name) ? t.name : (t.dig(:function, :name) || t.dig("function", "name") || t[:name])
151
+ raw_args = t.respond_to?(:arguments) ? t.arguments : (t.dig(:function, :arguments) || t.dig("function", "arguments") || t[:arguments])
152
+ args = raw_args.is_a?(String) ? raw_args : JSON.generate(raw_args)
153
+ { id:, type: "function", function: { name:, arguments: args } }
154
+ }
155
+ end
156
+ fm[:tool_call_id] = msg[:tool_call_id] || msg["tool_call_id"] if msg[:tool_call_id] || msg["tool_call_id"]
157
+ end.compact
158
+ end
159
+
65
160
  private
66
161
 
67
- # Extract and store any provider-specific config keys (e.g., opencode_api_key).
68
- # These are not part of the standard OpenAI config but are used by subclasses.
69
162
  def extract_provider_keys(config)
70
163
  return {} unless config.is_a?(Hash)
164
+
71
165
  known = %i[api_key base_url organization_id project_id openai_api_key]
72
166
  config.reject { |k, _| known.include?(k.to_sym) }
73
167
  end
74
168
 
75
- # Resolve provider config from passed options, env vars, and ask-auth chain.
76
169
  def normalize_config(config)
77
170
  return config if !config.is_a?(Hash)
78
171
 
@@ -93,57 +186,18 @@ module Ask
93
186
  end
94
187
 
95
188
  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)
189
+ LLM::HTTP.connection(api_base, headers:, request: { open_timeout: 30, timeout: 120 })
105
190
  end
106
191
 
107
192
  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] } } }
193
+ messages.map { |msg| format_message(msg) }
128
194
  end
129
195
 
130
196
  def chat_nonstream(payload, model)
131
197
  response = @http.post("chat/completions") { |r| r.body = payload }
132
198
  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
199
 
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") } }
200
+ parse_response(response.body, model)
147
201
  end
148
202
 
149
203
  def chat_stream(payload, model, &block)
@@ -151,14 +205,14 @@ module Ask
151
205
  init_sse_buffer
152
206
  @http.post("chat/completions") do |req|
153
207
  req.body = payload.merge(stream: true)
154
- req.options.on_data = proc { |data, _bytes, _env| process_chunk(data, stream, model, &block) }
208
+ req.options.on_data = proc { |data, _bytes, _env| parse_stream(data, stream, model, &block) }
155
209
  end.tap { |resp|
156
210
  unless resp.success?
157
211
  err_body = case resp.body
158
- when Hash then resp.body
159
- when String then (JSON.parse(resp.body) rescue { "error" => { "message" => "HTTP #{resp.status}: #{resp.body[0..200]}" } })
160
- else { "error" => { "message" => "HTTP #{resp.status}: empty response body" } }
161
- end
212
+ when Hash then resp.body
213
+ when String then (JSON.parse(resp.body) rescue { "error" => { "message" => "HTTP #{resp.status}: #{resp.body[0..200]}" } })
214
+ else { "error" => { "message" => "HTTP #{resp.status}: empty response body" } }
215
+ end
162
216
  err_body["error"] ||= {}
163
217
  err_body["error"]["_status"] = resp.status
164
218
  raise LLM::HTTP.map_error(resp.status, err_body, provider: "OpenAI")
@@ -168,31 +222,28 @@ module Ask
168
222
  stream
169
223
  end
170
224
 
171
- def process_chunk(raw, stream, model)
172
- each_sse_event(raw) do |data|
173
- parsed = JSON.parse(data) rescue next
174
- choice = parsed.dig("choices", 0) or next
175
- delta = choice["delta"] || {}
176
- thinking = extract_thinking(parsed, delta)
177
- 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)
178
- stream.add(chunk)
179
- yield chunk if block_given?
180
- end
225
+ def parse_tool_calls(calls)
226
+ return nil unless calls&.any?
227
+
228
+ calls.map { |tc|
229
+ { id: tc["id"], type: "function", name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments") }
230
+ }
231
+ end
232
+
233
+ def parse_stream_tool_calls(calls)
234
+ return nil unless calls&.any?
235
+
236
+ calls.map { |tc|
237
+ { id: tc["id"], name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments"), index: tc["index"] }
238
+ }
181
239
  end
182
240
 
183
- # Extract thinking/reasoning content from provider response.
184
- # Some providers (Anthropic, DeepSeek) send thinking in a separate field.
185
241
  def extract_thinking(parsed, delta)
186
242
  delta["reasoning_content"] || delta["thinking"] ||
187
243
  parsed.dig("choices", 0, "delta", "reasoning_content") ||
188
244
  parsed.dig("choices", 0, "delta", "thinking") ||
189
245
  parsed.dig("choices", 0, "reasoning_content")
190
246
  end
191
-
192
- def parse_stream_tool_calls(calls)
193
- return nil unless calls&.any?
194
- calls.map { |tc| { id: tc["id"], name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments"), index: tc["index"] } }
195
- end
196
247
  end
197
248
  end
198
249
  end
@@ -14,6 +14,9 @@ require_relative "ask/llm/sse_buffer"
14
14
  require_relative "ask/llm/catalog"
15
15
  require_relative "ask/llm/aliases"
16
16
 
17
+ # Provider transformation contract
18
+ require_relative "ask/llm/provider_config"
19
+
17
20
  # Load providers
18
21
  require_relative "ask/provider/openai"
19
22
  require_relative "ask/provider/anthropic"
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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -194,6 +194,7 @@ files:
194
194
  - lib/ask/llm/models/opencode_go.json
195
195
  - lib/ask/llm/models/openrouter.json
196
196
  - lib/ask/llm/models_schema.json
197
+ - lib/ask/llm/provider_config.rb
197
198
  - lib/ask/llm/sse_buffer.rb
198
199
  - lib/ask/llm/version.rb
199
200
  - lib/ask/provider/anthropic.rb