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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +303 -0
  5. data/lib/gigachat/auth/access_token.rb +18 -0
  6. data/lib/gigachat/auth/token_manager.rb +107 -0
  7. data/lib/gigachat/certs/russian_trusted_root_ca.pem +33 -0
  8. data/lib/gigachat/client.rb +242 -0
  9. data/lib/gigachat/configuration.rb +94 -0
  10. data/lib/gigachat/errors.rb +77 -0
  11. data/lib/gigachat/internal/chat_accumulator.rb +47 -0
  12. data/lib/gigachat/internal/mime_types.rb +23 -0
  13. data/lib/gigachat/internal/retry_policy.rb +67 -0
  14. data/lib/gigachat/internal/sse_decoder.rb +85 -0
  15. data/lib/gigachat/internal/transport.rb +99 -0
  16. data/lib/gigachat/internal/util.rb +21 -0
  17. data/lib/gigachat/internal/v1_chat_accumulator.rb +41 -0
  18. data/lib/gigachat/resources/base.rb +31 -0
  19. data/lib/gigachat/resources/batches.rb +55 -0
  20. data/lib/gigachat/resources/chat.rb +43 -0
  21. data/lib/gigachat/resources/chat_v1.rb +24 -0
  22. data/lib/gigachat/resources/embeddings.rb +13 -0
  23. data/lib/gigachat/resources/files.rb +55 -0
  24. data/lib/gigachat/resources/functions.rb +14 -0
  25. data/lib/gigachat/resources/models.rb +15 -0
  26. data/lib/gigachat/stream.rb +85 -0
  27. data/lib/gigachat/types/ai_check_result.rb +18 -0
  28. data/lib/gigachat/types/balance.rb +9 -0
  29. data/lib/gigachat/types/balance_entry.rb +10 -0
  30. data/lib/gigachat/types/base.rb +80 -0
  31. data/lib/gigachat/types/batch.rb +29 -0
  32. data/lib/gigachat/types/batch_list.rb +17 -0
  33. data/lib/gigachat/types/chat_completion.rb +33 -0
  34. data/lib/gigachat/types/chat_event.rb +28 -0
  35. data/lib/gigachat/types/content_part.rb +15 -0
  36. data/lib/gigachat/types/embedding.rb +12 -0
  37. data/lib/gigachat/types/embeddings.rb +13 -0
  38. data/lib/gigachat/types/file_deleted.rb +13 -0
  39. data/lib/gigachat/types/file_list.rb +9 -0
  40. data/lib/gigachat/types/file_object.rb +16 -0
  41. data/lib/gigachat/types/file_ref.rb +11 -0
  42. data/lib/gigachat/types/function_call.rb +22 -0
  43. data/lib/gigachat/types/function_validation.rb +20 -0
  44. data/lib/gigachat/types/message.rb +20 -0
  45. data/lib/gigachat/types/model.rb +12 -0
  46. data/lib/gigachat/types/model_list.rb +10 -0
  47. data/lib/gigachat/types/tokens_count.rb +11 -0
  48. data/lib/gigachat/types/tokens_count_list.rb +10 -0
  49. data/lib/gigachat/types/tool_execution.rb +12 -0
  50. data/lib/gigachat/types/usage.rb +12 -0
  51. data/lib/gigachat/types/v1/chat_completion.rb +18 -0
  52. data/lib/gigachat/types/v1/chat_completion_chunk.rb +18 -0
  53. data/lib/gigachat/types/v1/choice.rb +14 -0
  54. data/lib/gigachat/types/v1/message.rb +16 -0
  55. data/lib/gigachat/types/v1/usage.rb +14 -0
  56. data/lib/gigachat/version.rb +5 -0
  57. data/lib/gigachat-ruby.rb +4 -0
  58. data/lib/gigachat.rb +31 -0
  59. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GigaChat
4
+ module Types
5
+ class Balance < Base
6
+ enumerable :balance, BalanceEntry
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GigaChat
4
+ module Types
5
+ class BalanceEntry < Base
6
+ attribute :usage
7
+ attribute :value
8
+ end
9
+ end
10
+ 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