runapi-core 0.2.7 → 0.2.9
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 +4 -4
- data/README.md +16 -17
- data/lib/runapi/core/base_model.rb +17 -1
- data/lib/runapi/core/errors.rb +23 -2
- data/lib/runapi/core/files.rb +24 -13
- data/lib/runapi/core/http_client.rb +37 -1
- data/lib/runapi/core/polling.rb +20 -3
- data/lib/runapi/core/resource_helpers.rb +16 -2
- data/lib/runapi/core/response.rb +87 -0
- data/lib/runapi/core/types.rb +3 -1
- data/lib/runapi/core/version.rb +1 -1
- data/lib/runapi/core.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 952bc04d7631dd26161b8f055e0711a4574bf017f9ebf7c5a871c67af1a5c7dd
|
|
4
|
+
data.tar.gz: 44c1263fbe06f435878d2b2e7da5595921e1d4c3de5d2da789d4552c9b37884e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f89ff25ea6c94689ce1fde2741a774ae7d0f5db82bd083284846136922518d5029bee0d918fa451cdf231a1d6b0523a1b123ef363cdefb90a3ab71156ec6c27
|
|
7
|
+
data.tar.gz: e72c1b9336e37e6abba3d701fd6bd9a954a773158d091c428415750bb4521031d1138be18eed217b9d109adcede94e5c4a7e36e2946f566491654700f9d1fedf
|
data/README.md
CHANGED
|
@@ -10,33 +10,32 @@ gem install runapi-core
|
|
|
10
10
|
|
|
11
11
|
## Notes
|
|
12
12
|
|
|
13
|
-
Use the core gem for common client options, error classes, request helpers, and task polling behavior that
|
|
13
|
+
Use the core gem for common client options, error classes, request helpers, and task polling behavior that Provider Clients share. Public SDK docs live at https://runapi.ai/docs#runapi-sdks and the model catalog lives at https://runapi.ai/models.
|
|
14
14
|
|
|
15
15
|
## Request Identifiers
|
|
16
16
|
|
|
17
17
|
RunAPI accepts an optional `X-Client-Request-Id` header on public API calls. Use printable ASCII values up to 512 characters. Accepted values are echoed in the response and stored with the RunAPI task for support and reconciliation.
|
|
18
18
|
|
|
19
|
-
High-level Ruby
|
|
19
|
+
High-level Ruby Provider Client resource methods accept per-request options and keep response headers on the returned model object. This example uses the Suno Provider Client; install `runapi-suno` to run it.
|
|
20
20
|
|
|
21
21
|
```ruby
|
|
22
|
-
require "
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
request["X-Client-Request-Id"] = "order-123"
|
|
31
|
-
request.body = JSON.generate({
|
|
22
|
+
require "runapi/suno"
|
|
23
|
+
|
|
24
|
+
client = RunApi::Suno::Client.new(api_key: ENV.fetch("RUNAPI_API_KEY"))
|
|
25
|
+
options = RunApi::Core::RequestOptions.new(
|
|
26
|
+
headers: {"X-Client-Request-Id" => "order-123"}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
response = client.text_to_music.create(
|
|
32
30
|
prompt: "A chill lo-fi beat",
|
|
33
31
|
model: "suno-v4.5-plus",
|
|
34
|
-
vocal_mode: "instrumental"
|
|
35
|
-
|
|
32
|
+
vocal_mode: "instrumental",
|
|
33
|
+
options: options
|
|
34
|
+
)
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
runapi_task_id = response.runapi_task_id
|
|
37
|
+
# Equivalent case-insensitive lookup:
|
|
38
|
+
runapi_task_id = response.response_headers["X-RunAPI-Task-Id"]
|
|
40
39
|
```
|
|
41
40
|
|
|
42
41
|
## File Upload
|
|
@@ -40,7 +40,7 @@ module RunApi
|
|
|
40
40
|
nil
|
|
41
41
|
when BaseModel
|
|
42
42
|
if target_model && target_model <= BaseModel && !value.is_a?(target_model)
|
|
43
|
-
target_model.from_hash(value.to_h)
|
|
43
|
+
target_model.from_hash(value.to_h).with_response_headers(value.response_headers)
|
|
44
44
|
else
|
|
45
45
|
value
|
|
46
46
|
end
|
|
@@ -87,11 +87,27 @@ module RunApi
|
|
|
87
87
|
def initialize(attributes = {})
|
|
88
88
|
source = normalize_input(attributes)
|
|
89
89
|
@attributes = {}
|
|
90
|
+
@response_headers = ResponseHeaders.new
|
|
90
91
|
|
|
91
92
|
assign_declared_fields!(source)
|
|
92
93
|
assign_extra_fields!(source)
|
|
93
94
|
end
|
|
94
95
|
|
|
96
|
+
attr_reader :response_headers
|
|
97
|
+
|
|
98
|
+
def response_header(name)
|
|
99
|
+
response_headers[name]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def runapi_task_id
|
|
103
|
+
response_header("X-RunAPI-Task-Id")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def with_response_headers(headers)
|
|
107
|
+
@response_headers = headers.is_a?(ResponseHeaders) ? headers : ResponseHeaders.new(headers)
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
95
111
|
def [](key)
|
|
96
112
|
@attributes[key.to_s]
|
|
97
113
|
end
|
data/lib/runapi/core/errors.rb
CHANGED
|
@@ -13,12 +13,23 @@ module RunApi
|
|
|
13
13
|
attr_reader :request_id
|
|
14
14
|
# @return [Hash, String, nil] Parsed response body or error details.
|
|
15
15
|
attr_reader :details
|
|
16
|
+
# @return [ResponseHeaders] HTTP response headers when available.
|
|
17
|
+
attr_reader :response_headers
|
|
16
18
|
|
|
17
|
-
def initialize(message = nil, status: nil, request_id: nil, details: nil)
|
|
19
|
+
def initialize(message = nil, status: nil, request_id: nil, details: nil, response_headers: nil)
|
|
18
20
|
super(message)
|
|
19
21
|
@status = status
|
|
20
22
|
@request_id = request_id
|
|
21
23
|
@details = details
|
|
24
|
+
@response_headers = response_headers.is_a?(ResponseHeaders) ? response_headers : ResponseHeaders.new(response_headers)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def response_header(name)
|
|
28
|
+
response_headers[name]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def runapi_task_id
|
|
32
|
+
response_header("X-RunAPI-Task-Id")
|
|
22
33
|
end
|
|
23
34
|
|
|
24
35
|
def to_h
|
|
@@ -71,6 +82,7 @@ module RunApi
|
|
|
71
82
|
def from_response(response, body = nil)
|
|
72
83
|
status = response.code.to_i
|
|
73
84
|
request_id = response["x-request-id"]
|
|
85
|
+
headers = response_headers(response)
|
|
74
86
|
|
|
75
87
|
parsed_body = parse_body(body)
|
|
76
88
|
message = extract_message(parsed_body) ||
|
|
@@ -89,7 +101,8 @@ module RunApi
|
|
|
89
101
|
kwargs = {
|
|
90
102
|
status: status,
|
|
91
103
|
request_id: request_id,
|
|
92
|
-
details: parsed_body
|
|
104
|
+
details: parsed_body,
|
|
105
|
+
response_headers: headers
|
|
93
106
|
}
|
|
94
107
|
kwargs[:retry_after] = retry_after if error_class == RateLimitError
|
|
95
108
|
|
|
@@ -147,6 +160,14 @@ module RunApi
|
|
|
147
160
|
nil
|
|
148
161
|
end
|
|
149
162
|
end
|
|
163
|
+
|
|
164
|
+
def response_headers(response)
|
|
165
|
+
return {} unless response.respond_to?(:each_header)
|
|
166
|
+
|
|
167
|
+
headers = {}
|
|
168
|
+
response.each_header { |key, value| headers[key] = value }
|
|
169
|
+
headers
|
|
170
|
+
end
|
|
150
171
|
end
|
|
151
172
|
end
|
|
152
173
|
|
data/lib/runapi/core/files.rb
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
3
5
|
module RunApi
|
|
4
6
|
module Core
|
|
5
7
|
class Files
|
|
6
8
|
include RunApi::Core::ResourceHelpers
|
|
7
9
|
|
|
8
10
|
ENDPOINT = "/api/v1/files"
|
|
11
|
+
PREPARE_ENDPOINT = "#{ENDPOINT}/prepare"
|
|
12
|
+
CONFIRM_ENDPOINT = "#{ENDPOINT}/confirm"
|
|
9
13
|
|
|
10
14
|
class UploadResponse < RunApi::Core::BaseModel
|
|
11
15
|
required :file_name, String
|
|
@@ -25,13 +29,9 @@ module RunApi
|
|
|
25
29
|
def create(file: nil, source: nil, file_name: nil, options: nil)
|
|
26
30
|
validate_source!(file:, source:)
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
multipart_body(file, file_name:)
|
|
30
|
-
else
|
|
31
|
-
compact_params(source:, file_name:)
|
|
32
|
-
end
|
|
32
|
+
return upload_direct(file, file_name:, options:) if file
|
|
33
33
|
|
|
34
|
-
request(:post, ENDPOINT, body:, options:)
|
|
34
|
+
request(:post, ENDPOINT, body: compact_params(source:, file_name:), options:)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
private
|
|
@@ -43,15 +43,26 @@ module RunApi
|
|
|
43
43
|
raise ArgumentError, "Exactly one source is required: file or source"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
# Local files upload straight to storage: ask for a pre-authorized target,
|
|
47
|
+
# PUT the bytes there (never through the API), then confirm. The caller still
|
|
48
|
+
# makes a single create call.
|
|
49
|
+
def upload_direct(file, file_name:, options:)
|
|
47
50
|
path = file_path(file)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
bytes = File.binread(path)
|
|
52
|
+
prepared = @http.request(
|
|
53
|
+
:post,
|
|
54
|
+
PREPARE_ENDPOINT,
|
|
55
|
+
body: compact_params(
|
|
56
|
+
filename: file_name || File.basename(path),
|
|
57
|
+
byte_size: bytes.bytesize,
|
|
58
|
+
checksum: Digest::MD5.base64digest(bytes)
|
|
59
|
+
),
|
|
60
|
+
options:
|
|
54
61
|
)
|
|
62
|
+
|
|
63
|
+
@http.upload(prepared["upload_url"], headers: prepared["headers"], body: bytes)
|
|
64
|
+
|
|
65
|
+
request(:post, CONFIRM_ENDPOINT, body: {signed_id: prepared["signed_id"]}, options:)
|
|
55
66
|
end
|
|
56
67
|
|
|
57
68
|
def file_path(file)
|
|
@@ -37,7 +37,13 @@ module RunApi
|
|
|
37
37
|
raise NetworkError, e.message
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
if response.is_a?(Net::HTTPSuccess)
|
|
41
|
+
body = parse_body(response.body)
|
|
42
|
+
return nil if body.nil?
|
|
43
|
+
return body unless body.is_a?(Hash) || body.is_a?(Array)
|
|
44
|
+
|
|
45
|
+
return Response.new(body:, headers: response_headers(response))
|
|
46
|
+
end
|
|
41
47
|
|
|
42
48
|
error = Error.from_response(response, response.body)
|
|
43
49
|
|
|
@@ -54,6 +60,30 @@ module RunApi
|
|
|
54
60
|
close_multipart_files(req)
|
|
55
61
|
end
|
|
56
62
|
|
|
63
|
+
# PUT bytes straight to a pre-authorized upload URL with the exact headers
|
|
64
|
+
# issued for it. Skips the base URL, auth, and retries: the URL is single-use
|
|
65
|
+
# and the body is not safe to replay.
|
|
66
|
+
def upload(url, headers:, body:)
|
|
67
|
+
uri = URI.parse(url)
|
|
68
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
69
|
+
http.use_ssl = (uri.scheme == "https")
|
|
70
|
+
http.open_timeout = @options.timeout
|
|
71
|
+
http.read_timeout = @options.timeout
|
|
72
|
+
|
|
73
|
+
req = Net::HTTP::Put.new(uri.request_uri)
|
|
74
|
+
headers.each { |key, value| req[key.to_s] = value }
|
|
75
|
+
req.body = body
|
|
76
|
+
|
|
77
|
+
response = http.start { |connection| connection.request(req) }
|
|
78
|
+
return if response.is_a?(Net::HTTPSuccess)
|
|
79
|
+
|
|
80
|
+
raise Error.from_response(response, response.body)
|
|
81
|
+
rescue ::Net::OpenTimeout, ::Net::ReadTimeout => e
|
|
82
|
+
raise TimeoutError, e.message
|
|
83
|
+
rescue ::SocketError, ::Errno::ECONNREFUSED, ::Errno::ECONNRESET => e
|
|
84
|
+
raise NetworkError, e.message
|
|
85
|
+
end
|
|
86
|
+
|
|
57
87
|
private
|
|
58
88
|
|
|
59
89
|
def build_connection
|
|
@@ -132,6 +162,12 @@ module RunApi
|
|
|
132
162
|
rescue JSON::ParserError
|
|
133
163
|
body
|
|
134
164
|
end
|
|
165
|
+
|
|
166
|
+
def response_headers(response)
|
|
167
|
+
headers = {}
|
|
168
|
+
response.each_header { |key, value| headers[key] = value }
|
|
169
|
+
headers
|
|
170
|
+
end
|
|
135
171
|
end
|
|
136
172
|
end
|
|
137
173
|
end
|
data/lib/runapi/core/polling.rb
CHANGED
|
@@ -16,15 +16,27 @@ module RunApi
|
|
|
16
16
|
|
|
17
17
|
if status == "failed"
|
|
18
18
|
message = value_for(response, "error") || "Task failed"
|
|
19
|
-
raise TaskFailedError.new(
|
|
19
|
+
raise TaskFailedError.new(
|
|
20
|
+
message,
|
|
21
|
+
details: details_for(response),
|
|
22
|
+
response_headers: response_headers_for(response)
|
|
23
|
+
)
|
|
20
24
|
end
|
|
21
25
|
|
|
22
26
|
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
23
|
-
raise TaskTimeoutError
|
|
27
|
+
raise TaskTimeoutError.new(
|
|
28
|
+
"Task polling timed out after #{options.max_wait}s",
|
|
29
|
+
details: details_for(response),
|
|
30
|
+
response_headers: response_headers_for(response)
|
|
31
|
+
)
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
unless ACTIVE_STATUSES.include?(status)
|
|
27
|
-
raise TaskFailedError.new(
|
|
35
|
+
raise TaskFailedError.new(
|
|
36
|
+
"Unknown task status: #{status}",
|
|
37
|
+
details: details_for(response),
|
|
38
|
+
response_headers: response_headers_for(response)
|
|
39
|
+
)
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
sleep(options.poll_interval)
|
|
@@ -45,6 +57,11 @@ module RunApi
|
|
|
45
57
|
response.is_a?(Core::BaseModel) ? response.to_h : response
|
|
46
58
|
end
|
|
47
59
|
private_class_method :details_for
|
|
60
|
+
|
|
61
|
+
def self.response_headers_for(response)
|
|
62
|
+
response.response_headers if response.respond_to?(:response_headers)
|
|
63
|
+
end
|
|
64
|
+
private_class_method :response_headers_for
|
|
48
65
|
end
|
|
49
66
|
end
|
|
50
67
|
end
|
|
@@ -20,7 +20,10 @@ module RunApi
|
|
|
20
20
|
@http.request(method, path, **kwargs)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
Core::
|
|
23
|
+
payload = response.is_a?(Core::Response) ? response.body : response
|
|
24
|
+
result = Core::BaseModel.coerce(payload, as: response_class)
|
|
25
|
+
attach_response_headers(result, response.response_headers) if response.is_a?(Core::Response)
|
|
26
|
+
result
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def compact_params(params)
|
|
@@ -205,7 +208,18 @@ module RunApi
|
|
|
205
208
|
return response if response.is_a?(completed_class)
|
|
206
209
|
|
|
207
210
|
payload = response.is_a?(Core::BaseModel) ? response.to_h : response
|
|
208
|
-
completed_class.from_hash(payload)
|
|
211
|
+
completed = completed_class.from_hash(payload)
|
|
212
|
+
completed.with_response_headers(response.response_headers) if response.is_a?(Core::BaseModel)
|
|
213
|
+
completed
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def attach_response_headers(result, headers)
|
|
217
|
+
case result
|
|
218
|
+
when Core::BaseModel
|
|
219
|
+
result.with_response_headers(headers)
|
|
220
|
+
when Array
|
|
221
|
+
result.each { |item| attach_response_headers(item, headers) }
|
|
222
|
+
end
|
|
209
223
|
end
|
|
210
224
|
end
|
|
211
225
|
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RunApi
|
|
4
|
+
module Core
|
|
5
|
+
class ResponseHeaders
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(headers = {})
|
|
9
|
+
@headers = {}
|
|
10
|
+
headers&.each { |key, value| @headers[normalize(key)] = value }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def [](key)
|
|
14
|
+
@headers[normalize(key)]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(key, *fallback, &block)
|
|
18
|
+
@headers.fetch(normalize(key), *fallback, &block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def key?(key)
|
|
22
|
+
@headers.key?(normalize(key))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each(&block)
|
|
26
|
+
@headers.each(&block)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def empty?
|
|
30
|
+
@headers.empty?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
@headers.dup
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def normalize(key)
|
|
40
|
+
key.to_s.downcase
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class Response < Hash
|
|
45
|
+
attr_reader :response_headers
|
|
46
|
+
|
|
47
|
+
def initialize(body:, headers: nil)
|
|
48
|
+
super()
|
|
49
|
+
@body = body unless body.is_a?(Hash)
|
|
50
|
+
@response_headers = headers.is_a?(ResponseHeaders) ? headers : ResponseHeaders.new(headers)
|
|
51
|
+
update(body) if body.is_a?(Hash)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
alias_method :headers, :response_headers
|
|
55
|
+
|
|
56
|
+
def body
|
|
57
|
+
@body.nil? ? self : @body
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def [](key)
|
|
61
|
+
return @body[key] if @body.respond_to?(:[])
|
|
62
|
+
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def dig(*keys)
|
|
67
|
+
return @body.dig(*keys) if @body.respond_to?(:dig)
|
|
68
|
+
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def to_h
|
|
73
|
+
return @body.to_h if !@body.nil? && @body.respond_to?(:to_h)
|
|
74
|
+
|
|
75
|
+
super
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ==(other)
|
|
79
|
+
if @body
|
|
80
|
+
@body == (other.is_a?(Response) ? other.body : other)
|
|
81
|
+
else
|
|
82
|
+
super(other.is_a?(Response) ? other.to_h : other)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/runapi/core/types.rb
CHANGED
|
@@ -62,7 +62,9 @@ module RunApi
|
|
|
62
62
|
#
|
|
63
63
|
# @example
|
|
64
64
|
# client.text_to_image.run(
|
|
65
|
-
#
|
|
65
|
+
# prompt: "A sunset",
|
|
66
|
+
# model: "z-image",
|
|
67
|
+
# aspect_ratio: "1:1",
|
|
66
68
|
# options: RequestOptions.new(timeout: 30, headers: { "X-Custom" => "value" })
|
|
67
69
|
# )
|
|
68
70
|
#
|
data/lib/runapi/core/version.rb
CHANGED
data/lib/runapi/core.rb
CHANGED
|
@@ -10,6 +10,7 @@ require "connection_pool"
|
|
|
10
10
|
require_relative "core/version"
|
|
11
11
|
require_relative "core/constants"
|
|
12
12
|
require_relative "core/configuration"
|
|
13
|
+
require_relative "core/response"
|
|
13
14
|
require_relative "core/base_model"
|
|
14
15
|
require_relative "core/types"
|
|
15
16
|
require_relative "core/errors"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: runapi-core
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- RunAPI
|
|
@@ -50,6 +50,7 @@ files:
|
|
|
50
50
|
- lib/runapi/core/multipart_body.rb
|
|
51
51
|
- lib/runapi/core/polling.rb
|
|
52
52
|
- lib/runapi/core/resource_helpers.rb
|
|
53
|
+
- lib/runapi/core/response.rb
|
|
53
54
|
- lib/runapi/core/types.rb
|
|
54
55
|
- lib/runapi/core/version.rb
|
|
55
56
|
homepage: https://runapi.ai/models
|