ask-llm-providers 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f67f89691195dda7f5119ee70567669cd823d76c0c5217e686314194d01ac14
4
- data.tar.gz: 214aa37bd2027d77634fbae6781748bbe48774e57cdc8881b32156af431b0515
3
+ metadata.gz: 83d9430295753c63779ef81db7fbbc4e9c56c3e1848f44e53a763c70c43cc1f8
4
+ data.tar.gz: a9ea83f80669da1e5869204a42af7bc52e071d81f355da80df26002246d3b828
5
5
  SHA512:
6
- metadata.gz: ca101bc63e0132be44fe4c21ff87768ddb961fded211ee1a471f276aef7d04717b8afb4a1b54bcf4d849513317f07bc6f3bd71e68a72a0ea3f71bf9f0e26baca
7
- data.tar.gz: c7985c3ebd71b7b628510c75cf8b5b1cfc4901ff9de8051de5f5244698c215f003f91a2c69d9f419601612e13af5dde49270e94ca654a46067d82b196125d131
6
+ metadata.gz: 870a8543f6810948e62f65b18b39f0924a78ec393108f74e992ecff25cfe8e14ae8331d287d436afdb88f3792379c778598b3188642c4c5a002bb374f56a0c8e
7
+ data.tar.gz: 211593c5668424aab22e2f03c6230d6ad791a4f7f6002b60b8e26e879f2ae8d6e28782b29790611c796c8fa2d0edab1494e1a9d61ec638a4b78fde1fbba639a2
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## [0.5.0] — 2026-07-17
2
+
3
+ ### Added
4
+
5
+ - **OpenAI-compatible provider registry** (`Ask::LLM::OPENAI_COMPATIBLE`) — 12 providers defined as data, not classes. Adding a new OpenAI-compatible API (Groq, Together, Fireworks, etc.) is one line in the registry. No new file, no subclass.
6
+ - **`Ask::Providers::OpenAICompatible` class** — single class handling all registered providers by reading from the registry. Each provider gets an anonymous subclass with its slug, api_base, env var mapping, and quirks (reasoning_content, extra_headers) set from config.
7
+ - **Unified test** — `OpenAICompatibleTest` dynamically tests every registered provider: identity, slug, capabilities, api_base, env var resolution, request building, response parsing, streaming, and tool formatting.
8
+
9
+ ### Removed
10
+
11
+ - **5 subclass files** — `deepseek.rb`, `openrouter.rb`, `opencode.rb`, `opencode_go.rb`, `mimo.rb` deleted. Replaced by registry entries.
12
+ - **DeepSeek-specific test file** — covered by the unified test.
13
+
1
14
  ## [0.4.0] — 2026-07-16
2
15
 
