ruby_llm-providers-infomaniak 0.1.0 → 0.2.1

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: 264a19f883d98cbfdfb7a49a0e1784d964e1289e224ae42dc46641c1e59e9ba5
4
- data.tar.gz: 6cbc48e9ad718684ffb0609d10f2b6a4f88043ef6aeb22d19a99341089a2c27c
3
+ metadata.gz: 313aafbac43b95af0726c18ae23550a1d5af92ce8f2fd37df2d278a10e9ac38f
4
+ data.tar.gz: 1176ac3099d709cab842735916ee51de3276e1fb2e27ac8a4bb86309add7b2db
5
5
  SHA512:
6
- metadata.gz: dc84db9b0ff07fb6960b3a03344a1da8667a12758bb11d4e581bca04f67273c14debf118a5006c5b27dd93b6283763a6e3dc738e01279bca9e5ba9906f96dd21
7
- data.tar.gz: d1940a9651d6ea0493903893129b86189a12f97ab5181456ab4057c9da44dbe92aa62bb785fd8908d26091f65a6e53bf58baae5130e92dd043c2e652ad95a017
6
+ metadata.gz: dc6651ace176ee4dcdfb2966a3935c316d0b9b3ab61b8a3b4e0e015a1b29455a510522a3ee375fa5da1aa72d4640d2c1e8414e94661cd6eb1d023fbea8dba421
7
+ data.tar.gz: 8d9527b0e60fbb7462fa10bb2375305cac960599d4394cb72c7ecc0c52a024b220450c020fb96efe0406e883b6fa42014a9328369ebcb4e9a5aeb3938415ede0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - `RubyLLM.paint` without `size:` works: the size is left out instead of being sent as null,
6
+ which Infomaniak rejected.
7
+ - Error messages include Infomaniak's validation details, e.g.
8
+ "Validation failed: The size field must have a value. (validation_failed)".
9
+
10
+ ## 0.2.0
11
+
12
+ - Reranking (`RubyLLM.rerank`) with `BAAI/bge-reranker-v2-m3` and `Qwen/Qwen3-Reranker-0.6B`, on the
13
+ Cohere-compatible endpoint.
14
+ - Image generation (`RubyLLM.paint`) with Flux; the image type is read from the returned bytes (JPEG).
15
+ - Transcription (`RubyLLM.transcribe`) with Whisper, polling Infomaniak's asynchronous results
16
+ (`infomaniak_poll_interval`).
17
+ - The model catalog now also lists the rerankers, Flux and Whisper.
18
+ - Pricing: the catalog carries Infomaniak's CHF list prices, so `response.cost` works for chat,
19
+ embeddings and rerank (in CHF). Flux and Whisper per-minute prices are in the model metadata.
20
+ - Author name: Andi Idogawa.
21
+
3
22
  ## 0.1.0
4
23
 
