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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +84 -0
- data/generation.json +924 -0
- data/lib/evalgate/client.rb +229 -0
- data/lib/evalgate/models.rb +15464 -0
- data/lib/evalgate/operations.rb +6414 -0
- data/lib/evalgate.rb +7 -0
- data/sig/evalgate.rbs +41699 -0
- metadata +53 -0
|
@@ -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
|