evalgate-sdk 3.17.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,229 @@
1
+ # frozen_string_literal: true
2
+ module EvalGate
3
+ Response = Struct.new(:data, :status, :headers, keyword_init: true)
4
+
5
+ class APIError < StandardError
6
+ attr_reader :status, :code, :request_id, :headers, :data
7
+ def initialize(status:, headers:, data:)
8
+ @status, @headers, @data = status, headers, data
9
+ detail = data.is_a?(Hash) ? data : {}
10
+ nested = detail["error"].is_a?(Hash) ? detail["error"] : {}
11
+ @code = detail["code"] || nested["code"]
12
+ @request_id = headers["x-request-id"]
13
+ message = detail["message"] || nested["message"] || (detail["error"] if detail["error"].is_a?(String)) || "Request failed"
14
+ super("EvalGate HTTP #{status} (#{@code}): #{message}")
15
+ end
16
+ end
17
+
18
+ # Generated models validate documented required fields and primitive types.
19
+ # Unions that OpenAPI cannot express as a single Ruby class remain JSON values.
20
+ class Model
21
+ class << self
22
+ def fields(definitions = nil)
23
+ @fields = definitions if definitions
24
+ @fields || {}
25
+ end
26
+
27
+ def decode(type, value)
28
+ return nil if value.nil?
29
+ return decode(type.delete_prefix("*"), value) if type.start_with?("*")
30
+ return value.map { |item| decode(type.delete_prefix("[]"), item) } if type.start_with?("[]") && value.is_a?(Array)
31
+ return value.transform_values { |item| decode(type.delete_prefix("map[string]"), item) } if type.start_with?("map[string]") && value.is_a?(Hash)
32
+ resolved = Models::ALIASES.fetch(type, type)
33
+ return decode(resolved, value) if resolved != type
34
+ klass = Models::TYPES[type]
35
+ klass && value.is_a?(Hash) ? klass.new(**value.transform_keys(&:to_sym)) : value
36
+ end
37
+
38
+ def valid?(type, value)
39
+ return true if type == "any" || type == "json.RawMessage"
40
+ return value.nil? || valid?(type.delete_prefix("*"), value) if type.start_with?("*")
41
+ resolved = Models::ALIASES.fetch(type, type)
42
+ return valid?(resolved, value) if resolved != type
43
+ return value.is_a?(Array) && value.all? { |item| valid?(type.delete_prefix("[]"), item) } if type.start_with?("[]")
44
+ return value.is_a?(Hash) && value.values.all? { |item| valid?(type.delete_prefix("map[string]"), item) } if type.start_with?("map[string]")
45
+ case type
46
+ when "string" then value.is_a?(String)
47
+ when "int64" then value.is_a?(Integer)
48
+ when "float64" then value.is_a?(Numeric)
49
+ when "bool" then value == true || value == false
50
+ else value.is_a?(Hash) || (Models::TYPES[type] && value.is_a?(Models::TYPES[type]))
51
+ end
52
+ end
53
+ end
54
+
55
+ def initialize(**values)
56
+ values = values.transform_keys(&:to_s)
57
+ self.class.fields.each do |name, contract|
58
+ raise ArgumentError, "Missing required model property #{name}" if contract.fetch("required") && !values.key?(name)
59
+ raise ArgumentError, "Invalid model property #{name}: expected #{contract.fetch('type')}" if values.key?(name) && !Model.valid?(contract.fetch("type"), values[name])
60
+ end
61
+ @values = values.transform_values { |value| value }
62
+ self.class.fields.each do |name, contract|
63
+ @values[name] = Model.decode(contract.fetch("type"), values[name]) if values.key?(name)
64
+ end
65
+ end
66
+
67
+ def to_h
68
+ @values.transform_values { |value| serialize(value) }
69
+ end
70
+
71
+ def to_json(*arguments)
72
+ to_h.to_json(*arguments)
73
+ end
74
+
75
+ def [](name)
76
+ @values[name.to_s]
77
+ end
78
+
79
+ def method_missing(name, *arguments)
80
+ return @values[name.to_s] if arguments.empty? && self.class.fields.key?(name.to_s)
81
+ super
82
+ end
83
+
84
+ def respond_to_missing?(name, include_private = false)
85
+ self.class.fields.key?(name.to_s) || super
86
+ end
87
+
88
+ private
89
+
90
+ def serialize(value)
91
+ case value
92
+ when Model then value.to_h
93
+ when Array then value.map { |item| serialize(item) }
94
+ when Hash then value.transform_values { |item| serialize(item) }
95
+ else value
96
+ end
97
+ end
98
+ end
99
+
100
+ class Client
101
+ def initialize(api_key:, base_url: "https://www.evalgate.com", organization_id: nil, timeout: 30)
102
+ raise ArgumentError, "API key is required" if api_key.to_s.strip.empty?
103
+ @base_url = URI(base_url)
104
+ unless %w[http https].include?(@base_url.scheme) && @base_url.host && !@base_url.userinfo && !@base_url.query && !@base_url.fragment
105
+ raise ArgumentError, "base_url must be an absolute HTTP(S) origin or path without credentials, query, or fragment"
106
+ end
107
+ raise ArgumentError, "timeout must be positive" unless timeout.is_a?(Numeric) && timeout.positive? && timeout.finite?
108
+ @api_key, @organization_id, @timeout = api_key.strip, organization_id, timeout
109
+ end
110
+
111
+ # Never exposes credentials through default Object#inspect output.
112
+ def inspect
113
+ "#<EvalGate::Client base_url=#{@base_url}>"
114
+ end
115
+
116
+ def request(operation_id, path: {}, query: {}, headers: {}, body: nil, idempotency_key: nil)
117
+ operation = OPERATIONS.fetch(operation_id) { raise ArgumentError, "Unknown operation #{operation_id}" }
118
+ groups = { "path" => path.transform_keys(&:to_s), "query" => query.transform_keys(&:to_s), "header" => headers.transform_keys(&:to_s) }
119
+ groups.each do |location, values|
120
+ allowed = operation.fetch("#{location}Parameters")
121
+ required = operation.fetch("required#{location.capitalize}Parameters")
122
+ unknown = values.keys - allowed
123
+ raise ArgumentError, "Unknown #{location} parameters: #{unknown.join(', ')}" unless unknown.empty?
124
+ missing = required.select { |key| !values.key?(key) || values[key].nil? || values[key] == "" }
125
+ raise ArgumentError, "Missing #{location} parameters: #{missing.join(', ')}" unless missing.empty?
126
+ end
127
+ body = body.to_h if body.is_a?(Model)
128
+ raise ArgumentError, "Request body is required" if operation.fetch("requestBodyRequired") && body.nil?
129
+ raise ArgumentError, "Operation does not accept a request body" if !operation.fetch("requestBodyAllowed") && !body.nil?
130
+ required = operation.fetch("requiredBodyProperties")
131
+ unless body.nil? || required.empty?
132
+ raise ArgumentError, "Body must be an object" unless body.is_a?(Hash)
133
+ missing = required - body.keys.map(&:to_s)
134
+ raise ArgumentError, "Missing body properties: #{missing.join(', ')}" unless missing.empty?
135
+ end
136
+ endpoint = operation.fetch("path").gsub(/\{([^}]+)\}/) { escape_segment(groups.fetch("path").fetch(Regexp.last_match(1))) }
137
+ uri = URI(@base_url.to_s.sub(%r{/$}, "") + endpoint)
138
+ pairs = groups.fetch("query").flat_map do |name, value|
139
+ next [] if value.nil?
140
+ (value.is_a?(Array) ? value : [value]).map { |item| [name, item.is_a?(Hash) ? JSON.generate(item) : item.to_s] }
141
+ end
142
+ uri.query = URI.encode_www_form(pairs) unless pairs.empty?
143
+ request = Net::HTTPGenericRequest.new(operation.fetch("method"), !body.nil?, operation.fetch("method") != "HEAD", uri.request_uri)
144
+ request["Accept"] = "application/json"
145
+ request["Authorization"] = "Bearer #{@api_key}"
146
+ request["X-EvalGate-SDK-Version"] = VERSION
147
+ request["X-EvalGate-Spec-Version"] = SPEC_VERSION
148
+ request["X-Organization-Id"] = @organization_id if @organization_id
149
+ groups.fetch("header").each do |name, value|
150
+ raise ArgumentError, "Credential header cannot be overridden" if %w[authorization cookie host].include?(name.downcase)
151
+ request[name] = value.to_s unless value.nil?
152
+ end
153
+ request["Idempotency-Key"] = idempotency_key if idempotency_key
154
+ unless body.nil?
155
+ request["Content-Type"] = "application/json"
156
+ request.body = JSON.generate(body)
157
+ end
158
+ # Net::HTTP does not follow redirects. Disable automatic transport retries.
159
+ http = Net::HTTP.new(uri.host, uri.port, nil)
160
+ http.use_ssl = uri.scheme == "https"
161
+ http.open_timeout = @timeout
162
+ http.read_timeout = @timeout
163
+ http.write_timeout = @timeout
164
+ http.max_retries = 0
165
+ response = http.request(request)
166
+ encoded = response.body.to_s
167
+ raise IOError, "EvalGate response exceeds 32 MiB limit" if encoded.bytesize > 32 * 1024 * 1024
168
+ begin
169
+ data = encoded.strip.empty? ? nil : JSON.parse(encoded)
170
+ rescue JSON::ParserError
171
+ raise IOError, "Invalid EvalGate JSON response" if response.code.to_i.between?(200, 299)
172
+ data = { "error" => encoded }
173
+ end
174
+ response_headers = response.each_header.to_h
175
+ raise APIError.new(status: response.code.to_i, headers: response_headers, data: data) unless response.code.to_i.between?(200, 299)
176
+ Response.new(data: data, status: response.code.to_i, headers: response_headers)
177
+ end
178
+
179
+ def paginate(operation_id, path: {}, query: {}, headers: {}, max_pages: 100)
180
+ return enum_for(__method__, operation_id, path: path, query: query, headers: headers, max_pages: max_pages) unless block_given?
181
+ operation = OPERATIONS.fetch(operation_id)
182
+ raise ArgumentError, "Operation does not declare pagination" unless operation["pagination"]
183
+ raise ArgumentError, "max_pages must be a positive integer" unless max_pages.is_a?(Integer) && max_pages.positive?
184
+ query = query.transform_keys(&:to_s).dup
185
+ seen = {}
186
+ max_pages.times do
187
+ response = request(operation_id, path: path, query: query, headers: headers)
188
+ yield response
189
+ data = response.data
190
+ raise IOError, "Pagination requires an object or array response" unless data.is_a?(Hash) || data.is_a?(Array)
191
+ envelope = data.is_a?(Hash) ? data : {}
192
+ state = envelope["pagination"] || envelope.dig("meta", "pagination") || envelope["meta"] || envelope
193
+ has_more = state.key?("hasMore") ? state["hasMore"] : state["has_more"]
194
+ return if has_more == false
195
+ if operation.fetch("pagination") == "cursor"
196
+ cursor = state["nextCursor"] || state["next_cursor"]
197
+ return unless cursor.is_a?(String) && !cursor.empty?
198
+ raise IOError, "Pagination repeated cursor" if seen[cursor]
199
+ seen[cursor] = true
200
+ query["cursor"] = cursor
201
+ next
202
+ end
203
+ limit = Integer(query["limit"] || state["limit"] || 50)
204
+ raise ArgumentError, "Pagination limit must be positive" unless limit.positive?
205
+ items = data.is_a?(Array) ? data : %w[data items results evaluations traces].filter_map { |key| data[key] if data[key].is_a?(Array) }.first
206
+ return if has_more.nil? && items && items.length < limit
207
+ if operation.fetch("pagination") == "offset"
208
+ step = items ? items.length : limit
209
+ raise IOError, "Pagination made no progress" if step.zero?
210
+ query["offset"] = Integer(query["offset"] || state["offset"] || 0) + step
211
+ return if state["total"] && query["offset"] >= Integer(state["total"])
212
+ else
213
+ page = Integer(query["page"] || state["page"] || 1)
214
+ total = state["totalPages"] || state["total_pages"]
215
+ return if total && page >= Integer(total)
216
+ query["page"] = page + 1
217
+ end
218
+ end
219
+ raise IOError, "Pagination exceeded max_pages"
220
+ end
221
+
222
+ private
223
+
224
+ def escape_segment(value)
225
+ raise ArgumentError, "Dot path segments are not allowed" if %w[. ..].include?(value.to_s)
226
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
227
+ end
228
+ end
229
+ end