google-genai 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.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pagers'
4
+
5
+ module Google
6
+ module Genai
7
+ class Files
8
+ def initialize(api_client)
9
+ @api_client = api_client
10
+ end
11
+
12
+ def upload(file:, config: nil)
13
+ file_path = file.is_a?(String) ? file : file.path
14
+ raise ArgumentError, "File not found: #{file_path}" unless File.exist?(file_path)
15
+
16
+ config ||= {}
17
+ mime_type = config[:mime_type] || MimeMagic.by_path(file_path)&.type
18
+ raise ArgumentError, "Could not determine MIME type for file: #{file_path}" unless mime_type
19
+
20
+ display_name = config[:display_name] || File.basename(file_path)
21
+ file_size = File.size(file_path)
22
+
23
+ response_data = @api_client.upload_file(file_path, file_size, mime_type, display_name: display_name)
24
+ file_info = Types::File.new(response_data['file'])
25
+
26
+ # Poll for file processing to complete with exponential backoff
27
+ timeout = 120 # 2 minutes
28
+ start_time = Time.now
29
+ delay = 0.1 # Initial delay of 100ms
30
+
31
+ loop do
32
+ file_info = get(name: file_info.name)
33
+ case file_info.state
34
+ when 'ACTIVE'
35
+ return file_info
36
+ when 'FAILED'
37
+ raise "File processing failed: #{file_info.error}"
38
+ end
39
+
40
+ raise "File processing timed out" if Time.now - start_time > timeout
41
+
42
+ sleep delay
43
+ delay = [delay * 1.5, 5].min # Increase delay, but cap at 5 seconds
44
+ end
45
+ end
46
+
47
+ def get(name:, config: nil)
48
+ file_id = name.start_with?('files/') ? name.split('/').last : name
49
+ response = @api_client.get("v1beta/files/#{file_id}")
50
+ Types::File.new(JSON.parse(response.body))
51
+ end
52
+
53
+ def delete(name:, config: nil)
54
+ file_id = name.start_with?('files/') ? name.split('/').last : name
55
+ @api_client.delete("v1beta/files/#{file_id}")
56
+ nil
57
+ end
58
+
59
+ def list(config: nil)
60
+ list_request = ->(options) do
61
+ path = "v1beta/files"
62
+ params = {}
63
+ params[:pageToken] = options[:page_token] if options&.key?(:page_token)
64
+ params[:pageSize] = options[:page_size] if options&.key?(:page_size)
65
+ path += "?#{URI.encode_www_form(params)}" unless params.empty?
66
+ @api_client.get(path)
67
+ end
68
+
69
+ response = list_request.call(config)
70
+
71
+ Pager.new(
72
+ name: :files,
73
+ request: list_request,
74
+ response: response,
75
+ config: config,
76
+ item_class: Types::File
77
+ )
78
+ end
79
+
80
+ def download(file:, config: nil)
81
+ # TODO: Implement file download logic
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'websocket-client-simple'
4
+ require 'json'
5
+ require_relative 'live_music'
6
+
7
+ module Google
8
+ module Genai
9
+ class Live
10
+ def initialize(api_client)
11
+ @api_client = api_client
12
+ end
13
+
14
+ def music
15
+ @music ||= LiveMusic.new(@api_client)
16
+ end
17
+
18
+ def connect(model:, config: nil)
19
+ raise "Live API is not supported for Vertex AI yet" if @api_client.vertexai
20
+
21
+ base_url = "wss://generativelanguage.googleapis.com"
22
+ version = @api_client.instance_variable_get(:@http_options)&.[](:api_version) || 'v1beta'
23
+ api_key = @api_client.api_key
24
+
25
+ uri = "#{base_url}/ws/google.ai.generativelanguage.#{version}.GenerativeService.BidiGenerateContent?key=#{api_key}"
26
+
27
+ ws = WebSocket::Client::Simple.connect(uri)
28
+
29
+ session = Session.new(ws)
30
+
31
+ setup_message = {
32
+ setup: {
33
+ model: "models/#{model}",
34
+ generationConfig: config
35
+ }
36
+ }
37
+ ws.send(setup_message.to_json)
38
+
39
+ # Block until the connection is open and initial setup is confirmed.
40
+ # This is a simplified way to handle the async nature of websockets in a sync method.
41
+ # A more robust solution would involve a proper event loop.
42
+ sleep 0.1 until ws.open?
43
+
44
+ yield session
45
+ ensure
46
+ ws.close if ws&.open?
47
+ end
48
+ end
49
+
50
+ class Session
51
+ def initialize(websocket)
52
+ @ws = websocket
53
+ @message_queue = Queue.new
54
+
55
+ @ws.on :message do |msg|
56
+ @message_queue.push(JSON.parse(msg.data))
57
+ end
58
+
59
+ @ws.on :error do |err|
60
+ # For now, just print the error. A more robust error handling can be added.
61
+ puts "WebSocket Error: #{err.message}"
62
+ end
63
+ end
64
+
65
+ def send_client_content(turns:, turn_complete: true)
66
+ message = {
67
+ clientContent: {
68
+ turns: turns,
69
+ turnComplete: turn_complete
70
+ }
71
+ }
72
+ @ws.send(message.to_json)
73
+ end
74
+
75
+ def receive
76
+ @message_queue.pop
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'websocket-client-simple'
4
+ require 'json'
5
+
6
+ module Google
7
+ module Genai
8
+ class LiveMusic
9
+ def initialize(api_client)
10
+ @api_client = api_client
11
+ end
12
+
13
+ def connect(model:)
14
+ raise "Live Music API is not supported for Vertex AI yet" if @api_client.vertexai
15
+
16
+ base_url = "wss://generativelanguage.googleapis.com"
17
+ version = @api_client.instance_variable_get(:@http_options)&.[](:api_version) || 'v1beta'
18
+ api_key = @api_client.api_key
19
+
20
+ uri = "#{base_url}/ws/google.ai.generativelanguage.#{version}.GenerativeService.BidiGenerateMusic?key=#{api_key}"
21
+
22
+ ws = WebSocket::Client::Simple.connect(uri)
23
+
24
+ session = MusicSession.new(ws)
25
+
26
+ setup_message = {
27
+ setup: {
28
+ model: "models/#{model}"
29
+ }
30
+ }
31
+ ws.send(setup_message.to_json)
32
+
33
+ sleep 0.1 until ws.open?
34
+
35
+ yield session
36
+ ensure
37
+ ws.close if ws&.open?
38
+ end
39
+ end
40
+
41
+ class MusicSession
42
+ def initialize(websocket)
43
+ @ws = websocket
44
+ @message_queue = Queue.new
45
+
46
+ @ws.on :message do |msg|
47
+ @message_queue.push(JSON.parse(msg.data))
48
+ end
49
+
50
+ @ws.on :error do |err|
51
+ puts "WebSocket Error: #{err.message}"
52
+ end
53
+ end
54
+
55
+ def set_weighted_prompts(prompts:)
56
+ message = {
57
+ clientContent: {
58
+ weightedPrompts: prompts.map(&:to_h)
59
+ }
60
+ }
61
+ @ws.send(message.to_json)
62
+ end
63
+
64
+ def set_music_generation_config(config:)
65
+ @ws.send({ musicGenerationConfig: config }.to_json)
66
+ end
67
+
68
+ def play
69
+ _send_control_signal('PLAY')
70
+ end
71
+
72
+ def pause
73
+ _send_control_signal('PAUSE')
74
+ end
75
+
76
+ def stop
77
+ _send_control_signal('STOP')
78
+ end
79
+
80
+ def reset_context
81
+ _send_control_signal('RESET_CONTEXT')
82
+ end
83
+
84
+ def receive
85
+ @message_queue.pop
86
+ end
87
+
88
+ private
89
+
90
+ def _send_control_signal(control_signal)
91
+ message = {
92
+ playbackControl: control_signal
93
+ }
94
+ @ws.send(message.to_json)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'types'
4
+ require_relative 'pagers'
5
+
6
+ module Google
7
+ module Genai
8
+ class Models
9
+ def initialize(api_client)
10
+ @api_client = api_client
11
+ end
12
+
13
+ def generate_content(model:, contents:, config: nil)
14
+ path = "v1beta/models/#{model}:generateContent"
15
+ body = {
16
+ contents: normalize_contents(contents)
17
+ }
18
+ body[:generationConfig] = config if config
19
+
20
+ response = @api_client.request(:post, path, body: body)
21
+ Types::GenerateContentResponse.new(JSON.parse(response.body))
22
+ end
23
+
24
+ def list(config: nil)
25
+ query_base = config&.dig(:query_base) != false
26
+
27
+ list_request = ->(options) do
28
+ path = "v1beta/#{query_base ? 'models' : 'tunedModels'}"
29
+ params = {}
30
+ params[:pageToken] = options[:page_token] if options&.key?(:page_token)
31
+ params[:pageSize] = options[:page_size] if options&.key?(:page_size)
32
+ path += "?#{URI.encode_www_form(params)}" unless params.empty?
33
+ @api_client.get(path)
34
+ end
35
+
36
+ response = list_request.call(config)
37
+
38
+ Pager.new(
39
+ name: query_base ? :models : :tunedModels,
40
+ request: list_request,
41
+ response: response,
42
+ config: config,
43
+ item_class: Types::Model
44
+ )
45
+ end
46
+
47
+ private
48
+
49
+ def normalize_contents(contents)
50
+ contents = [contents] unless contents.is_a?(Array)
51
+
52
+ contents.map do |item|
53
+ case item
54
+ when String
55
+ { role: 'user', parts: [{ text: item }] }
56
+ when Hash
57
+ item # Assumes it's already in the correct format
58
+ when Types::Content
59
+ item.to_h
60
+ when Types::File
61
+ { role: 'user', parts: [{ file_data: { mime_type: item.mime_type, file_uri: item.uri } }] }
62
+ else
63
+ raise ArgumentError, "Unsupported content type: #{item.class}"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'types'
4
+
5
+ module Google
6
+ module Genai
7
+ class Operations
8
+ def initialize(api_client)
9
+ @api_client = api_client
10
+ end
11
+
12
+ def get(name:, config: nil)
13
+ response = @api_client.get("v1beta/#{name}")
14
+ Types::Operation.new(JSON.parse(response.body))
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Google
4
+ module Genai
5
+ class Pager
6
+ include Enumerable
7
+
8
+ attr_reader :name, :page_size, :config, :sdk_http_response
9
+
10
+ def initialize(name:, request:, response:, config:, item_class:)
11
+ @item_class = item_class
12
+ _init_page(name: name, request: request, response: response, config: config)
13
+ end
14
+
15
+ def each(&block)
16
+ loop do
17
+ @page.each(&block)
18
+ break unless next_page_token
19
+ next_page
20
+ end
21
+ end
22
+
23
+ def page
24
+ @page
25
+ end
26
+
27
+ def next_page
28
+ raise IndexError, 'No more pages to fetch.' unless next_page_token
29
+
30
+ response = @request.call(config: @config)
31
+ _init_page(name: @name, request: @request, response: response, config: @config)
32
+ @page
33
+ end
34
+
35
+ private
36
+
37
+ def _init_page(name:, request:, response:, config:)
38
+ @name = name
39
+ @request = request
40
+
41
+ response_body = JSON.parse(response.body)
42
+ @page = (response_body[name.to_s] || []).map { |item_data| @item_class.new(item_data) }
43
+
44
+ @sdk_http_response = response
45
+
46
+ @config = config ? config.dup : {}
47
+ @config[:page_token] = response_body['nextPageToken']
48
+
49
+ @page_size = @config[:page_size] || @page.length
50
+ end
51
+
52
+ def next_page_token
53
+ @config[:page_token]
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'types'
4
+
5
+ module Google
6
+ module Genai
7
+ class Tokens
8
+ def initialize(api_client)
9
+ @api_client = api_client
10
+ end
11
+
12
+ def create(config: nil)
13
+ raise "Tokens is only supported for Gemini API" if @api_client.vertexai
14
+
15
+ # This is a simplified port of the Python SDK's logic.
16
+ # The full logic for field masks is complex and will be implemented later.
17
+
18
+ body = {}
19
+ body[:expireTime] = config[:expire_time] if config&.key?(:expire_time)
20
+ body[:uses] = config[:uses] if config&.key?(:uses)
21
+
22
+ if config&.key?(:live_connect_constraints)
23
+ # This part of the conversion is complex and will require more detailed mapping.
24
+ # For now, we'll pass a simplified version.
25
+ body[:bidiGenerateContentSetup] = {
26
+ setup: {
27
+ model: config[:live_connect_constraints][:model]
28
+ }
29
+ }
30
+ if config[:live_connect_constraints][:config]
31
+ body[:bidiGenerateContentSetup][:setup][:generationConfig] = config[:live_connect_constraints][:config]
32
+ end
33
+ end
34
+
35
+ response = @api_client.request(:post, "v1alpha/authTokens", body: body)
36
+ Types::AuthToken.new(JSON.parse(response.body))
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'types'
4
+ require_relative 'pagers'
5
+
6
+ module Google
7
+ module Genai
8
+ class Tunings
9
+ def initialize(api_client)
10
+ @api_client = api_client
11
+ end
12
+
13
+ def create(base_model:, training_dataset:, config: nil)
14
+ raise "Tuning is only supported for Vertex AI" unless @api_client.vertexai
15
+
16
+ body = {
17
+ baseModel: base_model,
18
+ supervisedTuningSpec: {
19
+ trainingDatasetUri: training_dataset[:gcs_uri] || training_dataset[:vertex_dataset_resource]
20
+ }
21
+ }
22
+ # TODO: Add other config options
23
+
24
+ response = @api_client.request(:post, "v1beta/tuningJobs", body: body)
25
+ Types::TuningJob.new(JSON.parse(response.body))
26
+ end
27
+
28
+ def get(name:, config: nil)
29
+ raise "Tuning is only supported for Vertex AI" unless @api_client.vertexai
30
+ response = @api_client.get("v1beta/#{name}")
31
+ Types::TuningJob.new(JSON.parse(response.body))
32
+ end
33
+
34
+ def list(config: nil)
35
+ raise "Tuning is only supported for Vertex AI" unless @api_client.vertexai
36
+
37
+ list_request = ->(options) do
38
+ path = "v1beta/tuningJobs"
39
+ params = {}
40
+ params[:pageToken] = options[:page_token] if options&.key?(:page_token)
41
+ params[:pageSize] = options[:page_size] if options&.key?(:page_size)
42
+ path += "?#{URI.encode_www_form(params)}" unless params.empty?
43
+ @api_client.get(path)
44
+ end
45
+
46
+ response = list_request.call(config)
47
+
48
+ Pager.new(
49
+ name: :tuningJobs,
50
+ request: list_request,
51
+ response: response,
52
+ config: config,
53
+ item_class: Types::TuningJob
54
+ )
55
+ end
56
+
57
+ def cancel(name:, config: nil)
58
+ raise "Tuning is only supported for Vertex AI" unless @api_client.vertexai
59
+ @api_client.request(:post, "v1beta/#{name}:cancel", body: {})
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+ require "base64"
3
+
4
+ module Google
5
+ module Genai
6
+ module Types
7
+ class Base
8
+ def initialize(attributes = {})
9
+ attributes.each do |key, value|
10
+ setter = "#{key}="
11
+ public_send(setter, value) if respond_to?(setter)
12
+ end
13
+ end
14
+ end
15
+
16
+ class Blob < Base
17
+ attr_accessor :mime_type, :data
18
+
19
+ def to_h
20
+ { mimeType: mime_type, data: Base64.strict_encode64(data) }
21
+ end
22
+ end
23
+
24
+ class FileData < Base
25
+ attr_accessor :mime_type, :file_uri
26
+
27
+ def to_h
28
+ { mimeType: mime_type, fileUri: file_uri }
29
+ end
30
+ end
31
+
32
+ class Part < Base
33
+ attr_accessor :text, :inline_data, :file_data
34
+
35
+ def to_h
36
+ data = {}
37
+ data[:text] = text if text
38
+ data[:inlineData] = inline_data.to_h if inline_data
39
+ data[:fileData] = file_data.to_h if file_data
40
+ data
41
+ end
42
+ end
43
+
44
+ class Content < Base
45
+ attr_accessor :role, :parts
46
+
47
+ def initialize(attributes = {})
48
+ super
49
+ self.parts = Array(self.parts).map { |p| p.is_a?(Part) ? p : Part.new(p) }
50
+ end
51
+
52
+ def to_h
53
+ {
54
+ role: role,
55
+ parts: parts.map(&:to_h)
56
+ }
57
+ end
58
+ end
59
+
60
+ class GenerateContentResponse < Base
61
+ attr_accessor :candidates
62
+
63
+ def initialize(attributes = {})
64
+ super
65
+ self.candidates = Array(self.candidates).map { |c| c.is_a?(Candidate) ? c : Candidate.new(c) }
66
+ end
67
+
68
+ def text
69
+ candidates&.first&.content&.parts&.map(&:text)&.join
70
+ end
71
+ end
72
+
73
+ class Candidate < Base
74
+ attr_accessor :content, :finish_reason, :safety_ratings
75
+
76
+ def initialize(attributes = {})
77
+ super
78
+ self.content = Content.new(self.content) if self.content.is_a?(Hash)
79
+ end
80
+ end
81
+
82
+ class File < Base
83
+ attr_accessor :name, :display_name, :mime_type, :size_bytes, :create_time, :update_time, :expiration_time, :sha256_hash, :uri, :state, :error
84
+ end
85
+
86
+ class TuningJob < Base
87
+ attr_accessor :name, :state, :create_time, :end_time, :tuned_model
88
+ end
89
+
90
+ class CachedContent < Base
91
+ attr_accessor :name, :display_name, :model, :create_time, :update_time, :expire_time, :usage_metadata
92
+ end
93
+
94
+ class BatchJob < Base
95
+ attr_accessor :name, :model, :state, :create_time, :end_time, :update_time, :error
96
+ end
97
+
98
+ class Operation < Base
99
+ attr_accessor :name, :metadata, :done, :error, :response
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Google
4
+ module Genai
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ loader = Zeitwerk::Loader.for_gem
5
+ loader.setup
6
+
7
+ require_relative "genai/version"
8
+ require_relative "genai/api_client"
9
+ require_relative "genai/models"
10
+ require_relative "genai/chats"
11
+ require_relative "genai/files"
12
+ require_relative "genai/tunings"
13
+ require_relative "genai/caches"
14
+ require_relative "genai/batches"
15
+ require_relative "genai/operations"
16
+ require_relative "genai/tokens"
17
+ require_relative "genai/live"
18
+ require_relative "genai/client"
19
+
20
+ module Google
21
+ module Genai
22
+ # Your code goes here...
23
+ end
24
+ end