ruby_llm-pollinations 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: 8739629c1d55bee37679e2c7a25cc6fe11d77451e6e986504ac04a6d82d9d7d4
4
+ data.tar.gz: 000d89d0c0bfcb2c398d2cac9917b46ab04d285d54910b74415d2c068dbaf691
5
+ SHA512:
6
+ metadata.gz: 60b83e16c6cd2c13f8a19a69978f31c5ae04c2d4125ef9625883d01088ce3c950373624f999341af762bdf4a0ecb7d6dd74a9e071d63a86586d60a92b3d7ed1c
7
+ data.tar.gz: b284bef0524943e972940c11f773a8791d4b4cb4fb4343d07a9ddc8dd6c330868add9ea028721ed524ee5ac72b2228b4e5426d86d16fe5904c62194b99e6cee8
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Compasify
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,199 @@
1
+ # ruby_llm-pollinations
2
+
3
+ [Pollinations AI](https://pollinations.ai) provider plugin for [RubyLLM](https://github.com/crmne/ruby_llm).
4
+
5
+ Adds chat, image/video generation, text-to-speech, music generation, and transcription — without modifying RubyLLM core.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem 'ruby_llm'
13
+ gem 'ruby_llm-pollinations', github: 'compasify/ruby_llm-pollinations'
14
+ ```
15
+
16
+ Then:
17
+
18
+ ```bash
19
+ bundle install
20
+ ```
21
+
22
+ ## Configuration
23
+
24
+ ```ruby
25
+ RubyLLM.configure do |c|
26
+ c.pollinations_api_key = ENV['POLLINATIONS_API_KEY']
27
+
28
+ # Optional
29
+ c.pollinations_api_base = 'https://gen.pollinations.ai' # default
30
+ c.default_audio_model = 'tts-1' # default
31
+ end
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ### Chat
37
+
38
+ ```ruby
39
+ chat = RubyLLM.chat(model: 'openai', provider: :pollinations)
40
+ response = chat.ask("What is Pollinations AI?")
41
+ puts response.content
42
+ ```
43
+
44
+ Streaming:
45
+
46
+ ```ruby
47
+ chat = RubyLLM.chat(model: 'openai', provider: :pollinations)
48
+ chat.ask("Tell me a story") { |chunk| print chunk.content }
49
+ ```
50
+
51
+ ### Image Generation
52
+
53
+ ```ruby
54
+ image = RubyLLM.paint(
55
+ "a cyberpunk cat in neon lights",
56
+ model: 'flux',
57
+ provider: :pollinations,
58
+ size: '1024x1024',
59
+ seed: 42,
60
+ enhance: true,
61
+ negative_prompt: 'blurry, low quality'
62
+ )
63
+
64
+ image.save('cat.jpg')
65
+ ```
66
+
67
+ ### Video Generation
68
+
69
+ ```ruby
70
+ video = RubyLLM.paint(
71
+ "a cat walking on the moon",
72
+ model: 'veo',
73
+ provider: :pollinations,
74
+ size: '1024x576',
75
+ duration: 6,
76
+ aspect_ratio: '16:9',
77
+ audio: true
78
+ )
79
+
80
+ video.save('moon_cat.mp4')
81
+ ```
82
+
83
+ Supported video models: `veo`, `seedance`, `seedance-pro`, `grok-video`, `ltx-2`
84
+
85
+ ### Text-to-Speech
86
+
87
+ ```ruby
88
+ audio = RubyLLM.speak(
89
+ "Hello from Pollinations!",
90
+ model: 'tts-1',
91
+ voice: 'alloy',
92
+ provider: :pollinations
93
+ )
94
+
95
+ audio.save('hello.mp3')
96
+ ```
97
+
98
+ Available voices: `alloy`, `echo`, `fable`, `onyx`, `shimmer`, `coral`, `verse`, `ballad`, `ash`, `sage`, `amuch`, `dan`, `rachel`, `bella`
99
+
100
+ ### Music Generation
101
+
102
+ ```ruby
103
+ music = RubyLLM.speak(
104
+ "Upbeat electronic track with synth leads",
105
+ model: 'elevenmusic',
106
+ provider: :pollinations,
107
+ duration: 120,
108
+ instrumental: true
109
+ )
110
+
111
+ music.save('track.mp3')
112
+ ```
113
+
114
+ ### Transcription
115
+
116
+ ```ruby
117
+ transcription = RubyLLM.transcribe(
118
+ 'audio.mp3',
119
+ model: 'whisper-large-v3',
120
+ provider: :pollinations,
121
+ language: 'en'
122
+ )
123
+
124
+ puts transcription.text
125
+ ```
126
+
127
+ ### Tool Calling
128
+
129
+ ```ruby
130
+ class WeatherTool < RubyLLM::Tool
131
+ description "Get current weather"
132
+ param :city, type: :string, desc: "City name"
133
+
134
+ def execute(city:)
135
+ "25°C, sunny in #{city}"
136
+ end
137
+ end
138
+
139
+ chat = RubyLLM.chat(model: 'openai', provider: :pollinations)
140
+ chat.with_tool(WeatherTool)
141
+ chat.ask("What's the weather in Tokyo?")
142
+ ```
143
+
144
+ ### Account & Billing
145
+
146
+ ```ruby
147
+ config = RubyLLM::Configuration.new.tap { |c| c.pollinations_api_key = ENV['POLLINATIONS_API_KEY'] }
148
+ provider = RubyLLM::Pollinations::Provider::Pollinations.new(config)
149
+
150
+ provider.balance # => { balance: 1234.56 }
151
+ provider.profile # => { name: "...", tier: "seed", ... }
152
+ provider.usage # => { usage: [...], count: 42 }
153
+ provider.usage_daily # => { usage: [...], count: 7 }
154
+ provider.key_info # => { valid: true, type: "secret", pollen_budget: 10000, ... }
155
+ ```
156
+
157
+ ### List Available Models
158
+
159
+ ```ruby
160
+ RubyLLM.models.refresh!
161
+ pollinations_models = RubyLLM.models.all.select { |m| m.provider == 'pollinations' }
162
+ pollinations_models.each { |m| puts "#{m.id} (#{m.family})" }
163
+ ```
164
+
165
+ ## How It Works
166
+
167
+ This gem registers itself as a RubyLLM provider via `Provider.register` and applies three defensive monkey-patches that automatically skip themselves if RubyLLM adds native support:
168
+
169
+ | Patch | Purpose |
170
+ |---|---|
171
+ | `Provider#paint(**options)` | Passes extra options (seed, enhance, negative_prompt) through the paint call chain |
172
+ | `Image.paint(**options)` | Forwards options from the public API to the provider |
173
+ | `RubyLLM.speak` | Adds text-to-speech support (not yet in RubyLLM core) |
174
+
175
+ ## Supported Models
176
+
177
+ | Type | Models |
178
+ |---|---|
179
+ | Chat | `openai`, `gemini`, `claude`, and others via Pollinations |
180
+ | Image | `flux`, `zimage`, `gptimage`, `seedream`, `nanobanana`, `kontext`, `imagen`, `klein`, `wan` |
181
+ | Video | `veo`, `seedance`, `seedance-pro`, `grok-video`, `ltx-2` |
182
+ | Audio/TTS | `tts-1` |
183
+ | Music | `elevenmusic`, `music` |
184
+ | Transcription | `whisper-large-v3`, `whisper-1` |
185
+
186
+ Run `RubyLLM.models.refresh!` to get the latest list from the API.
187
+
188
+ ## Development
189
+
190
+ ```bash
191
+ git clone https://github.com/compasify/ruby_llm-pollinations.git
192
+ cd ruby_llm-pollinations
193
+ bundle install
194
+ bundle exec rspec
195
+ ```
196
+
197
+ ## License
198
+
199
+ MIT License. See [LICENSE](LICENSE).
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ class AudioOutput
6
+ attr_reader :data, :mime_type, :model_id, :duration
7
+
8
+ def initialize(data: nil, mime_type: nil, model_id: nil, duration: nil)
9
+ @data = data
10
+ @mime_type = mime_type
11
+ @model_id = model_id
12
+ @duration = duration
13
+ end
14
+
15
+ def base64?
16
+ !@data.nil?
17
+ end
18
+
19
+ def to_blob
20
+ return unless base64?
21
+
22
+ Base64.decode64(@data)
23
+ end
24
+
25
+ def save(path)
26
+ File.binwrite(File.expand_path(path), to_blob)
27
+ path
28
+ end
29
+
30
+ def self.speak(input, model: nil, voice: nil, provider: nil, assume_model_exists: false, context: nil, **options) # rubocop:disable Metrics/ParameterLists
31
+ config = context&.config || RubyLLM.config
32
+ model ||= config.default_audio_model
33
+ model, provider_instance = RubyLLM::Models.resolve(model, provider: provider,
34
+ assume_exists: assume_model_exists, config: config)
35
+ model_id = model.id
36
+
37
+ provider_instance.speak(input, model: model_id, voice: voice, **options)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Image
5
+ class << self
6
+ unless method_defined?(:_pollinations_patched_paint)
7
+ alias _original_paint paint
8
+
9
+ def paint(prompt, model: nil, provider: nil, assume_model_exists: false, size: '1024x1024', context: nil, **options) # rubocop:disable Metrics/ParameterLists
10
+ config = context&.config || RubyLLM.config
11
+ model ||= config.default_image_model
12
+ model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
13
+ config: config)
14
+ model_id = model.id
15
+
16
+ provider_instance.paint(prompt, model: model_id, size: size, **options)
17
+ end
18
+
19
+ def _pollinations_patched_paint = true
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ class Provider
5
+ unless instance_method(:paint).parameters.any? { |type, _| type == :keyrest }
6
+ def paint(prompt, model:, size:, **options)
7
+ payload = render_image_payload(prompt, model: model, size: size, **options)
8
+ response = @connection.post images_url, payload
9
+ parse_image_response(response, model: model)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module SpeakMethod
5
+ def speak(...)
6
+ Pollinations::AudioOutput.speak(...)
7
+ end
8
+ end
9
+
10
+ extend SpeakMethod unless respond_to?(:speak)
11
+
12
+ unless Configuration.options.include?(:default_audio_model)
13
+ Configuration.send(:option, :default_audio_model, 'tts-1')
14
+ end
15
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ module Account
7
+ def profile
8
+ response = @connection.get('account/profile')
9
+ normalize_profile(response.body)
10
+ end
11
+
12
+ def balance
13
+ response = @connection.get('account/balance')
14
+ { balance: response.body['balance'] }
15
+ end
16
+
17
+ def usage(format: :json, limit: 100, before: nil)
18
+ response = @connection.get('account/usage') do |req|
19
+ req.params[:format] = format.to_s
20
+ req.params[:limit] = limit
21
+ req.params[:before] = before if before
22
+ end
23
+ normalize_usage(response.body, format)
24
+ end
25
+
26
+ def usage_daily(format: :json)
27
+ response = @connection.get('account/usage/daily') do |req|
28
+ req.params[:format] = format.to_s
29
+ end
30
+ normalize_usage(response.body, format)
31
+ end
32
+
33
+ def key_info
34
+ response = @connection.get('account/key')
35
+ normalize_key_info(response.body)
36
+ end
37
+
38
+ private
39
+
40
+ def normalize_profile(data)
41
+ {
42
+ name: data['name'],
43
+ email: data['email'],
44
+ github_username: data['githubUsername'],
45
+ image: data['image'],
46
+ tier: data['tier'],
47
+ created_at: parse_timestamp(data['createdAt']),
48
+ next_reset_at: parse_timestamp(data['nextResetAt'])
49
+ }
50
+ end
51
+
52
+ def normalize_usage(data, format)
53
+ return data if format.to_sym == :csv
54
+
55
+ {
56
+ usage: data['usage'] || [],
57
+ count: data['count']
58
+ }
59
+ end
60
+
61
+ def normalize_key_info(data)
62
+ {
63
+ valid: data['valid'],
64
+ type: data['type'],
65
+ name: data['name'],
66
+ expires_at: parse_timestamp(data['expiresAt']),
67
+ expires_in: data['expiresIn'],
68
+ permissions: normalize_permissions(data['permissions']),
69
+ pollen_budget: data['pollenBudget'],
70
+ rate_limit_enabled: data['rateLimitEnabled']
71
+ }
72
+ end
73
+
74
+ def normalize_permissions(permissions)
75
+ return nil unless permissions
76
+
77
+ {
78
+ models: permissions['models'],
79
+ account: permissions['account']
80
+ }
81
+ end
82
+
83
+ def parse_timestamp(value)
84
+ return nil unless value
85
+
86
+ Time.parse(value)
87
+ rescue ArgumentError
88
+ nil
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ module Audio
7
+ module_function
8
+
9
+ MUSIC_MODELS = %w[elevenmusic music].freeze
10
+ DEFAULT_VOICE = 'alloy'
11
+ DEFAULT_FORMAT = 'mp3'
12
+ DEFAULT_SPEED = 1.0
13
+ MAX_INPUT_LENGTH = 4096
14
+
15
+ VOICE_ENUM = %w[
16
+ alloy echo fable onyx shimmer coral verse ballad ash sage amuch dan
17
+ rachel bella
18
+ ].freeze
19
+
20
+ FORMAT_ENUM = %w[mp3 opus aac flac wav pcm].freeze
21
+
22
+ def speech_url
23
+ 'v1/audio/speech'
24
+ end
25
+
26
+ def render_speech_payload(input, model:, voice:, **options)
27
+ validate_speech_input!(input)
28
+ build_speech_payload(input, model, voice, options)
29
+ end
30
+
31
+ def validate_speech_input!(input)
32
+ raise ArgumentError, 'Input text is required' if input.nil? || input.to_s.empty?
33
+ return unless input.length > MAX_INPUT_LENGTH
34
+
35
+ raise ArgumentError, "Input exceeds maximum length of #{MAX_INPUT_LENGTH} characters"
36
+ end
37
+
38
+ def build_speech_payload(input, model, voice, options)
39
+ payload = {
40
+ input: input,
41
+ model: model,
42
+ voice: voice || DEFAULT_VOICE,
43
+ response_format: options[:response_format] || DEFAULT_FORMAT,
44
+ speed: options[:speed] || DEFAULT_SPEED
45
+ }
46
+ add_music_options(payload, model, options)
47
+ payload.compact
48
+ end
49
+
50
+ def add_music_options(payload, model, options)
51
+ return unless music_model?(model)
52
+
53
+ payload[:duration] = options[:duration] if options[:duration]
54
+ payload[:instrumental] = options[:instrumental] if options.key?(:instrumental)
55
+ end
56
+
57
+ def parse_speech_response(response, model:)
58
+ content_type = response.headers['content-type'] || ''
59
+ mime_type = content_type.empty? ? 'audio/mpeg' : content_type.split(';').first.strip
60
+ data = Base64.strict_encode64(response.body)
61
+
62
+ RubyLLM::Pollinations::AudioOutput.new(
63
+ data: data,
64
+ mime_type: mime_type,
65
+ model_id: model
66
+ )
67
+ end
68
+
69
+ def music_model?(model)
70
+ return false unless model
71
+
72
+ MUSIC_MODELS.include?(model.to_s.downcase)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ module Capabilities
7
+ module_function
8
+
9
+ DEFAULT_CONTEXT_WINDOW = 128_000
10
+ DEFAULT_MAX_TOKENS = 16_384
11
+ CHAT_MODEL_FAMILIES = %w[gemini claude openai].freeze
12
+
13
+ def model_family(model_id)
14
+ case model_id
15
+ when /^openai/ then 'openai'
16
+ when /^gemini/ then 'gemini'
17
+ when /^claude/ then 'claude'
18
+ when /^flux|^zimage|^gptimage|^seedream|^nanobanana|^kontext|^imagen|^klein|^wan/ then 'image'
19
+ when /^veo|^seedance|^grok-video|^ltx/ then 'video'
20
+ when /^whisper/ then 'transcription'
21
+ when /^tts|^elevenmusic|^music/ then 'audio'
22
+ else 'other'
23
+ end
24
+ end
25
+
26
+ def model_type(model_id)
27
+ case model_family(model_id)
28
+ when 'image', 'video' then 'image'
29
+ when 'transcription', 'audio' then 'audio'
30
+ else 'chat'
31
+ end
32
+ end
33
+
34
+ def context_window_for(model_id)
35
+ case model_family(model_id)
36
+ when 'gemini' then 1_000_000
37
+ when 'claude' then 200_000
38
+ else DEFAULT_CONTEXT_WINDOW
39
+ end
40
+ end
41
+
42
+ def max_tokens_for(model_id)
43
+ case model_family(model_id)
44
+ when 'gemini', 'claude' then 8_192
45
+ else DEFAULT_MAX_TOKENS
46
+ end
47
+ end
48
+
49
+ def supports_vision?(model_id)
50
+ CHAT_MODEL_FAMILIES.include?(model_family(model_id))
51
+ end
52
+
53
+ def supports_functions?(model_id)
54
+ CHAT_MODEL_FAMILIES.include?(model_family(model_id))
55
+ end
56
+
57
+ def supports_structured_output?(model_id)
58
+ CHAT_MODEL_FAMILIES.include?(model_family(model_id))
59
+ end
60
+
61
+ def supports_json_mode?(model_id)
62
+ supports_structured_output?(model_id)
63
+ end
64
+
65
+ def input_price_for(_model_id)
66
+ 0.0
67
+ end
68
+
69
+ def output_price_for(_model_id)
70
+ 0.0
71
+ end
72
+
73
+ def format_display_name(model_id)
74
+ model_id.tr('-', ' ').split.map(&:capitalize).join(' ')
75
+ end
76
+
77
+ def modalities_for(model_id)
78
+ case model_type(model_id)
79
+ when 'image'
80
+ { input: ['text'], output: ['image'] }
81
+ when 'audio'
82
+ { input: %w[text audio], output: ['audio'] }
83
+ else
84
+ modalities = { input: ['text'], output: ['text'] }
85
+ modalities[:input] << 'image' if supports_vision?(model_id)
86
+ modalities
87
+ end
88
+ end
89
+
90
+ def capabilities_for(model_id)
91
+ capabilities = []
92
+ capabilities << 'streaming' if model_type(model_id) == 'chat'
93
+ capabilities << 'function_calling' if supports_functions?(model_id)
94
+ capabilities << 'structured_output' if supports_structured_output?(model_id)
95
+ capabilities
96
+ end
97
+
98
+ def pricing_for(model_id)
99
+ {
100
+ text_tokens: {
101
+ standard: {
102
+ input_per_million: input_price_for(model_id),
103
+ output_per_million: output_price_for(model_id)
104
+ }
105
+ }
106
+ }
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ module Chat
7
+ def completion_url
8
+ 'v1/chat/completions'
9
+ end
10
+
11
+ module_function
12
+
13
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil, thinking: nil) # rubocop:disable Metrics/ParameterLists
14
+ payload = {
15
+ model: model.id,
16
+ messages: format_messages(messages),
17
+ stream: stream
18
+ }
19
+
20
+ payload[:temperature] = temperature unless temperature.nil?
21
+ payload[:tools] = tools.map { |_, tool| Tools.tool_for(tool) } if tools.any?
22
+
23
+ if schema
24
+ strict = schema[:strict] != false
25
+ payload[:response_format] = {
26
+ type: 'json_schema',
27
+ json_schema: {
28
+ name: 'response',
29
+ schema: schema,
30
+ strict: strict
31
+ }
32
+ }
33
+ end
34
+
35
+ effort = resolve_effort(thinking)
36
+ payload[:reasoning_effort] = effort if effort
37
+
38
+ payload[:stream_options] = { include_usage: true } if stream
39
+ payload
40
+ end
41
+
42
+ def parse_completion_response(response)
43
+ data = response.body
44
+ return if data.nil? || data.empty?
45
+
46
+ raise RubyLLM::Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')
47
+
48
+ message_data = data.dig('choices', 0, 'message')
49
+ return unless message_data
50
+
51
+ usage = data['usage'] || {}
52
+ cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
53
+ thinking_tokens = usage.dig('completion_tokens_details', 'reasoning_tokens')
54
+ content, thinking_from_blocks = extract_content_and_thinking(message_data['content'])
55
+ thinking_text = thinking_from_blocks || extract_thinking_text(message_data)
56
+
57
+ RubyLLM::Message.new(
58
+ role: :assistant,
59
+ content: content,
60
+ thinking: RubyLLM::Thinking.build(text: thinking_text),
61
+ tool_calls: Tools.parse_tool_calls(message_data['tool_calls']),
62
+ input_tokens: usage['prompt_tokens'],
63
+ output_tokens: usage['completion_tokens'],
64
+ cached_tokens: cached_tokens,
65
+ cache_creation_tokens: 0,
66
+ thinking_tokens: thinking_tokens,
67
+ model_id: data['model'],
68
+ raw: response
69
+ )
70
+ end
71
+
72
+ def format_messages(messages)
73
+ messages.map do |msg|
74
+ {
75
+ role: msg.role.to_s,
76
+ content: Media.format_content(msg.content),
77
+ tool_calls: Tools.format_tool_calls(msg.tool_calls),
78
+ tool_call_id: msg.tool_call_id
79
+ }.compact
80
+ end
81
+ end
82
+
83
+ def resolve_effort(thinking)
84
+ return nil unless thinking
85
+
86
+ thinking.respond_to?(:effort) ? thinking.effort : thinking
87
+ end
88
+
89
+ def extract_thinking_text(message_data)
90
+ candidate = message_data['reasoning_content'] || message_data['reasoning'] || message_data['thinking']
91
+ candidate.is_a?(String) ? candidate : nil
92
+ end
93
+
94
+ def extract_content_and_thinking(content)
95
+ return extract_think_tag_content(content) if content.is_a?(String)
96
+ return [content, nil] unless content.is_a?(Array)
97
+
98
+ text = extract_text_from_blocks(content)
99
+ thinking = extract_thinking_from_blocks(content)
100
+
101
+ [text.empty? ? nil : text, thinking.empty? ? nil : thinking]
102
+ end
103
+
104
+ def extract_text_from_blocks(blocks)
105
+ blocks.filter_map do |block|
106
+ block['text'] if block['type'] == 'text' && block['text'].is_a?(String)
107
+ end.join
108
+ end
109
+
110
+ def extract_thinking_from_blocks(blocks)
111
+ blocks.filter_map do |block|
112
+ block['thinking'] if block['type'] == 'thinking' && block['thinking'].is_a?(String)
113
+ end.join
114
+ end
115
+
116
+ def extract_think_tag_content(text)
117
+ return [text, nil] unless text.include?('<think>')
118
+
119
+ thinking = text.scan(%r{<think>(.*?)</think>}m).join
120
+ content = text.gsub(%r{<think>.*?</think>}m, '').strip
121
+
122
+ [content.empty? ? nil : content, thinking.empty? ? nil : thinking]
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module RubyLLM
6
+ module Pollinations
7
+ module Provider
8
+ module Images
9
+ module_function
10
+
11
+ VIDEO_MODELS = %w[veo seedance seedance-pro grok-video ltx-2].freeze
12
+ DEFAULT_IMAGE_MODEL = 'flux'
13
+
14
+ def images_url(prompt, **options)
15
+ encoded_prompt = ERB::Util.url_encode(prompt)
16
+ params = build_image_params(options)
17
+ query_string = params.empty? ? '' : "?#{URI.encode_www_form(params)}"
18
+ "image/#{encoded_prompt}#{query_string}"
19
+ end
20
+
21
+ def render_image_payload(prompt, model:, size:, **options)
22
+ width, height = parse_size(size)
23
+ {
24
+ prompt: prompt,
25
+ model: model || DEFAULT_IMAGE_MODEL,
26
+ width: width,
27
+ height: height
28
+ }.merge(options.slice(:image, :seed, :quality, :safe, :enhance, :negative_prompt,
29
+ :duration, :aspect_ratio, :audio))
30
+ end
31
+
32
+ def parse_image_response(response, model:)
33
+ content_type = response.headers['content-type'] || ''
34
+ is_video = video_response?(content_type, model)
35
+ mime_type = extract_mime_type(content_type, is_video)
36
+ data = Base64.strict_encode64(response.body)
37
+
38
+ RubyLLM::Image.new(
39
+ data: data,
40
+ mime_type: mime_type,
41
+ model_id: model
42
+ )
43
+ end
44
+
45
+ def video_model?(model)
46
+ VIDEO_MODELS.include?(model.to_s.downcase)
47
+ end
48
+
49
+ def video_response?(content_type, model)
50
+ content_type.include?('video/') || video_model?(model)
51
+ end
52
+
53
+ BASIC_PARAMS = %i[model width height seed quality].freeze
54
+ BOOLEAN_PARAMS = %i[safe enhance].freeze
55
+
56
+ def build_image_params(options)
57
+ params = extract_basic_params(options)
58
+ extract_boolean_params(params, options)
59
+ params[:negative_prompt] = options[:negative_prompt] if options[:negative_prompt]
60
+ params[:image] = options[:image] if options[:image]
61
+ params[:nologo] = true
62
+ params[:private] = true
63
+ add_video_params(params, options) if options[:model] && video_model?(options[:model])
64
+ params
65
+ end
66
+
67
+ def extract_basic_params(options)
68
+ BASIC_PARAMS.each_with_object({}) do |key, params|
69
+ params[key] = options[key] if options[key]
70
+ end
71
+ end
72
+
73
+ def extract_boolean_params(params, options)
74
+ BOOLEAN_PARAMS.each do |key|
75
+ params[key] = options[key] if options.key?(key)
76
+ end
77
+ end
78
+
79
+ def add_video_params(params, options)
80
+ params[:duration] = options[:duration] if options[:duration]
81
+ params[:aspectRatio] = options[:aspect_ratio] if options[:aspect_ratio]
82
+ params[:audio] = options[:audio] if options.key?(:audio)
83
+ end
84
+
85
+ def parse_size(size)
86
+ return [1024, 1024] unless size
87
+
88
+ parts = size.to_s.split('x')
89
+ return [1024, 1024] unless parts.length == 2
90
+
91
+ [parts[0].to_i, parts[1].to_i]
92
+ end
93
+
94
+ def extract_mime_type(content_type, is_video)
95
+ return 'video/mp4' if is_video && !content_type.include?('video/')
96
+ return content_type.split(';').first.strip unless content_type.empty?
97
+
98
+ is_video ? 'video/mp4' : 'image/jpeg'
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ module Media
7
+ module_function
8
+
9
+ def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
10
+ return content.value if content.is_a?(RubyLLM::Content::Raw)
11
+ return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
12
+ return content unless content.is_a?(RubyLLM::Content)
13
+
14
+ parts = []
15
+ parts << format_text(content.text) if content.text
16
+
17
+ content.attachments.each do |attachment|
18
+ case attachment.type
19
+ when :image
20
+ parts << format_image(attachment)
21
+ when :pdf
22
+ parts << format_pdf(attachment)
23
+ when :audio
24
+ parts << format_audio(attachment)
25
+ when :text
26
+ parts << format_text_file(attachment)
27
+ else
28
+ raise RubyLLM::UnsupportedAttachmentError, attachment.type
29
+ end
30
+ end
31
+
32
+ parts
33
+ end
34
+
35
+ def format_image(image)
36
+ {
37
+ type: 'image_url',
38
+ image_url: {
39
+ url: image.url? ? image.source.to_s : image.for_llm
40
+ }
41
+ }
42
+ end
43
+
44
+ def format_pdf(pdf)
45
+ {
46
+ type: 'file',
47
+ file: {
48
+ filename: pdf.filename,
49
+ file_data: pdf.for_llm
50
+ }
51
+ }
52
+ end
53
+
54
+ def format_text_file(text_file)
55
+ {
56
+ type: 'text',
57
+ text: text_file.for_llm
58
+ }
59
+ end
60
+
61
+ def format_audio(audio)
62
+ {
63
+ type: 'input_audio',
64
+ input_audio: {
65
+ data: audio.encoded,
66
+ format: audio.format
67
+ }
68
+ }
69
+ end
70
+
71
+ def format_text(text)
72
+ {
73
+ type: 'text',
74
+ text: text
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ module Models
7
+ module_function
8
+
9
+ def models_url
10
+ 'v1/models'
11
+ end
12
+
13
+ def parse_list_models_response(response, slug, capabilities)
14
+ Array(response.body['data']).map do |model_data|
15
+ build_model_info(model_data, slug, capabilities)
16
+ end
17
+ end
18
+
19
+ def build_model_info(model_data, slug, capabilities)
20
+ model_id = model_data['id']
21
+
22
+ RubyLLM::Model::Info.new(
23
+ id: model_id,
24
+ name: capabilities.format_display_name(model_id),
25
+ provider: slug,
26
+ family: capabilities.model_family(model_id),
27
+ created_at: parse_created_at(model_data['created']),
28
+ context_window: capabilities.context_window_for(model_id),
29
+ max_output_tokens: capabilities.max_tokens_for(model_id),
30
+ modalities: capabilities.modalities_for(model_id),
31
+ capabilities: capabilities.capabilities_for(model_id),
32
+ pricing: capabilities.pricing_for(model_id),
33
+ metadata: build_metadata(model_data)
34
+ )
35
+ end
36
+
37
+ def parse_created_at(created)
38
+ return nil unless created
39
+
40
+ Time.at(created)
41
+ end
42
+
43
+ def build_metadata(model_data)
44
+ {
45
+ object: model_data['object'],
46
+ owned_by: model_data['owned_by']
47
+ }.compact
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ class Pollinations < RubyLLM::Provider
7
+ include Chat
8
+ include Media
9
+ include Streaming
10
+ include Tools
11
+ include Images
12
+ include Audio
13
+ include Transcription
14
+ include Models
15
+ include Account
16
+
17
+ IMAGE_API_BASE = 'https://gen.pollinations.ai'
18
+
19
+ def api_base
20
+ @config.pollinations_api_base || 'https://gen.pollinations.ai'
21
+ end
22
+
23
+ def headers
24
+ {
25
+ 'Authorization' => "Bearer #{@config.pollinations_api_key}",
26
+ 'Content-Type' => 'application/json'
27
+ }.compact
28
+ end
29
+
30
+ def paint(prompt, model:, size:, **options)
31
+ payload = render_image_payload(prompt, model: model, size: size, **options)
32
+ url = images_url(prompt, **payload)
33
+ response = image_connection.get(url)
34
+ parse_image_response(response, model: model)
35
+ end
36
+
37
+ def speak(input, model:, voice: nil, **options)
38
+ payload = render_speech_payload(input, model: model, voice: voice, **options)
39
+ response = @connection.post(speech_url, payload)
40
+ parse_speech_response(response, model: model)
41
+ end
42
+
43
+ class << self
44
+ def capabilities
45
+ Capabilities
46
+ end
47
+
48
+ def configuration_options
49
+ %i[pollinations_api_key pollinations_api_base]
50
+ end
51
+
52
+ def configuration_requirements
53
+ %i[pollinations_api_key]
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def image_connection
60
+ @image_connection ||= Faraday.new(IMAGE_API_BASE) do |faraday|
61
+ faraday.options.timeout = @config.request_timeout || 600
62
+ faraday.response :logger, RubyLLM.logger, bodies: false, log_level: :debug
63
+ faraday.request :retry, max: @config.max_retries || 3, retry_statuses: [429, 500, 502, 503, 504]
64
+ faraday.adapter :net_http
65
+ faraday.use :llm_errors, provider: self
66
+ faraday.headers['Authorization'] = "Bearer #{@config.pollinations_api_key}"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ module Streaming
7
+ module_function
8
+
9
+ def stream_url
10
+ completion_url
11
+ end
12
+
13
+ def build_chunk(data)
14
+ usage = data['usage'] || {}
15
+ cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
16
+ delta = data.dig('choices', 0, 'delta') || {}
17
+ content_source = delta['content'] || data.dig('choices', 0, 'message', 'content')
18
+ content, thinking_from_blocks = Chat.extract_content_and_thinking(content_source)
19
+
20
+ RubyLLM::Chunk.new(
21
+ role: :assistant,
22
+ model_id: data['model'],
23
+ content: content,
24
+ thinking: RubyLLM::Thinking.build(
25
+ text: thinking_from_blocks || delta['reasoning_content'] || delta['reasoning']
26
+ ),
27
+ tool_calls: Tools.parse_tool_calls(delta['tool_calls'], parse_arguments: false),
28
+ input_tokens: usage['prompt_tokens'],
29
+ output_tokens: usage['completion_tokens'],
30
+ cached_tokens: cached_tokens,
31
+ cache_creation_tokens: 0,
32
+ thinking_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens')
33
+ )
34
+ end
35
+
36
+ def parse_streaming_error(data)
37
+ error_data = JSON.parse(data)
38
+ return unless error_data['error']
39
+
40
+ error = error_data['error']
41
+ code = error['code'] || error['type']
42
+
43
+ case code
44
+ when 'RATE_LIMIT', 'rate_limit_exceeded', 'insufficient_quota'
45
+ [429, error['message']]
46
+ when 'INTERNAL_ERROR', 'server_error'
47
+ [500, error['message']]
48
+ when 'UNAUTHORIZED'
49
+ [401, error['message']]
50
+ when 'PAYMENT_REQUIRED'
51
+ [402, error['message']]
52
+ else
53
+ [400, error['message']]
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ module Tools
7
+ module_function
8
+
9
+ EMPTY_PARAMETERS_SCHEMA = {
10
+ 'type' => 'object',
11
+ 'properties' => {},
12
+ 'required' => [],
13
+ 'additionalProperties' => false,
14
+ 'strict' => true
15
+ }.freeze
16
+
17
+ def parameters_schema_for(tool)
18
+ tool.params_schema || schema_from_parameters(tool.parameters)
19
+ end
20
+
21
+ def schema_from_parameters(parameters)
22
+ schema_definition = RubyLLM::Tool::SchemaDefinition.from_parameters(parameters)
23
+ schema_definition&.json_schema || EMPTY_PARAMETERS_SCHEMA
24
+ end
25
+
26
+ def tool_for(tool)
27
+ parameters_schema = parameters_schema_for(tool)
28
+
29
+ definition = {
30
+ type: 'function',
31
+ function: {
32
+ name: tool.name,
33
+ description: tool.description,
34
+ parameters: parameters_schema
35
+ }
36
+ }
37
+
38
+ return definition if tool.provider_params.empty?
39
+
40
+ RubyLLM::Utils.deep_merge(definition, tool.provider_params)
41
+ end
42
+
43
+ def format_tool_calls(tool_calls)
44
+ return nil unless tool_calls&.any?
45
+
46
+ tool_calls.map do |_, tc|
47
+ {
48
+ id: tc.id,
49
+ type: 'function',
50
+ function: {
51
+ name: tc.name,
52
+ arguments: JSON.generate(tc.arguments)
53
+ }
54
+ }
55
+ end
56
+ end
57
+
58
+ def parse_tool_call_arguments(tool_call)
59
+ arguments = tool_call.dig('function', 'arguments')
60
+
61
+ if arguments.nil? || arguments.empty?
62
+ {}
63
+ else
64
+ JSON.parse(arguments)
65
+ end
66
+ end
67
+
68
+ def parse_tool_calls(tool_calls, parse_arguments: true)
69
+ return nil unless tool_calls&.any?
70
+
71
+ tool_calls.to_h do |tc|
72
+ [
73
+ tc['id'],
74
+ RubyLLM::ToolCall.new(
75
+ id: tc['id'],
76
+ name: tc.dig('function', 'name'),
77
+ arguments: parse_arguments ? parse_tool_call_arguments(tc) : tc.dig('function', 'arguments')
78
+ )
79
+ ]
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ module Provider
6
+ module Transcription
7
+ module_function
8
+
9
+ SUPPORTED_MODELS = %w[whisper-large-v3 whisper-1].freeze
10
+ DEFAULT_MODEL = 'whisper-large-v3'
11
+ RESPONSE_FORMATS = %w[json text srt verbose_json vtt].freeze
12
+
13
+ def transcription_url
14
+ 'v1/audio/transcriptions'
15
+ end
16
+
17
+ def render_transcription_payload(file_part, model:, language:, **options)
18
+ {
19
+ file: file_part,
20
+ model: model || DEFAULT_MODEL,
21
+ language: language,
22
+ prompt: options[:prompt],
23
+ response_format: options[:response_format] || 'json',
24
+ temperature: options[:temperature]
25
+ }.compact
26
+ end
27
+
28
+ def parse_transcription_response(response, model:)
29
+ data = response.body
30
+ return RubyLLM::Transcription.new(text: data, model: model) if data.is_a?(String)
31
+
32
+ RubyLLM::Transcription.new(
33
+ text: data['text'],
34
+ model: model,
35
+ language: data['language'],
36
+ duration: data['duration'],
37
+ segments: data['segments']
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Pollinations
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+
5
+ require_relative 'ruby_llm/pollinations/version'
6
+ require_relative 'ruby_llm/pollinations/audio_output'
7
+ require_relative 'ruby_llm/pollinations/patches/provider_paint'
8
+ require_relative 'ruby_llm/pollinations/patches/image_paint'
9
+ require_relative 'ruby_llm/pollinations/patches/speak'
10
+ require_relative 'ruby_llm/pollinations/provider/capabilities'
11
+ require_relative 'ruby_llm/pollinations/provider/chat'
12
+ require_relative 'ruby_llm/pollinations/provider/streaming'
13
+ require_relative 'ruby_llm/pollinations/provider/tools'
14
+ require_relative 'ruby_llm/pollinations/provider/media'
15
+ require_relative 'ruby_llm/pollinations/provider/images'
16
+ require_relative 'ruby_llm/pollinations/provider/audio'
17
+ require_relative 'ruby_llm/pollinations/provider/transcription'
18
+ require_relative 'ruby_llm/pollinations/provider/models'
19
+ require_relative 'ruby_llm/pollinations/provider/account'
20
+ require_relative 'ruby_llm/pollinations/provider/pollinations'
21
+
22
+ RubyLLM::Provider.register :pollinations, RubyLLM::Pollinations::Provider::Pollinations
metadata ADDED
@@ -0,0 +1,81 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-pollinations
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Compasify
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ruby_llm
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.14.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 1.14.0
27
+ description: Adds Pollinations AI support to RubyLLM — chat, image/video generation,
28
+ TTS/music, and transcription via the Pollinations API. Installs as a standalone
29
+ gem without modifying RubyLLM core.
30
+ email:
31
+ - timdapan.com@gmail.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE
37
+ - README.md
38
+ - lib/ruby_llm-pollinations.rb
39
+ - lib/ruby_llm/pollinations/audio_output.rb
40
+ - lib/ruby_llm/pollinations/patches/image_paint.rb
41
+ - lib/ruby_llm/pollinations/patches/provider_paint.rb
42
+ - lib/ruby_llm/pollinations/patches/speak.rb
43
+ - lib/ruby_llm/pollinations/provider/account.rb
44
+ - lib/ruby_llm/pollinations/provider/audio.rb
45
+ - lib/ruby_llm/pollinations/provider/capabilities.rb
46
+ - lib/ruby_llm/pollinations/provider/chat.rb
47
+ - lib/ruby_llm/pollinations/provider/images.rb
48
+ - lib/ruby_llm/pollinations/provider/media.rb
49
+ - lib/ruby_llm/pollinations/provider/models.rb
50
+ - lib/ruby_llm/pollinations/provider/pollinations.rb
51
+ - lib/ruby_llm/pollinations/provider/streaming.rb
52
+ - lib/ruby_llm/pollinations/provider/tools.rb
53
+ - lib/ruby_llm/pollinations/provider/transcription.rb
54
+ - lib/ruby_llm/pollinations/version.rb
55
+ homepage: https://github.com/compasify/ruby_llm-pollinations
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://github.com/compasify/ruby_llm-pollinations
60
+ source_code_uri: https://github.com/compasify/ruby_llm-pollinations
61
+ rubygems_mfa_required: 'true'
62
+ post_install_message:
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: 3.1.3
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.5.3
78
+ signing_key:
79
+ specification_version: 4
80
+ summary: Pollinations AI provider for RubyLLM
81
+ test_files: []