ask-llm-providers 0.1.0 → 0.1.2

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: 677ec905a0f11d7072c4574d03193b85720065778678e940b38252d2adc2f1a0
4
- data.tar.gz: 9cb65bb51e2ea18e6b7c1b92e0d7fcce64aab4b4d8e9d5493215200928e35eb9
3
+ metadata.gz: b0b23746b8ee8cc98e50c44f9e88df977ea81d9214bbda9a25be770021234cc2
4
+ data.tar.gz: 2a2e628b12eb8e731ea9d46ae0f4076dd42591f03db0cf50759d77d6485e748e
5
5
  SHA512:
6
- metadata.gz: cf49fac238b8ce8a9a8df31dcab9a3854d35401eb45699bbb964b01bf50417e544ba5095a9c9b1e40c68b11095f1d3e35bad386b5549a6a6a8793e28ebb0b85b
7
- data.tar.gz: 99e31531be1bbc2b0930f957630118d77de620e3e668750f04ea966a1bcd0c627339623ba59fae69464c7aa07bfe39be354f5934f145d2b6dcb3d8fb24c73c81
6
+ metadata.gz: 4e31b5f82ae3aaab7a7bf337df1ebd5afc20b272efc2b630523145bbf82ebbd12faddcaacdb30e531df3cd3a379a82d9d2e96b97d9ff33877eff6eb0c638d320
7
+ data.tar.gz: 2db527541cb1a8934c6f5ff2e62fbb8ebddf633600e332b040231461eaf9c9b5cb37ec3a70f0079a85404cf8b5fb3751c4f9b88ef075366681044d046ebfdd6b
data/README.md CHANGED
@@ -7,14 +7,14 @@ from `ask-core` with a capabilities-based interface.
7
7
 
8
8
  | Provider | Auth | Implementation |
9
9
  |---|---|---|
10
- | **OpenAI** + all OpenAI-compatible | `Ask::Auth.resolve(:openai_api_key)` | `Ask::Provider::OpenAI` |
11
- | **Anthropic** (Claude) | `Ask::Auth.resolve(:anthropic_api_key)` | `Ask::Provider::Anthropic` |
12
- | **Google Gemini** | `Ask::Auth.resolve(:gemini_api_key)` | `Ask::Provider::Google` |
13
- | **Vertex AI** | GCP service account | `Ask::Provider::VertexAI` |
14
- | **Amazon Bedrock** | AWS credentials chain | `Ask::Provider::Bedrock` |
15
- | **Ollama** (local) | None needed | `Ask::Provider::Ollama` |
16
- | **Mistral AI** | `Ask::Auth.resolve(:mistral_api_key)` | `Ask::Provider::Mistral` |
17
- | **Cloudflare Workers AI** | `Ask::Auth.resolve(:cloudflare_api_key)` | `Ask::Provider::Cloudflare` |
10
+ | **OpenAI** + all OpenAI-compatible | `Ask::Auth.resolve(:openai_api_key)` | `Ask::Providers::OpenAI` |
11
+ | **Anthropic** (Claude) | `Ask::Auth.resolve(:anthropic_api_key)` | `Ask::Providers::Anthropic` |
12
+ | **Google Gemini** | `Ask::Auth.resolve(:gemini_api_key)` | `Ask::Providers::Google` |
13
+ | **Vertex AI** | GCP service account | `Ask::Providers::Google` (via Vertex) |
14
+ | **Amazon Bedrock** | AWS credentials chain | `Ask::Providers::Bedrock` |
15
+ | **Ollama** (local) | None needed | `Ask::Providers::Ollama` |
16
+ | **Mistral AI** | `Ask::Auth.resolve(:mistral_api_key)` | `Ask::Providers::Mistral` |
17
+ | **Cloudflare Workers AI** | `Ask::Auth.resolve(:cloudflare_api_key)` | `Ask::Providers::Cloudflare` |
18
18
 
19
19
  ## Installation
20
20
 
@@ -32,7 +32,7 @@ models = Ask::Models.find("gpt-4o")
32
32
  # => { provider: :openai, capabilities: [...] }
33
33
 
34
34
  # Use a provider directly
