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 +7 -0
- data/LICENSE +21 -0
- data/README.md +199 -0
- data/lib/ruby_llm/pollinations/audio_output.rb +41 -0
- data/lib/ruby_llm/pollinations/patches/image_paint.rb +23 -0
- data/lib/ruby_llm/pollinations/patches/provider_paint.rb +13 -0
- data/lib/ruby_llm/pollinations/patches/speak.rb +15 -0
- data/lib/ruby_llm/pollinations/provider/account.rb +93 -0
- data/lib/ruby_llm/pollinations/provider/audio.rb +77 -0
- data/lib/ruby_llm/pollinations/provider/capabilities.rb +111 -0
- data/lib/ruby_llm/pollinations/provider/chat.rb +127 -0
- data/lib/ruby_llm/pollinations/provider/images.rb +103 -0
- data/lib/ruby_llm/pollinations/provider/media.rb +80 -0
- data/lib/ruby_llm/pollinations/provider/models.rb +52 -0
- data/lib/ruby_llm/pollinations/provider/pollinations.rb +72 -0
- data/lib/ruby_llm/pollinations/provider/streaming.rb +59 -0
- data/lib/ruby_llm/pollinations/provider/tools.rb +85 -0
- data/lib/ruby_llm/pollinations/provider/transcription.rb +43 -0
- data/lib/ruby_llm/pollinations/version.rb +7 -0
- data/lib/ruby_llm-pollinations.rb +22 -0
- metadata +81 -0
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,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: []
|