buble 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.
data/lib/buble/chat.rb ADDED
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'streaming'
4
+
5
+ module Buble
6
+ class ChatModelsResource
7
+ def initialize(http)
8
+ @http = http
9
+ end
10
+
11
+ def list
12
+ @http.request('GET', '/api/v1/models')
13
+ end
14
+ end
15
+
16
+ class ChatCompletionsResource
17
+ def initialize(http)
18
+ @http = http
19
+ end
20
+
21
+ def create(body = nil, **params)
22
+ payload = normalize_body(body, params).merge('stream' => false)
23
+ @http.request('POST', '/api/v1/chat/completions', body: payload)
24
+ end
25
+
26
+ def stream(body = nil, **params)
27
+ payload = normalize_body(body, params).merge('stream' => true)
28
+ Streaming.events_from_lines(@http.stream_lines('POST', '/api/v1/chat/completions', body: payload))
29
+ end
30
+
31
+ def stream_text(body = nil, **params)
32
+ Streaming.text_stream(stream(body, **params), :openai)
33
+ end
34
+
35
+ private
36
+
37
+ def normalize_body(body, params)
38
+ source = body || params
39
+ source.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
40
+ end
41
+ end
42
+
43
+ class MessagesResource
44
+ def initialize(http)
45
+ @http = http
46
+ end
47
+
48
+ def create(body = nil, **params)
49
+ payload = normalize_body(body, params).merge('stream' => false)
50
+ @http.request('POST', '/api/v1/messages', body: payload)
51
+ end
52
+
53
+ def stream(body = nil, **params)
54
+ payload = normalize_body(body, params).merge('stream' => true)
55
+ Streaming.events_from_lines(@http.stream_lines('POST', '/api/v1/messages', body: payload))
56
+ end
57
+
58
+ def stream_text(body = nil, **params)
59
+ Streaming.text_stream(stream(body, **params), :anthropic)
60
+ end
61
+
62
+ private
63
+
64
+ def normalize_body(body, params)
65
+ source = body || params
66
+ source.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
67
+ end
68
+ end
69
+
70
+ class GeminiResource
71
+ def initialize(http)
72
+ @http = http
73
+ end
74
+
75
+ def generate_content(model, body)
76
+ @http.request('POST', "/api/v1beta/models/#{HTTP.encode_model_path(model)}:generateContent", body: body)
77
+ end
78
+
79
+ def stream_generate_content(model, body)
80
+ path = "/api/v1beta/models/#{HTTP.encode_model_path(model)}:streamGenerateContent"
81
+ Streaming.events_from_lines(
82
+ @http.stream_lines('POST', path, body: body)
83
+ )
84
+ end
85
+
86
+ def stream_text(model, body)
87
+ Streaming.text_stream(stream_generate_content(model, body), :gemini)
88
+ end
89
+ end
90
+
91
+ class ChatResource
92
+ attr_reader :models, :completions, :messages, :gemini
93
+
94
+ def initialize(http)
95
+ @models = ChatModelsResource.new(http)
96
+ @completions = ChatCompletionsResource.new(http)
97
+ @messages = MessagesResource.new(http)
98
+ @gemini = GeminiResource.new(http)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'generations'
4
+ require_relative 'apps'
5
+ require_relative 'chat'
6
+ require_relative 'files'
7
+ require_relative 'http'
8
+ require_relative 'media_models'
9
+
10
+ module Buble
11
+ class Client
12
+ attr_reader :media_models, :files, :generations, :apps, :chat
13
+
14
+ def initialize(api_key: nil, base_url: nil, timeout: HTTP::DEFAULT_TIMEOUT, headers: {}, transport: nil)
15
+ resolved_api_key = first_present(api_key, ENV.fetch('BUBLE_API_KEY', nil))
16
+ resolved_base_url = first_present(base_url, ENV.fetch('BUBLE_BASE_URL', nil), HTTP::DEFAULT_BASE_URL)
17
+ @transport = transport || HTTP.new(
18
+ api_key: resolved_api_key,
19
+ base_url: resolved_base_url,
20
+ timeout: timeout,
21
+ headers: headers
22
+ )
23
+ @media_models = MediaModelsResource.new(@transport)
24
+ @files = FilesResource.new(@transport)
25
+ @generations = GenerationsResource.new(@transport)
26
+ @apps = AppsResource.new(@transport)
27
+ @chat = ChatResource.new(@transport)
28
+ end
29
+
30
+ private
31
+
32
+ def first_present(*values)
33
+ values.find { |value| value && !value.to_s.strip.empty? }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Buble
4
+ class Error < StandardError; end
5
+
6
+ class APIError < Error
7
+ attr_reader :status, :code, :details, :response_body
8
+
9
+ def initialize(message, status:, code: nil, details: nil, response_body: nil)
10
+ super(message)
11
+ @status = status
12
+ @code = code
13
+ @details = details
14
+ @response_body = response_body
15
+ end
16
+ end
17
+
18
+ class TimeoutError < Error
19
+ attr_reader :timeout
20
+
21
+ def initialize(message, timeout:)
22
+ super(message)
23
+ @timeout = timeout
24
+ end
25
+ end
26
+
27
+ class GenerationFailedError < Error
28
+ attr_reader :task
29
+
30
+ def initialize(message, task:)
31
+ super(message)
32
+ @task = task
33
+ end
34
+ end
35
+
36
+ class GenerationCanceledError < Error
37
+ attr_reader :task
38
+
39
+ def initialize(message, task:)
40
+ super(message)
41
+ @task = task
42
+ end
43
+ end
44
+
45
+ class UnsupportedGenerationFieldError < Error
46
+ attr_reader :field
47
+
48
+ def initialize(field)
49
+ super(%("#{field}" is an internal Buble field and is not supported by the public API.))
50
+ @field = field
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Buble
4
+ class FileUpload
5
+ attr_reader :io, :filename, :content_type, :close_after
6
+
7
+ def initialize(io:, filename:, content_type: 'application/octet-stream', close_after: false)
8
+ @io = io
9
+ @filename = filename
10
+ @content_type = content_type
11
+ @close_after = close_after
12
+ end
13
+
14
+ def self.from_path(path, content_type: 'application/octet-stream')
15
+ new(
16
+ io: File.open(path, 'rb'),
17
+ filename: File.basename(path),
18
+ content_type: content_type,
19
+ close_after: true
20
+ )
21
+ end
22
+
23
+ def self.from_io(io, filename:, content_type: 'application/octet-stream')
24
+ new(io: io, filename: filename, content_type: content_type, close_after: false)
25
+ end
26
+
27
+ def to_http_part
28
+ HTTP::FilePart.new(io: io, filename: filename, content_type: content_type, close_after: close_after)
29
+ end
30
+ end
31
+
32
+ class FilesResource
33
+ def initialize(http)
34
+ @http = http
35
+ end
36
+
37
+ def upload(file, file_type: nil, model: nil, mode: nil, **fields)
38
+ upload = coerce_upload(file)
39
+ @http.multipart(
40
+ '/api/v1/files',
41
+ fields: { file_type: file_type, model: model, mode: mode }.merge(fields),
42
+ file: upload.to_http_part
43
+ )
44
+ end
45
+
46
+ private
47
+
48
+ def coerce_upload(file)
49
+ return file if file.is_a?(FileUpload)
50
+ return FileUpload.from_path(file) if file.is_a?(String)
51
+
52
+ if file.respond_to?(:read)
53
+ filename = file.respond_to?(:path) && file.path ? File.basename(file.path) : 'upload'
54
+ return FileUpload.from_io(file, filename: filename)
55
+ end
56
+
57
+ raise Error, 'file must be a path, IO, or Buble::FileUpload.'
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Buble
6
+ class GenerationsResource
7
+ FORBIDDEN_FIELDS = %w[
8
+ input
9
+ options
10
+ scene
11
+ sub_mode_id
12
+ subModeId
13
+ provider
14
+ mediaType
15
+ media_type
16
+ images
17
+ image_input
18
+ video_input
19
+ audio_input
20
+ ].freeze
21
+
22
+ TERMINAL_STATUSES = %w[success failed canceled].freeze
23
+
24
+ def initialize(http)
25
+ @http = http
26
+ end
27
+
28
+ def create(model:, mode: nil, prompt: nil, image_urls: nil, start_frame: nil, end_frame: nil,
29
+ video_urls: nil, audio_urls: nil, is_public: nil, copy_protected: nil, **params)
30
+ body = compact_body({
31
+ model: model,
32
+ mode: mode,
33
+ prompt: prompt,
34
+ image_urls: image_urls,
35
+ start_frame: start_frame,
36
+ end_frame: end_frame,
37
+ video_urls: video_urls,
38
+ audio_urls: audio_urls,
39
+ is_public: is_public,
40
+ copy_protected: copy_protected
41
+ }.merge(params))
42
+ assert_public_body!(body)
43
+ @http.request('POST', '/api/v1/generations', body: body)
44
+ end
45
+
46
+ def retrieve(id)
47
+ @http.request('GET', "/api/v1/generations/#{HTTP.encode_segment(id)}")
48
+ end
49
+
50
+ def wait(id, interval: 2, timeout: 600, throw_on_failed: true, throw_on_canceled: true)
51
+ deadline = Time.now + timeout
52
+
53
+ loop do
54
+ envelope = retrieve(id)
55
+ task = envelope['data'] || {}
56
+ status = task['status']
57
+ if TERMINAL_STATUSES.include?(status)
58
+ raise_if_terminal_error!(id, task, status, throw_on_failed, throw_on_canceled)
59
+ return envelope
60
+ end
61
+
62
+ if Time.now >= deadline
63
+ raise TimeoutError.new(
64
+ "Generation #{id} did not finish within #{timeout} seconds.",
65
+ timeout: timeout
66
+ )
67
+ end
68
+
69
+ sleep interval
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def raise_if_terminal_error!(id, task, status, throw_on_failed, throw_on_canceled)
76
+ if status == 'failed' && throw_on_failed
77
+ error = task['error'] || {}
78
+ raise GenerationFailedError.new(error['message'] || 'Generation failed.', task: task)
79
+ end
80
+
81
+ return unless status == 'canceled' && throw_on_canceled
82
+
83
+ raise GenerationCanceledError.new("Generation #{id} was canceled.", task: task)
84
+ end
85
+
86
+ def compact_body(body)
87
+ body.each_with_object({}) do |(key, value), out|
88
+ next if value.nil?
89
+ next if value.respond_to?(:empty?) && value.empty?
90
+
91
+ out[key.to_s] = value
92
+ end
93
+ end
94
+
95
+ def assert_public_body!(body)
96
+ body.each_key do |key|
97
+ raise UnsupportedGenerationFieldError, key if FORBIDDEN_FIELDS.include?(key.to_s)
98
+ end
99
+ end
100
+ end
101
+ end
data/lib/buble/http.rb ADDED
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi/escape'
4
+ require 'json'
5
+ require 'net/http'
6
+ require 'openssl'
7
+ require 'uri'
8
+
9
+ module Buble
10
+ class HTTP
11
+ DEFAULT_BASE_URL = 'https://buble.ai'
12
+ DEFAULT_TIMEOUT = 60
13
+
14
+ FilePart = Struct.new(:io, :filename, :content_type, :close_after, keyword_init: true) do
15
+ def close
16
+ io.close if close_after && io.respond_to?(:close) && !io.closed?
17
+ end
18
+ end
19
+
20
+ def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, headers: {})
21
+ raise Error, 'Missing Buble API key. Pass api_key or set BUBLE_API_KEY.' if blank?(api_key)
22
+
23
+ @api_key = api_key
24
+ @base_url = base_url.to_s.sub(%r{/+\z}, '')
25
+ @timeout = timeout
26
+ @headers = stringify_hash(headers)
27
+ end
28
+
29
+ def request(method, path, query: nil, body: nil, headers: nil, timeout: nil)
30
+ response = perform(method, path, query: query, body: body, headers: headers, timeout: timeout)
31
+ decode_response(response)
32
+ end
33
+
34
+ def multipart(path, fields:, file:, query: nil, headers: nil, timeout: nil)
35
+ part = coerce_file_part(file)
36
+ request = Net::HTTP::Post.new(resolve(path, query))
37
+ request_headers(headers).each { |name, value| request[name] = value }
38
+ request['Accept'] = 'application/json'
39
+
40
+ form = stringify_hash(fields).reject { |_key, value| value.nil? || value == '' }.map do |key, value|
41
+ [key, value.to_s]
42
+ end
43
+ form << ['file', part.io, { filename: part.filename, content_type: part.content_type }]
44
+ request.set_form(form, 'multipart/form-data')
45
+
46
+ response = start_http(request.uri, timeout || @timeout) { |http| http.request(request) }
47
+ decode_response(response)
48
+ ensure
49
+ part&.close
50
+ end
51
+
52
+ def stream_lines(method, path, query: nil, body: nil, headers: nil, timeout: nil)
53
+ Enumerator.new do |yielder|
54
+ request = build_request(method, path, query: query, body: body, headers: {
55
+ 'Accept' => 'text/event-stream'
56
+ }.merge(stringify_hash(headers || {})))
57
+
58
+ buffer = +''
59
+ start_http(request.uri, timeout || @timeout) do |http|
60
+ http.request(request) do |response|
61
+ raise api_error(response) unless success?(response)
62
+
63
+ response.read_body do |chunk|
64
+ buffer << chunk
65
+ while (index = buffer.index("\n"))
66
+ line = buffer.slice!(0..index)
67
+ yielder << line.chomp
68
+ end
69
+ end
70
+ end
71
+ end
72
+ yielder << buffer unless buffer.empty?
73
+ end
74
+ end
75
+
76
+ def self.encode_segment(value)
77
+ CGI.escape(value.to_s).gsub('+', '%20')
78
+ end
79
+
80
+ def self.encode_model_path(value)
81
+ value.to_s.split('/').map { |segment| encode_segment(segment) }.join('/')
82
+ end
83
+
84
+ private
85
+
86
+ def perform(method, path, query:, body:, headers:, timeout:)
87
+ request = build_request(method, path, query: query, body: body, headers: headers)
88
+ start_http(request.uri, timeout || @timeout) { |http| http.request(request) }
89
+ end
90
+
91
+ def build_request(method, path, query:, body:, headers:)
92
+ uri = resolve(path, query)
93
+ request_class = request_class_for(method)
94
+ request = request_class.new(uri)
95
+ request_headers(headers).each { |name, value| request[name] = value }
96
+
97
+ unless body.nil?
98
+ request['Content-Type'] = 'application/json'
99
+ request.body = JSON.generate(body)
100
+ end
101
+
102
+ request
103
+ end
104
+
105
+ def start_http(uri, timeout, &)
106
+ Net::HTTP.start(
107
+ uri.host,
108
+ uri.port,
109
+ use_ssl: uri.scheme == 'https',
110
+ open_timeout: timeout,
111
+ read_timeout: timeout, &
112
+ )
113
+ rescue Net::OpenTimeout, Net::ReadTimeout
114
+ raise TimeoutError.new("Buble API request timed out after #{timeout} seconds.", timeout: timeout)
115
+ end
116
+
117
+ def resolve(path, query)
118
+ normalized = path.start_with?('/') ? path : "/#{path}"
119
+ uri = URI.parse("#{@base_url}#{normalized}")
120
+ query_hash = stringify_hash(query || {}).compact
121
+ uri.query = URI.encode_www_form(query_hash) unless query_hash.empty?
122
+ uri
123
+ end
124
+
125
+ def request_headers(extra)
126
+ {
127
+ 'Authorization' => "Bearer #{@api_key}",
128
+ 'Accept' => 'application/json'
129
+ }.merge(@headers).merge(stringify_hash(extra || {}))
130
+ end
131
+
132
+ def request_class_for(method)
133
+ case method.to_s.upcase
134
+ when 'GET' then Net::HTTP::Get
135
+ when 'POST' then Net::HTTP::Post
136
+ when 'PUT' then Net::HTTP::Put
137
+ when 'PATCH' then Net::HTTP::Patch
138
+ when 'DELETE' then Net::HTTP::Delete
139
+ else
140
+ raise Error, "Unsupported HTTP method: #{method}"
141
+ end
142
+ end
143
+
144
+ def decode_response(response)
145
+ raise api_error(response) unless success?(response)
146
+ return {} if response.body.nil? || response.body.empty?
147
+
148
+ content_type = response['content-type'].to_s
149
+ return JSON.parse(response.body) if content_type.include?('application/json')
150
+
151
+ response.body
152
+ rescue JSON::ParserError => e
153
+ raise Error, "Failed to parse Buble API response: #{e.message}"
154
+ end
155
+
156
+ def api_error(response)
157
+ body = response.body.to_s
158
+ message = body.empty? ? "Buble API request failed with status #{response.code}." : body
159
+ code = nil
160
+ details = nil
161
+
162
+ begin
163
+ decoded = body.empty? ? nil : JSON.parse(body)
164
+ error = decoded.is_a?(Hash) ? decoded['error'] : nil
165
+ if error.is_a?(Hash)
166
+ message = error['message'] || message
167
+ code = error['code']
168
+ details = error['details']
169
+ end
170
+ rescue JSON::ParserError
171
+ # Keep the raw response body as the message.
172
+ end
173
+
174
+ APIError.new(message, status: response.code.to_i, code: code, details: details, response_body: body)
175
+ end
176
+
177
+ def success?(response)
178
+ response.code.to_i >= 200 && response.code.to_i < 300
179
+ end
180
+
181
+ def coerce_file_part(file)
182
+ return file if file.is_a?(FilePart)
183
+
184
+ if file.is_a?(String)
185
+ # The multipart request owns and closes this handle after Net::HTTP consumes it.
186
+ # rubocop:disable Style/FileOpen
187
+ io = File.open(file, 'rb')
188
+ # rubocop:enable Style/FileOpen
189
+ return FilePart.new(
190
+ io: io,
191
+ filename: File.basename(file),
192
+ content_type: 'application/octet-stream',
193
+ close_after: true
194
+ )
195
+ end
196
+
197
+ if file.respond_to?(:read)
198
+ filename = file.respond_to?(:path) && file.path ? File.basename(file.path) : 'upload'
199
+ return FilePart.new(io: file, filename: filename, content_type: 'application/octet-stream', close_after: false)
200
+ end
201
+
202
+ raise Error, 'file must be a path, IO, or Buble::HTTP::FilePart.'
203
+ end
204
+
205
+ def stringify_hash(hash)
206
+ hash.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
207
+ end
208
+
209
+ def blank?(value)
210
+ value.nil? || value.to_s.strip.empty?
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Buble
4
+ class MediaModelsResource
5
+ def initialize(http)
6
+ @http = http
7
+ end
8
+
9
+ def list(media_type: nil, **query)
10
+ @http.request('GET', '/api/v1/media_models', query: { media_type: media_type }.merge(query))
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Buble
6
+ module Streaming
7
+ Event = Struct.new(:event, :data, :json, keyword_init: true)
8
+
9
+ class SSEParser
10
+ def initialize
11
+ @event = nil
12
+ @data = []
13
+ end
14
+
15
+ def push_line(line)
16
+ line = line.to_s.sub(/\r\z/, '')
17
+ return finish_event if line.empty?
18
+ return nil if line.start_with?(':')
19
+
20
+ field, value = line.split(':', 2)
21
+ value = value ? value.sub(/\A /, '') : ''
22
+
23
+ case field
24
+ when 'event'
25
+ @event = value
26
+ when 'data'
27
+ @data << value
28
+ end
29
+ nil
30
+ end
31
+
32
+ def finish
33
+ finish_event
34
+ end
35
+
36
+ private
37
+
38
+ def finish_event
39
+ return nil if @event.nil? && @data.empty?
40
+
41
+ data = @data.join("\n")
42
+ json = parse_json(data)
43
+ event = Event.new(event: @event, data: data, json: json)
44
+ @event = nil
45
+ @data = []
46
+ event
47
+ end
48
+
49
+ def parse_json(data)
50
+ return nil if data.empty? || data == '[DONE]'
51
+
52
+ JSON.parse(data)
53
+ rescue JSON::ParserError
54
+ nil
55
+ end
56
+ end
57
+
58
+ module_function
59
+
60
+ def events_from_lines(lines)
61
+ Enumerator.new do |yielder|
62
+ parser = SSEParser.new
63
+ lines.each do |line|
64
+ event = parser.push_line(line)
65
+ yielder << event if event
66
+ end
67
+ final = parser.finish
68
+ yielder << final if final
69
+ end
70
+ end
71
+
72
+ def text_from_event(event, protocol)
73
+ return nil if event.data == '[DONE]'
74
+
75
+ body = event.json
76
+ return nil unless body.is_a?(Hash)
77
+
78
+ case protocol
79
+ when :openai
80
+ body.dig('choices', 0, 'delta', 'content')
81
+ when :anthropic
82
+ body.dig('delta', 'text')
83
+ when :gemini
84
+ body.dig('candidates', 0, 'content', 'parts', 0, 'text')
85
+ end
86
+ end
87
+
88
+ def text_stream(events, protocol)
89
+ Enumerator.new do |yielder|
90
+ events.each do |event|
91
+ text = text_from_event(event, protocol)
92
+ yielder << text if text && !text.empty?
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Buble
4
+ VERSION = '0.1.0'
5
+ end
data/lib/buble.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'buble/version'
4
+ require_relative 'buble/errors'
5
+ require_relative 'buble/client'
6
+
7
+ module Buble
8
+ end