ruby_llm-voyage 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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Voyage
6
+ # Voyage contextualized chunk embedding mapping.
7
+ # @api private
8
+ module ContextualizedEmbeddings
9
+ # Executes a contextualized chunk embedding request.
10
+ # @return [RubyLLM::Voyage::ContextualizedEmbedding]
11
+ def contextualized_embed(inputs, model:, input_type: RubyLLM::Voyage::UNSET, dimensions: nil,
12
+ output_dtype: RubyLLM::Voyage::UNSET,
13
+ encoding_format: RubyLLM::Voyage::UNSET, auto_chunk: nil, chunk_size: nil,
14
+ chunk_overlap: nil, provider_options: {})
15
+ payload = {
16
+ inputs: inputs,
17
+ model: model,
18
+ input_type: contextualized_option(input_type, :voyage_default_input_type),
19
+ output_dimension: dimensions || @config.voyage_default_output_dimension,
20
+ output_dtype: contextualized_option(output_dtype, :voyage_default_output_dtype),
21
+ encoding_format: contextualized_option(encoding_format, :voyage_default_encoding_format),
22
+ enable_auto_chunking: auto_chunk,
23
+ chunk_size: chunk_size,
24
+ chunk_overlap: chunk_overlap
25
+ }.compact.merge(provider_options.transform_keys(&:to_sym))
26
+ response = @connection.post('contextualizedembeddings', payload)
27
+ parse_contextualized_response(response, output_dtype: payload[:output_dtype])
28
+ end
29
+
30
+ private
31
+
32
+ def contextualized_option(value, config_key)
33
+ value = @config.public_send(config_key) if value.equal?(RubyLLM::Voyage::UNSET)
34
+ value&.to_s
35
+ end
36
+
37
+ def parse_contextualized_response(response, output_dtype: nil)
38
+ body = response.body
39
+ results = body.fetch('data').map do |group|
40
+ chunks = group.fetch('data').map do |item|
41
+ vector = item.fetch('embedding')
42
+ vector = RubyLLM::Voyage::VectorDecoder.decode(vector, output_dtype) if vector.is_a?(String)
43
+ RubyLLM::Voyage::ContextualizedChunk.new(
44
+ embedding: vector,
45
+ index: item.fetch('index'),
46
+ text: item['text']
47
+ )
48
+ end
49
+ RubyLLM::Voyage::ContextualizedResult.new(chunks: chunks, index: group.fetch('index'))
50
+ end
51
+
52
+ RubyLLM::Voyage::ContextualizedEmbedding.new(
53
+ results: results,
54
+ model: body['model'],
55
+ input_tokens: body.dig('usage', 'total_tokens') || 0,
56
+ chunker_version: body['chunker_version']
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Voyage
6
+ # Voyage embeddings request and response mapping.
7
+ # @api private
8
+ module Embeddings
9
+ # Returns the embeddings endpoint relative to the configured API base.
10
+ # @return [String]
11
+ # @api private
12
+ def embedding_url(...)
13
+ 'embeddings'
14
+ end
15
+
16
+ # Converts RubyLLM embedding arguments to a Voyage request body.
17
+ # A +nil+ dimensions argument falls back to the configured
18
+ # +voyage_default_output_dimension+.
19
+ # @param text [String, Array<String>]
20
+ # @param model [String]
21
+ # @param dimensions [Integer, nil]
22
+ # @param input_type [String, Symbol, nil]
23
+ # @param truncation [Boolean, nil]
24
+ # @param output_dtype [String, Symbol, nil]
25
+ # @param encoding_format [String, Symbol, nil]
26
+ # @return [Hash]
27
+ # @api private
28
+ def render_embedding_payload(text, model:, dimensions: nil, input_type: RubyLLM::Voyage::UNSET,
29
+ truncation: RubyLLM::Voyage::UNSET, output_dtype: RubyLLM::Voyage::UNSET,
30
+ encoding_format: RubyLLM::Voyage::UNSET)
31
+ {
32
+ model: model,
33
+ input: text,
34
+ input_type: enum_option(input_type, :voyage_default_input_type),
35
+ truncation: configured_option(truncation, :voyage_default_truncation),
36
+ output_dimension: dimensions || @config.voyage_default_output_dimension,
37
+ output_dtype: enum_option(output_dtype, :voyage_default_output_dtype),
38
+ encoding_format: enum_option(encoding_format, :voyage_default_encoding_format)
39
+ }.compact
40
+ end
41
+
42
+ # Converts a Voyage response into a RubyLLM embedding result.
43
+ # @param response [Faraday::Response]
44
+ # @param model [String]
45
+ # @param text [String, Array<String>]
46
+ # @param output_dtype [String, Symbol, nil]
47
+ # @return [RubyLLM::Embedding]
48
+ # @raise [KeyError] when the response omits embedding data
49
+ # @raise [ArgumentError] when Base64 data or its output type is invalid
50
+ # @api private
51
+ def parse_embedding_response(response, model:, text:, output_dtype: nil)
52
+ data = response.body
53
+ vectors = data.fetch('data').map { |item| item.fetch('embedding') }
54
+ if vectors.first.is_a?(String)
55
+ vectors.map! do |vector|
56
+ RubyLLM::Voyage::VectorDecoder.decode(vector, output_dtype)
57
+ end
58
+ end
59
+ vectors = vectors.first if vectors.length == 1 && !text.is_a?(Array)
60
+
61
+ Embedding.new(
62
+ vectors: vectors,
63
+ model: data['model'] || model,
64
+ input_tokens: data.dig('usage', 'total_tokens') || 0
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ def configured_option(value, config_key)
71
+ value.equal?(RubyLLM::Voyage::UNSET) ? @config.public_send(config_key) : value
72
+ end
73
+
74
+ def enum_option(value, config_key)
75
+ configured_option(value, config_key)&.to_s
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class Voyage
8
+ # Voyage Files API operations.
9
+ # @api private
10
+ module Files
11
+ # Maximum redirect hops followed when downloading file content.
12
+ MAX_DOWNLOAD_REDIRECTS = 3
13
+ private_constant :MAX_DOWNLOAD_REDIRECTS
14
+
15
+ # Uploads one JSONL file.
16
+ # @param path [String, Pathname]
17
+ # @param purpose [String]
18
+ # @return [RubyLLM::Voyage::VoyageFile]
19
+ def upload_file(path, purpose: 'batch')
20
+ expanded = File.expand_path(path)
21
+ unless File.extname(expanded).casecmp?('.jsonl')
22
+ raise ArgumentError, 'Voyage file uploads must use the .jsonl extension'
23
+ end
24
+
25
+ # RubyLLM's connection stack loads faraday-multipart and uses this
26
+ # same class for its own uploads; 1.x has no public helper for it.
27
+ part = Faraday::Multipart::FilePart.new(expanded, 'application/jsonl', File.basename(expanded))
28
+ response = @connection.post('files', { file: part, purpose: purpose })
29
+ parse_file(response.body)
30
+ end
31
+
32
+ # Retrieves file metadata.
33
+ # @param file_id [String]
34
+ # @return [RubyLLM::Voyage::VoyageFile]
35
+ def retrieve_file(file_id)
36
+ parse_file(@connection.get("files/#{escape_id(file_id)}").body)
37
+ end
38
+
39
+ # Lists file metadata with cursor pagination.
40
+ # @return [RubyLLM::Voyage::Page]
41
+ def list_files(purpose: nil, limit: nil, order: nil, after: nil)
42
+ query = URI.encode_www_form({ purpose: purpose, limit: limit, order: order, after: after }.compact)
43
+ path = query.empty? ? 'files' : "files?#{query}"
44
+ parse_page(@connection.get(path).body) { |item| parse_file(item) }
45
+ end
46
+
47
+ # Downloads raw file content.
48
+ # @param file_id [String]
49
+ # @return [String]
50
+ def file_content(file_id)
51
+ response = @connection.get("files/#{escape_id(file_id)}/content") do |request|
52
+ request.headers['Accept'] = 'text/plain'
53
+ end
54
+ return response.body unless response.status.between?(300, 399)
55
+
56
+ download_redirect(response.headers['location'])
57
+ end
58
+
59
+ # Atomically deletes one or more files. Voyage treats the operation
60
+ # as all-or-nothing and reports the outcome in a +success+ field,
61
+ # with +error_file_ids+ available in +raw+ on failure.
62
+ # @param file_ids [Array<String>]
63
+ # @return [RubyLLM::Voyage::FileDeletion]
64
+ def delete_files(file_ids)
65
+ body = @connection.post('files/delete', { file_ids: file_ids }).body
66
+ deleted = body.is_a?(Hash) && body.key?('success') ? body['success'] : true
67
+ RubyLLM::Voyage::FileDeletion.new(file_ids: file_ids, deleted: deleted, raw: body)
68
+ end
69
+
70
+ private
71
+
72
+ def parse_file(item)
73
+ RubyLLM::Voyage::VoyageFile.new(
74
+ id: item['id'], object: item['object'], purpose: item['purpose'], filename: item['filename'],
75
+ bytes: item['bytes'], created_at: item['created_at'], expires_at: item['expires_at'], raw: item
76
+ )
77
+ end
78
+
79
+ def parse_page(body, &)
80
+ RubyLLM::Voyage::Page.new(
81
+ data: body.fetch('data').map(&),
82
+ first_id: body['first_id'], last_id: body['last_id'], has_more: body['has_more'], raw: body
83
+ )
84
+ end
85
+
86
+ def escape_id(id)
87
+ URI.encode_www_form_component(id.to_s)
88
+ end
89
+
90
+ def download_redirect(location)
91
+ connection = download_connection
92
+
93
+ MAX_DOWNLOAD_REDIRECTS.times do
94
+ uri = validated_redirect_uri(location)
95
+ response = connection.get(uri.to_s) { |request| request.headers['Accept'] = 'text/plain' }
96
+ return response.body unless response.status.between?(300, 399)
97
+
98
+ location = response.headers['location']
99
+ end
100
+
101
+ raise RubyLLM::Error, 'Voyage file download followed too many redirects'
102
+ end
103
+
104
+ def validated_redirect_uri(location)
105
+ uri = URI(location.to_s)
106
+ unless uri.is_a?(URI::HTTPS) && uri.host && !uri.userinfo
107
+ raise RubyLLM::Error, 'Voyage returned an unsafe file download redirect'
108
+ end
109
+
110
+ uri
111
+ end
112
+
113
+ # Signed storage URLs must be fetched outside the provider connection
114
+ # so the Voyage Authorization header is not forwarded. Uses RubyLLM's
115
+ # own side-connection builder and honors its configured adapter and
116
+ # timeout.
117
+ def download_connection
118
+ RubyLLM::Connection.basic do |faraday|
119
+ faraday.options.timeout = @config.request_timeout
120
+ faraday.adapter(@config.faraday_adapter || :net_http)
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class Voyage
6
+ # Voyage reranking request and response mapping.
7
+ # @api private
8
+ module Reranking
9
+ # Executes a Voyage reranking request.
10
+ # @return [RubyLLM::Voyage::Reranking]
11
+ def rerank(query:, documents:, model:, top_k: nil, return_documents: RubyLLM::Voyage::UNSET,
12
+ truncation: RubyLLM::Voyage::UNSET, provider_options: {})
13
+ payload = {
14
+ query: query,
15
+ documents: documents,
16
+ model: model,
17
+ top_k: top_k,
18
+ return_documents: return_documents,
19
+ truncation: truncation
20
+ }.reject { |_key, value| value.nil? || value.equal?(RubyLLM::Voyage::UNSET) }
21
+ .merge(provider_options.transform_keys(&:to_sym))
22
+ response = @connection.post('rerank', payload)
23
+ parse_rerank_response(response)
24
+ end
25
+
26
+ private
27
+
28
+ def parse_rerank_response(response)
29
+ body = response.body
30
+ results = body.fetch('data').map do |item|
31
+ RubyLLM::Voyage::RerankResult.new(
32
+ index: item.fetch('index'),
33
+ relevance_score: item.fetch('relevance_score'),
34
+ document: item['document']
35
+ )
36
+ end
37
+
38
+ RubyLLM::Voyage::Reranking.new(
39
+ results: results,
40
+ model: body['model'],
41
+ input_tokens: body.dig('usage', 'total_tokens') || 0
42
+ )
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Provider implementations registered with RubyLLM.
5
+ module Providers
6
+ # Voyage retrieval API provider for RubyLLM.
7
+ #
8
+ # Most applications should use +RubyLLM.embed+ or
9
+ # {RubyLLM::Voyage.embed} instead of instantiating this class directly.
10
+ class Voyage < Provider
11
+ require 'ruby_llm/providers/voyage/embeddings'
12
+ require 'ruby_llm/providers/voyage/reranking'
13
+ require 'ruby_llm/providers/voyage/contextualized_embeddings'
14
+ require 'ruby_llm/providers/voyage/files'
15
+
16
+ include Voyage::Embeddings
17
+ include Voyage::Reranking
18
+ include Voyage::ContextualizedEmbeddings
19
+ include Voyage::Files
20
+
21
+ # Returns the configured Voyage API base URL.
22
+ # @return [String]
23
+ def api_base
24
+ @config.voyage_api_base || 'https://api.voyageai.com/v1'
25
+ end
26
+
27
+ # Returns authentication headers for Voyage requests.
28
+ # @return [Hash{String => String}]
29
+ def headers
30
+ { 'Authorization' => "Bearer #{@config.voyage_api_key}" }
31
+ end
32
+
33
+ # RubyLLM 1.x does not expose per-request provider options for embeddings,
34
+ # so the extension's facade calls this richer provider method directly.
35
+ #
36
+ # @api private
37
+ # @param text [String, Array<String>]
38
+ # @param model [String]
39
+ # @param dimensions [Integer, nil]
40
+ # @param provider_options [Hash]
41
+ # @return [RubyLLM::Embedding]
42
+ def embed(text, model:, dimensions: nil, provider_options: {}, **options)
43
+ payload = render_embedding_payload(text, model:, dimensions:, **options)
44
+ .merge(provider_options.transform_keys(&:to_sym))
45
+ response = @connection.post(embedding_url(model:), payload)
46
+ parse_embedding_response(response, model:, text:, output_dtype: payload[:output_dtype])
47
+ end
48
+
49
+ class << self
50
+ # Lists configuration keys registered for the Voyage provider.
51
+ # @return [Array<Symbol>]
52
+ # @api private
53
+ def configuration_options
54
+ %i[
55
+ voyage_api_key
56
+ voyage_api_base
57
+ voyage_default_input_type
58
+ voyage_default_truncation
59
+ voyage_default_output_dimension
60
+ voyage_default_output_dtype
61
+ voyage_default_encoding_format
62
+ ]
63
+ end
64
+
65
+ # Lists configuration keys required to use the Voyage provider.
66
+ # @return [Array<Symbol>]
67
+ # @api private
68
+ def configuration_requirements
69
+ %i[voyage_api_key]
70
+ end
71
+
72
+ # Indicates that Voyage model IDs can be used without registry entries.
73
+ # @return [Boolean]
74
+ def assume_models_exist?
75
+ true
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Voyage
5
+ # One document returned by a reranking request.
6
+ RerankResult = Struct.new(:index, :relevance_score, :document, keyword_init: true)
7
+
8
+ # Typed result returned by {Voyage.rerank}.
9
+ Reranking = Struct.new(:results, :model, :input_tokens, keyword_init: true) do
10
+ # Reorders the original candidate collection by relevance.
11
+ #
12
+ # Pass the same collection whose contents were sent as +documents:+.
13
+ # Returns the matching items in reranked order, truncated to +top_k+
14
+ # when one was requested.
15
+ #
16
+ # @param collection [Array, #to_a] candidates in their original order
17
+ # @return [Array] the reranked items
18
+ # @raise [IndexError] when the collection is smaller than the
19
+ # document list that was reranked
20
+ #
21
+ # @example
22
+ # reranking = RubyLLM::Voyage.rerank(
23
+ # query: query, documents: articles.map(&:content), top_k: 5
24
+ # )
25
+ # reranking.reorder(articles) # => the five most relevant articles
26
+ def reorder(collection)
27
+ items = collection.to_a
28
+ results.map { |result| items.fetch(result.index) }
29
+ end
30
+ end
31
+
32
+ # One contextualized chunk and its vector.
33
+ ContextualizedChunk = Struct.new(:embedding, :index, :text, keyword_init: true) do
34
+ # Summarizes the vector so console output stays readable.
35
+ # @return [String]
36
+ def inspect
37
+ dimensions = embedding.respond_to?(:length) ? "[#{embedding.length} values]" : embedding.inspect
38
+ "#<RubyLLM::Voyage::ContextualizedChunk index=#{index} embedding=#{dimensions} " \
39
+ "text=#{text&.slice(0, 60).inspect}>"
40
+ end
41
+ end
42
+
43
+ # Contextualized chunks belonging to one input document or query.
44
+ ContextualizedResult = Struct.new(:chunks, :index, keyword_init: true)
45
+
46
+ # Typed result returned by {Voyage.contextualized_embed}.
47
+ ContextualizedEmbedding = Struct.new(
48
+ :results,
49
+ :model,
50
+ :input_tokens,
51
+ :chunker_version,
52
+ keyword_init: true
53
+ )
54
+
55
+ # Metadata for a file managed by Voyage's Files API.
56
+ VoyageFile = Struct.new(
57
+ :id,
58
+ :object,
59
+ :purpose,
60
+ :filename,
61
+ :bytes,
62
+ :created_at,
63
+ :expires_at,
64
+ :raw,
65
+ keyword_init: true
66
+ )
67
+
68
+ # A cursor-based page returned by a Voyage list endpoint.
69
+ Page = Struct.new(:data, :first_id, :last_id, :has_more, :raw, keyword_init: true)
70
+
71
+ # Result of an all-or-nothing bulk file deletion.
72
+ FileDeletion = Struct.new(:file_ids, :deleted, :raw, keyword_init: true)
73
+ end
74
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Voyage
5
+ # Decodes Voyage Base64 vector representations.
6
+ #
7
+ # The gem decodes API responses automatically; use this directly when
8
+ # parsing embeddings out of Voyage batch result files, which contain the
9
+ # same Base64 encoding.
10
+ module VectorDecoder
11
+ module_function
12
+
13
+ # Decodes a Base64 vector for the requested Voyage output type.
14
+ # @param vector [String] Base64-encoded vector data
15
+ # @param output_dtype [String, Symbol, nil] the +output_dtype+ the
16
+ # embedding was requested with; +nil+ decodes as +float+
17
+ # @return [Array<Float>, Array<Integer>]
18
+ # @raise [ArgumentError] when the Base64 data or output type is invalid
19
+ #
20
+ # @example Decode an embedding from a batch results file
21
+ # line = JSON.parse(jsonl_line)
22
+ # encoded = line.dig('response', 'body', 'data', 0, 'embedding')
23
+ # RubyLLM::Voyage::VectorDecoder.decode(encoded, :float)
24
+ def decode(vector, output_dtype)
25
+ # unpack1('m0') is strict Base64: it raises ArgumentError on invalid
26
+ # input, matching Base64.strict_decode64 without requiring the
27
+ # base64 gem (no longer a default gem as of Ruby 3.5).
28
+ bytes = vector.unpack1('m0')
29
+
30
+ case (output_dtype || 'float').to_s
31
+ when 'float' then bytes.unpack('e*')
32
+ when 'int8', 'binary' then bytes.unpack('c*')
33
+ when 'uint8', 'ubinary' then bytes.unpack('C*')
34
+ else raise ArgumentError, "Unsupported Voyage output dtype: #{output_dtype.inspect}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Voyage retrieval integration for RubyLLM.
5
+ #
6
+ # Use {.embed_query} and {.embed_documents} for the two sides of retrieval,
7
+ # {.embed} for request-level Voyage options, or the conventional
8
+ # +RubyLLM.embed(..., provider: :voyage)+ interface.
9
+ module Voyage
10
+ # Current gem version.
11
+ VERSION = '0.1.0'
12
+
13
+ # Internal sentinel used to distinguish an omitted option from an explicit
14
+ # +nil+ value.
15
+ # @api private
16
+ UNSET = Object.new.freeze
17
+ end
18
+ end