savvy_openrouter 0.2.0 → 0.4.2
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/CHANGELOG.md +44 -0
- data/README.md +78 -15
- data/lib/generators/savvy_openrouter/install/templates/savvy_openrouter.yml +31 -0
- data/lib/savvy_openrouter/api_call_logger.rb +119 -6
- data/lib/savvy_openrouter/client.rb +10 -0
- data/lib/savvy_openrouter/configuration.rb +35 -1
- data/lib/savvy_openrouter/connection.rb +40 -1
- data/lib/savvy_openrouter/connection_api_call_recording.rb +102 -0
- data/lib/savvy_openrouter/connection_api_call_recording_transport.rb +111 -0
- data/lib/savvy_openrouter/connection_instrumentation.rb +27 -92
- data/lib/savvy_openrouter/patterns/structured_output.rb +183 -0
- data/lib/savvy_openrouter/patterns.rb +9 -0
- data/lib/savvy_openrouter/request_plugins.rb +164 -0
- data/lib/savvy_openrouter/resources/base.rb +8 -0
- data/lib/savvy_openrouter/resources/chat.rb +27 -17
- data/lib/savvy_openrouter/resources/embeddings.rb +7 -2
- data/lib/savvy_openrouter/resources/generations.rb +6 -2
- data/lib/savvy_openrouter/resources/models.rb +18 -8
- data/lib/savvy_openrouter/resources/responses.rb +27 -1
- data/lib/savvy_openrouter/responses_retry_policy.rb +112 -0
- data/lib/savvy_openrouter/structured_output_error.rb +17 -0
- data/lib/savvy_openrouter/version.rb +1 -1
- data/lib/savvy_openrouter.rb +1 -0
- data/sig/savvy_openrouter.rbs +27 -0
- metadata +14 -8
- data/savvy_openrouter-0.1.0.gem +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "patterns/structured_output"
|
|
5
|
+
|
|
6
|
+
module SavvyOpenrouter
|
|
7
|
+
# Optional chat / Responses request shaping (OpenRouter plugins). Pure Ruby.
|
|
8
|
+
#
|
|
9
|
+
# require "savvy_openrouter/request_plugins"
|
|
10
|
+
# SavvyOpenrouter::RequestPlugins.prepare_chat_body!(body, pdf_engine: "native")
|
|
11
|
+
module RequestPlugins
|
|
12
|
+
RESPONSE_HEALING_PLUGIN = { id: "response-healing" }.freeze
|
|
13
|
+
FILE_PARSER_DEFAULT_ENGINE = "cloudflare-ai"
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def prepare_chat_body!(body, pdf_engine: nil)
|
|
18
|
+
return body unless body.is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
ensure_response_healing_for_chat!(body) if SavvyOpenrouter::Patterns.chat_structured_requested?(body)
|
|
21
|
+
ensure_pdf_file_parser_plugin!(body, pdf_engine: pdf_engine)
|
|
22
|
+
body
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def prepare_responses_body!(body)
|
|
26
|
+
return body unless body.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
ensure_response_healing_for_responses!(body) if SavvyOpenrouter::Patterns.responses_structured_requested?(body)
|
|
29
|
+
body
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ensure_response_healing_for_chat!(body)
|
|
33
|
+
plugins_arr = plugins_array_from(body)
|
|
34
|
+
return if response_healing_plugin_present?(plugins_arr)
|
|
35
|
+
|
|
36
|
+
body[:plugins] = plugins_arr + [RESPONSE_HEALING_PLUGIN.dup]
|
|
37
|
+
body.delete("plugins")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ensure_response_healing_for_responses!(body)
|
|
41
|
+
plugins_arr = plugins_array_from(body)
|
|
42
|
+
return if response_healing_plugin_present?(plugins_arr)
|
|
43
|
+
|
|
44
|
+
body[:plugins] = plugins_arr + [RESPONSE_HEALING_PLUGIN.dup]
|
|
45
|
+
body.delete("plugins")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def plugins_array_from(body)
|
|
49
|
+
p = body[:plugins] || body["plugins"]
|
|
50
|
+
p.is_a?(Array) ? p.dup : []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def response_healing_plugin_present?(plugins)
|
|
54
|
+
plugin_present?(plugins, id: "response-healing")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# +pdf_engine+ — optional override (e.g. from config: +"native"+ for Gemini, +"mistral-ocr"+ for scans).
|
|
58
|
+
# When nil, uses {FILE_PARSER_DEFAULT_ENGINE}.
|
|
59
|
+
def ensure_pdf_file_parser_plugin!(body, pdf_engine: nil)
|
|
60
|
+
messages = body[:messages] || body["messages"]
|
|
61
|
+
return unless chat_messages_include_pdf_attachment?(messages)
|
|
62
|
+
|
|
63
|
+
engine = normalize_pdf_engine(pdf_engine)
|
|
64
|
+
plugins = plugins_array_from(body)
|
|
65
|
+
idx = plugins.find_index { |p| plugin_entry_id(p) == "file-parser" }
|
|
66
|
+
|
|
67
|
+
if idx
|
|
68
|
+
return if file_parser_pdf_engine_set?(plugins[idx])
|
|
69
|
+
|
|
70
|
+
fp = plugins[idx].dup
|
|
71
|
+
pdf_src = fp[:pdf] || fp["pdf"]
|
|
72
|
+
merged_pdf =
|
|
73
|
+
if pdf_src.is_a?(Hash)
|
|
74
|
+
pdf_src.transform_keys(&:to_sym)
|
|
75
|
+
else
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
merged_pdf[:engine] = engine
|
|
79
|
+
fp[:pdf] = merged_pdf
|
|
80
|
+
fp.delete("pdf")
|
|
81
|
+
plugins[idx] = fp
|
|
82
|
+
body[:plugins] = plugins
|
|
83
|
+
else
|
|
84
|
+
body[:plugins] = plugins + [{ id: "file-parser", pdf: { engine: engine } }]
|
|
85
|
+
end
|
|
86
|
+
body.delete("plugins")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def normalize_pdf_engine(pdf_engine)
|
|
90
|
+
s = pdf_engine.to_s.strip
|
|
91
|
+
s.empty? ? FILE_PARSER_DEFAULT_ENGINE : s
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def chat_messages_include_pdf_attachment?(messages)
|
|
95
|
+
return false unless messages.is_a?(Array)
|
|
96
|
+
|
|
97
|
+
messages.any? { |msg| message_includes_pdf_file?(msg) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def message_includes_pdf_file?(msg)
|
|
101
|
+
return false unless msg.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
content = msg[:content] || msg["content"]
|
|
104
|
+
return false unless content.is_a?(Array)
|
|
105
|
+
|
|
106
|
+
content.any? { |part| content_part_is_pdf_file?(part) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def content_part_is_pdf_file?(part)
|
|
110
|
+
return false unless part.is_a?(Hash)
|
|
111
|
+
|
|
112
|
+
type = (part[:type] || part["type"]).to_s
|
|
113
|
+
return false unless type == "file"
|
|
114
|
+
|
|
115
|
+
file = part[:file] || part["file"]
|
|
116
|
+
return false unless file.is_a?(Hash)
|
|
117
|
+
|
|
118
|
+
pdf_file_attachment?(file)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def pdf_file_attachment?(file)
|
|
122
|
+
name = (file[:filename] || file["filename"]).to_s
|
|
123
|
+
return true if name.match?(/\.pdf\z/i)
|
|
124
|
+
|
|
125
|
+
data = file[:file_data] || file["file_data"] || file[:fileData] || file["fileData"]
|
|
126
|
+
return false unless data.is_a?(String)
|
|
127
|
+
|
|
128
|
+
return true if data.match?(%r{\Adata:application/pdf(?:;|\z)}i)
|
|
129
|
+
|
|
130
|
+
pdf_url?(data)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def pdf_url?(url)
|
|
134
|
+
return false unless url.match?(%r{\Ahttps?://}i)
|
|
135
|
+
|
|
136
|
+
uri = URI.parse(url)
|
|
137
|
+
uri.path.to_s.match?(/\.pdf\z/i)
|
|
138
|
+
rescue URI::InvalidURIError
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def file_parser_pdf_engine_set?(plugin)
|
|
143
|
+
return false unless plugin.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
pdf = plugin[:pdf] || plugin["pdf"]
|
|
146
|
+
return false unless pdf.is_a?(Hash)
|
|
147
|
+
|
|
148
|
+
eng = (pdf[:engine] || pdf["engine"]).to_s
|
|
149
|
+
!eng.strip.empty?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def plugin_present?(plugins, id:)
|
|
153
|
+
return false unless plugins.is_a?(Array)
|
|
154
|
+
|
|
155
|
+
plugins.any? { |p| plugin_entry_id(p) == id.to_s }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def plugin_entry_id(entry)
|
|
159
|
+
return unless entry.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
(entry[:id] || entry["id"]).to_s
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -11,6 +11,14 @@ module SavvyOpenrouter
|
|
|
11
11
|
|
|
12
12
|
attr_reader :client
|
|
13
13
|
|
|
14
|
+
def logical_model_from_body(body)
|
|
15
|
+
return nil unless body.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
m = body[:model] || body["model"]
|
|
18
|
+
s = m.to_s.strip
|
|
19
|
+
s.empty? ? nil : s
|
|
20
|
+
end
|
|
21
|
+
|
|
14
22
|
def conn
|
|
15
23
|
client.connection
|
|
16
24
|
end
|
|
@@ -10,35 +10,45 @@ module SavvyOpenrouter
|
|
|
10
10
|
def completions(messages:, **params)
|
|
11
11
|
policy = CompletionRetryPolicy.new(config.chat_retries)
|
|
12
12
|
body = config.merge_chat_body({ messages: messages }.merge(params))
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
lm = logical_model_from_body(body)
|
|
14
|
+
conn.with_call_context(endpoint: "chat_completions", logical_model: lm) do
|
|
15
|
+
attempt = 0
|
|
16
|
+
last_response = nil
|
|
17
17
|
begin
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
loop do
|
|
19
|
+
attempt += 1
|
|
20
|
+
begin
|
|
21
|
+
last_response = conn.post("/chat/completions", body: body)
|
|
22
|
+
rescue SavvyOpenrouter::ApiError => e
|
|
23
|
+
raise e if attempt >= policy.max_attempts || !policy.retry_http_error?(e)
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
policy.wait_after_attempt(attempt)
|
|
26
|
+
next
|
|
27
|
+
end
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
return last_response unless policy.retry_response?(last_response)
|
|
30
|
+
return last_response if attempt >= policy.max_attempts
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
policy.wait_after_attempt(attempt)
|
|
33
|
+
end
|
|
34
|
+
ensure
|
|
35
|
+
conn.flush_deferred_chat_log!
|
|
36
|
+
end
|
|
30
37
|
end
|
|
31
38
|
end
|
|
32
39
|
|
|
33
40
|
# Yields each SSE `data:` payload string (JSON text from the model stream).
|
|
34
41
|
def completions_stream(messages:, **params, &block)
|
|
35
42
|
body = config.merge_chat_body({ messages: messages, stream: true }.merge(params))
|
|
36
|
-
|
|
43
|
+
lm = logical_model_from_body(body)
|
|
44
|
+
conn.with_call_context(endpoint: "chat_completions", logical_model: lm) do
|
|
45
|
+
return enum_for(:completions_stream, messages: messages, **params) unless block
|
|
37
46
|
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
chunk_enum = Enumerator.new do |y|
|
|
48
|
+
conn.stream_post("/chat/completions", body) { |ch| y << ch }
|
|
49
|
+
end
|
|
50
|
+
Streaming.each_sse_data(chunk_enum, &block)
|
|
40
51
|
end
|
|
41
|
-
Streaming.each_sse_data(chunk_enum, &block)
|
|
42
52
|
end
|
|
43
53
|
end
|
|
44
54
|
end
|
|
@@ -7,11 +7,16 @@ module SavvyOpenrouter
|
|
|
7
7
|
class Embeddings < Base
|
|
8
8
|
def create(**params)
|
|
9
9
|
body = config.merge_chat_body(params)
|
|
10
|
-
|
|
10
|
+
lm = logical_model_from_body(body)
|
|
11
|
+
conn.with_call_context(endpoint: "embeddings", logical_model: lm) do
|
|
12
|
+
conn.post("/embeddings", body: body)
|
|
13
|
+
end
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def models
|
|
14
|
-
conn.
|
|
17
|
+
conn.with_call_context(endpoint: "embeddings_models", logical_model: nil) do
|
|
18
|
+
conn.get("/embeddings/models")
|
|
19
|
+
end
|
|
15
20
|
end
|
|
16
21
|
end
|
|
17
22
|
end
|
|
@@ -6,11 +6,15 @@ module SavvyOpenrouter
|
|
|
6
6
|
module Resources
|
|
7
7
|
class Generations < Base
|
|
8
8
|
def get(id:)
|
|
9
|
-
conn.
|
|
9
|
+
conn.with_call_context(endpoint: "generation", logical_model: nil) do
|
|
10
|
+
conn.get("/generation", params: { id: id })
|
|
11
|
+
end
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def content(**params)
|
|
13
|
-
conn.
|
|
15
|
+
conn.with_call_context(endpoint: "generation_content", logical_model: nil) do
|
|
16
|
+
conn.get("/generation/content", params: params)
|
|
17
|
+
end
|
|
14
18
|
end
|
|
15
19
|
end
|
|
16
20
|
end
|
|
@@ -8,28 +8,38 @@ module SavvyOpenrouter
|
|
|
8
8
|
# Query params match OpenRouter GET /models (e.g. category, output_modalities, supported_parameters).
|
|
9
9
|
def list(**params)
|
|
10
10
|
query = stringify_query(params)
|
|
11
|
-
conn.
|
|
11
|
+
conn.with_call_context(endpoint: "models", logical_model: nil) do
|
|
12
|
+
conn.get("/models", params: query.empty? ? nil : query)
|
|
13
|
+
end
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
# Uses GET /models with filters, then returns the first model whose prompt
|
|
15
|
-
# OpenRouter returns models in curated rank order
|
|
16
|
-
# site “top free” picks when combined with output_modalities=text.
|
|
16
|
+
# Uses GET /models with category + free-price filters, then returns the first model whose prompt and
|
|
17
|
+
# completion pricing are both zero. OpenRouter returns models in curated rank order; the first free
|
|
18
|
+
# match aligns with site “top free” picks when combined with output_modalities=text.
|
|
19
|
+
# Models scheduled for removal (expiration_date) are still eligible — callers often want the current
|
|
20
|
+
# top free pick even when it is temporary.
|
|
17
21
|
def first_ranked_free_text_model(category:, output_modalities: "text")
|
|
18
|
-
res = list(category: category, output_modalities: output_modalities)
|
|
22
|
+
res = list(category: category, output_modalities: output_modalities, max_price: 0)
|
|
19
23
|
data = res[:data] || []
|
|
20
24
|
data.find { |m| free_pricing?(m) }
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def count
|
|
24
|
-
conn.
|
|
28
|
+
conn.with_call_context(endpoint: "models_count", logical_model: nil) do
|
|
29
|
+
conn.get("/models/count")
|
|
30
|
+
end
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
def user
|
|
28
|
-
conn.
|
|
34
|
+
conn.with_call_context(endpoint: "models_user", logical_model: nil) do
|
|
35
|
+
conn.get("/models/user")
|
|
36
|
+
end
|
|
29
37
|
end
|
|
30
38
|
|
|
31
39
|
def endpoints(author:, slug:)
|
|
32
|
-
conn.
|
|
40
|
+
conn.with_call_context(endpoint: "models_endpoints", logical_model: nil) do
|
|
41
|
+
conn.get("/models/#{author}/#{slug}/endpoints")
|
|
42
|
+
end
|
|
33
43
|
end
|
|
34
44
|
|
|
35
45
|
private
|
|
@@ -1,13 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../responses_retry_policy"
|
|
4
5
|
|
|
5
6
|
module SavvyOpenrouter
|
|
6
7
|
module Resources
|
|
7
8
|
class Responses < Base
|
|
8
9
|
def create(**params)
|
|
10
|
+
policy = ResponsesRetryPolicy.new(config.responses_retries)
|
|
9
11
|
body = config.merge_responses_body(params)
|
|
10
|
-
|
|
12
|
+
lm = logical_model_from_body(body)
|
|
13
|
+
conn.with_call_context(endpoint: "responses", logical_model: lm) do
|
|
14
|
+
attempt = 0
|
|
15
|
+
last_response = nil
|
|
16
|
+
begin
|
|
17
|
+
loop do
|
|
18
|
+
attempt += 1
|
|
19
|
+
begin
|
|
20
|
+
last_response = conn.post("/responses", body: body)
|
|
21
|
+
rescue SavvyOpenrouter::ApiError => e
|
|
22
|
+
raise e if attempt >= policy.max_attempts || !policy.retry_http_error?(e)
|
|
23
|
+
|
|
24
|
+
policy.wait_after_attempt(attempt)
|
|
25
|
+
next
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return last_response unless policy.retry_response?(last_response)
|
|
29
|
+
return last_response if attempt >= policy.max_attempts
|
|
30
|
+
|
|
31
|
+
policy.wait_after_attempt(attempt)
|
|
32
|
+
end
|
|
33
|
+
ensure
|
|
34
|
+
conn.flush_deferred_responses_log!
|
|
35
|
+
end
|
|
36
|
+
end
|
|
11
37
|
end
|
|
12
38
|
end
|
|
13
39
|
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SavvyOpenrouter
|
|
4
|
+
# Retries for +Resources::Responses#create+ (zero output tokens / selected HTTP errors).
|
|
5
|
+
class ResponsesRetryPolicy
|
|
6
|
+
DEFAULT_ON = {
|
|
7
|
+
"zero_output_tokens" => true,
|
|
8
|
+
"rate_limit" => true,
|
|
9
|
+
"bad_gateway" => true,
|
|
10
|
+
"internal_server_error" => true,
|
|
11
|
+
"service_unavailable" => true
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(raw)
|
|
15
|
+
@raw =
|
|
16
|
+
case raw
|
|
17
|
+
when false, nil then {}
|
|
18
|
+
when Hash then Configuration.stringify_keys_static(raw)
|
|
19
|
+
else
|
|
20
|
+
raise ArgumentError, "responses_retries must be a Hash or false"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def max_attempts
|
|
25
|
+
n = @raw["max_attempts"]
|
|
26
|
+
i = Integer(n, exception: false)
|
|
27
|
+
i&.positive? ? i : 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def enabled?
|
|
31
|
+
max_attempts > 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def retry_http_error?(error)
|
|
35
|
+
return false unless enabled?
|
|
36
|
+
return false unless error.is_a?(SavvyOpenrouter::ApiError)
|
|
37
|
+
|
|
38
|
+
code = error.status_code
|
|
39
|
+
return false unless code
|
|
40
|
+
|
|
41
|
+
flag =
|
|
42
|
+
case code
|
|
43
|
+
when 429 then "rate_limit"
|
|
44
|
+
when 502 then "bad_gateway"
|
|
45
|
+
when 500, 501 then "internal_server_error"
|
|
46
|
+
when 503 then "service_unavailable"
|
|
47
|
+
end
|
|
48
|
+
flag ? on?(flag) : false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def retry_response?(response)
|
|
52
|
+
return false unless enabled?
|
|
53
|
+
|
|
54
|
+
on?("zero_output_tokens") && responses_zero_output_retry?(response)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def wait_after_attempt(attempt_number)
|
|
58
|
+
base = integer_opt(@raw["base_delay_ms"], 400)
|
|
59
|
+
max_d = integer_opt(@raw["max_delay_ms"], 10_000)
|
|
60
|
+
exponential = @raw["exponential_backoff"] != false
|
|
61
|
+
jitter_ratio = float_opt(@raw["jitter_ratio"], 0.15)
|
|
62
|
+
|
|
63
|
+
delay_ms =
|
|
64
|
+
if exponential
|
|
65
|
+
base * (2**(attempt_number - 1))
|
|
66
|
+
else
|
|
67
|
+
base
|
|
68
|
+
end
|
|
69
|
+
delay_ms = [delay_ms, max_d].min
|
|
70
|
+
jitter = jitter_ratio.clamp(0.0, 1.0) * delay_ms * rand
|
|
71
|
+
sleep((delay_ms + jitter) / 1000.0)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def on?(key)
|
|
77
|
+
DEFAULT_ON.merge(explicit_on)[key.to_s] == true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def explicit_on
|
|
81
|
+
h = @raw["on"]
|
|
82
|
+
h.is_a?(Hash) ? Configuration.stringify_keys_static(h) : {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Aligns with OpenRouter zero-completion / empty Responses output heuristics.
|
|
86
|
+
def responses_zero_output_retry?(json)
|
|
87
|
+
return false unless json.is_a?(Hash)
|
|
88
|
+
|
|
89
|
+
usage = json[:usage] || json["usage"]
|
|
90
|
+
return false unless usage.is_a?(Hash)
|
|
91
|
+
|
|
92
|
+
out = usage[:output_tokens] || usage["output_tokens"]
|
|
93
|
+
return false if out.nil?
|
|
94
|
+
return false if out.to_i.positive?
|
|
95
|
+
|
|
96
|
+
status = (json[:status] || json["status"]).to_s
|
|
97
|
+
return true if %w[failed cancelled incomplete queued in_progress].include?(status)
|
|
98
|
+
|
|
99
|
+
status == "completed"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def integer_opt(val, default)
|
|
103
|
+
i = Integer(val, exception: false)
|
|
104
|
+
i&.positive? ? i : default
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def float_opt(val, default)
|
|
108
|
+
f = Float(val, exception: false)
|
|
109
|
+
f.nil? || f.negative? ? default : f
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SavvyOpenrouter
|
|
4
|
+
# Raised when json_object/json_schema was requested but the successful response body
|
|
5
|
+
# does not contain usable JSON in assistant/output text. Optional +require "savvy_openrouter/patterns"+.
|
|
6
|
+
#
|
|
7
|
+
# reason: +:empty_content+, +:invalid_json+, +:no_choices+
|
|
8
|
+
class StructuredOutputError < StandardError
|
|
9
|
+
attr_reader :reason, :response_body
|
|
10
|
+
|
|
11
|
+
def initialize(message, reason:, response_body: nil)
|
|
12
|
+
@reason = reason
|
|
13
|
+
@response_body = response_body
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/savvy_openrouter.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "savvy_openrouter/version"
|
|
|
4
4
|
require_relative "savvy_openrouter/errors"
|
|
5
5
|
require_relative "savvy_openrouter/configuration"
|
|
6
6
|
require_relative "savvy_openrouter/completion_retry_policy"
|
|
7
|
+
require_relative "savvy_openrouter/responses_retry_policy"
|
|
7
8
|
require_relative "savvy_openrouter/api_call_logger"
|
|
8
9
|
require_relative "savvy_openrouter/connection"
|
|
9
10
|
require_relative "savvy_openrouter/streaming"
|
data/sig/savvy_openrouter.rbs
CHANGED
|
@@ -40,6 +40,21 @@ module SavvyOpenrouter
|
|
|
40
40
|
class TimeoutPollError < Error
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
class StructuredOutputError < StandardError
|
|
44
|
+
attr_reader reason: Symbol
|
|
45
|
+
attr_reader response_body: untyped
|
|
46
|
+
def initialize: (String message, reason: Symbol, ?response_body: untyped) -> void
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class ResponsesRetryPolicy
|
|
50
|
+
def initialize: (Hash[String, untyped] | false? raw) -> void
|
|
51
|
+
def max_attempts: () -> Integer
|
|
52
|
+
def enabled?: () -> bool
|
|
53
|
+
def retry_http_error?: (ApiError error) -> bool
|
|
54
|
+
def retry_response?: (Hash[Symbol | String, untyped] response) -> bool
|
|
55
|
+
def wait_after_attempt: (Integer attempt_number) -> void
|
|
56
|
+
end
|
|
57
|
+
|
|
43
58
|
class Configuration
|
|
44
59
|
attr_accessor api_key: String?
|
|
45
60
|
attr_accessor base_url: String
|
|
@@ -51,6 +66,8 @@ module SavvyOpenrouter
|
|
|
51
66
|
attr_reader responses_defaults: Hash[String, untyped]
|
|
52
67
|
attr_reader api_call_log: Hash[String, untyped]
|
|
53
68
|
attr_reader chat_retries: Hash[String, untyped]
|
|
69
|
+
attr_reader responses_retries: Hash[String, untyped]
|
|
70
|
+
attr_reader file_parser_pdf_engine: String?
|
|
54
71
|
|
|
55
72
|
def initialize: (?config_path: String?, **untyped options) -> void
|
|
56
73
|
def merge_chat_body: (Hash[Symbol | String, untyped] body) -> Hash[String, untyped]
|
|
@@ -64,6 +81,8 @@ module SavvyOpenrouter
|
|
|
64
81
|
def initialize: (Hash[String, untyped] config) -> void
|
|
65
82
|
def enabled?: () -> bool
|
|
66
83
|
def max_body_limit: () -> Integer
|
|
84
|
+
def chat_attempts_final?: () -> bool
|
|
85
|
+
def responses_attempts_final?: () -> bool
|
|
67
86
|
def record: (Hash[String, untyped] attrs) -> untyped
|
|
68
87
|
def self.format_body_for_log: (untyped obj, ?max_bytes: Integer) -> String
|
|
69
88
|
end
|
|
@@ -81,8 +100,13 @@ module SavvyOpenrouter
|
|
|
81
100
|
DEFAULT_SUCCESS: Array[Integer]
|
|
82
101
|
|
|
83
102
|
attr_reader config: Configuration
|
|
103
|
+
attr_reader api_call_logger: ApiCallLogger
|
|
84
104
|
|
|
85
105
|
def initialize: (Configuration config) -> void
|
|
106
|
+
def with_call_context: (?Hash[Symbol | String, untyped] context) { () -> untyped } -> untyped
|
|
107
|
+
def record_manual_api_call: (Hash[Symbol | String, untyped] attrs) -> untyped
|
|
108
|
+
def flush_deferred_chat_log!: () -> void
|
|
109
|
+
def flush_deferred_responses_log!: () -> void
|
|
86
110
|
def get: (String path, ?params: Hash[Symbol | String, untyped]?, ?success: Array[Integer]?) -> untyped
|
|
87
111
|
def delete: (String path, ?params: Hash[Symbol | String, untyped]?, ?success: Array[Integer]?) -> untyped
|
|
88
112
|
def post: (String path, body: Hash[Symbol | String, untyped], ?success: Array[Integer]?) -> untyped
|
|
@@ -100,6 +124,9 @@ module SavvyOpenrouter
|
|
|
100
124
|
|
|
101
125
|
def initialize: (?config_path: String?, **untyped options) -> void
|
|
102
126
|
|
|
127
|
+
def api_call_logger: () -> ApiCallLogger
|
|
128
|
+
def record_api_call: (Hash[Symbol | String, untyped] attrs) -> untyped
|
|
129
|
+
|
|
103
130
|
def chat: -> Resources::Chat
|
|
104
131
|
def responses: -> Resources::Responses
|
|
105
132
|
def anthropic_messages: -> Resources::AnthropicMessages
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: savvy_openrouter
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2
|
|
4
|
+
version: 0.4.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bryan Feller
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: faraday
|
|
@@ -112,8 +112,13 @@ files:
|
|
|
112
112
|
- lib/savvy_openrouter/completion_retry_policy.rb
|
|
113
113
|
- lib/savvy_openrouter/configuration.rb
|
|
114
114
|
- lib/savvy_openrouter/connection.rb
|
|
115
|
+
- lib/savvy_openrouter/connection_api_call_recording.rb
|
|
116
|
+
- lib/savvy_openrouter/connection_api_call_recording_transport.rb
|
|
115
117
|
- lib/savvy_openrouter/connection_instrumentation.rb
|
|
116
118
|
- lib/savvy_openrouter/errors.rb
|
|
119
|
+
- lib/savvy_openrouter/patterns.rb
|
|
120
|
+
- lib/savvy_openrouter/patterns/structured_output.rb
|
|
121
|
+
- lib/savvy_openrouter/request_plugins.rb
|
|
117
122
|
- lib/savvy_openrouter/resources/analytics.rb
|
|
118
123
|
- lib/savvy_openrouter/resources/anthropic_messages.rb
|
|
119
124
|
- lib/savvy_openrouter/resources/api_keys.rb
|
|
@@ -133,17 +138,18 @@ files:
|
|
|
133
138
|
- lib/savvy_openrouter/resources/responses.rb
|
|
134
139
|
- lib/savvy_openrouter/resources/videos.rb
|
|
135
140
|
- lib/savvy_openrouter/resources/workspaces.rb
|
|
141
|
+
- lib/savvy_openrouter/responses_retry_policy.rb
|
|
136
142
|
- lib/savvy_openrouter/streaming.rb
|
|
143
|
+
- lib/savvy_openrouter/structured_output_error.rb
|
|
137
144
|
- lib/savvy_openrouter/version.rb
|
|
138
|
-
- savvy_openrouter-0.1.0.gem
|
|
139
145
|
- sig/savvy_openrouter.rbs
|
|
140
|
-
homepage: https://github.com/
|
|
146
|
+
homepage: https://github.com/bfeller/savvy_openrouter
|
|
141
147
|
licenses:
|
|
142
148
|
- MIT
|
|
143
149
|
metadata:
|
|
144
|
-
homepage_uri: https://github.com/
|
|
145
|
-
source_code_uri: https://github.com/
|
|
146
|
-
changelog_uri: https://github.com/
|
|
150
|
+
homepage_uri: https://github.com/bfeller/savvy_openrouter
|
|
151
|
+
source_code_uri: https://github.com/bfeller/savvy_openrouter
|
|
152
|
+
changelog_uri: https://github.com/bfeller/savvy_openrouter/blob/master/CHANGELOG.md
|
|
147
153
|
rubygems_mfa_required: 'true'
|
|
148
154
|
rdoc_options: []
|
|
149
155
|
require_paths:
|
|
@@ -159,7 +165,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
159
165
|
- !ruby/object:Gem::Version
|
|
160
166
|
version: '0'
|
|
161
167
|
requirements: []
|
|
162
|
-
rubygems_version:
|
|
168
|
+
rubygems_version: 4.0.3
|
|
163
169
|
specification_version: 4
|
|
164
170
|
summary: Ruby client for the OpenRouter unified AI API.
|
|
165
171
|
test_files: []
|
data/savvy_openrouter-0.1.0.gem
DELETED
|
Binary file
|