5
24
  - First release: `infomaniak` provider for RubyLLM 2.x on Infomaniak AI Tools' OpenAI-compatible API, with
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Andreas Idogawa
3
+ Copyright (c) 2026 Andi Idogawa
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  [![Gem Version](https://badge.fury.io/rb/ruby_llm-providers-infomaniak.svg)](https://rubygems.org/gems/ruby_llm-providers-infomaniak)
4
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
5
 
6
+ ![ruby_llm-providers-infomaniak: Swiss-hosted open models, in plain Ruby](docs/social/twitter-card.png)
7
+
6
8
  **Open-weight models hosted in Switzerland, through the RubyLLM API you already know.**
7
9
 
8
10
  This gem adds [Infomaniak AI Tools](https://www.infomaniak.com/en/hosting/ai-tools) as a provider to
@@ -17,6 +19,7 @@ chat.ask('Explain Ruby blocks in one sentence.').content
17
19
  ```
18
20
 
19
21
  - Chat, streaming, tool calling, structured output, image input, embeddings
22
+ - Reranking, image generation (Flux) and transcription (Whisper)
20
23
  - Thinking on/off per request, with the model's reasoning on `response.thinking`
21
24
  - Only an API token to configure: the AI Tools product is looked up for you
22
25
  - A model catalog with context sizes and capabilities, refreshable at runtime
@@ -60,7 +63,8 @@ caches the answer per token.
60
63
  |---|---|---|
61
64
  | `infomaniak_api_key` | required | API token with the AI Tools scope |
62
65
  | `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 |
66
+ | `infomaniak_api_base` | `https://api.infomaniak.com/2/ai/{product_id}/openai/v1` | Override for a proxy or gateway. Rerank, image and transcription routes are derived from it |
67
+ | `infomaniak_poll_interval` | `2` | Seconds between checks while a transcription runs (bounded by `request_timeout`) |
64
68
 
65
69
  ## Models
66
70
 
@@ -68,25 +72,30 @@ The chat and embedding models Infomaniak served on 2026-09-30, and how each beha
68
72
  Your product lists its own with `bundle exec rake models`, or live with
69
73
  `RubyLLM::Providers::Infomaniak.refresh_models!`.
70
74
 
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 |
75
+ | Model | Context | Images | Thinking | CHF per 1M tokens, in / out |
76
+ |---|---|---|---|---|
77
+ | `moonshotai/Kimi-K2.6` (beta) | 256K | yes | on by default | 0.60 / 3.00 |
78
+ | `Qwen/Qwen3.5-397B-A17B-FP8` (beta) | 200K | yes | on by default | 0.80 / 3.60 |
79
+ | `Qwen/Qwen3.5-122B-A10B-FP8` | 200K | yes | on by default | 0.40 / 3.20 |
80
+ | `google/gemma-4-31B-it` | 100K | yes | off, opt in with an effort | 0.20 / 0.40 |
81
+ | `mistralai/Mistral-Small-4-119B-2603` | 256K | yes | off, opt in with an effort | 0.20 / 0.75 |
82
+ | `mistralai/Ministral-3-14B-Instruct-2512` | 100K | yes | none | 0.30 / 0.40 |
83
+ | `swiss-ai/Apertus-v1.5-70B` (beta) | 100K | yes | none | 0.70 / 2.50 |
84
+
85
+ | Embedding model | Input tokens | CHF per 1M tokens |
86
+ |---|---|---|
87
+ | `Qwen/Qwen3-Embedding-8B` | 8192 | 0.07 |
88
+ | `bge_multilingual_gemma2` | 8000 | 0.065 |
89
+ | `mini_lm_l12_v2` | 128 | free |
86
90
 
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.
91
+ | Other models | For | CHF |
92
+ |---|---|---|
93
+ | `BAAI/bge-reranker-v2-m3` | `RubyLLM.rerank` | 0.01 per 1M tokens |
94
+ | `Qwen/Qwen3-Reranker-0.6B` | `RubyLLM.rerank` | 0.009 per 1M tokens |
95
+ | `flux` | `RubyLLM.paint`, returns JPEG | 0.30 per minute of compute |
96
+ | `whisper` | `RubyLLM.transcribe` | 0.006 per audio minute |
97
+
98
+ Photomaker, which needs reference photos on its own route, is not covered.
90
99
 
91
100
  ### The model catalog
92
101
 
@@ -175,6 +184,53 @@ chat.ask('What is on this receipt?', with: 'receipt.jpg').content
175
184
  RubyLLM.embed('Grüezi mitenand', model: 'Qwen/Qwen3-Embedding-8B', provider: :infomaniak).vectors
176
185
  ```
177
186
 
187
+ ### Reranking
188
+
189
+ Sort retrieved passages by relevance before handing them to a chat model:
190
+
191
+ ```ruby
192
+ rerank = RubyLLM.rerank('Which metal is the densest?', passages,
193
+ model: 'BAAI/bge-reranker-v2-m3', provider: :infomaniak, top_n: 3)
194
+ rerank.results.map { |result| [result.score, result.document] }
195
+ ```
196
+
197
+ ### Image generation
198
+
199
+ ```ruby
200
+ image = RubyLLM.paint('A red Swiss train crossing a stone viaduct, watercolor',
201
+ model: 'flux', provider: :infomaniak, size: '1024x1024') # or 1024x1792, 1792x1024
202
+ image.save('train.jpg')
203
+ ```
204
+
205
+ Prompts work best in English, and are limited to 77 tokens. Infomaniak's extra options pass through
206
+ `provider_options:`, e.g. `provider_options: { style: 'photographic' }`.
207
+
208
+ ### Transcription
209
+
210
+ ```ruby
211
+ RubyLLM.transcribe('meeting.m4a', model: 'whisper', provider: :infomaniak, language: 'de').text
212
+ ```
213
+
214
+ Infomaniak runs transcriptions as background jobs: the gem uploads the file, then polls until the
215
+ transcript is ready, so the call blocks for about as long as the job takes. Streaming (passing a block)
216
+ is not possible. mp3, mp4, m4a, wav, flac, ogg, opus, aac, wma and webm are accepted.
217
+
218
+ ### Costs
219
+
220
+ The catalog carries Infomaniak's list prices, so ruby_llm computes costs from the token counts:
221
+
222
+ ```ruby
223
+ response = chat.ask('Summarise this contract.', with: 'contract.txt')
224
+ response.cost.total # => 0.0021 (CHF)
225
+ chat.cost.total # the whole conversation
226
+ ```
227
+
228
+ **Costs from this provider are in CHF**, exactly as Infomaniak bills them, although ruby_llm documents its
229
+ prices as USD. Don't add them to costs from other providers without converting. Chat, embedding and
230
+ rerank costs are computed; Flux and Whisper bill per minute, which ruby_llm cannot compute, so their
231
+ prices are only in `model.metadata[:price_per_minute]`. The prices date from 2026-09-30 and live in
232
+ `lib/ruby_llm/providers/infomaniak/pricing.rb`.
233
+
178
234
  ## How Infomaniak differs from OpenAI
179
235
 
180
236
  The gem handles these, so the RubyLLM API behaves as usual:
@@ -189,6 +245,10 @@ The gem handles these, so the RubyLLM API behaves as usual:
189
245
  | End user | `user` field | `with_end_user('id')` is sent as `user` |
190
246
  | 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
247
  | Errors | `{"error": {"code", "description"}}` on its own endpoints | The description ends up in the `RubyLLM::Error` message |
248
+ | Routes | Chat and embeddings on `/2/.../openai/v1`, rerank on `/2/.../cohere/v2`, images and transcription on `/1/.../openai` | Each call goes to its route |
249
+ | Reranking | Cohere v2 format, usage as `usage.total_tokens` | Read into `rerank.tokens.input` |
250
+ | Images | Only base64 JPEG, no edits | The image type is read from the bytes; `with:` raises `ArgumentError` |
251
+ | Transcription | Asynchronous: upload returns a batch id, results are polled | Polls every `infomaniak_poll_interval` seconds until done, failed or `request_timeout` |
192
252
 
193
253
  ## Development
194
254
 
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Infomaniak < Provider
6
+ # Image generation (Flux) and transcription (Whisper) on Infomaniak's
7
+ # OpenAI-style v1 routes. Transcription is asynchronous there: the upload
8
+ # returns a batch id, and the transcript is polled from /results.
9
+ class Media < Protocol
10
+ include Protocols::ChatCompletions::Images
11
+ include Protocols::ChatCompletions::Transcription
12
+
13
+ public :render_transcription_options
14
+
15
+ IMAGE_SIGNATURES = { "\x89PNG".b => 'image/png', "\xFF\xD8\xFF".b => 'image/jpeg', 'RIFF'.b => 'image/webp' }.freeze
16
+ Body = Struct.new(:body)
17
+
18
+ def images_url(**)
19
+ @provider.product_url(1, 'openai/images/generations')
20
+ end
21
+
22
+ # Infomaniak rejects "size": null, so a paint call without size: leaves it out.
23
+ def render_image_payload(prompt, model:, size:, **)
24
+ super.compact
25
+ end
26
+
27
+ def validate_paint_inputs!(with:, mask:)
28
+ raise ArgumentError, 'Infomaniak generates images from a prompt only; it cannot edit images' if editing?(with, mask)
29
+ end
30
+
31
+ # ruby_llm assumes PNG for OpenAI-style images; read the real type from the bytes.
32
+ def parse_image_responses(response, model:)
33
+ entries = Array(response.body['data'])
34
+ raise Error.new('Infomaniak returned no image', response: response) if entries.empty?
35
+
36
+ entries.map do |entry|
37
+ Image.new(data: entry['b64_json'], mime_type: image_mime_type(entry['b64_json']),
38
+ revised_prompt: entry['revised_prompt'], model: model, usage: {})
39
+ end
40
+ end
41
+
42
+ def transcription_url
43
+ @provider.product_url(1, 'openai/audio/transcriptions')
44
+ end
45
+
46
+ def stream_transcription(*)
47
+ raise Error, 'Infomaniak transcribes asynchronously and cannot stream; call transcribe without a block'
48
+ end
49
+
50
+ def parse_transcription_response(response, model:)
51
+ batch_id = unwrap(response.body)['batch_id']
52
+ raise Error.new('Infomaniak returned no transcription batch id', response: response) unless batch_id
53
+
54
+ super(Body.new(transcript(await_batch(batch_id))), model:)
55
+ end
56
+
57
+ private
58
+
59
+ def image_mime_type(base64)
60
+ head = base64.to_s[0, 24].unpack1('m')
61
+ IMAGE_SIGNATURES.find { |signature, _| head.start_with?(signature) }&.last || 'image/png'
62
+ end
63
+
64
+ def await_batch(batch_id)
65
+ deadline = monotonic_now + @config.request_timeout
66
+ loop do
67
+ result = unwrap(@connection.get(@provider.product_url(1, "results/#{batch_id}")).body)
68
+ case result['status']
69
+ when 'success' then return result
70
+ when 'failed', 'cancelled' then raise Error, "Infomaniak transcription #{result['status']} (batch #{batch_id})"
71
+ end
72
+ raise Error, "Infomaniak transcription not done after #{@config.request_timeout}s (batch #{batch_id})" if monotonic_now > deadline
73
+
74
+ sleep(@config.infomaniak_poll_interval || 2)
75
+ end
76
+ end
77
+
78
+ # The finished batch carries the output inline, or only a download URL.
79
+ def transcript(result)
80
+ data = result['data'] || @connection.get(result['url']).body
81
+ return data unless data.is_a?(String)
82
+
83
+ parsed = JSON.parse(data)
84
+ parsed.is_a?(Hash) ? parsed : data
85
+ rescue JSON::ParserError
86
+ data
87
+ end
88
+
89
+ # Infomaniak's own routes wrap payloads as {"result": "success", "data": {...}}.
90
+ def unwrap(body)
91
+ body.is_a?(Hash) && body.key?('result') && body['data'].is_a?(Hash) ? body['data'] : body
92
+ end
93
+
94
+ def monotonic_now
95
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -18,11 +18,23 @@ module RubyLLM
18
18
  /ix
19
19
  EMBEDDING = /embed|bge|\be5\b|gte|mini_?lm/i
20
20
 
21
+ # Catalog types served outside the OpenAI endpoint, so missing from its list.
22
+ OTHER_ENDPOINT_TYPES = %w[reranker image stt].freeze
23
+ # Photomaker needs its own route with reference photos, which the gem does not cover.
24
+ UNSUPPORTED = /photo_?maker/i
25
+
21
26
  def list_models
22
27
  details = catalog_details
23
- listed_models(@connection.get(models_url)).map do |entry|
24
- build_model(entry, details_for(details, entry['id']))
28
+ listed = listed_models(@connection.get(models_url))
29
+ listed_ids = listed.map { |entry| entry['id'].to_s.downcase }
30
+ elsewhere = details.values.select do |entry|
31
+ name = entry['name'].to_s
32
+ OTHER_ENDPOINT_TYPES.include?(entry['type']) && !name.match?(UNSUPPORTED) &&
33
+ !listed_ids.include?(name.downcase)
25
34
  end
35
+
36
+ listed.map { |entry| build_model(entry, details_for(details, entry['id'])) } +
37
+ elsewhere.map { |entry| build_model({ 'id' => entry['name'] }, entry) }
26
38
  end
27
39
 
28
40
  private
@@ -63,7 +75,8 @@ module RubyLLM
63
75
  capabilities: capabilities_for(type, vision:, thinking:),
64
76
  modalities: modalities_for(type, vision:),
65
77
  reasoning_options: thinking ? [EFFORT_OPTION] : [],
66
- metadata: metadata_for(entry, details)
78
+ pricing: Pricing.pricing_for(id),
79
+ metadata: metadata_for(entry, details).merge(Pricing.metadata_for(id))
67
80
  )
68
81
  end
69
82
 
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Infomaniak < Provider
6
+ # Infomaniak's list prices in CHF, from
7
+ # https://www.infomaniak.com/en/hosting/ai-services/prices. The API does
8
+ # not report prices, so they are kept here and written into the catalog
9
+ # by `rake models`. They go in as CHF: ruby_llm calls its prices USD,
10
+ # but response.cost for this provider is in CHF, matching the invoice.
11
+ module Pricing
12
+ AS_OF = '2026-09-30'
13
+ CURRENCY = 'CHF'
14
+
15
+ # Per million tokens: [input, output]. Rerankers and embeddings bill input only.
16
+ PER_MILLION_TOKENS = {
17
+ 'Qwen/Qwen3.5-122B-A10B-FP8' => [0.40, 3.20],
18
+ 'Qwen/Qwen3.5-397B-A17B-FP8' => [0.80, 3.60],
19
+ 'mistralai/Ministral-3-14B-Instruct-2512' => [0.30, 0.40],
20
+ 'mistralai/Mistral-Small-4-119B-2603' => [0.20, 0.75],
21
+ 'google/gemma-4-31B-it' => [0.20, 0.40],
22
+ 'moonshotai/Kimi-K2.6' => [0.60, 3.00],
23
+ 'swiss-ai/Apertus-v1.5-70B' => [0.70, 2.50],
24
+ 'BAAI/bge-reranker-v2-m3' => [0.01],
25
+ 'Qwen/Qwen3-Reranker-0.6B' => [0.009],
26
+ 'bge_multilingual_gemma2' => [0.065],
27
+ 'Qwen/Qwen3-Embedding-8B' => [0.07],
28
+ 'mini_lm_l12_v2' => [0]
29
+ }.freeze
30
+
31
+ # Billed per minute (audio length for Whisper, compute time for Flux),
32
+ # which ruby_llm's per-token pricing cannot express. Metadata only.
33
+ PER_MINUTE = { 'whisper' => 0.006, 'flux' => 0.30 }.freeze
34
+
35
+ module_function
36
+
37
+ def pricing_for(id)
38
+ input, output = PER_MILLION_TOKENS[id]
39
+ return {} unless input
40
+
41
+ { text_tokens: { standard: { input_per_million: input, output_per_million: output }.compact } }
42
+ end
43
+
44
+ def metadata_for(id)
45
+ per_minute = PER_MINUTE[id]
46
+ return {} unless per_minute || PER_MILLION_TOKENS.key?(id)
47
+
48
+ { currency: CURRENCY, prices_as_of: AS_OF, price_per_minute: per_minute }.compact
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Infomaniak < Provider
6
+ # Reranking on Infomaniak's Cohere v2 compatible endpoint, which sits
7
+ # next to the OpenAI one and reports usage OpenAI-style instead of in meta.
8
+ class CohereRerank < Protocol
9
+ include Protocols::Cohere::Rerank
10
+
11
+ def rerank_url
12
+ @provider.product_url(2, 'cohere/v2/rerank')
13
+ end
14
+
15
+ def parse_rerank_response(response, model:, documents: [])
16
+ data = response.body
17
+ RubyLLM::Rerank.new(
18
+ results: parse_rerank_results(data, documents),
19
+ model: data['model'] || model,
20
+ raw: data,
21
+ input_tokens: data.dig('usage', 'total_tokens')
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -3,7 +3,7 @@
3
3
  module RubyLLM
4
4
  module Providers
5
5
  class Infomaniak < Provider
6
- VERSION = '0.1.0'
6
+ VERSION = '0.2.1'
7
7
  end
8
8
  end
9
9
  end
@@ -3,12 +3,17 @@
3
3
  require 'ruby_llm'
4
4
  require_relative 'infomaniak/version'
5
5
  require_relative 'infomaniak/chat'
6
+ require_relative 'infomaniak/pricing'
6
7
  require_relative 'infomaniak/models'
8
+ require_relative 'infomaniak/rerank'
9
+ require_relative 'infomaniak/media'
7
10
 
8
11
  module RubyLLM
9
12
  module Providers
10
13
  # Infomaniak AI Tools: OpenAI-compatible chat completions, embeddings and
11
- # model listing under https://api.infomaniak.com/2/ai/{product_id}/openai/v1.
14
+ # model listing under https://api.infomaniak.com/2/ai/{product_id}/openai/v1,
15
+ # reranking under /2/ai/{product_id}/cohere/v2, and image generation and
16
+ # transcription under /1/ai/{product_id}/openai.
12
17
  class Infomaniak < Provider
13
18
  API_HOST = 'https://api.infomaniak.com'
14
19
 
@@ -19,6 +24,23 @@ module RubyLLM
19
24
  end
20
25
 
21
26
  protocol :chat_completions, ChatCompletions
27
+ protocol :rerank, CohereRerank
28
+ protocol :media, Media
29
+
30
+ def protocol_for(model, operation: nil, **)
31
+ case operation
32
+ when :rerank then protocols[:rerank]
33
+ when :paint, :transcribe then protocols[:media]
34
+ else super
35
+ end
36
+ end
37
+
38
+ # The URL of +path+ under the product on API +version+ (1 or 2), derived
39
+ # from api_base so an override moves every route along.
40
+ def product_url(version, path)
41
+ base = api_base.delete_suffix('/').delete_suffix('/openai/v1')
42
+ "#{base.sub(%r{/2/ai/}, "/#{version}/ai/")}/#{path}"
43
+ end
22
44
 
23
45
  def api_base
24
46
  @config.infomaniak_api_base || "#{API_HOST}/2/ai/#{product_id}/openai/v1"
@@ -43,18 +65,21 @@ module RubyLLM
43
65
  @account_connection ||= Transport::Connection.new(self, @config, api_base: API_HOST)
44
66
  end
45
67
 
46
- # Infomaniak's own endpoints answer {"result":"error","error":{"code":..,"description":..}}.
68
+ # Infomaniak's own endpoints answer {"result":"error","error":{"code":..,"description":..}},
69
+ # with the specifics of a validation failure in error.errors[].description.
47
70
  def parse_error(response)
48
71
  body = parse_error_body(response)
49
72
  error = body['error'] if body.is_a?(Hash)
50
73
  return super unless error.is_a?(Hash) && error['description']
51
74
 
52
- [error['description'], error['code']].compact.uniq.join(' - ')
75
+ details = Array(error['errors']).filter_map { |item| item['description'] if item.is_a?(Hash) }
76
+ message = details.any? ? "#{error['description']}: #{details.join(' ')}" : error['description']
77
+ error['code'] ? "#{message} (#{error['code']})" : message
53
78
  end
54
79
 
55
80
  class << self
56
81
  def configuration_options
57
- %i[infomaniak_api_key infomaniak_product_id infomaniak_api_base]
82
+ %i[infomaniak_api_key infomaniak_product_id infomaniak_api_base infomaniak_poll_interval]
58
83
  end
59
84
 
60
85
  def configuration_requirements
data/models.json CHANGED
@@ -17,7 +17,13 @@
17
17
  ]
18
18
  },
19
19
  "capabilities": [],
20
- "pricing": {},
20
+ "pricing": {
21
+ "text_tokens": {
22
+ "standard": {
23
+ "input_per_million": 0.065
24
+ }
25
+ }
26
+ },
21
27
  "metadata": {
22
28
  "owned_by": "system",
23
29
  "type": "embedding",
@@ -26,7 +32,9 @@
26
32
  "status": "coming_soon",
27
33
  "beta": false,
28
34
  "coder": false,
29
- "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/embeddings"
35
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/embeddings",
36
+ "currency": "CHF",
37
+ "prices_as_of": "2026-09-30"
30
38
  }
31
39
  },
32
40
  {
@@ -47,7 +55,13 @@
47
55
  ]
48
56
  },
49
57
  "capabilities": [],
50
- "pricing": {},
58
+ "pricing": {
59
+ "text_tokens": {
60
+ "standard": {
61
+ "input_per_million": 0
62
+ }
63
+ }
64
+ },
51
65
  "metadata": {
52
66
  "owned_by": "system",
53
67
  "type": "embedding",
@@ -56,7 +70,9 @@
56
70
  "status": "coming_soon",
57
71
  "beta": false,
58
72
  "coder": false,
59
- "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/embeddings"
73
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/embeddings",
74
+ "currency": "CHF",
75
+ "prices_as_of": "2026-09-30"
60
76
  }
61
77
  },
62
78
  {
@@ -77,7 +93,13 @@
77
93
  ]
78
94
  },
79
95
  "capabilities": [],
80
- "pricing": {},
96
+ "pricing": {
97
+ "text_tokens": {
98
+ "standard": {
99
+ "input_per_million": 0.07
100
+ }
101
+ }
102
+ },
81
103
  "metadata": {
82
104
  "owned_by": "system",
83
105
  "type": "embedding",
@@ -85,7 +107,9 @@
85
107
  "status": "coming_soon",
86
108
  "beta": false,
87
109
  "coder": false,
88
- "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/embeddings"
110
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/embeddings",
111
+ "currency": "CHF",
112
+ "prices_as_of": "2026-09-30"
89
113
  }
90
114
  },
91
115
  {
@@ -113,7 +137,14 @@
113
137
  "json_mode",
114
138
  "vision"
115
139
  ],
116
- "pricing": {},
140
+ "pricing": {
141
+ "text_tokens": {
142
+ "standard": {
143
+ "input_per_million": 0.3,
144
+ "output_per_million": 0.4
145
+ }
146
+ }
147
+ },
117
148
  "metadata": {
118
149
  "owned_by": "system",
119
150
  "type": "llm",
@@ -121,7 +152,9 @@
121
152
  "status": "ready",
122
153
  "beta": false,
123
154
  "coder": false,
124
- "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions"
155
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
156
+ "currency": "CHF",
157
+ "prices_as_of": "2026-09-30"
125
158
  }
126
159
  },
127
160
  {
@@ -150,7 +183,14 @@
150
183
  "vision",
151
184
  "reasoning"
152
185
  ],
153
- "pricing": {},
186
+ "pricing": {
187
+ "text_tokens": {
188
+ "standard": {
189
+ "input_per_million": 0.4,
190
+ "output_per_million": 3.2
191
+ }
192
+ }
193
+ },
154
194
  "metadata": {
155
195
  "owned_by": "system",
156
196
  "type": "llm",
@@ -159,6 +199,8 @@
159
199
  "beta": false,
160
200
  "coder": false,
161
201
  "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
202
+ "currency": "CHF",
203
+ "prices_as_of": "2026-09-30",
162
204
  "reasoning_options": [
163
205
  {
164
206
  "type": "effort",
@@ -198,7 +240,14 @@
198
240
  "vision",
199
241
  "reasoning"
200
242
  ],
201
- "pricing": {},
243
+ "pricing": {
244
+ "text_tokens": {
245
+ "standard": {
246
+ "input_per_million": 0.2,
247
+ "output_per_million": 0.4
248
+ }
249
+ }
250
+ },
202
251
  "metadata": {
203
252
  "owned_by": "system",
204
253
  "type": "llm",
@@ -207,6 +256,8 @@
207
256
  "beta": false,
208
257
  "coder": false,
209
258
  "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
259
+ "currency": "CHF",
260
+ "prices_as_of": "2026-09-30",
210
261
  "reasoning_options": [
211
262
  {
212
263
  "type": "effort",
@@ -246,7 +297,14 @@
246
297
  "vision",
247
298
  "reasoning"
248
299
  ],
249
- "pricing": {},
300
+ "pricing": {
301
+ "text_tokens": {
302
+ "standard": {
303
+ "input_per_million": 0.6,
304
+ "output_per_million": 3.0
305
+ }
306
+ }
307
+ },
250
308
  "metadata": {
251
309
  "owned_by": "system",
252
310
  "type": "llm",
@@ -255,6 +313,8 @@
255
313
  "beta": true,
256
314
  "coder": false,
257
315
  "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
316
+ "currency": "CHF",
317
+ "prices_as_of": "2026-09-30",
258
318
  "reasoning_options": [
259
319
  {
260
320
  "type": "effort",
@@ -294,7 +354,14 @@
294
354
  "vision",
295
355
  "reasoning"
296
356
  ],
297
- "pricing": {},
357
+ "pricing": {
358
+ "text_tokens": {
359
+ "standard": {
360
+ "input_per_million": 0.2,
361
+ "output_per_million": 0.75
362
+ }
363
+ }
364
+ },
298
365
  "metadata": {
299
366
  "owned_by": "system",
300
367
  "type": "llm",
@@ -303,6 +370,8 @@
303
370
  "beta": false,
304
371
  "coder": false,
305
372
  "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
373
+ "currency": "CHF",
374
+ "prices_as_of": "2026-09-30",
306
375
  "reasoning_options": [
307
376
  {
308
377
  "type": "effort",
@@ -342,7 +411,14 @@
342
411
  "vision",
343
412
  "reasoning"
344
413
  ],
345
- "pricing": {},
414
+ "pricing": {
415
+ "text_tokens": {
416
+ "standard": {
417
+ "input_per_million": 0.8,
418
+ "output_per_million": 3.6
419
+ }
420
+ }
421
+ },
346
422
  "metadata": {
347
423
  "owned_by": "system",
348
424
  "type": "llm",
@@ -351,6 +427,8 @@
351
427
  "beta": true,
352
428
  "coder": false,
353
429
  "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
430
+ "currency": "CHF",
431
+ "prices_as_of": "2026-09-30",
354
432
  "reasoning_options": [
355
433
  {
356
434
  "type": "effort",
@@ -389,7 +467,14 @@
389
467
  "json_mode",
390
468
  "vision"
391
469
  ],
392
- "pricing": {},
470
+ "pricing": {
471
+ "text_tokens": {
472
+ "standard": {
473
+ "input_per_million": 0.7,
474
+ "output_per_million": 2.5
475
+ }
476
+ }
477
+ },
393
478
  "metadata": {
394
479
  "owned_by": "system",
395
480
  "type": "llm",
@@ -397,7 +482,145 @@
397
482
  "status": "coming_soon",
398
483
  "beta": true,
399
484
  "coder": false,
400
- "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions"
485
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/openai/v1/chat/completions",
486
+ "currency": "CHF",
487
+ "prices_as_of": "2026-09-30"
488
+ }
489
+ },
490
+ {
491
+ "id": "whisper",
492
+ "name": "whisper",
493
+ "provider": "infomaniak",
494
+ "family": null,
495
+ "created_at": null,
496
+ "context_window": null,
497
+ "max_output_tokens": null,
498
+ "knowledge_cutoff": null,
499
+ "modalities": {
500
+ "input": [
501
+ "audio"
502
+ ],
503
+ "output": [
504
+ "text"
505
+ ]
506
+ },
507
+ "capabilities": [],
508
+ "pricing": {},
509
+ "metadata": {
510
+ "type": "stt",
511
+ "description": "Whisper V3",
512
+ "version": "3.0",
513
+ "status": "ready",
514
+ "beta": false,
515
+ "coder": false,
516
+ "documentation": "https://developer.infomaniak.com/docs/api/post/1/ai/%7Bproduct_id%7D/openai/audio/transcriptions",
517
+ "currency": "CHF",
518
+ "prices_as_of": "2026-09-30",
519
+ "price_per_minute": 0.006
520
+ }
521
+ },
522
+ {
523
+ "id": "flux",
524
+ "name": "flux",
525
+ "provider": "infomaniak",
526
+ "family": null,
527
+ "created_at": null,
528
+ "context_window": null,
529
+ "max_output_tokens": null,
530
+ "knowledge_cutoff": null,
531
+ "modalities": {
532
+ "input": [
533
+ "text"
534
+ ],
535
+ "output": [
536
+ "image"
537
+ ]
538
+ },
539
+ "capabilities": [],
540
+ "pricing": {},
541
+ "metadata": {
542
+ "type": "image",
543
+ "description": "Flux schnell",
544
+ "version": "0.1",
545
+ "status": "ready",
546
+ "beta": false,
547
+ "coder": false,
548
+ "documentation": "https://developer.infomaniak.com/docs/api/post/1/ai/%7Bproduct_id%7D/openai/images/generations",
549
+ "currency": "CHF",
550
+ "prices_as_of": "2026-09-30",
551
+ "price_per_minute": 0.3
552
+ }
553
+ },
554
+ {
555
+ "id": "BAAI/bge-reranker-v2-m3",
556
+ "name": "BAAI/bge-reranker-v2-m3",
557
+ "provider": "infomaniak",
558
+ "family": "baai",
559
+ "created_at": null,
560
+ "context_window": null,
561
+ "max_output_tokens": null,
562
+ "knowledge_cutoff": null,
563
+ "modalities": {
564
+ "input": [
565
+ "text"
566
+ ],
567
+ "output": [
568
+ "rerank"
569
+ ]
570
+ },
571
+ "capabilities": [],
572
+ "pricing": {
573
+ "text_tokens": {
574
+ "standard": {
575
+ "input_per_million": 0.01
576
+ }
577
+ }
578
+ },
579
+ "metadata": {
580
+ "type": "reranker",
581
+ "description": "BAAI/bge-reranker-v2-m3",
582
+ "status": "coming_soon",
583
+ "beta": false,
584
+ "coder": false,
585
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/cohere/v2/rerank",
586
+ "currency": "CHF",
587
+ "prices_as_of": "2026-09-30"
588
+ }
589
+ },
590
+ {
591
+ "id": "Qwen/Qwen3-Reranker-0.6B",
592
+ "name": "Qwen/Qwen3-Reranker-0.6B",
593
+ "provider": "infomaniak",
594
+ "family": "qwen",
595
+ "created_at": null,
596
+ "context_window": null,
597
+ "max_output_tokens": null,
598
+ "knowledge_cutoff": null,
599
+ "modalities": {
600
+ "input": [
601
+ "text"
602
+ ],
603
+ "output": [
604
+ "rerank"
605
+ ]
606
+ },
607
+ "capabilities": [],
608
+ "pricing": {
609
+ "text_tokens": {
610
+ "standard": {
611
+ "input_per_million": 0.009
612
+ }
613
+ }
614
+ },
615
+ "metadata": {
616
+ "type": "reranker",
617
+ "description": "Qwen/Qwen3-Reranker-0.6B",
618
+ "status": "coming_soon",
619
+ "beta": false,
620
+ "coder": false,
621
+ "documentation": "https://developer.infomaniak.com/docs/api/post/2/ai/%7Bproduct_id%7D/cohere/v2/rerank",
622
+ "currency": "CHF",
623
+ "prices_as_of": "2026-09-30"
401
624
  }
402
625
  }
403
626
  ]
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-providers-infomaniak
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
- - Andreas Idogawa
7
+ - Andi Idogawa
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -37,7 +37,10 @@ files:
37
37
  - README.md
38
38
  - lib/ruby_llm/providers/infomaniak.rb
39
39
  - lib/ruby_llm/providers/infomaniak/chat.rb
40
+ - lib/ruby_llm/providers/infomaniak/media.rb
40
41
  - lib/ruby_llm/providers/infomaniak/models.rb
42
+ - lib/ruby_llm/providers/infomaniak/pricing.rb
43
+ - lib/ruby_llm/providers/infomaniak/rerank.rb
41
44
  - lib/ruby_llm/providers/infomaniak/version.rb
42
45
  - models.json
43
46
  homepage: https://github.com/Largo/ruby_llm-providers-infomaniak