35
- provider = Ask::Provider::OpenAI.new
35
+ provider = Ask::Providers::OpenAI.new
36
36
  provider.chat(conversation, tools: [], model: "gpt-4o") do |chunk|
37
37
  print chunk.content
38
38
  end
@@ -43,21 +43,75 @@ end
43
43
  Each provider and model exposes its capabilities:
44
44
 
45
45
  ```ruby
46
- provider = Ask::Provider::OpenAI.new
46
+ provider = Ask::Providers::OpenAI.new
47
47
  provider.capabilities
48
- # => [:chat, :streaming, :tool_calls, :vision, :thinking,
48
+ # => { chat: true, streaming: true, tool_calls: true, vision: true, thinking: true,
49
49
  # :structured_output, :embed, :transcribe, :paint, :moderate]
50
50
 
51
51
  model = Ask::Models.find("claude-sonnet-4-5")
52
52
  model[:capabilities]
53
- # => [:chat, :streaming, :tool_calls, :vision, :thinking, :prompt_caching]
53
+ # => { chat: true, streaming: true, tool_calls: true, vision: true, thinking: true, :prompt_caching]
54
54
 
55
55
  # Unsupported capabilities raise a helpful error
56
- provider = Ask::Provider::Anthropic.new
56
+ provider = Ask::Providers::Anthropic.new
57
57
  provider.embed(["text"], model: "claude-sonnet-4-5")
58
58
  # => Ask::CapabilityNotSupported: Anthropic (claude-sonnet-4-5) does not support embeddings.
59
59
  ```
60
60
 
61
+
62
+
63
+ ## Streaming
64
+
65
+ ```ruby
66
+ stream = provider.chat(
67
+ [{ role: "user", content: "Tell me a story" }],
68
+ model: "gpt-4o",
69
+ stream: true
70
+ ) do |chunk|
71
+ print chunk.content
72
+ end
73
+
74
+ # After streaming completes, you can access the full response
75
+ puts stream.accumulated_text
76
+ puts stream.accumulated_usage
77
+ ```
78
+
79
+ ## Tool Calls
80
+
81
+ ```ruby
82
+ tools = [{
83
+ name: "get_weather",
84
+ description: "Get weather for a location",
85
+ parameters: {
86
+ type: "object",
87
+ properties: { location: { type: "string" } },
88
+ required: ["location"]
89
+ }
90
+ }]
91
+
92
+ response = provider.chat(
93
+ [{ role: "user", content: "What's the weather in NYC?" }],
94
+ model: "gpt-4o",
95
+ tools: tools
96
+ )
97
+ # response.tool_call? => true
98
+ # response.tool_calls => [{ id: "call_1", name: "get_weather", arguments: '{"location":"NYC"}' }]
99
+ ```
100
+
101
+ ## Error Handling
102
+
103
+ Provider errors map to structured `Ask::Error` types:
104
+
105
+ ```ruby
106
+ Ask::RateLimitError # 429 — retry with backoff
107
+ Ask::Unauthorized # 401/403 — check your API key
108
+ Ask::ServerError # 500 — provider issue
109
+ Ask::ServiceUnavailable # 503 — temporary
110
+ Ask::ContextLengthExceeded # context window exceeded
111
+ Ask::ProviderError # other provider errors
112
+ Ask::CapabilityNotSupported # feature not available on this model
113
+ ```
114
+
61
115
  ## Development
62
116
 
