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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +273 -0
- data/docs/technical-design.md +145 -0
- data/examples/anthropic_messages.rb +16 -0
- data/examples/app_generation.rb +14 -0
- data/examples/gemini_generate.rb +18 -0
- data/examples/image_to_image.rb +22 -0
- data/examples/openai_chat.rb +15 -0
- data/examples/text_to_image.rb +16 -0
- data/examples/text_to_video.rb +17 -0
- data/lib/buble/apps.rb +72 -0
- data/lib/buble/chat.rb +101 -0
- data/lib/buble/client.rb +36 -0
- data/lib/buble/errors.rb +53 -0
- data/lib/buble/files.rb +60 -0
- data/lib/buble/generations.rb +101 -0
- data/lib/buble/http.rb +213 -0
- data/lib/buble/media_models.rb +13 -0
- data/lib/buble/streaming.rb +97 -0
- data/lib/buble/version.rb +5 -0
- data/lib/buble.rb +8 -0
- data/tools/live_smoke.rb +18 -0
- metadata +113 -0
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
|
data/lib/buble/client.rb
ADDED
|
@@ -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
|
data/lib/buble/errors.rb
ADDED
|
@@ -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
|
data/lib/buble/files.rb
ADDED
|
@@ -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
|