ask-llm-providers 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/ask/llm/version.rb +1 -1
- data/lib/ask/provider/deepseek.rb +11 -0
- data/lib/ask/provider/openai.rb.bak +201 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ca86d4692c3bdd09134c7f3b356c62ee924e55751dfa9e5a443aceb05828c59
|
|
4
|
+
data.tar.gz: c6267cbaa6513caf8159d2fec37906ce6eae362e4067f3524c5db1f4888d41b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dd36e3ba8058b2bb33d8076c2feb00a09ee8a7ffd8e41a0a28888394711c775fdecf3a261f1bfe3eebce72358e6a255e9134e030c07bedd3edd3d39dfc76cf02
|
|
7
|
+
data.tar.gz: 77c177f586e71e71c2abf11c328e0649a5182f53c50be5fa880c66654de5a267082d8bc75d5809ea0292d41c7ee00dc2f08a6a1eefb03db15e1cb100debcf01d
|
data/lib/ask/llm/version.rb
CHANGED
|
@@ -21,6 +21,17 @@ module Ask
|
|
|
21
21
|
h
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# DeepSeek requires reasoning_content in every assistant message
|
|
25
|
+
# that includes tool_calls. Override format_messages to inject it.
|
|
26
|
+
def format_messages(messages)
|
|
27
|
+
super.map do |fm|
|
|
28
|
+
if fm[:role] == "assistant" && fm[:tool_calls]
|
|
29
|
+
fm[:reasoning_content] = fm[:reasoning_content] || ""
|
|
30
|
+
end
|
|
31
|
+
fm
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
24
35
|
class << self
|
|
25
36
|
def slug; "deepseek"; end
|
|
26
37
|
def configuration_options; %i[api_key base_url]; end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Providers
|
|
5
|
+
# OpenAI API provider. Also handles all OpenAI-compatible providers
|
|
6
|
+
# (OpenRouter, DeepSeek, Azure, XAI, Perplexity, GPUStack, etc.) via
|
|
7
|
+
# +base_url+ override.
|
|
8
|
+
class OpenAI < Ask::Provider
|
|
9
|
+
def initialize(config = {})
|
|
10
|
+
@provider_keys = extract_provider_keys(config)
|
|
11
|
+
config = normalize_config(config)
|
|
12
|
+
super(config)
|
|
13
|
+
@http = build_http
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def api_base
|
|
17
|
+
@config.base_url || "https://api.openai.com/v1"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def headers
|
|
21
|
+
key = @config.api_key || @config.openai_api_key
|
|
22
|
+
h = { "Content-Type" => "application/json" }
|
|
23
|
+
h["Authorization"] = "Bearer #{key}" if key
|
|
24
|
+
h["OpenAI-Organization"] = @config.organization_id if @config.organization_id
|
|
25
|
+
h["OpenAI-Project"] = @config.project_id if @config.project_id
|
|
26
|
+
h
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
|
|
30
|
+
msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
|
|
31
|
+
payload = build_chat_payload(msgs, model, tools, temperature, stream, schema, **params)
|
|
32
|
+
stream ? chat_stream(payload, model, &block) : chat_nonstream(payload, model)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def embed(texts, model:)
|
|
36
|
+
texts = Array(texts)
|
|
37
|
+
response = @http.post("embeddings") { |r| r.body = { model: model, input: texts } }
|
|
38
|
+
raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success?
|
|
39
|
+
embeddings = response.body["data"].map { |d| d["embedding"] }
|
|
40
|
+
Ask::Result.success(embeddings.one? ? embeddings.first : embeddings)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def list_models
|
|
44
|
+
response = @http.get("models")
|
|
45
|
+
return [] unless response.success?
|
|
46
|
+
response.body["data"].map { |m| Ask::ModelInfo.new(id: m["id"], provider: slug, metadata: { owned_by: m["owned_by"] }) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_error(response)
|
|
50
|
+
body = response.body rescue nil
|
|
51
|
+
body&.dig("error", "message") || body&.dig("error", "code")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class << self
|
|
55
|
+
def slug; "openai"; end
|
|
56
|
+
def capabilities
|
|
57
|
+
{ chat: true, streaming: true, tool_calls: true, vision: true, thinking: true, structured_output: true, embed: true, transcribe: true, paint: true, moderate: true }
|
|
58
|
+
end
|
|
59
|
+
def configuration_options; %i[api_key base_url organization_id project_id]; end
|
|
60
|
+
def configuration_requirements; %i[api_key]; end
|
|
61
|
+
def assume_models_exist?; false; end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Extract and store any provider-specific config keys (e.g., opencode_api_key).
|
|
67
|
+
# These are not part of the standard OpenAI config but are used by subclasses.
|
|
68
|
+
def extract_provider_keys(config)
|
|
69
|
+
return {} unless config.is_a?(Hash)
|
|
70
|
+
known = %i[api_key base_url organization_id project_id openai_api_key]
|
|
71
|
+
config.reject { |k, _| known.include?(k.to_sym) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Resolve provider config from passed options, env vars, and ask-auth chain.
|
|
75
|
+
def normalize_config(config)
|
|
76
|
+
return config if !config.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
slug = self.class.slug
|
|
79
|
+
env_key = ENV["#{slug.upcase}_API_KEY"]
|
|
80
|
+
auth_key = Ask::Auth.resolve(:"#{slug}_api_key") rescue nil
|
|
81
|
+
|
|
82
|
+
merged = {
|
|
83
|
+
api_key: config[:api_key] || config["api_key"] ||
|
|
84
|
+
config[:"#{slug}_api_key"] || config[:openai_api_key] ||
|
|
85
|
+
env_key || auth_key,
|
|
86
|
+
base_url: config[:base_url] || config["base_url"] ||
|
|
87
|
+
ENV["#{slug.upcase}_API_BASE"],
|
|
88
|
+
organization_id: config[:organization_id] || config["organization_id"],
|
|
89
|
+
project_id: config[:project_id] || config["project_id"]
|
|
90
|
+
}.merge(config.reject { |k, _| %i[api_key base_url organization_id project_id openai_api_key].include?(k.to_sym) })
|
|
91
|
+
|
|
92
|
+
Ask::LLM::Config.new(merged)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def build_http
|
|
96
|
+
LLM::HTTP.connection(api_base, headers: headers, request: { open_timeout: 30, timeout: 120 })
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def build_chat_payload(messages, model, tools, temperature, stream, schema, **params)
|
|
100
|
+
payload = { model: model, messages: format_messages(messages), stream: stream || false }
|
|
101
|
+
payload[:temperature] = temperature if temperature
|
|
102
|
+
payload[:tools] = format_tools(tools) if tools&.any?
|
|
103
|
+
payload[:response_format] = { type: "json_schema", json_schema: { name: "response", schema: schema, strict: true } } if schema
|
|
104
|
+
payload.merge(params)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def format_messages(messages)
|
|
108
|
+
messages.map do |msg|
|
|
109
|
+
role = msg[:role] || msg["role"] || :user
|
|
110
|
+
{ role: role.to_s, content: msg[:content] || msg["content"] }.tap do |fm|
|
|
111
|
+
if (tc = msg[:tool_calls] || msg["tool_calls"])
|
|
112
|
+
calls = tc.is_a?(Hash) ? tc.values : tc
|
|
113
|
+
fm[:tool_calls] = calls.map { |t|
|
|
114
|
+
id = t.respond_to?(:id) ? t.id : (t[:id] || t["id"])
|
|
115
|
+
name = t.respond_to?(:name) ? t.name : (t.dig(:function, :name) || t.dig("function", "name") || t[:name])
|
|
116
|
+
raw_args = t.respond_to?(:arguments) ? t.arguments : (t.dig(:function, :arguments) || t.dig("function", "arguments") || t[:arguments])
|
|
117
|
+
args = raw_args.is_a?(String) ? raw_args : JSON.generate(raw_args)
|
|
118
|
+
{ id: id, type: "function", function: { name: name, arguments: args } }
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
fm[:tool_call_id] = msg[:tool_call_id] || msg["tool_call_id"] if msg[:tool_call_id] || msg["tool_call_id"]
|
|
122
|
+
end.compact
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def format_tools(tools)
|
|
127
|
+
tools.map { |t| { type: "function", function: { name: t.respond_to?(:name) ? t.name : t[:name], description: t.respond_to?(:description) ? t.description : t[:description], parameters: t.respond_to?(:parameters) ? t.parameters : t[:parameters] } } }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def chat_nonstream(payload, model)
|
|
131
|
+
response = @http.post("chat/completions") { |r| r.body = payload }
|
|
132
|
+
raise LLM::HTTP.map_error(response.status, response.body, provider: "OpenAI") unless response.success?
|
|
133
|
+
parse_response(response.body, model)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_response(body, model)
|
|
137
|
+
choice = body.dig("choices", 0)
|
|
138
|
+
return Ask::Message.new(role: :assistant, content: nil) unless choice
|
|
139
|
+
msg = choice["message"]
|
|
140
|
+
usage = body["usage"] || {}
|
|
141
|
+
Ask::Message.new(role: :assistant, content: msg["content"], tool_calls: parse_tool_calls(msg["tool_calls"]), metadata: { model: body["model"] || model, finish_reason: choice["finish_reason"], input_tokens: usage["prompt_tokens"], output_tokens: usage["completion_tokens"], raw: body })
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parse_tool_calls(calls)
|
|
145
|
+
return nil unless calls&.any?
|
|
146
|
+
calls.map { |tc| { id: tc["id"], type: "function", name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments") } }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def chat_stream(payload, model, &block)
|
|
150
|
+
stream = Ask::Stream.new
|
|
151
|
+
@http.post("chat/completions") do |req|
|
|
152
|
+
req.body = payload.merge(stream: true)
|
|
153
|
+
req.options.on_data = proc { |data, _bytes, _env| process_chunk(data, stream, model, &block) }
|
|
154
|
+
end.tap { |resp|
|
|
155
|
+
unless resp.success?
|
|
156
|
+
err_body = case resp.body
|
|
157
|
+
when Hash then resp.body
|
|
158
|
+
when String then (JSON.parse(resp.body) rescue { "error" => { "message" => "HTTP #{resp.status}: #{resp.body[0..200]}" } })
|
|
159
|
+
else { "error" => { "message" => "HTTP #{resp.status}: empty response body" } }
|
|
160
|
+
end
|
|
161
|
+
err_body["error"] ||= {}
|
|
162
|
+
err_body["error"]["_status"] = resp.status
|
|
163
|
+
raise LLM::HTTP.map_error(resp.status, err_body, provider: "OpenAI")
|
|
164
|
+
end
|
|
165
|
+
}
|
|
166
|
+
stream.finish!
|
|
167
|
+
stream
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def process_chunk(raw, stream, model)
|
|
172
|
+
raw.each_line do |line|
|
|
173
|
+
line = line.strip
|
|
174
|
+
next if line.empty? || line.start_with?(":") || !line.start_with?("data: ")
|
|
175
|
+
data = line[6..]; next if data == "[DONE]"
|
|
176
|
+
parsed = JSON.parse(data) rescue next
|
|
177
|
+
choice = parsed.dig("choices", 0) or next
|
|
178
|
+
delta = choice["delta"] || {}
|
|
179
|
+
thinking = extract_thinking(parsed, delta)
|
|
180
|
+
chunk = Ask::Chunk.new(content: delta["content"], tool_calls: parse_stream_tool_calls(delta["tool_calls"]), finish_reason: choice["finish_reason"], usage: parsed["usage"], thinking: thinking)
|
|
181
|
+
stream.add(chunk)
|
|
182
|
+
yield chunk if block_given?
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Extract thinking/reasoning content from provider response.
|
|
187
|
+
# Some providers (Anthropic, DeepSeek) send thinking in a separate field.
|
|
188
|
+
def extract_thinking(parsed, delta)
|
|
189
|
+
delta["reasoning_content"] || delta["thinking"] ||
|
|
190
|
+
parsed.dig("choices", 0, "delta", "reasoning_content") ||
|
|
191
|
+
parsed.dig("choices", 0, "delta", "thinking") ||
|
|
192
|
+
parsed.dig("choices", 0, "reasoning_content")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_stream_tool_calls(calls)
|
|
196
|
+
return nil unless calls&.any?
|
|
197
|
+
calls.map { |tc| { id: tc["id"], name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments"), index: tc["index"] } }
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-llm-providers
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.14
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -188,6 +188,7 @@ files:
|
|
|
188
188
|
- lib/ask/provider/mistral.rb
|
|
189
189
|
- lib/ask/provider/ollama.rb
|
|
190
190
|
- lib/ask/provider/openai.rb
|
|
191
|
+
- lib/ask/provider/openai.rb.bak
|
|
191
192
|
- lib/ask/provider/opencode.rb
|
|
192
193
|
- lib/ask/provider/opencode_go.rb
|
|
193
194
|
- lib/ask/provider/openrouter.rb
|