ruby_llm-providers-infomaniak 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 264a19f883d98cbfdfb7a49a0e1784d964e1289e224ae42dc46641c1e59e9ba5
4
+ data.tar.gz: 6cbc48e9ad718684ffb0609d10f2b6a4f88043ef6aeb22d19a99341089a2c27c
5
+ SHA512:
6
+ metadata.gz: dc84db9b0ff07fb6960b3a03344a1da8667a12758bb11d4e581bca04f67273c14debf118a5006c5b27dd93b6283763a6e3dc738e01279bca9e5ba9906f96dd21
7
+ data.tar.gz: d1940a9651d6ea0493903893129b86189a12f97ab5181456ab4057c9da44dbe92aa62bb785fd8908d26091f65a6e53bf58baae5130e92dd043c2e652ad95a017
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First release: `infomaniak` provider for RubyLLM 2.x on Infomaniak AI Tools' OpenAI-compatible API, with
6
+ product id discovery, streaming, tools, structured output, thinking on/off, images, embeddings and a
7
+ model catalog built from the account's model list (`rake models`).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andreas Idogawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # ruby_llm-providers-infomaniak
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/ruby_llm-providers-infomaniak.svg)](https://rubygems.org/gems/ruby_llm-providers-infomaniak)
4
+ [![tests](https://github.com/Largo/ruby_llm-providers-infomaniak/actions/workflows/tests.yml/badge.svg)](https://github.com/Largo/ruby_llm-providers-infomaniak/actions/workflows/tests.yml)
5
+
6
+ **Open-weight models hosted in Switzerland, through the RubyLLM API you already know.**
7
+
8
+ This gem adds [Infomaniak AI Tools](https://www.infomaniak.com/en/hosting/ai-tools) as a provider to
9
+ [RubyLLM](https://rubyllm.com). Kimi, Qwen, Gemma, Mistral and Apertus run in Infomaniak's Swiss data
10
+ centres; your Ruby code keeps using `RubyLLM.chat`, tools, schemas, streaming and embeddings as with any
11
+ other provider.
12
+
13
+ ```ruby
14
+ chat = RubyLLM.chat(model: 'moonshotai/Kimi-K2.6', provider: :infomaniak)
15
+ chat.ask('Explain Ruby blocks in one sentence.').content
16
+ # => "A Ruby block is a chunk of code you pass to a method ..."
17
+ ```
18
+
19
+ - Chat, streaming, tool calling, structured output, image input, embeddings
20
+ - Thinking on/off per request, with the model's reasoning on `response.thinking`
21
+ - Only an API token to configure: the AI Tools product is looked up for you
22
+ - A model catalog with context sizes and capabilities, refreshable at runtime
23
+ - Irons out Infomaniak's differences from OpenAI's API, such as Kimi's broken JSON while thinking
24
+
25
+ ## Contents
26
+
27
+ - [Installation](#installation)
28
+ - [Configuration](#configuration)
29
+ - [Models](#models)
30
+ - [Usage](#usage)
31
+ - [How Infomaniak differs from OpenAI](#how-infomaniak-differs-from-openai)
32
+ - [Development](#development)
33
+ - [Releasing](#releasing)
34
+
35
+ ## Installation
36
+
37
+ ```ruby
38
+ # Gemfile
39
+ gem 'ruby_llm-providers-infomaniak', require: 'ruby_llm/providers/infomaniak'
40
+ ```
41
+
42
+ Requires Ruby 3.2+ and RubyLLM 2.x.
43
+
44
+ ## Configuration
45
+
46
+ You need an Infomaniak API token with the **AI Tools** scope (Infomaniak Manager > API tokens).
47
+
48
+ ```ruby
49
+ require 'ruby_llm/providers/infomaniak'
50
+
51
+ RubyLLM.configure do |config|
52
+ config.infomaniak_api_key = ENV['INFOMANIAK_API_KEY']
53
+ end
54
+ ```
55
+
56
+ That is all. On first use the provider asks `GET /1/ai` which AI Tools product the token belongs to, and
57
+ caches the answer per token.
58
+
59
+ | Option | Default | |
60
+ |---|---|---|
61
+ | `infomaniak_api_key` | required | API token with the AI Tools scope |
62
+ | `infomaniak_product_id` | looked up | The product to bill. Set it when the token reaches several products (the error lists them) or to skip the lookup |
63
+ | `infomaniak_api_base` | `https://api.infomaniak.com/2/ai/{product_id}/openai/v1` | Override for a proxy or gateway |
64
+
65
+ ## Models
66
+
67
+ The chat and embedding models Infomaniak served on 2026-09-30, and how each behaved when probed.
68
+ Your product lists its own with `bundle exec rake models`, or live with
69
+ `RubyLLM::Providers::Infomaniak.refresh_models!`.
70
+
71
+ | Model | Context | Images | Thinking |
72
+ |---|---|---|---|
73
+ | `moonshotai/Kimi-K2.6` (beta) | 256K | yes | on by default |
74
+ | `Qwen/Qwen3.5-397B-A17B-FP8` (beta) | 200K | yes | on by default |
75
+ | `Qwen/Qwen3.5-122B-A10B-FP8` | 200K | yes | on by default |
76
+ | `google/gemma-4-31B-it` | 100K | yes | off, opt in with an effort |
77
+ | `mistralai/Mistral-Small-4-119B-2603` | 256K | yes | off, opt in with an effort |
78
+ | `mistralai/Ministral-3-14B-Instruct-2512` | 100K | yes | none |
79
+ | `swiss-ai/Apertus-v1.5-70B` (beta) | 100K | yes | none |
80
+
81
+ | Embedding model | Input tokens |
82
+ |---|---|
83
+ | `Qwen/Qwen3-Embedding-8B` | 8192 |
84
+ | `bge_multilingual_gemma2` | 8000 |
85
+ | `mini_lm_l12_v2` | 128 |
86
+
87
+ Infomaniak also lists image generation (Flux, Photomaker), transcription (Whisper) and rerankers
88
+ (`BAAI/bge-reranker-v2-m3`, `Qwen/Qwen3-Reranker-0.6B`). They live on other endpoints this gem does not
89
+ cover yet.
90
+
91
+ ### The model catalog
92
+
93
+ Any model id your product serves works as given, catalog or not. The gem ships a catalog, `models.json`,
94
+ so that ruby_llm also knows each model's context size and capabilities. That enables:
95
+
96
+ - `with_thinking` and `with_thinking(false)`, which need to know how a model switches thinking
97
+ - `RubyLLM.chat(model: 'moonshotai/Kimi-K2.6')` without `provider:`
98
+ - `RubyLLM.models.by_provider(:infomaniak)` without a network call
99
+
100
+ The catalog is a snapshot from the gem release. To know about models Infomaniak added since, load the
101
+ live list at boot (two API calls):
102
+
103
+ ```ruby
104
+ RubyLLM::Providers::Infomaniak.refresh_models!
105
+ ```
106
+
107
+ `RubyLLM.models.refresh!` does not do this: it skips providers that ship their own catalog.
108
+
109
+ ## Usage
110
+
111
+ ### Chat and streaming
112
+
113
+ ```ruby
114
+ chat = RubyLLM.chat(model: 'moonshotai/Kimi-K2.6', provider: :infomaniak)
115
+
116
+ chat.with_instructions('Answer like a Swiss train conductor.')
117
+ chat.ask('When does the next train to Bern leave?').content
118
+
119
+ chat.ask('Tell me a story about a marmot.') { |chunk| print chunk.content }
120
+ ```
121
+
122
+ ### Tools
123
+
124
+ ```ruby
125
+ class Weather < RubyLLM::Tool
126
+ description 'Current weather for a city'
127
+ parameter :city, description: 'City name'
128
+
129
+ def execute(city:) = WeatherService.current(city)
130
+ end
131
+
132
+ chat.with_tools(Weather).ask('Do I need an umbrella in Lausanne today?').content
133
+ ```
134
+
135
+ ### Structured output
136
+
137
+ ```ruby
138
+ schema = {
139
+ type: 'object',
140
+ properties: { city: { type: 'string' }, population: { type: 'integer' } },
141
+ required: %w[city population],
142
+ additionalProperties: false
143
+ }
144
+
145
+ chat.with_schema(schema).ask('Largest city in Switzerland?').parsed
146
+ # => {"city" => "Zurich", "population" => 443000}
147
+ ```
148
+
149
+ ### Thinking
150
+
151
+ Kimi and Qwen think before they answer, unless told not to. The reasoning comes back separately from the
152
+ answer:
153
+
154
+ ```ruby
155
+ response = chat.ask('Is 1001 prime?')
156
+ response.thinking&.text # the model's reasoning
157
+ response.content # the answer
158
+
159
+ chat.with_thinking(effort: :none).ask('Quick: 17 * 23?') # thinking off: faster, cheaper
160
+ RubyLLM.chat(model: 'google/gemma-4-31B-it', provider: :infomaniak)
161
+ .with_thinking(effort: :high).ask('Plan a 3-day Ticino trip.') # thinking on
162
+ ```
163
+
164
+ With the model in the catalog, `with_thinking` and `with_thinking(false)` work too.
165
+
166
+ ### Images
167
+
168
+ ```ruby
169
+ chat.ask('What is on this receipt?', with: 'receipt.jpg').content
170
+ ```
171
+
172
+ ### Embeddings
173
+
174
+ ```ruby
175
+ RubyLLM.embed('Grüezi mitenand', model: 'Qwen/Qwen3-Embedding-8B', provider: :infomaniak).vectors
176
+ ```
177
+
178
+ ## How Infomaniak differs from OpenAI
179
+
180
+ The gem handles these, so the RubyLLM API behaves as usual:
181
+
182
+ | | What Infomaniak does | What the gem does |
183
+ |---|---|---|
184
+ | Thinking | `reasoning_effort` is an on/off switch: `none` or `low`/`medium`/`high` | `:none` turns thinking off; `:minimal` is sent as `low`, `:xhigh` and `:max` as `high` |
185
+ | Kimi + schema | While thinking, Kimi answers `{{ ... }` instead of JSON | Kimi schema requests go out with thinking off, unless you ask for thinking, which logs a warning |
186
+ | System prompt | `system` role, not OpenAI's `developer` | Sends `system` |
187
+ | Output limit | `max_completion_tokens` | `with_max_output_tokens` maps to it |
188
+ | Attachments | Images inline; no audio, no PDF | Audio and PDFs raise `UnsupportedAttachmentError` before sending; text files are inlined |
189
+ | End user | `user` field | `with_end_user('id')` is sent as `user` |
190
+ | Prompt caching | Repeated prompt prefixes answer faster, but no cached-token counts come back and `prompt_cache_key` has no visible effect | `with_caching` options are not sent; keep long shared context (instructions, documents) at the start of the conversation to benefit |
191
+ | Errors | `{"error": {"code", "description"}}` on its own endpoints | The description ends up in the `RubyLLM::Error` message |
192
+
193
+ ## Development
194
+
195
+ ```sh
196
+ bundle install
197
+ cp test/.env.example test/.env # put your API token in test/.env (gitignored)
198
+ ```
199
+
200
+ | Command | |
201
+ |---|---|
202
+ | `bundle exec ruby bin/console` | IRB with `chat`, `models` and a `Weather` tool, configured from `test/.env` |
203
+ | `bundle exec rake test` | Offline tests, all HTTP stubbed with WebMock. What CI runs |
204
+ | `bundle exec rake live` | The same features against the real API, with your token |
205
+ | `bundle exec rake models` | Rebuilds `models.json` from your product's model list |
206
+
207
+ `test/.env` also takes `INFOMANIAK_MODEL` (the console and live-test model, default Kimi-K2.6),
208
+ `INFOMANIAK_EMBEDDING_MODEL` (adds embeddings to `rake live`) and `RUBYLLM_DEBUG=1` (logs every request).
209
+
210
+ ## Releasing
211
+
212
+ Tags publish to RubyGems through trusted publishing, without a stored API key. See
213
+ [RELEASING.md](RELEASING.md).
214
+
215
+ ## License
216
+
217
+ MIT. Not affiliated with Infomaniak.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Infomaniak < Provider
6
+ # How Infomaniak's Chat Completions differ from OpenAI's: a plain system
7
+ # role, max_completion_tokens, reasoning_effort as an on/off switch, no
8
+ # OpenAI prompt-cache fields, and images as the only binary attachments.
9
+ module Chat
10
+ EFFORTS = %w[none low medium high].freeze
11
+
12
+ # Models whose schema-constrained output breaks while they think: Kimi
13
+ # answers "{{ ... }" instead of JSON (seen 2026-09-30).
14
+ SCHEMA_WITHOUT_THINKING = /kimi/i
15
+
16
+ module_function
17
+
18
+ def render_payload(messages, tools:, temperature:, model:, stream: false, max_output_tokens: nil,
19
+ schema: nil, thinking: nil, citations: false, caching: nil, tool_prefs: nil)
20
+ payload = super
21
+ schema_without_thinking(payload, model) if schema && model.id.match?(SCHEMA_WITHOUT_THINKING)
22
+ payload
23
+ end
24
+
25
+ # Thinking goes off for structured output unless it was asked for.
26
+ def schema_without_thinking(payload, model)
27
+ effort = payload[:reasoning_effort]
28
+ return payload[:reasoning_effort] = 'none' if effort.nil?
29
+ return if effort == 'none'
30
+
31
+ RubyLLM.logger.warn(
32
+ "#{model.id} returns malformed JSON for a schema while thinking; use with_thinking(effort: :none)"
33
+ )
34
+ end
35
+
36
+ def format_role(role)
37
+ role.to_s
38
+ end
39
+
40
+ def max_output_tokens_field(_model)
41
+ :max_completion_tokens
42
+ end
43
+
44
+ # Infomaniak reads reasoning_effort as a switch: "none" turns thinking
45
+ # off, any other value turns it on. Efforts outside its four values are
46
+ # clamped to the nearest one.
47
+ def resolve_effort(thinking)
48
+ return 'none' if thinking.respond_to?(:disabled?) && thinking.disabled?
49
+
50
+ effort = super
51
+ return effort if effort.nil? || EFFORTS.include?(effort)
52
+
53
+ effort == 'minimal' ? 'low' : 'high'
54
+ end
55
+
56
+ def openai_prompt_caching?
57
+ false
58
+ end
59
+
60
+ def apply_end_user(payload, identifier)
61
+ payload.merge(user: identifier)
62
+ end
63
+
64
+ def format_content(content, attachments = [])
65
+ Protocols::ChatCompletions::Media.format_content(
66
+ content,
67
+ attachments,
68
+ document_attachments: :none,
69
+ audio_attachments: false
70
+ )
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Infomaniak < Provider
6
+ # Model listing. The OpenAI-style list names the ids this product can
7
+ # call; GET /1/ai/models adds the type, context size and beta flag.
8
+ # Neither reports vision or thinking, so those come from the model name,
9
+ # following what the served models did when probed (2026-09-30).
10
+ module Models
11
+ EFFORT_OPTION = { type: 'effort', values: Chat::EFFORTS }.freeze
12
+
13
+ # No reasoning output even with effort: :high. Mistral Small 4 does think.
14
+ NO_THINKING = /apertus|ministral|mistral-?3\b|mistral-small-3|llama|granite/i
15
+ VISION = /
16
+ \bvl\b|-vl-|vision|pixtral|qwen3\.5|kimi-k2\.[5-9]|gemma-?[34]|
17
+ ministral-3|mistral-small-4|apertus-v1\.5|llama-4
18
+ /ix
19
+ EMBEDDING = /embed|bge|\be5\b|gte|mini_?lm/i
20
+
21
+ def list_models
22
+ details = catalog_details
23
+ listed_models(@connection.get(models_url)).map do |entry|
24
+ build_model(entry, details_for(details, entry['id']))
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def listed_models(response)
31
+ data = response.body['data']
32
+ data.is_a?(Hash) ? [data] : Array(data)
33
+ end
34
+
35
+ # The catalog names a model either by its full id or without the
36
+ # organisation prefix.
37
+ def details_for(details, id)
38
+ key = id.to_s.downcase
39
+ details[key] || details[key.split('/').last] || {}
40
+ end
41
+
42
+ def catalog_details
43
+ data = Array(@provider.account_connection.get('1/ai/models').body['data'])
44
+ data.to_h { |entry| [entry['name'].to_s.downcase, entry] }
45
+ rescue RubyLLM::Error, Faraday::Error => e
46
+ RubyLLM.logger.warn("Infomaniak model details unavailable (#{e.message}); listing ids only")
47
+ {}
48
+ end
49
+
50
+ def build_model(entry, details)
51
+ id = entry['id']
52
+ type = model_type(id, details['type'])
53
+ vision = type == :chat && (id.match?(VISION) || details['description'].to_s.match?(/vision|image/i))
54
+ thinking = type == :chat && !id.match?(NO_THINKING)
55
+
56
+ Model.new(
57
+ id: id,
58
+ name: id,
59
+ provider: @provider.slug,
60
+ family: id.include?('/') ? id.split('/').first.downcase : nil,
61
+ created_at: entry['created'] ? Time.at(entry['created']) : nil,
62
+ context_window: details['max_token_input'],
63
+ capabilities: capabilities_for(type, vision:, thinking:),
64
+ modalities: modalities_for(type, vision:),
65
+ reasoning_options: thinking ? [EFFORT_OPTION] : [],
66
+ metadata: metadata_for(entry, details)
67
+ )
68
+ end
69
+
70
+ def model_type(id, type)
71
+ case type.to_s.downcase
72
+ when /embed/ then :embedding
73
+ when /rerank/ then :rerank
74
+ when /stt|speech|audio|whisper/ then :transcription
75
+ when /image/ then :image
76
+ when '' then id.match?(EMBEDDING) ? :embedding : :chat
77
+ else :chat
78
+ end
79
+ end
80
+
81
+ def capabilities_for(type, vision:, thinking:)
82
+ return [] unless type == :chat
83
+
84
+ capabilities = %w[streaming function_calling structured_output json_mode]
85
+ capabilities << 'vision' if vision
86
+ capabilities << 'reasoning' if thinking
87
+ capabilities
88
+ end
89
+
90
+ def modalities_for(type, vision:)
91
+ case type
92
+ when :embedding then { input: %w[text], output: %w[embeddings] }
93
+ when :rerank then { input: %w[text], output: %w[rerank] }
94
+ when :transcription then { input: %w[audio], output: %w[text] }
95
+ when :image then { input: %w[text], output: %w[image] }
96
+ else { input: vision ? %w[text image] : %w[text], output: %w[text] }
97
+ end
98
+ end
99
+
100
+ def metadata_for(entry, details)
101
+ {
102
+ owned_by: entry['owned_by'],
103
+ type: details['type'],
104
+ description: details['description'],
105
+ version: details['version'],
106
+ status: details['info_status'],
107
+ beta: details.dig('meta', 'is_beta'),
108
+ coder: details.dig('meta', 'is_coder'),
109
+ documentation: details['documentation_link']
110
+ }.compact
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Infomaniak < Provider
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require_relative 'infomaniak/version'
5
+ require_relative 'infomaniak/chat'
6
+ require_relative 'infomaniak/models'
7
+
8
+ module RubyLLM
9
+ module Providers
10
+ # Infomaniak AI Tools: OpenAI-compatible chat completions, embeddings and
11
+ # model listing under https://api.infomaniak.com/2/ai/{product_id}/openai/v1.
12
+ class Infomaniak < Provider
13
+ API_HOST = 'https://api.infomaniak.com'
14
+
15
+ # Infomaniak's dialect of the Chat Completions API.
16
+ class ChatCompletions < Protocols::ChatCompletions
17
+ include Infomaniak::Chat
18
+ include Infomaniak::Models
19
+ end
20
+
21
+ protocol :chat_completions, ChatCompletions
22
+
23
+ def api_base
24
+ @config.infomaniak_api_base || "#{API_HOST}/2/ai/#{product_id}/openai/v1"
25
+ end
26
+
27
+ def headers
28
+ { 'Authorization' => "Bearer #{@config.infomaniak_api_key}" }
29
+ end
30
+
31
+ # The AI Tools product requests are billed to: infomaniak_product_id, or
32
+ # the token's only product, looked up once per token with GET /1/ai.
33
+ def product_id
34
+ configured = @config.infomaniak_product_id.to_s.strip
35
+ return configured unless configured.empty?
36
+
37
+ self.class.product_ids[@config.infomaniak_api_key] ||= discover_product_id
38
+ end
39
+
40
+ # The account-level API, which serves product and model metadata
41
+ # outside the OpenAI base path.
42
+ def account_connection # :nodoc:
43
+ @account_connection ||= Transport::Connection.new(self, @config, api_base: API_HOST)
44
+ end
45
+
46
+ # Infomaniak's own endpoints answer {"result":"error","error":{"code":..,"description":..}}.
47
+ def parse_error(response)
48
+ body = parse_error_body(response)
49
+ error = body['error'] if body.is_a?(Hash)
50
+ return super unless error.is_a?(Hash) && error['description']
51
+
52
+ [error['description'], error['code']].compact.uniq.join(' - ')
53
+ end
54
+
55
+ class << self
56
+ def configuration_options
57
+ %i[infomaniak_api_key infomaniak_product_id infomaniak_api_base]
58
+ end
59
+
60
+ def configuration_requirements
61
+ %i[infomaniak_api_key]
62
+ end
63
+
64
+ # Returns +true+: any model id the product serves is accepted as given.
65
+ # `rake models` refreshes the bundled catalog with capabilities.
66
+ def assume_models_exist?
67
+ true
68
+ end
69
+
70
+ # Loads the live model list into RubyLLM.models in place of the
71
+ # catalog bundled with the gem, so models Infomaniak added since the
72
+ # release are known with their capabilities. Returns those models.
73
+ #
74
+ # RubyLLM::Providers::Infomaniak.refresh_models!
75
+ #
76
+ def refresh_models!(config = RubyLLM.config)
77
+ live = new(config).list_models
78
+ registry = RubyLLM.models
79
+ others = registry.all_including_unlisted.reject { |model| model.provider == slug }
80
+ # RubyLLM has no public way to replace one provider's entries, and
81
+ # RubyLLM.models.refresh! skips providers that ship a catalog.
82
+ registry.instance_variable_set(:@models, others + live)
83
+ live
84
+ end
85
+
86
+ def product_ids # :nodoc:
87
+ @product_ids ||= {}
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def discover_product_id
94
+ products = fetch_products
95
+ return products.first['product_id'].to_s if products.one?
96
+
97
+ raise ConfigurationError, product_error(products)
98
+ end
99
+
100
+ def fetch_products
101
+ Array(account_connection.get('1/ai').body['data'])
102
+ rescue RubyLLM::Error, Faraday::Error => e
103
+ raise ConfigurationError,
104
+ "Could not look up the Infomaniak AI Tools product (#{e.message}). " \
105
+ 'Set config.infomaniak_product_id, or check that the token has the AI Tools scope.'
106
+ end
107
+
108
+ def product_error(products)
109
+ return 'No Infomaniak AI Tools product is visible to this token. Set config.infomaniak_product_id.' if products.empty?
110
+
111
+ listed = products.map { |p| "#{p['product_id']} (#{p['product_name']}, #{p['account_name']})" }
112
+ "This token reaches #{products.size} AI Tools products: #{listed.join(', ')}. " \
113
+ 'Set config.infomaniak_product_id to the one to use.'
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ RubyLLM::Provider.register :infomaniak, RubyLLM::Providers::Infomaniak,
120
+ models: File.expand_path('../../../models.json', __dir__)
data/models.json ADDED
@@ -0,0 +1,403 @@
1
+ [
2
+ {
3
+ "id": "bge_multilingual_gemma2",
4
+ "name": "bge_multilingual_gemma2",
5
+ "provider": "infomaniak",
6
+ "family": null,
7
+ "created_at": "2024-12-03 23:00:00 UTC",
8
+ "context_window": 8000,
9
+ "max_output_tokens": null,
10
+ "knowledge_cutoff": null,
11
+ "modalities": {
12
+ "input": [
13
+ "text"
14
+ ],
15
+ "output": [
16
+ "embeddings"
17
+ ]
18
+ },
19
+ "capabilities": [],
20
+ "pricing": {},
21
+ "metadata": {
22
+ "owned_by": "system",
23
+ "type": "embedding",
24
+ "description": "Bge Multilingual Gemma2",
25
+ "version": "1.0",
26
+ "status": "coming_soon",
27
+ "beta": false,
28
+ "coder": false,
29
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/embeddings"
30
+ }
31
+ },
32
+ {
33
+ "id": "mini_lm_l12_v2",
34
+ "name": "mini_lm_l12_v2",
35
+ "provider": "infomaniak",
36
+ "family": null,
37
+ "created_at": "2024-12-03 23:00:00 UTC",
38
+ "context_window": 128,
39
+ "max_output_tokens": null,
40
+ "knowledge_cutoff": null,
41
+ "modalities": {
42
+ "input": [
43
+ "text"
44
+ ],
45
+ "output": [
46
+ "embeddings"
47
+ ]
48
+ },
49
+ "capabilities": [],
50
+ "pricing": {},
51
+ "metadata": {
52
+ "owned_by": "system",
53
+ "type": "embedding",
54
+ "description": "All MiniLM L12 v2",
55
+ "version": "2.0",
56
+ "status": "coming_soon",
57
+ "beta": false,
58
+ "coder": false,
59
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/embeddings"
60
+ }
61
+ },
62
+ {
63
+ "id": "Qwen/Qwen3-Embedding-8B",
64
+ "name": "Qwen/Qwen3-Embedding-8B",
65
+ "provider": "infomaniak",
66
+ "family": "qwen",
67
+ "created_at": "2025-11-23 23:00:00 UTC",
68
+ "context_window": 8192,
69
+ "max_output_tokens": null,
70
+ "knowledge_cutoff": null,
71
+ "modalities": {
72
+ "input": [
73
+ "text"
74
+ ],
75
+ "output": [
76
+ "embeddings"
77
+ ]
78
+ },
79
+ "capabilities": [],
80
+ "pricing": {},
81
+ "metadata": {
82
+ "owned_by": "system",
83
+ "type": "embedding",
84
+ "description": "Qwen/Qwen3-Embedding-8B",
85
+ "status": "coming_soon",
86
+ "beta": false,
87
+ "coder": false,
88
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/embeddings"
89
+ }
90
+ },
91
+ {
92
+ "id": "mistralai/Ministral-3-14B-Instruct-2512",
93
+ "name": "mistralai/Ministral-3-14B-Instruct-2512",
94
+ "provider": "infomaniak",
95
+ "family": "mistralai",
96
+ "created_at": "2026-02-01 23:00:00 UTC",
97
+ "context_window": 100000,
98
+ "max_output_tokens": null,
99
+ "knowledge_cutoff": null,
100
+ "modalities": {
101
+ "input": [
102
+ "text",
103
+ "image"
104
+ ],
105
+ "output": [
106
+ "text"
107
+ ]
108
+ },
109
+ "capabilities": [
110
+ "streaming",
111
+ "function_calling",
112
+ "structured_output",
113
+ "json_mode",
114
+ "vision"
115
+ ],
116
+ "pricing": {},
117
+ "metadata": {
118
+ "owned_by": "system",
119
+ "type": "llm",
120
+ "description": "mistralai/Ministral-3-14B-Instruct-2512",
121
+ "status": "ready",
122
+ "beta": false,
123
+ "coder": false,
124
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions"
125
+ }
126
+ },
127
+ {
128
+ "id": "Qwen/Qwen3.5-122B-A10B-FP8",
129
+ "name": "Qwen/Qwen3.5-122B-A10B-FP8",
130
+ "provider": "infomaniak",
131
+ "family": "qwen",
132
+ "created_at": null,
133
+ "context_window": 200000,
134
+ "max_output_tokens": null,
135
+ "knowledge_cutoff": null,
136
+ "modalities": {
137
+ "input": [
138
+ "text",
139
+ "image"
140
+ ],
141
+ "output": [
142
+ "text"
143
+ ]
144
+ },
145
+ "capabilities": [
146
+ "streaming",
147
+ "function_calling",
148
+ "structured_output",
149
+ "json_mode",
150
+ "vision",
151
+ "reasoning"
152
+ ],
153
+ "pricing": {},
154
+ "metadata": {
155
+ "owned_by": "system",
156
+ "type": "llm",
157
+ "description": "Qwen/Qwen3.5-122B-A10B-FP8",
158
+ "status": "coming_soon",
159
+ "beta": false,
160
+ "coder": false,
161
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
162
+ "reasoning_options": [
163
+ {
164
+ "type": "effort",
165
+ "values": [
166
+ "none",
167
+ "low",
168
+ "medium",
169
+ "high"
170
+ ]
171
+ }
172
+ ]
173
+ }
174
+ },
175
+ {
176
+ "id": "google/gemma-4-31B-it",
177
+ "name": "google/gemma-4-31B-it",
178
+ "provider": "infomaniak",
179
+ "family": "google",
180
+ "created_at": null,
181
+ "context_window": 100000,
182
+ "max_output_tokens": null,
183
+ "knowledge_cutoff": null,
184
+ "modalities": {
185
+ "input": [
186
+ "text",
187
+ "image"
188
+ ],
189
+ "output": [
190
+ "text"
191
+ ]
192
+ },
193
+ "capabilities": [
194
+ "streaming",
195
+ "function_calling",
196
+ "structured_output",
197
+ "json_mode",
198
+ "vision",
199
+ "reasoning"
200
+ ],
201
+ "pricing": {},
202
+ "metadata": {
203
+ "owned_by": "system",
204
+ "type": "llm",
205
+ "description": "google/gemma-4-31B-it",
206
+ "status": "coming_soon",
207
+ "beta": false,
208
+ "coder": false,
209
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
210
+ "reasoning_options": [
211
+ {
212
+ "type": "effort",
213
+ "values": [
214
+ "none",
215
+ "low",
216
+ "medium",
217
+ "high"
218
+ ]
219
+ }
220
+ ]
221
+ }
222
+ },
223
+ {
224
+ "id": "moonshotai/Kimi-K2.6",
225
+ "name": "moonshotai/Kimi-K2.6",
226
+ "provider": "infomaniak",
227
+ "family": "moonshotai",
228
+ "created_at": null,
229
+ "context_window": 256000,
230
+ "max_output_tokens": null,
231
+ "knowledge_cutoff": null,
232
+ "modalities": {
233
+ "input": [
234
+ "text",
235
+ "image"
236
+ ],
237
+ "output": [
238
+ "text"
239
+ ]
240
+ },
241
+ "capabilities": [
242
+ "streaming",
243
+ "function_calling",
244
+ "structured_output",
245
+ "json_mode",
246
+ "vision",
247
+ "reasoning"
248
+ ],
249
+ "pricing": {},
250
+ "metadata": {
251
+ "owned_by": "system",
252
+ "type": "llm",
253
+ "description": "moonshotai/Kimi-K2.6",
254
+ "status": "coming_soon",
255
+ "beta": true,
256
+ "coder": false,
257
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
258
+ "reasoning_options": [
259
+ {
260
+ "type": "effort",
261
+ "values": [
262
+ "none",
263
+ "low",
264
+ "medium",
265
+ "high"
266
+ ]
267
+ }
268
+ ]
269
+ }
270
+ },
271
+ {
272
+ "id": "mistralai/Mistral-Small-4-119B-2603",
273
+ "name": "mistralai/Mistral-Small-4-119B-2603",
274
+ "provider": "infomaniak",
275
+ "family": "mistralai",
276
+ "created_at": null,
277
+ "context_window": 256000,
278
+ "max_output_tokens": null,
279
+ "knowledge_cutoff": null,
280
+ "modalities": {
281
+ "input": [
282
+ "text",
283
+ "image"
284
+ ],
285
+ "output": [
286
+ "text"
287
+ ]
288
+ },
289
+ "capabilities": [
290
+ "streaming",
291
+ "function_calling",
292
+ "structured_output",
293
+ "json_mode",
294
+ "vision",
295
+ "reasoning"
296
+ ],
297
+ "pricing": {},
298
+ "metadata": {
299
+ "owned_by": "system",
300
+ "type": "llm",
301
+ "description": "mistralai/Mistral-Small-4-119B-2603",
302
+ "status": "coming_soon",
303
+ "beta": false,
304
+ "coder": false,
305
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
306
+ "reasoning_options": [
307
+ {
308
+ "type": "effort",
309
+ "values": [
310
+ "none",
311
+ "low",
312
+ "medium",
313
+ "high"
314
+ ]
315
+ }
316
+ ]
317
+ }
318
+ },
319
+ {
320
+ "id": "Qwen/Qwen3.5-397B-A17B-FP8",
321
+ "name": "Qwen/Qwen3.5-397B-A17B-FP8",
322
+ "provider": "infomaniak",
323
+ "family": "qwen",
324
+ "created_at": null,
325
+ "context_window": 200000,
326
+ "max_output_tokens": null,
327
+ "knowledge_cutoff": null,
328
+ "modalities": {
329
+ "input": [
330
+ "text",
331
+ "image"
332
+ ],
333
+ "output": [
334
+ "text"
335
+ ]
336
+ },
337
+ "capabilities": [
338
+ "streaming",
339
+ "function_calling",
340
+ "structured_output",
341
+ "json_mode",
342
+ "vision",
343
+ "reasoning"
344
+ ],
345
+ "pricing": {},
346
+ "metadata": {
347
+ "owned_by": "system",
348
+ "type": "llm",
349
+ "description": "Qwen/Qwen3.5-397B-A17B-FP8",
350
+ "status": "coming_soon",
351
+ "beta": true,
352
+ "coder": false,
353
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
354
+ "reasoning_options": [
355
+ {
356
+ "type": "effort",
357
+ "values": [
358
+ "none",
359
+ "low",
360
+ "medium",
361
+ "high"
362
+ ]
363
+ }
364
+ ]
365
+ }
366
+ },
367
+ {
368
+ "id": "swiss-ai/Apertus-v1.5-70B",
369
+ "name": "swiss-ai/Apertus-v1.5-70B",
370
+ "provider": "infomaniak",
371
+ "family": "swiss-ai",
372
+ "created_at": null,
373
+ "context_window": 100000,
374
+ "max_output_tokens": null,
375
+ "knowledge_cutoff": null,
376
+ "modalities": {
377
+ "input": [
378
+ "text",
379
+ "image"
380
+ ],
381
+ "output": [
382
+ "text"
383
+ ]
384
+ },
385
+ "capabilities": [
386
+ "streaming",
387
+ "function_calling",
388
+ "structured_output",
389
+ "json_mode",
390
+ "vision"
391
+ ],
392
+ "pricing": {},
393
+ "metadata": {
394
+ "owned_by": "system",
395
+ "type": "llm",
396
+ "description": "swiss-ai/Apertus-v1.5-70B",
397
+ "status": "coming_soon",
398
+ "beta": true,
399
+ "coder": false,
400
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions"
401
+ }
402
+ }
403
+ ]
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-providers-infomaniak
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Andreas Idogawa
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.0'
26
+ description: 'Use the models hosted by Infomaniak AI Tools in Switzerland (Kimi, Qwen,
27
+ Apertus, Mistral, ...) through RubyLLM: chat, streaming, tools, structured output,
28
+ thinking, images and embeddings.'
29
+ email:
30
+ - web@idogawa.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - lib/ruby_llm/providers/infomaniak.rb
39
+ - lib/ruby_llm/providers/infomaniak/chat.rb
40
+ - lib/ruby_llm/providers/infomaniak/models.rb
41
+ - lib/ruby_llm/providers/infomaniak/version.rb
42
+ - models.json
43
+ homepage: https://github.com/Largo/ruby_llm-providers-infomaniak
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ source_code_uri: https://github.com/Largo/ruby_llm-providers-infomaniak
48
+ changelog_uri: https://github.com/Largo/ruby_llm-providers-infomaniak/blob/main/CHANGELOG.md
49
+ bug_tracker_uri: https://github.com/Largo/ruby_llm-providers-infomaniak/issues
50
+ rubygems_mfa_required: 'true'
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '3.2'
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 4.0.20
66
+ specification_version: 4
67
+ summary: RubyLLM provider for Infomaniak AI Tools
68
+ test_files: []