ask-llm-providers 0.1.14 → 0.1.15

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: 8ca86d4692c3bdd09134c7f3b356c62ee924e55751dfa9e5a443aceb05828c59
4
- data.tar.gz: c6267cbaa6513caf8159d2fec37906ce6eae362e4067f3524c5db1f4888d41b3
3
+ metadata.gz: d05af6c6de7917048a5b86d1fea1c0acfd3ef820c71c7672f19375a5024fe138
4
+ data.tar.gz: 3cf21a18360b52e5fd44d01359c0312e05163a234407f07b515f325abf9ebee7
5
5
  SHA512:
6
- metadata.gz: dd36e3ba8058b2bb33d8076c2feb00a09ee8a7ffd8e41a0a28888394711c775fdecf3a261f1bfe3eebce72358e6a255e9134e030c07bedd3edd3d39dfc76cf02
7
- data.tar.gz: 77c177f586e71e71c2abf11c328e0649a5182f53c50be5fa880c66654de5a267082d8bc75d5809ea0292d41c7ee00dc2f08a6a1eefb03db15e1cb100debcf01d
6
+ metadata.gz: 7e91f0e672b159d2d8c7af3de889d0155ede9fc60191b38a78c4f34e675892f0c867ef30dfd6d7feac055d04ef0be347f06fa9888b8d3282b48d34d26dcd59c5
7
+ data.tar.gz: 813cc7dc3ca391fca712dae4c59a356019ef363d34a1b2a19376faf1a0b0ac2f2dddb065bd842b64adb7909045936d048bceadc47d2f07de3377115e748f2f08
data/lib/ask/llm/http.rb CHANGED
@@ -15,10 +15,12 @@ module Ask
15
15
 
16
16
  # Map an HTTP exception or error response to the appropriate Ask::Error.
17
17
  def self.map_error(status, body, provider:)
18
+ body = JSON.parse(body) rescue body if body.is_a?(String)
18
19
  message = extract_error_message(body, status) || "HTTP #{status} from #{provider}"
19
20
 
20
21
  # Check for context length exceeded regardless of status code
21
- if body&.dig("error", "code") == "context_length_exceeded"
22
+ err_code = body.respond_to?(:dig) ? body.dig("error", "code") : nil
23
+ if err_code == "context_length_exceeded"
22
24
  return Ask::ContextLengthExceeded.new("#{provider}: #{message}")
23
25
  end
24
26
 
@@ -36,11 +38,15 @@ module Ask
36
38
  def self.extract_error_message(body, status)
37
39
  return nil unless body
38
40
 
39
- body.dig("error", "message") ||
40
- body.dig("error", "msg") ||
41
- body.dig("error", "error") ||
42
- body["message"] ||
41
+ if body.respond_to?(:dig)
42
+ body.dig("error", "message") ||
43
+ body.dig("error", "msg") ||
44
+ body.dig("error", "error") ||
45
+ body["message"] ||
46
+ body.to_s
47
+ else
43
48
  body.to_s
49
+ end
44
50
  end
45
51
  end
46
52
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.1.14"
5
+ VERSION = "0.1.15"
6
6
  end
7
7
  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.14
4
+ version: 0.1.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -188,7 +188,6 @@ 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
192
191
  - lib/ask/provider/opencode.rb
193
192
  - lib/ask/provider/opencode_go.rb
194
193
  - lib/ask/provider/openrouter.rb
@@ -1,201 +0,0 @@
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