gigachat-ruby 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/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +303 -0
- data/lib/gigachat/auth/access_token.rb +18 -0
- data/lib/gigachat/auth/token_manager.rb +107 -0
- data/lib/gigachat/certs/russian_trusted_root_ca.pem +33 -0
- data/lib/gigachat/client.rb +242 -0
- data/lib/gigachat/configuration.rb +94 -0
- data/lib/gigachat/errors.rb +77 -0
- data/lib/gigachat/internal/chat_accumulator.rb +47 -0
- data/lib/gigachat/internal/mime_types.rb +23 -0
- data/lib/gigachat/internal/retry_policy.rb +67 -0
- data/lib/gigachat/internal/sse_decoder.rb +85 -0
- data/lib/gigachat/internal/transport.rb +99 -0
- data/lib/gigachat/internal/util.rb +21 -0
- data/lib/gigachat/internal/v1_chat_accumulator.rb +41 -0
- data/lib/gigachat/resources/base.rb +31 -0
- data/lib/gigachat/resources/batches.rb +55 -0
- data/lib/gigachat/resources/chat.rb +43 -0
- data/lib/gigachat/resources/chat_v1.rb +24 -0
- data/lib/gigachat/resources/embeddings.rb +13 -0
- data/lib/gigachat/resources/files.rb +55 -0
- data/lib/gigachat/resources/functions.rb +14 -0
- data/lib/gigachat/resources/models.rb +15 -0
- data/lib/gigachat/stream.rb +85 -0
- data/lib/gigachat/types/ai_check_result.rb +18 -0
- data/lib/gigachat/types/balance.rb +9 -0
- data/lib/gigachat/types/balance_entry.rb +10 -0
- data/lib/gigachat/types/base.rb +80 -0
- data/lib/gigachat/types/batch.rb +29 -0
- data/lib/gigachat/types/batch_list.rb +17 -0
- data/lib/gigachat/types/chat_completion.rb +33 -0
- data/lib/gigachat/types/chat_event.rb +28 -0
- data/lib/gigachat/types/content_part.rb +15 -0
- data/lib/gigachat/types/embedding.rb +12 -0
- data/lib/gigachat/types/embeddings.rb +13 -0
- data/lib/gigachat/types/file_deleted.rb +13 -0
- data/lib/gigachat/types/file_list.rb +9 -0
- data/lib/gigachat/types/file_object.rb +16 -0
- data/lib/gigachat/types/file_ref.rb +11 -0
- data/lib/gigachat/types/function_call.rb +22 -0
- data/lib/gigachat/types/function_validation.rb +20 -0
- data/lib/gigachat/types/message.rb +20 -0
- data/lib/gigachat/types/model.rb +12 -0
- data/lib/gigachat/types/model_list.rb +10 -0
- data/lib/gigachat/types/tokens_count.rb +11 -0
- data/lib/gigachat/types/tokens_count_list.rb +10 -0
- data/lib/gigachat/types/tool_execution.rb +12 -0
- data/lib/gigachat/types/usage.rb +12 -0
- data/lib/gigachat/types/v1/chat_completion.rb +18 -0
- data/lib/gigachat/types/v1/chat_completion_chunk.rb +18 -0
- data/lib/gigachat/types/v1/choice.rb +14 -0
- data/lib/gigachat/types/v1/message.rb +16 -0
- data/lib/gigachat/types/v1/usage.rb +14 -0
- data/lib/gigachat/version.rb +5 -0
- data/lib/gigachat-ruby.rb +4 -0
- data/lib/gigachat.rb +31 -0
- metadata +144 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Internal
|
|
5
|
+
# Builds the Faraday connections (API and OAuth) with TLS settings and default headers.
|
|
6
|
+
class Transport
|
|
7
|
+
CA_FILE = File.expand_path("../certs/russian_trusted_root_ca.pem", __dir__)
|
|
8
|
+
USER_AGENT = "gigachat-ruby/#{VERSION} ruby/#{RUBY_VERSION}".freeze
|
|
9
|
+
X_HEADERS = %w[x-request-id x-session-id x-client-id].freeze
|
|
10
|
+
TLS_HINT = "GigaChat certificates chain to the Russian Trusted Root CA; " \
|
|
11
|
+
"see the TLS section of the gigachat-ruby README"
|
|
12
|
+
|
|
13
|
+
def self.wrap_errors
|
|
14
|
+
yield
|
|
15
|
+
rescue Faraday::TimeoutError => e
|
|
16
|
+
raise APITimeoutError, e.message
|
|
17
|
+
rescue Faraday::SSLError => e
|
|
18
|
+
raise APIConnectionError, "#{e.message}. #{TLS_HINT}"
|
|
19
|
+
rescue Faraday::ConnectionFailed => e
|
|
20
|
+
raise APIConnectionError, e.message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.x_headers(headers) = X_HEADERS.to_h { [it, headers[it]] }.compact
|
|
24
|
+
|
|
25
|
+
@cert_stores = {}
|
|
26
|
+
@cert_stores_lock = Mutex.new
|
|
27
|
+
|
|
28
|
+
# System roots plus the bundled Russian root (and an optional extra bundle); additions never replace.
|
|
29
|
+
# Loading the system roots takes ~3 ms, so clients with the same CA settings share one store. A CA file
|
|
30
|
+
# replaced on disk is picked up after a restart.
|
|
31
|
+
def self.cert_store(bundled_ca:, ca_bundle_file:)
|
|
32
|
+
@cert_stores_lock.synchronize do
|
|
33
|
+
@cert_stores[[bundled_ca, ca_bundle_file]] ||= OpenSSL::X509::Store.new.tap do |store|
|
|
34
|
+
store.set_default_paths
|
|
35
|
+
store.add_file(CA_FILE) if bundled_ca
|
|
36
|
+
add_ca_bundle(store, ca_bundle_file) if ca_bundle_file
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.add_ca_bundle(store, path)
|
|
42
|
+
store.add_file(path)
|
|
43
|
+
rescue OpenSSL::X509::StoreError => e
|
|
44
|
+
raise ConfigurationError, "Cannot load ca_bundle_file #{path}: #{e.message}"
|
|
45
|
+
end
|
|
46
|
+
private_class_method :add_ca_bundle
|
|
47
|
+
|
|
48
|
+
attr_reader :config
|
|
49
|
+
|
|
50
|
+
def initialize(config)
|
|
51
|
+
@config = config
|
|
52
|
+
warn_insecure unless config.verify_ssl_certs
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def api = @api ||= connection(base_url, client_cert: true)
|
|
56
|
+
|
|
57
|
+
def auth = @auth ||= connection(config.auth_url)
|
|
58
|
+
|
|
59
|
+
def base_url = @base_url ||= config.base_url.end_with?("/") ? config.base_url : "#{config.base_url}/"
|
|
60
|
+
|
|
61
|
+
# v2 chat lives next to the versioned base path: .../v1/ -> .../v2/chat/completions.
|
|
62
|
+
def chat_v2_url = @chat_v2_url ||= "#{base_url.sub(%r{/v\d+/\z}, "/")}v2/chat/completions"
|
|
63
|
+
|
|
64
|
+
def cert_store
|
|
65
|
+
@cert_store ||= self.class.cert_store(bundled_ca: config.bundled_ca, ca_bundle_file: config.ca_bundle_file)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def connection(url, client_cert: false)
|
|
71
|
+
request = { timeout: config.timeout, open_timeout: config.open_timeout, write_timeout: config.timeout }
|
|
72
|
+
Faraday.new(url:, headers: default_headers, ssl: ssl_options(client_cert), request:) do |f|
|
|
73
|
+
f.request :multipart
|
|
74
|
+
f.adapter Faraday.default_adapter
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def default_headers
|
|
79
|
+
{ "User-Agent" => USER_AGENT, "X-Client-ID" => config.client_id, "X-Session-ID" => config.session_id }.compact
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ssl_options(client_cert)
|
|
83
|
+
options = { verify: config.verify_ssl_certs, cert_store: }
|
|
84
|
+
return options unless client_cert && config.cert_file
|
|
85
|
+
|
|
86
|
+
options[:client_cert] = OpenSSL::X509::Certificate.new(File.read(config.cert_file))
|
|
87
|
+
if config.key_file
|
|
88
|
+
options[:client_key] = OpenSSL::PKey.read(File.read(config.key_file), config.key_file_password)
|
|
89
|
+
end
|
|
90
|
+
options
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def warn_insecure
|
|
94
|
+
message = "GigaChat: TLS certificate verification is disabled (verify_ssl_certs: false)"
|
|
95
|
+
config.logger ? config.logger.warn(message) : Kernel.warn(message)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Internal
|
|
5
|
+
module Util
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Parses JSON into symbol-keyed data. Proxies and gateways return HTML or empty bodies on errors,
|
|
9
|
+
# so anything that is not JSON comes back as the (UTF-8) text itself.
|
|
10
|
+
def parse_json(text)
|
|
11
|
+
string = text.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
12
|
+
string = string.scrub unless string.valid_encoding? # e.g. a Windows-1251 proxy error page
|
|
13
|
+
return string if string.strip.empty?
|
|
14
|
+
|
|
15
|
+
JSON.parse(string, symbolize_names: true)
|
|
16
|
+
rescue JSON::ParserError
|
|
17
|
+
string
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Internal
|
|
5
|
+
# Folds v1 stream chunks into one V1::ChatCompletion, per choice index.
|
|
6
|
+
class V1ChatAccumulator
|
|
7
|
+
META = %i[created model object usage].freeze
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@meta = {}
|
|
11
|
+
@choices = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def <<(chunk)
|
|
15
|
+
@meta.merge!(chunk.to_h.slice(*META).compact)
|
|
16
|
+
(chunk.choices || []).each { merge_choice(it) }
|
|
17
|
+
self
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def result(x_headers: {})
|
|
21
|
+
choices = @choices.sort.map { |index, choice| choice.merge(index:) }
|
|
22
|
+
Types::V1::ChatCompletion.new({ **@meta, choices: }, x_headers:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def merge_choice(choice)
|
|
28
|
+
entry = @choices[choice.index || 0] ||= { message: { role: "assistant", content: +"" } }
|
|
29
|
+
entry[:finish_reason] = choice.finish_reason if choice.finish_reason
|
|
30
|
+
delta = choice.delta
|
|
31
|
+
return if delta.nil? || delta.role == "function_in_progress" # built-in function progress, not content
|
|
32
|
+
|
|
33
|
+
message = entry[:message]
|
|
34
|
+
message[:content] << delta.content.to_s
|
|
35
|
+
message[:role] = delta.role if delta.role
|
|
36
|
+
message[:function_call] = delta[:function_call] if delta[:function_call]
|
|
37
|
+
message[:functions_state_id] = delta.functions_state_id if delta.functions_state_id
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Resources
|
|
5
|
+
# Shared plumbing for resource classes: they only build bodies and pick response types.
|
|
6
|
+
class Base
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def escape(segment) = URI.encode_uri_component(segment.to_s)
|
|
14
|
+
|
|
15
|
+
def reject_stream_flag!(params)
|
|
16
|
+
raise ArgumentError, "Use #stream for streaming; #create does not accept stream: true" if params[:stream]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Block given: iterate now and return the accumulated response. No block: return the lazy stream.
|
|
20
|
+
def build_stream(protocol:, path:, body:, request_options:, &block)
|
|
21
|
+
stream = GigaChat::Stream.new(protocol:) do |&on_chunk|
|
|
22
|
+
@client.request_stream(path:, body: body.merge(stream: true), request_options:, &on_chunk)
|
|
23
|
+
end
|
|
24
|
+
return stream unless block
|
|
25
|
+
|
|
26
|
+
stream.each(&block)
|
|
27
|
+
stream.response
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Resources
|
|
5
|
+
# Asynchronous batch processing (pay-as-you-go/CORP only).
|
|
6
|
+
class Batches < Base
|
|
7
|
+
METHODS = %w[chat_completions embedder].freeze
|
|
8
|
+
|
|
9
|
+
def create(input, method:, request_options: {})
|
|
10
|
+
kind = method.to_s
|
|
11
|
+
raise ArgumentError, "method: must be one of #{METHODS.join(", ")}" unless METHODS.include?(kind)
|
|
12
|
+
|
|
13
|
+
@client.request(method: :post, path: "batches", query: { method: kind }, body: jsonl(input),
|
|
14
|
+
headers: { "Content-Type" => "application/octet-stream" }, type: Types::Batch,
|
|
15
|
+
request_options:)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def list(request_options: {})
|
|
19
|
+
@client.request(method: :get, path: "batches", type: Types::BatchList, request_options:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def retrieve(id, request_options: {})
|
|
23
|
+
found = @client.request(method: :get, path: "batches", query: { batch_id: id }, type: Types::BatchList,
|
|
24
|
+
request_options:)
|
|
25
|
+
return Types::Batch.new(found.to_h, x_headers: found.x_headers) if found[:id]
|
|
26
|
+
|
|
27
|
+
found.first || raise(NotFoundError.new(status: 404, body: found.to_h, headers: found.x_headers,
|
|
28
|
+
message: "Batch #{id} not found"))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Downloads the batch output (JSONL) and parses one Hash per non-empty line.
|
|
32
|
+
def results(batch, request_options: {})
|
|
33
|
+
file_id = batch.is_a?(Types::Batch) ? batch.output_file_id : batch.to_s
|
|
34
|
+
raise Error, "The batch has no output file yet" if file_id.to_s.empty?
|
|
35
|
+
|
|
36
|
+
@client.files.content(file_id, request_options:).force_encoding(Encoding::UTF_8).each_line.filter_map do |line|
|
|
37
|
+
JSON.parse(line, symbolize_names: true) unless line.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def jsonl(input)
|
|
44
|
+
case input
|
|
45
|
+
when Array then input.map { "#{JSON.generate(it)}\n" }.join
|
|
46
|
+
when String, Pathname then File.binread(input)
|
|
47
|
+
else
|
|
48
|
+
raise ArgumentError, "input must be an Array of requests, a path or an IO" unless input.respond_to?(:read)
|
|
49
|
+
|
|
50
|
+
input.read
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Resources
|
|
5
|
+
# Chat completions v2 (primary API). v1 lives under #v1.
|
|
6
|
+
class Chat < Base
|
|
7
|
+
attr_reader :v1
|
|
8
|
+
|
|
9
|
+
def initialize(client)
|
|
10
|
+
super
|
|
11
|
+
@v1 = ChatV1.new(client)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create(messages:, model: nil, request_options: {}, **params)
|
|
15
|
+
reject_stream_flag!(params)
|
|
16
|
+
@client.request(method: :post, path: @client.chat_v2_url, body: body(messages, model, params),
|
|
17
|
+
type: Types::ChatCompletion, request_options:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stream(messages:, model: nil, request_options: {}, **params, &)
|
|
21
|
+
build_stream(protocol: :v2, path: @client.chat_v2_url, body: body(messages, model, params), request_options:, &)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def body(messages, model, params)
|
|
27
|
+
{ model: @client.config.resolve_model(model), messages: messages.map { normalize(it) }, **params }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# v2 content is an array of parts; accept the familiar `content: "text"` shorthand.
|
|
31
|
+
def normalize(message)
|
|
32
|
+
return message unless message.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
key = message.key?(:content) ? :content : "content"
|
|
35
|
+
case message[key]
|
|
36
|
+
when String then message.merge(key => [{ text: message[key] }])
|
|
37
|
+
when Hash then message.merge(key => [message[key]])
|
|
38
|
+
else message
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Resources
|
|
5
|
+
# Chat completions v1 (POST /chat/completions). Fully supported, not deprecated.
|
|
6
|
+
class ChatV1 < Base
|
|
7
|
+
PATH = "chat/completions"
|
|
8
|
+
|
|
9
|
+
def create(messages:, model: nil, request_options: {}, **params)
|
|
10
|
+
reject_stream_flag!(params)
|
|
11
|
+
@client.request(method: :post, path: PATH, body: body(messages, model, params),
|
|
12
|
+
type: Types::V1::ChatCompletion, request_options:)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def stream(messages:, model: nil, request_options: {}, **params, &)
|
|
16
|
+
build_stream(protocol: :v1, path: PATH, body: body(messages, model, params), request_options:, &)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def body(messages, model, params) = { model: @client.config.resolve_model(model), messages:, **params }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Resources
|
|
5
|
+
class Embeddings < Base
|
|
6
|
+
# The chat model from config does not apply here; "Embeddings" matches both official SDKs.
|
|
7
|
+
def create(input:, model: "Embeddings", request_options: {}, **params)
|
|
8
|
+
@client.request(method: :post, path: "embeddings", body: { model:, input:, **params },
|
|
9
|
+
type: Types::Embeddings, request_options:)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Resources
|
|
5
|
+
class Files < Base
|
|
6
|
+
def upload(file, purpose: "general", filename: nil, content_type: nil, request_options: {})
|
|
7
|
+
io, name, owned = open_upload(file, filename)
|
|
8
|
+
part = Faraday::Multipart::FilePart.new(io, content_type || Internal::MimeTypes.for(name), name)
|
|
9
|
+
@client.request(method: :post, path: "files", form: { file: part, purpose: }, type: Types::FileObject,
|
|
10
|
+
request_options:)
|
|
11
|
+
ensure
|
|
12
|
+
io.close if owned && io
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list(request_options: {})
|
|
16
|
+
@client.request(method: :get, path: "files", type: Types::FileList, request_options:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def retrieve(id, request_options: {})
|
|
20
|
+
@client.request(method: :get, path: "files/#{escape(id)}", type: Types::FileObject, request_options:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The API deletes with POST /files/{id}/delete, not DELETE.
|
|
24
|
+
def delete(id, request_options: {})
|
|
25
|
+
@client.request(method: :post, path: "files/#{escape(id)}/delete", type: Types::FileDeleted, request_options:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def content(id, request_options: {})
|
|
29
|
+
@client.request(method: :get, path: "files/#{escape(id)}/content", headers: { "Accept" => "*/*" },
|
|
30
|
+
binary: true, request_options:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Returns [io, filename, owned]; `owned` IOs are opened here and closed after the request.
|
|
36
|
+
def open_upload(file, filename)
|
|
37
|
+
case file
|
|
38
|
+
when String, Pathname
|
|
39
|
+
path = Pathname(file)
|
|
40
|
+
[path.open("rb"), nfc(filename || path.basename.to_s), true]
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError, "file must be a path or an IO-like object" unless file.respond_to?(:read)
|
|
43
|
+
|
|
44
|
+
name = filename || (File.basename(file.path) if file.respond_to?(:path) && file.path)
|
|
45
|
+
raise ArgumentError, "filename: is required when the IO has no path" if name.to_s.empty?
|
|
46
|
+
|
|
47
|
+
[file, nfc(name), false]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# macOS returns NFD names from the file system ("й" as "и" + U+0306); send the composed form.
|
|
52
|
+
def nfc(name) = name.unicode_normalize(:nfc)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Resources
|
|
5
|
+
class Functions < Base
|
|
6
|
+
# Accepts a function description as a Hash, as keywords, or both (keywords win).
|
|
7
|
+
def validate(function = nil, request_options: {}, **attributes)
|
|
8
|
+
body = (function || {}).to_h.merge(attributes)
|
|
9
|
+
@client.request(method: :post, path: "functions/validate", body:, type: Types::FunctionValidation,
|
|
10
|
+
request_options:)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Resources
|
|
5
|
+
class Models < Base
|
|
6
|
+
def list(request_options: {})
|
|
7
|
+
@client.request(method: :get, path: "models", type: Types::ModelList, request_options:)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def retrieve(id, request_options: {})
|
|
11
|
+
@client.request(method: :get, path: "models/#{escape(id)}", type: Types::Model, request_options:)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
# A lazily performed chat stream. The HTTP request starts on the first #each; the stream is single-use.
|
|
5
|
+
# Breaking out of #each closes the connection; #response returns what was accumulated so far.
|
|
6
|
+
class Stream
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def initialize(protocol:, &requester)
|
|
10
|
+
@protocol = protocol
|
|
11
|
+
@requester = requester
|
|
12
|
+
@accumulator = protocol == :v2 ? Internal::ChatAccumulator.new : Internal::V1ChatAccumulator.new
|
|
13
|
+
@state = :pending
|
|
14
|
+
@x_headers = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def each(&block)
|
|
18
|
+
return enum_for(:each) unless block
|
|
19
|
+
raise Error, "Stream has already been consumed" unless @state == :pending
|
|
20
|
+
|
|
21
|
+
@state = :streaming
|
|
22
|
+
consume(&block)
|
|
23
|
+
@state = :finished
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def text
|
|
28
|
+
return enum_for(:text) unless block_given?
|
|
29
|
+
|
|
30
|
+
each do |event|
|
|
31
|
+
piece = event.text
|
|
32
|
+
yield piece unless piece.empty?
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def response
|
|
37
|
+
each { nil } if @state == :pending
|
|
38
|
+
@accumulator.result(x_headers: @x_headers)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def consume(&)
|
|
44
|
+
decoder = Internal::SSEDecoder.new
|
|
45
|
+
catch(:done) do
|
|
46
|
+
@requester.call do |chunk, headers|
|
|
47
|
+
@x_headers = Internal::Transport.x_headers(headers) if headers && @x_headers.empty?
|
|
48
|
+
decoder.feed(chunk) { |sse| handle(sse, &) }
|
|
49
|
+
end
|
|
50
|
+
decoder.finish { |sse| handle(sse, &) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def handle(sse)
|
|
55
|
+
raise_error(sse) if sse.event == "error"
|
|
56
|
+
return unless (event = parse(sse))
|
|
57
|
+
|
|
58
|
+
@accumulator << event
|
|
59
|
+
yield event
|
|
60
|
+
throw :done if event.is_a?(Types::ChatEvent) && event.done?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse(sse)
|
|
64
|
+
if @protocol == :v1
|
|
65
|
+
throw :done if sse.data == "[DONE]"
|
|
66
|
+
Types::V1::ChatCompletionChunk.new(decode(sse))
|
|
67
|
+
else
|
|
68
|
+
Types::ChatEvent.new(decode(sse), type: sse.event)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def decode(sse)
|
|
73
|
+
JSON.parse(sse.data, symbolize_names: true)
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
raise APIError.new(status: 200, body: sse.data, headers: @x_headers,
|
|
76
|
+
message: "Malformed stream event: #{sse.data[0, 200]}")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def raise_error(sse)
|
|
80
|
+
body = Internal::Util.parse_json(sse.data)
|
|
81
|
+
status = body.is_a?(Hash) && body[:status].is_a?(Integer) ? body[:status] : 500
|
|
82
|
+
raise APIError.for(status:, body:, headers: @x_headers)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Types
|
|
5
|
+
class AiCheckResult < Base
|
|
6
|
+
attribute :category
|
|
7
|
+
attribute :characters
|
|
8
|
+
attribute :tokens
|
|
9
|
+
attribute :ai_intervals
|
|
10
|
+
|
|
11
|
+
def ai? = category == "ai"
|
|
12
|
+
|
|
13
|
+
def human? = category == "human"
|
|
14
|
+
|
|
15
|
+
def mixed? = category == "mixed"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Types
|
|
5
|
+
# Lenient response object: typed readers for documented fields, raw access for everything else.
|
|
6
|
+
# The API adds fields without notice, so unknown keys are kept and never raise.
|
|
7
|
+
class Base
|
|
8
|
+
class << self
|
|
9
|
+
def attributes
|
|
10
|
+
@attributes ||= superclass.respond_to?(:attributes) ? superclass.attributes.dup : {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def attribute(name, type = nil, array: false)
|
|
14
|
+
attributes[name] = [type, array]
|
|
15
|
+
define_method(name) { read_attribute(name) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Declares the list field the object enumerates over.
|
|
19
|
+
def enumerable(name, type)
|
|
20
|
+
attribute(name, type, array: true)
|
|
21
|
+
include Enumerable
|
|
22
|
+
|
|
23
|
+
define_method(:each) { |&block| (public_send(name) || []).each(&block) }
|
|
24
|
+
define_method(:to_h) { @data } # Enumerable#to_h would otherwise shadow Base#to_h
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def coerce(value)
|
|
28
|
+
return new(value.to_h) if value.is_a?(Base) && !value.is_a?(self)
|
|
29
|
+
return new(value) if value.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :x_headers
|
|
36
|
+
|
|
37
|
+
def initialize(data = {}, x_headers: nil)
|
|
38
|
+
@data = data.to_h.transform_keys(&:to_sym).freeze
|
|
39
|
+
@x_headers = x_headers || {}
|
|
40
|
+
@cache = {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def [](key) = @data[key.to_sym]
|
|
44
|
+
|
|
45
|
+
def dig(*keys) = @data.dig(*keys)
|
|
46
|
+
|
|
47
|
+
def to_h = @data
|
|
48
|
+
|
|
49
|
+
def to_json(*) = @data.to_json(*)
|
|
50
|
+
|
|
51
|
+
def deconstruct_keys(keys) = keys ? @data.slice(*keys) : @data
|
|
52
|
+
|
|
53
|
+
def request_id = @x_headers["x-request-id"]
|
|
54
|
+
|
|
55
|
+
def ==(other) = other.class == self.class && other.to_h == @data
|
|
56
|
+
|
|
57
|
+
alias eql? ==
|
|
58
|
+
|
|
59
|
+
def hash = [self.class, @data].hash
|
|
60
|
+
|
|
61
|
+
def inspect = "#<#{self.class.name} #{@data.inspect}>"
|
|
62
|
+
|
|
63
|
+
alias to_s inspect
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def read_attribute(name)
|
|
68
|
+
return @cache[name] if @cache.key?(name)
|
|
69
|
+
|
|
70
|
+
type, array = self.class.attributes.fetch(name)
|
|
71
|
+
raw = @data[name]
|
|
72
|
+
@cache[name] =
|
|
73
|
+
if type.nil? || raw.nil? then raw
|
|
74
|
+
elsif array then (raw.is_a?(Array) ? raw : [raw]).map { type.coerce(it) }
|
|
75
|
+
else type.coerce(raw)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Types
|
|
5
|
+
class Batch < Base
|
|
6
|
+
class RequestCounts < Base
|
|
7
|
+
attribute :total
|
|
8
|
+
attribute :completed
|
|
9
|
+
attribute :failed
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
attribute :id
|
|
13
|
+
attribute :request_counts, RequestCounts
|
|
14
|
+
attribute :status
|
|
15
|
+
attribute :output_file_id
|
|
16
|
+
attribute :created_at
|
|
17
|
+
attribute :updated_at
|
|
18
|
+
|
|
19
|
+
# A `method` reader would shadow Object#method; the raw value stays available via `[:method]`.
|
|
20
|
+
def batch_method = self[:method]
|
|
21
|
+
|
|
22
|
+
def created? = status == "created"
|
|
23
|
+
|
|
24
|
+
def in_progress? = status == "in_progress"
|
|
25
|
+
|
|
26
|
+
def completed? = status == "completed"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GigaChat
|
|
4
|
+
module Types
|
|
5
|
+
# GET /batches. The spec documents `{ batches: [...] }`; the JS SDK assumes a bare array, which the
|
|
6
|
+
# client wraps as `{ data: [...] }`. Both shapes enumerate the same way.
|
|
7
|
+
class BatchList < Base
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
def batches = @batches ||= (self[:batches] || self[:data] || []).map { Batch.coerce(it) }
|
|
11
|
+
|
|
12
|
+
def each(&) = batches.each(&)
|
|
13
|
+
|
|
14
|
+
def to_h = @data
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|