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
data/lib/ask/provider/ollama.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
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|
|
|
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
|
data/lib/ask/provider/openai.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
data/lib/ask-llm-providers.rb
CHANGED
|
@@ -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.
|
|
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
|