63
117
  ```bash
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Model definitions for OpenAI and compatible providers.
4
+ # Registered on gem load via Ask::Models.register.
5
+ module Ask
6
+ module LLM
7
+ module Models
8
+ OPENAI_MODELS = [
9
+ { id: "gpt-4o", family: "gpt4o", capabilities: %w[chat streaming function_calling structured_output vision], context: 128000, output: 16384 },
10
+ { id: "gpt-4o-mini", family: "gpt4o_mini", capabilities: %w[chat streaming function_calling structured_output vision], context: 128000, output: 16384 },
11
+ { id: "gpt-4.1", family: "gpt41", capabilities: %w[chat streaming function_calling structured_output vision], context: 1047576, output: 32768 },
12
+ { id: "gpt-4.1-mini", family: "gpt41_mini", capabilities: %w[chat streaming function_calling structured_output vision], context: 1047576, output: 32768 },
13
+ { id: "gpt-4.1-nano", family: "gpt41_nano", capabilities: %w[chat streaming function_calling structured_output vision], context: 1047576, output: 32768 },
14
+ { id: "gpt-4-turbo", family: "gpt4_turbo", capabilities: %w[chat streaming function_calling vision], context: 128000, output: 4096 },
15
+ { id: "gpt-4", family: "gpt4", capabilities: %w[chat streaming function_calling], context: 8192, output: 8192 },
16
+ { id: "o1", family: "o1", capabilities: %w[chat streaming function_calling structured_output reasoning], context: 200000, output: 100000 },
17
+ { id: "o1-mini", family: "o1_mini", capabilities: %w[chat streaming function_calling reasoning], context: 128000, output: 65536 },
18
+ { id: "o3-mini", family: "o3_mini", capabilities: %w[chat streaming function_calling structured_output reasoning], context: 200000, output: 100000 },
19
+ { id: "gpt-4o-audio-preview", family: "gpt4o_audio", capabilities: %w[chat streaming audio], context: 128000 },
20
+ { id: "gpt-4o-realtime-preview", family: "gpt4o_realtime", capabilities: %w[chat streaming audio], context: 128000 },
21
+ { id: "gpt-4o-mini-realtime-preview", family: "gpt4o_mini_realtime", capabilities: %w[chat streaming audio], context: 128000 },
22
+ { id: "gpt-4.5-preview", family: "gpt45", capabilities: %w[chat streaming function_calling structured_output vision], context: 128000, output: 16384 },
23
+ { id: "text-embedding-3-large", family: "embedding3_large", capabilities: %w[embed], context: 8191 },
24
+ { id: "text-embedding-3-small", family: "embedding3_small", capabilities: %w[embed], context: 8191 },
25
+ { id: "whisper-1", family: "whisper", capabilities: %w[transcribe] },
26
+ { id: "tts-1", family: "tts1", capabilities: %w[tts] },
27
+ { id: "tts-1-hd", family: "tts1_hd", capabilities: %w[tts] },
28
+ { id: "dall-e-3", family: "dall_e", capabilities: %w[paint] },
29
+ { id: "dall-e-2", family: "dall_e", capabilities: %w[paint] }
30
+ ].freeze
31
+
32
+ ANTHROPIC_MODELS = [
33
+ { id: "claude-sonnet-4-5", family: "claude_sonnet", capabilities: %w[chat streaming function_calling vision thinking prompt_caching], context: 200000, output: 8192 },
34
+ { id: "claude-sonnet-4", family: "claude_sonnet", capabilities: %w[chat streaming function_calling vision thinking prompt_caching], context: 200000, output: 8192 },
35
+ { id: "claude-4-opus", family: "claude_opus", capabilities: %w[chat streaming function_calling vision thinking prompt_caching], context: 200000, output: 8192 },
36
+ { id: "claude-3.5-sonnet", family: "claude_sonnet", capabilities: %w[chat streaming function_calling vision thinking], context: 200000, output: 8192 },
37
+ { id: "claude-3.5-haiku", family: "claude_haiku", capabilities: %w[chat streaming function_calling vision thinking], context: 200000, output: 8192 },
38
+ { id: "claude-3-opus", family: "claude_opus", capabilities: %w[chat streaming function_calling vision thinking], context: 200000, output: 4096 },
39
+ { id: "claude-3-sonnet", family: "claude_sonnet", capabilities: %w[chat streaming function_calling vision], context: 200000, output: 4096 },
40
+ { id: "claude-3-haiku", family: "claude_haiku", capabilities: %w[chat streaming function_calling vision], context: 200000, output: 4096 }
41
+ ].freeze
42
+
43
+ GOOGLE_MODELS = [
44
+ { id: "gemini-2.5-pro", family: "gemini", capabilities: %w[chat streaming function_calling structured_output vision reasoning], context: 1048576, output: 65536 },
45
+ { id: "gemini-2.5-flash", family: "gemini", capabilities: %w[chat streaming function_calling structured_output vision], context: 1048576, output: 65536 },
46
+ { id: "gemini-2.0-flash", family: "gemini", capabilities: %w[chat streaming function_calling structured_output vision], context: 1048576, output: 8192 },
47
+ { id: "gemini-1.5-pro", family: "gemini", capabilities: %w[chat streaming function_calling structured_output vision], context: 2097152, output: 8192 },
48
+ { id: "gemini-1.5-flash", family: "gemini", capabilities: %w[chat streaming function_calling structured_output vision], context: 1048576, output: 8192 },
49
+ { id: "text-embedding-004", family: "embedding", capabilities: %w[embed], context: 2048 }
50
+ ].freeze
51
+
52
+ MISTRAL_MODELS = [
53
+ { id: "mistral-large-2501", family: "mistral", capabilities: %w[chat streaming function_calling structured_output], context: 128000, output: 4096 },
54
+ { id: "mistral-small-2501", family: "mistral", capabilities: %w[chat streaming function_calling structured_output], context: 128000, output: 4096 },
55
+ { id: "mistral-embed", family: "mistral", capabilities: %w[embed], context: 8192 }
56
+ ].freeze
57
+
58
+ OLLAMA_MODELS = [
59
+ { id: "llama3.2", family: "llama", capabilities: %w[chat streaming], context: 8192 },
60
+ { id: "llama3.3", family: "llama", capabilities: %w[chat streaming], context: 8192 },
61
+ { id: "mistral", family: "mistral", capabilities: %w[chat streaming], context: 8192 },
62
+ { id: "gemma3", family: "gemma", capabilities: %w[chat streaming], context: 8192 },
63
+ { id: "phi4", family: "phi", capabilities: %w[chat streaming], context: 8192 },
64
+ { id: "qwen2.5", family: "qwen", capabilities: %w[chat streaming], context: 32768 },
65
+ { id: "deepseek-r1", family: "deepseek", capabilities: %w[chat streaming reasoning], context: 8192 }
66
+ ].freeze
67
+ end
68
+ end
69
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.2"
6
6
  end
7
7
  end
@@ -33,7 +33,7 @@ module Ask
33
33
  end
34
34
 
35
35
  def embed(_texts, model: nil)
36
- raise Ask::UnsupportedFeature, "Anthropic does not support embeddings"
36
+ raise Ask::CapabilityNotSupported, "Anthropic does not support embeddings"
37
37
  end
38
38
 
39
39
  def list_models
@@ -25,7 +25,7 @@ module Ask
25
25
  end
26
26
 
27
27
  def embed(_texts, model: nil)
28
- raise Ask::UnsupportedFeature, "Bedrock does not support embeddings via Converse API"
28
+ raise Ask::CapabilityNotSupported, "Bedrock does not support embeddings via Converse API"
29
29
  end
30
30
 
31
31
  def list_models
@@ -7,6 +7,7 @@ module Ask
7
7
  # +base_url+ override.
8
8
  class OpenAI < Ask::Provider
9
9
  def initialize(config = {})
10
+ @provider_keys = extract_provider_keys(config)
10
11
  config = normalize_config(config)
11
12
  super(config)
12
13
  @http = build_http
@@ -51,28 +52,37 @@ module Ask
51
52
  end
52
53
 
53
54
  class << self
54
- def slug; "openai"; end
55
- def capabilities
55
+ def slug; "openai"; end
56
+ def capabilities
56
57
  { chat: true, streaming: true, tool_calls: true, vision: true, thinking: true, structured_output: true, embed: true, transcribe: true, paint: true, moderate: true }
57
58
  end
58
59
  def configuration_options; %i[api_key base_url organization_id project_id]; end
59
60
  def configuration_requirements; %i[api_key]; end
60
- def configured?(config)
61
- (config.respond_to?(:api_key) && !config.api_key.to_s.empty?) ||
62
- (config.respond_to?(:openai_api_key) && !config.openai_api_key.to_s.empty?)
63
- end
61
+ def assume_models_exist?; false; end
64
62
  end
65
63
 
66
64
  private
67
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
+ # Restore provider-specific keys after normalize_config strips standard ones.
68
75
  def normalize_config(config)
69
76
  return config if !config.is_a?(Hash)
70
- Ask::LLM::Config.new(
77
+
78
+ merged = {
71
79
  api_key: config[:api_key] || config["api_key"] || config[:openai_api_key],
72
80
  base_url: config[:base_url] || config["base_url"],
73
81
  organization_id: config[:organization_id] || config["organization_id"],
74
82
  project_id: config[:project_id] || config["project_id"]
75
- )
83
+ }.merge(@provider_keys)
84
+
85
+ Ask::LLM::Config.new(merged)
76
86
  end
77
87
 
78
88
  def build_http
@@ -140,12 +150,22 @@ def slug; "openai"; end
140
150
  parsed = JSON.parse(data) rescue next
141
151
  choice = parsed.dig("choices", 0) or next
142
152
  delta = choice["delta"] || {}
143
- chunk = Ask::Chunk.new(content: delta["content"], tool_calls: parse_stream_tool_calls(delta["tool_calls"]), finish_reason: choice["finish_reason"], usage: parsed["usage"])
153
+ thinking = extract_thinking(parsed, delta)
154
+ 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)
144
155
  stream.add(chunk)
145
156
  yield chunk if block_given?
146
157
  end
147
158
  end
148
159
 
160
+ # Extract thinking/reasoning content from provider response.
161
+ # Some providers (Anthropic, DeepSeek) send thinking in a separate field.
162
+ def extract_thinking(parsed, delta)
163
+ delta["reasoning_content"] || delta["thinking"] ||
164
+ parsed.dig("choices", 0, "delta", "reasoning_content") ||
165
+ parsed.dig("choices", 0, "delta", "thinking") ||
166
+ parsed.dig("choices", 0, "reasoning_content")
167
+ end
168
+
149
169
  def parse_stream_tool_calls(calls)
150
170
  return nil unless calls&.any?
151
171
  calls.map { |tc| { id: tc["id"], name: tc.dig("function", "name"), arguments: tc.dig("function", "arguments"), index: tc["index"] } }
@@ -10,6 +10,7 @@ require "base64"
10
10
  # Common infrastructure
11
11
  require_relative "ask/llm/config"
12
12
  require_relative "ask/llm/http"
13
+ require_relative "ask/llm/models/openai"
13
14
 
14
15
  # Load providers
15
16
  require_relative "ask/provider/openai"
@@ -28,3 +29,21 @@ Ask::Provider.register(:bedrock, Ask::Providers::Bedrock)
28
29
  Ask::Provider.register(:ollama, Ask::Providers::Ollama)
29
30
  Ask::Provider.register(:mistral, Ask::Providers::Mistral)
30
31
  Ask::Provider.register(:cloudflare, Ask::Providers::Cloudflare)
32
+
33
+
34
+ # Register known models for each provider in the catalog
35
+ [
36
+ [Ask::Providers::OpenAI, Ask::LLM::Models::OPENAI_MODELS],
37
+ [Ask::Providers::Anthropic, Ask::LLM::Models::ANTHROPIC_MODELS],
38
+ [Ask::Providers::Google, Ask::LLM::Models::GOOGLE_MODELS],
39
+ [Ask::Providers::Mistral, Ask::LLM::Models::MISTRAL_MODELS],
40
+ [Ask::Providers::Ollama, Ask::LLM::Models::OLLAMA_MODELS]
41
+ ].each do |provider, models|
42
+ models.each do |m|
43
+ Ask::ModelCatalog.instance.register(Ask::ModelInfo.new(
44
+ id: m[:id], provider: provider.slug, family: m[:family],
45
+ capabilities: m[:capabilities],
46
+ context_window: m[:context], max_output_tokens: m[:output]
47
+ ))
48
+ end
49
+ 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.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -13,16 +13,16 @@ dependencies:
13
13
  name: ask-core
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '0.1'
18
+ version: 0.1.1
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
- - - "~>"
23
+ - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '0.1'
25
+ version: 0.1.1
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ask-auth
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: base64
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.2'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.2'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: minitest
84
98
  requirement: !ruby/object:Gem::Requirement
@@ -163,6 +177,7 @@ files:
163
177
  - lib/ask-llm-providers.rb
164
178
  - lib/ask/llm/config.rb
165
179
  - lib/ask/llm/http.rb
180
+ - lib/ask/llm/models/openai.rb
166
181
  - lib/ask/llm/version.rb
167
182
  - lib/ask/provider/anthropic.rb
168
183
  - lib/ask/provider/bedrock.rb