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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8a74bb6d0bdeec2f86e1ec7e352c121e2c69c44837293e545d78a6a189552c0
4
- data.tar.gz: 654529c6b21c4ba943f8d0bcf8eb7361a209ab470b918b4bc77f3afc4ba37321
3
+ metadata.gz: 8ca86d4692c3bdd09134c7f3b356c62ee924e55751dfa9e5a443aceb05828c59
4
+ data.tar.gz: c6267cbaa6513caf8159d2fec37906ce6eae362e4067f3524c5db1f4888d41b3
5
5
  SHA512:
6
- metadata.gz: d9b3fa669828ecadda4d43b6295019b9a47af5aef9cfeb2f1289fc8025a7275e41d83ab6f1a8154964872b7ff3055f54601b73df10e84cc4f7c2318912a191f9
7
- data.tar.gz: 633f84f8cfcbfa71d266bbb8ca6c813157e0a312f7cebd69879cf2d5ee53b81cc63d4733ad967290742bf03b781c142197f9c5b23446f5ea72929689b6e262ac
6
+ metadata.gz: dd36e3ba8058b2bb33d8076c2feb00a09ee8a7ffd8e41a0a28888394711c775fdecf3a261f1bfe3eebce72358e6a255e9134e030c07bedd3edd3d39dfc76cf02
7
+ data.tar.gz: 77c177f586e71e71c2abf11c328e0649a5182f53c50be5fa880c66654de5a267082d8bc75d5809ea0292d41c7ee00dc2f08a6a1eefb03db15e1cb100debcf01d
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.1.13"
5
+ VERSION = "0.1.14"
6
6
  end
7
7
  end
@@ -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.13
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