3
16
  ### Added
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module LLM
5
+ # Registry of OpenAI-compatible providers (data, not classes).
6
+ #
7
+ # Each entry is configuration for {Ask::Providers::OpenAICompatible}.
8
+ # To add a new provider, add one line here — no new file, no subclass.
9
+ #
10
+ # @example Adding Groq
11
+ # groq: { api_base: "https://api.groq.com/openai/v1", api_key_env: "GROQ_API_KEY" }
12
+ #
13
+ OPENAI_COMPATIBLE = {
14
+ deepseek: { api_base: "https://api.deepseek.com", api_key_env: "DEEPSEEK_API_KEY",
15
+ reasoning_content: true,
16
+ capabilities: { chat: true, streaming: true, tool_calls: true, thinking: true } },
17
+
18
+ openrouter: { api_base: "https://openrouter.ai/api/v1", api_key_env: "OPENROUTER_API_KEY",
19
+ extra_headers: { "HTTP-Referer" => "https://github.com/ask-rb",
20
+ "X-Title" => "ask-rb" },
21
+ capabilities: { chat: true, streaming: true, tool_calls: true, vision: true,
22
+ thinking: true, structured_output: true } },
23
+
24
+ opencode: { api_base: "https://opencode.ai/zen/v1", api_key_env: "OPENCODE_API_KEY",
25
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
26
+
27
+ opencode_go: { api_base: "https://opencode.ai/zen/go/v1", api_key_env: "OPENCODE_GO_API_KEY",
28
+ alternate_env: "OPENCODE_API_KEY",
29
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
30
+
31
+ mimo: { api_base: "https://token-plan-sgp.xiaomimimo.com/v1", api_key_env: "MIMO_API_KEY",
32
+ capabilities: { chat: true, streaming: true } },
33
+
34
+ groq: { api_base: "https://api.groq.com/openai/v1", api_key_env: "GROQ_API_KEY",
35
+ capabilities: { chat: true, streaming: true, tool_calls: true, vision: true } },
36
+
37
+ together: { api_base: "https://api.together.xyz/v1", api_key_env: "TOGETHER_API_KEY",
38
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
39
+
40
+ fireworks: { api_base: "https://api.fireworks.ai/inference/v1", api_key_env: "FIREWORKS_API_KEY",
41
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
42
+
43
+ perplexity: { api_base: "https://api.perplexity.ai", api_key_env: "PERPLEXITY_API_KEY",
44
+ capabilities: { chat: true, streaming: true } },
45
+
46
+ cerebras: { api_base: "https://api.cerebras.ai/v1", api_key_env: "CEREBRAS_API_KEY",
47
+ capabilities: { chat: true, streaming: true, tool_calls: true } },
48
+
49
+ xai: { api_base: "https://api.x.ai/v1", api_key_env: "XAI_API_KEY",
50
+ capabilities: { chat: true, streaming: true, tool_calls: true, vision: true,
51
+ thinking: true } },
52
+
53
+ moonshot: { api_base: "https://api.moonshot.ai/v1", api_key_env: "MOONSHOT_API_KEY",
54
+ capabilities: { chat: true, streaming: true } }
55
+ }.freeze
56
+ end
57
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Providers
5
+ # Single class for all OpenAI-compatible providers.
6
+ #
7
+ # Reads its behavior from {Ask::LLM::OPENAI_COMPATIBLE} via the class-level
8
+ # +compat_config+ that gets set at registration time. Each registered
9
+ # provider is an anonymous subclass with its own compat_config.
10
+ #
11
+ # This eliminates one subclass file per provider — adding a new
12
+ # OpenAI-compatible API is a one-line config entry.
13
+ class OpenAICompatible < Ask::Providers::OpenAI
14
+ class << self
15
+ attr_reader :compat_config
16
+
17
+ def slug
18
+ compat_config[:slug].to_s
19
+ end
20
+
21
+ def capabilities
22
+ compat_config[:capabilities] || { chat: true, streaming: true, tool_calls: true }
23
+ end
24
+
25
+ def configuration_options
26
+ %i[api_key base_url]
27
+ end
28
+
29
+ def configuration_requirements
30
+ %i[api_key]
31
+ end
32
+
33
+ def configured?(config)
34
+ key = config.respond_to?(:api_key) ? config.api_key : nil
35
+ key ||= ENV[compat_config[:api_key_env].to_s]
36
+ key ||= ENV[compat_config[:alternate_env].to_s] if compat_config[:alternate_env]
37
+ key.to_s.length > 0
38
+ end
39
+
40
+ def assume_models_exist?
41
+ false
42
+ end
43
+ end
44
+
45
+ def initialize(config = {})
46
+ @compat_cfg = self.class.compat_config || {}
47
+ config = normalize_compat_config(config)
48
+ super(config)
49
+ end
50
+
51
+ def api_base
52
+ @config.base_url || @compat_cfg[:api_base] || super
53
+ end
54
+
55
+ def headers
56
+ h = { "Content-Type" => "application/json" }
57
+ key = @config.api_key
58
+ h["Authorization"] = "Bearer #{key}" if key
59
+ if (extra = @compat_cfg[:extra_headers])
60
+ extra.each { |k, v| h[k] = v }
61
+ end
62
+ h
63
+ end
64
+
65
+ def format_messages(messages)
66
+ result = super
67
+ if @compat_cfg[:reasoning_content]
68
+ result.each { |fm| fm[:reasoning_content] ||= "" if fm[:role] == "assistant" && fm[:tool_calls] }
69
+ end
70
+ result
71
+ end
72
+
73
+ private
74
+
75
+ def normalize_compat_config(config)
76
+ return config unless config.is_a?(Hash)
77
+
78
+ slug = self.class.slug
79
+ api_key = config[:api_key] || config["api_key"] ||
80
+ config[:"#{slug}_api_key"] ||
81
+ ENV[@compat_cfg[:api_key_env].to_s] ||
82
+ (ENV[@compat_cfg[:alternate_env].to_s] if @compat_cfg[:alternate_env]) ||
83
+ ENV["#{slug.upcase}_API_KEY"]
84
+
85
+ base_url = config[:base_url] || config["base_url"] ||
86
+ ENV["#{slug.upcase}_API_BASE"] ||
87
+ @compat_cfg[:api_base]
88
+
89
+ Ask::LLM::Config.new(api_key:, base_url:)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -17,21 +17,20 @@ require_relative "ask/llm/aliases"
17
17
  # Provider transformation contract
18
18
  require_relative "ask/llm/provider_config"
19
19
 
20
+ # OpenAI-compatible provider registry (data, not classes)
21
+ require_relative "ask/llm/openai_compatible"
22
+
20
23
  # Load providers
21
24
  require_relative "ask/provider/openai"
25
+ require_relative "ask/provider/openai_compatible"
22
26
  require_relative "ask/provider/anthropic"
23
27
  require_relative "ask/provider/google"
24
28
  require_relative "ask/provider/bedrock"
25
29
  require_relative "ask/provider/ollama"
26
30
  require_relative "ask/provider/mistral"
27
31
  require_relative "ask/provider/cloudflare"
28
- require_relative "ask/provider/opencode"
29
- require_relative "ask/provider/opencode_go"
30
- require_relative "ask/provider/mimo"
31
- require_relative "ask/provider/deepseek"
32
- require_relative "ask/provider/openrouter"
33
32
 
34
- # Register providers with the Ask::Provider registry
33
+ # Register canonical providers
35
34
  Ask::Provider.register(:openai, Ask::Providers::OpenAI)
36
35
  Ask::Provider.register(:anthropic, Ask::Providers::Anthropic)
37
36
  Ask::Provider.register(:gemini, Ask::Providers::Google)
@@ -39,11 +38,13 @@ Ask::Provider.register(:bedrock, Ask::Providers::Bedrock)
39
38
  Ask::Provider.register(:ollama, Ask::Providers::Ollama)
40
39
  Ask::Provider.register(:mistral, Ask::Providers::Mistral)
41
40
  Ask::Provider.register(:cloudflare, Ask::Providers::Cloudflare)
42
- Ask::Provider.register(:opencode, Ask::Providers::OpenCode)
43
- Ask::Provider.register(:opencode_go, Ask::Providers::OpenCodeGo)
44
- Ask::Provider.register(:mimo, Ask::Providers::Mimo)
45
- Ask::Provider.register(:deepseek, Ask::Providers::DeepSeek)
46
- Ask::Provider.register(:openrouter, Ask::Providers::OpenRouter)
41
+
42
+ # Register OpenAI-compatible providers from the registry (data → classes)
43
+ Ask::LLM::OPENAI_COMPATIBLE.each do |name, cfg|
44
+ klass = Class.new(Ask::Providers::OpenAICompatible)
45
+ klass.define_singleton_method(:compat_config) { cfg.merge(slug: name.to_s) }
46
+ Ask::Provider.register(name, klass)
47
+ end
47
48
 
48
49
  # Load bundled model catalog into Ask::ModelCatalog
49
50
  Ask::LLM::Catalog.load!
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.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -194,21 +194,18 @@ 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/openai_compatible.rb
197
198
  - lib/ask/llm/provider_config.rb
198
199
  - lib/ask/llm/sse_buffer.rb
199
200
  - lib/ask/llm/version.rb
200
201
  - lib/ask/provider/anthropic.rb
201
202
  - lib/ask/provider/bedrock.rb
202
203
  - lib/ask/provider/cloudflare.rb
203
- - lib/ask/provider/deepseek.rb
204
204
  - lib/ask/provider/google.rb
205
- - lib/ask/provider/mimo.rb
206
205
  - lib/ask/provider/mistral.rb
207
206
  - lib/ask/provider/ollama.rb
208
207
  - lib/ask/provider/openai.rb
209
- - lib/ask/provider/opencode.rb
210
- - lib/ask/provider/opencode_go.rb
211
- - lib/ask/provider/openrouter.rb
208
+ - lib/ask/provider/openai_compatible.rb
212
209
  - lib/ask/skills/providers.model_select/SKILL.md
213
210
  homepage: https://github.com/ask-rb/ask-llm-providers
214
211
  licenses:
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Providers
5
- # DeepSeek API — an OpenAI-compatible provider at api.deepseek.com.
6
- # Supports DeepSeek V2, V3, V4 Chat, R1 reasoning, and all DeepSeek models
7
- # via the OpenAI-compatible endpoint.
8
- #
9
- # Configuration via environment:
10
- # DEEPSEEK_API_KEY — required, your DeepSeek API key
11
- # DEEPSEEK_API_BASE — optional, base URL (default: https://api.deepseek.com)
12
- class DeepSeek < OpenAI
13
- def api_base
14
- @config.base_url || ENV["DEEPSEEK_API_BASE"] || "https://api.deepseek.com"
15
- end
16
-
17
- def headers
18
- key = @config.api_key || ENV["DEEPSEEK_API_KEY"]
19
- h = { "Content-Type" => "application/json" }
20
- h["Authorization"] = "Bearer #{key}" if key
21
- h
22
- end
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
-
35
- class << self
36
- def slug; "deepseek"; end
37
- def configuration_options; %i[api_key base_url]; end
38
- def configuration_requirements; %i[api_key]; end
39
- def configured?(config)
40
- key = config.respond_to?(:api_key) ? config.api_key : nil
41
- key ||= ENV["DEEPSEEK_API_KEY"]
42
- key.to_s.length > 0
43
- end
44
- end
45
- end
46
- end
47
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Providers
5
- # Mimo API — an OpenAI-compatible provider
6
- class Mimo < OpenAI
7
- def api_base
8
- @config.base_url || ENV["MIMO_API_BASE"] || "https://token-plan-sgp.xiaomimimo.com/v1"
9
- end
10
-
11
- def headers
12
- key = @config.api_key || ENV["MIMO_API_KEY"]
13
- h = { "Content-Type" => "application/json" }
14
- h["Authorization"] = "Bearer #{key}" if key
15
- h
16
- end
17
-
18
- class << self
19
- def slug; "mimo"; end
20
- def configuration_options; %i[api_key base_url]; end
21
- def configuration_requirements; %i[api_key]; end
22
- def configured?(config)
23
- key = config.respond_to?(:api_key) ? config.api_key : nil
24
- key ||= ENV["MIMO_API_KEY"]
25
- key.to_s.length > 0
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Providers
5
- # OpenCode API — an OpenAI-compatible provider at opencode.ai
6
- class OpenCode < OpenAI
7
- def api_base
8
- @config.base_url || ENV["OPENCODE_API_BASE"] || "https://opencode.ai/zen/v1"
9
- end
10
-
11
- def headers
12
- key = @config.api_key || ENV["OPENCODE_API_KEY"]
13
- h = { "Content-Type" => "application/json" }
14
- h["Authorization"] = "Bearer #{key}" if key
15
- h
16
- end
17
-
18
- class << self
19
- def slug; "opencode"; end
20
- def configuration_options; %i[api_key base_url]; end
21
- def configuration_requirements; %i[api_key]; end
22
- def configured?(config)
23
- key = config.respond_to?(:api_key) ? config.api_key : nil
24
- key ||= ENV["OPENCODE_API_KEY"]
25
- key.to_s.length > 0
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Providers
5
- # OpenCode Go API — an OpenAI-compatible provider at opencode.ai/zen/go
6
- class OpenCodeGo < OpenAI
7
- def api_base
8
- @config.base_url || ENV["OPENCODE_GO_API_BASE"] || "https://opencode.ai/zen/go/v1"
9
- end
10
-
11
- def headers
12
- key = @config.api_key || ENV["OPENCODE_API_KEY"] || ENV["OPENCODE_GO_API_KEY"]
13
- h = { "Content-Type" => "application/json" }
14
- h["Authorization"] = "Bearer #{key}" if key
15
- h
16
- end
17
-
18
- class << self
19
- def slug; "opencode_go"; end
20
- def configuration_options; %i[api_key base_url]; end
21
- def configuration_requirements; %i[api_key]; end
22
- def configured?(config)
23
- key = config.respond_to?(:api_key) ? config.api_key : nil
24
- key ||= ENV["OPENCODE_API_KEY"] || ENV["OPENCODE_GO_API_KEY"]
25
- key.to_s.length > 0
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ask
4
- module Providers
5
- # OpenRouter API — an OpenAI-compatible aggregator at openrouter.ai.
6
- # Provides access to many models through a single endpoint.
7
- #
8
- # Configuration via environment:
9
- # OPENROUTER_API_KEY — required, your OpenRouter API key
10
- # OPENROUTER_API_BASE — optional, base URL (default: https://openrouter.ai/api/v1)
11
- class OpenRouter < OpenAI
12
- def api_base
13
- @config.base_url || ENV["OPENROUTER_API_BASE"] || "https://openrouter.ai/api/v1"
14
- end
15
-
16
- def headers
17
- h = super
18
- key = @config.api_key || ENV["OPENROUTER_API_KEY"]
19
- h["Authorization"] = "Bearer #{key}" if key
20
- h["HTTP-Referer"] = ENV["OPENROUTER_REFERER"] || "https://github.com/ask-rb"
21
- h["X-Title"] = ENV["OPENROUTER_APP_TITLE"] || "ask-rb"
22
- h
23
- end
24
-
25
- class << self
26
- def slug; "openrouter"; end
27
- def configuration_options; %i[api_key base_url]; end
28
- def configuration_requirements; %i[api_key]; end
29
- def configured?(config)
30
- key = config.respond_to?(:api_key) ? config.api_key : nil
31
- key ||= ENV["OPENROUTER_API_KEY"]
32
- key.to_s.length > 0
33
- end
34
- end
35
- end
36
- end
37
- end