savvy_openrouter 0.2.0 → 0.4.1
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 +38 -0
- data/README.md +75 -12
- 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 +12 -4
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b72086a1800026ac12dae2c055b06051b61dec6ea35704d9ebe3a96aac9dee5
|
|
4
|
+
data.tar.gz: f2d1a708927e1b8fbaf57c10c648a4928cf19714bbae20ee0b4865b918c852b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e6cae64c814dd9a1a8162638f28740c05f617085081c32982fce542a1eff413a2010d00977d7a4433886ac2a3d022bc8edeab48693132e53be5ae95b071b03fe
|
|
7
|
+
data.tar.gz: b50221b09dfce3abbf179bd76d6d8ac951e1382d3bf899cd43008f7cd1f6f2e0d8aa9cc6c08b8deba11e721bb929db33ddabc4cb76c151a1c88c6f87ca9629a0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.4.1] - 2026-05-15
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`file_parser_pdf_engine`** in YAML / `Configuration` (or **`OPENROUTER_FILE_PARSER_PDF_ENGINE`**): choose OpenRouter **`file-parser`** PDF engine (`native`, `cloudflare-ai`, `mistral-ocr`, …). **`RequestPlugins.prepare_chat_body!(body, pdf_engine: …)`** applies it when injecting or completing the **`file-parser`** plugin.
|
|
8
|
+
- **`api_call_log`:** failed JSON HTTP responses populate **`error_message`** from the API **`error.message`** when the column is mapped (`response_json` still holds the full body).
|
|
9
|
+
|
|
10
|
+
### Documentation
|
|
11
|
+
|
|
12
|
+
- README: PDF engine configuration.
|
|
13
|
+
|
|
14
|
+
## [0.4.0] - 2026-05-15
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`require "savvy_openrouter/patterns"`:** Pure-Ruby structured JSON validation after HTTP 200 for chat (`json_object` / `json_schema`) and Responses (`text.format`). Raises **`SavvyOpenrouter::StructuredOutputError`** (`reason`, `response_body`).
|
|
19
|
+
- **`require "savvy_openrouter/request_plugins"`:** **`prepare_chat_body!`** / **`prepare_responses_body!`** inject OpenRouter **response-healing** and **file-parser** (Cloudflare PDF) for chat when applicable.
|
|
20
|
+
- **`responses_retries`** in YAML or on **`Client`:** same shape as **`chat_retries`** for **`POST /responses`** — zero **`usage.output_tokens`**, selected **`status`** values, and optional HTTP error retries; **`Resources::Responses#create`** runs the retry loop with backoff.
|
|
21
|
+
- **`api_call_log.responses_attempts`:** **`final`** defers Responses logging to the last HTTP attempt (like **`chat_attempts`**); **`all`** logs every attempt (default). **`Connection#flush_deferred_responses_log!`** flushes after the loop.
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
- README: patterns, request plugins, `responses_retries`, `responses_attempts`.
|
|
26
|
+
|
|
27
|
+
## [0.3.0] - 2026-05-15
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **`api_call_log`:** Column map keys are a **whitelist**: every `source => db_column` entry is copied from the logged attrs when `source` is present, so apps can map **passthrough** context (for example `bill_forward_event_id`) without gem changes.
|
|
32
|
+
- **`api_call_log` / connection:** Additional canonical attrs populated on JSON requests when mapped: **`http_status`**, **`success`**, **`generation_id`** (from `x-generation-id` or JSON `id`), **`usage`**, **`cost`** (derived from usage when possible), **`logical_model`**, **`endpoint` / resource context**, structured **`request_json`** / **`response_json`** (JSON columns should receive serialized objects; coercion formats these for storage).
|
|
33
|
+
- **`chat_attempts`:** `final` defers chat completion logging until the **last** HTTP attempt of a retry loop (one row per logical call); `all` logs **each** attempt (default). Set under `api_call_log` in YAML or client options.
|
|
34
|
+
- **`Connection#with_call_context`:** Merges a shallow hash into the active logging context for nested calls (e.g. billing ids); **`suppress_api_call_log: true`** skips persistence for that scope.
|
|
35
|
+
- **`Connection#record_manual_api_call` / `Client#record_api_call`:** Persist a row after HTTP success for **logical** failures (invalid structured JSON, validation), merged with the current call context.
|
|
36
|
+
|
|
37
|
+
### Documentation
|
|
38
|
+
|
|
39
|
+
- README and install template: expanded `api_call_log` examples and new options.
|
|
40
|
+
|
|
3
41
|
## [0.2.0] - 2026-05-09
|
|
4
42
|
|
|
5
43
|
### Added
|
data/README.md
CHANGED
|
@@ -60,26 +60,52 @@ app_title: "Your App"
|
|
|
60
60
|
|
|
61
61
|
Optional persistence of **every outbound OpenRouter HTTP request** made through this gem (JSON clients, raw/binary downloads, and streaming chat). Configure **`api_call_log`** in YAML or pass **`api_call_log:`** when building **`SavvyOpenrouter::Client`**.
|
|
62
62
|
|
|
63
|
-
It depends on **Active Record** (or any Ruby class you configure) exposing **`create!(attributes)`** — the usual Rails pattern. Define a migration for whatever columns you map (strings / integers / booleans / text); avoid indexing huge raw payloads on Postgres without care.
|
|
63
|
+
It depends on **Active Record** (or any Ruby class you configure) exposing **`create!(attributes)`** — the usual Rails pattern. Define a migration for whatever columns you map (strings / integers / booleans / text / jsonb); avoid indexing huge raw payloads on Postgres without care.
|
|
64
|
+
|
|
65
|
+
Each entry under **`columns`** is **`<source_key>: <your_column>`**. If the logged payload includes **`source_key`**, it is copied into the row (after coercion). Reserved keys (**`model`**, **`columns`**, **`max_body_bytes`**, **`chat_attempts`**, **`responses_attempts`**) are never read from logged attrs. Any other **`source_key`** you whitelist can carry **app-specific** context (for example `bill_forward_event_id`), provided your code merges it via **`connection.with_call_context`**.
|
|
64
66
|
|
|
65
67
|
```yaml
|
|
66
|
-
# Optional — persist each outbound HTTP exchange for debugging (Faraday JSON + raw + SSE streams)
|
|
67
68
|
api_call_log:
|
|
68
69
|
model: OpenRouterApiCallLog
|
|
69
70
|
max_body_bytes: 65536
|
|
71
|
+
# With chat_retries, log only the final HTTP attempt (one row per logical completion).
|
|
72
|
+
# Use "all" (or omit) to persist every retry attempt.
|
|
73
|
+
chat_attempts: final
|
|
70
74
|
columns:
|
|
71
|
-
method: http_method
|
|
72
|
-
path: request_url
|
|
73
|
-
status: response_status
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
method: http_method
|
|
76
|
+
path: request_url
|
|
77
|
+
status: response_status # same as http_status when Faraday returned a response
|
|
78
|
+
http_status: http_status
|
|
79
|
+
success: success # boolean: 2xx HTTP
|
|
80
|
+
duration_ms: duration_ms
|
|
81
|
+
request_body: request_body
|
|
82
|
+
response_body: response_body
|
|
83
|
+
request_json: request_json # structured body; coercion JSON-serializes for json/text columns
|
|
84
|
+
response_json: response_json
|
|
85
|
+
usage: usage # usage hash from JSON responses when present
|
|
86
|
+
cost: cost # BigDecimal parsed from usage when present
|
|
87
|
+
generation_id: generation_id # from x-generation-id header or response id
|
|
88
|
+
logical_model: logical_model # model string from request body when present
|
|
89
|
+
endpoint: endpoint # resource endpoint label when set via call context
|
|
90
|
+
error_class: error_class
|
|
91
|
+
error_message: error_message
|
|
92
|
+
streaming: streaming
|
|
93
|
+
bill_forward_event_id: bill_forward_event_id # example passthrough key
|
|
80
94
|
```
|
|
81
95
|
|
|
82
|
-
|
|
96
|
+
**Call context:** wrap outbound calls to attach keys merged into every log row for that scope:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
client.connection.with_call_context(bill_forward_event_id: event.id) do
|
|
100
|
+
client.chat.completions(...)
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Set **`suppress_api_call_log: true`** in the context hash to skip persistence for that block (for example when the app performs its own higher-level logging / retries).
|
|
105
|
+
|
|
106
|
+
**Logical failures after HTTP 200** (invalid structured JSON, validation): use **`client.record_api_call(attrs)`** (delegates to **`connection.record_manual_api_call`**). Attributes are merged with the current **`with_call_context`** stack.
|
|
107
|
+
|
|
108
|
+
Canonical source keys the gem may set include: **`method`**, **`path`**, **`status`**, **`http_status`**, **`duration_ms`**, **`request_body`**, **`response_body`**, **`request_json`**, **`response_json`**, **`usage`**, **`cost`**, **`generation_id`**, **`logical_model`**, **`endpoint`**, **`success`**, **`error_class`**, **`error_message`** (for failed JSON HTTP responses, from the API **`error.message`** when present), **`streaming`**, plus any keys you pass through **`with_call_context`**. Omit mappings you do not need. Set **`api_call_log: false`** (or omit **`model`** / **`columns`**) to disable.
|
|
83
109
|
|
|
84
110
|
Logging failures never raise into your app code. Large bodies are truncated; **`Authorization`** / **`sk-or-v1-*`** patterns in serialized bodies are redacted (still treat logs as sensitive).
|
|
85
111
|
|
|
@@ -107,6 +133,43 @@ chat_retries:
|
|
|
107
133
|
|
|
108
134
|
After the last attempt, the gem returns the **final response body** (for 200s) or **re-raises** the last API error. **`completions_stream`** does not use this policy—handle streaming retries in your own code if needed.
|
|
109
135
|
|
|
136
|
+
### Responses API retries (`responses_retries`)
|
|
137
|
+
|
|
138
|
+
For **`client.responses.create`** only, configure **`responses_retries`** in YAML or pass **`responses_retries:`** to **`SavvyOpenrouter::Client`**. Same timing options as **`chat_retries`** (`max_attempts`, `base_delay_ms`, …). When **`on.zero_output_tokens`** is true (default), the gem retries on **successful HTTP 200** responses where **`usage.output_tokens`** is zero and **`status`** indicates an incomplete or empty generation (see `ResponsesRetryPolicy`).
|
|
139
|
+
|
|
140
|
+
With **`api_call_log`**, set **`responses_attempts: final`** to persist **one** row for the last HTTP attempt of a retry loop; **`all`** logs each attempt (default).
|
|
141
|
+
|
|
142
|
+
### Structured output validation (`savvy_openrouter/patterns`)
|
|
143
|
+
|
|
144
|
+
Optional pure-Ruby checks that assistant / Responses text is non-empty **parseable JSON** when the request asked for **`json_object`** or **`json_schema`**:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
require "savvy_openrouter/patterns"
|
|
148
|
+
|
|
149
|
+
SavvyOpenrouter::Patterns.validate_after_success!(
|
|
150
|
+
endpoint: "chat_completions",
|
|
151
|
+
request: request_body_hash,
|
|
152
|
+
response: parsed_response_hash
|
|
153
|
+
)
|
|
154
|
+
# raises SavvyOpenrouter::StructuredOutputError (reason: :empty_content, :invalid_json, :no_choices)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Helpers: **`chat_structured_requested?`**, **`responses_structured_requested?`**, **`extract_chat_assistant_text`**, **`extract_responses_output_text`**, **`assert_parseable_json!`**.
|
|
158
|
+
|
|
159
|
+
### Request plugins (`savvy_openrouter/request_plugins`)
|
|
160
|
+
|
|
161
|
+
Optional injection of OpenRouter plugins before **`chat.completions`** / **`responses.create`**:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
require "savvy_openrouter/request_plugins"
|
|
165
|
+
|
|
166
|
+
SavvyOpenrouter::RequestPlugins.prepare_chat_body!(body) # PDF engine from config / default cloudflare-ai
|
|
167
|
+
SavvyOpenrouter::RequestPlugins.prepare_chat_body!(body, pdf_engine: "native") # e.g. Gemini native PDF
|
|
168
|
+
SavvyOpenrouter::RequestPlugins.prepare_responses_body!(body) # response-healing for structured Responses
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
YAML (or `OPENROUTER_FILE_PARSER_PDF_ENGINE`): **`file_parser_pdf_engine`** — `native`, `cloudflare-ai`, `mistral-ocr`, etc. Apps using **`OpenRouterService`** pass this through from the loaded **`SavvyOpenrouter::Client`** config automatically.
|
|
172
|
+
|
|
110
173
|
### Install templates
|
|
111
174
|
|
|
112
175
|
**Rails**
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# OpenRouter defaults — loaded automatically when present at config/savvy_openrouter.yml
|
|
2
2
|
# or .savvy_openrouter.yml (see SavvyOpenrouter::Configuration).
|
|
3
3
|
#
|
|
4
|
+
# PDF file-parser engine when messages include PDFs (openrouter file-parser plugin):
|
|
5
|
+
# file_parser_pdf_engine: native # model-native when supported (e.g. Gemini)
|
|
6
|
+
# file_parser_pdf_engine: cloudflare-ai # free conversion to markdown (gem default if unset)
|
|
7
|
+
# file_parser_pdf_engine: mistral-ocr # paid; scans / complex PDFs
|
|
8
|
+
# Or: ENV OPENROUTER_FILE_PARSER_PDF_ENGINE
|
|
9
|
+
#
|
|
4
10
|
# Precedence: keyword arguments to SavvyOpenrouter::Client > this file > ENV variables.
|
|
5
11
|
|
|
6
12
|
# api_key: "sk-or-v1-..."
|
|
@@ -35,17 +41,42 @@
|
|
|
35
41
|
# api_call_log:
|
|
36
42
|
# model: OpenRouterApiCallLog
|
|
37
43
|
# max_body_bytes: 65536
|
|
44
|
+
# # With chat_retries: "final" logs only the last attempt; "all" logs each HTTP try.
|
|
45
|
+
# chat_attempts: final
|
|
46
|
+
# # With responses_retries loops: "final" logs only the last POST /responses attempt.
|
|
47
|
+
# responses_attempts: final
|
|
38
48
|
# columns:
|
|
39
49
|
# method: http_method
|
|
40
50
|
# path: request_url
|
|
41
51
|
# status: response_status
|
|
52
|
+
# http_status: http_status
|
|
53
|
+
# success: success
|
|
42
54
|
# duration_ms: duration_ms
|
|
43
55
|
# request_body: request_body
|
|
44
56
|
# response_body: response_body
|
|
57
|
+
# request_json: request_json
|
|
58
|
+
# response_json: response_json
|
|
59
|
+
# usage: usage
|
|
60
|
+
# cost: cost
|
|
61
|
+
# generation_id: generation_id
|
|
62
|
+
# logical_model: logical_model
|
|
63
|
+
# endpoint: endpoint
|
|
45
64
|
# error_class: error_class
|
|
46
65
|
# error_message: error_message
|
|
47
66
|
# streaming: streaming
|
|
48
67
|
#
|
|
68
|
+
# Optional: retry POST /responses on zero output_tokens or transient HTTP errors
|
|
69
|
+
# responses_retries:
|
|
70
|
+
# max_attempts: 3
|
|
71
|
+
# base_delay_ms: 400
|
|
72
|
+
# max_delay_ms: 8000
|
|
73
|
+
# on:
|
|
74
|
+
# zero_output_tokens: true
|
|
75
|
+
# rate_limit: true
|
|
76
|
+
# bad_gateway: true
|
|
77
|
+
# internal_server_error: true
|
|
78
|
+
# service_unavailable: true
|
|
79
|
+
#
|
|
49
80
|
# Optional: retry chat/completions (non-streaming) on empty output, zero completion tokens, or transient HTTP errors
|
|
50
81
|
# chat_retries:
|
|
51
82
|
# max_attempts: 4
|
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "bigdecimal"
|
|
4
5
|
|
|
5
6
|
module SavvyOpenrouter
|
|
6
7
|
# Persists OpenRouter HTTP exchanges when configured via +api_call_log+ (YAML or Client kwargs).
|
|
7
8
|
# Failures while saving never raise into application code.
|
|
9
|
+
#
|
|
10
|
+
# Column map (+columns+ hash): every +source_key => db_column+ entry is a whitelist. If +attrs+
|
|
11
|
+
# includes +source_key+, its value is copied to the row (after optional coercion). This allows
|
|
12
|
+
# app-specific passthrough keys (e.g. +bill_forward_event_id+) without extending the gem enum.
|
|
13
|
+
#
|
|
14
|
+
# Documented source keys populated by Connection/resources: +method+, +path+, +status+, +http_status+,
|
|
15
|
+
# +duration_ms+, +request_body+, +response_body+, +error_class+, +error_message+, +streaming+,
|
|
16
|
+
# +endpoint+, +logical_model+, +generation_id+, +success+, +cost+, +usage+, +request_json+,
|
|
17
|
+
# +response_json+.
|
|
8
18
|
class ApiCallLogger
|
|
9
19
|
DEFAULT_MAX_BODY_BYTES = 65_536
|
|
10
20
|
|
|
21
|
+
# Reserved keys in api_call_log YAML — never treated as column sources.
|
|
22
|
+
RESERVED_CONFIG_KEYS = %w[model columns max_body_bytes chat_attempts responses_attempts].freeze
|
|
23
|
+
|
|
24
|
+
# Keys the gem may set automatically (subset); full set is any key allowed in +columns+.
|
|
11
25
|
CANONICAL_KEYS = %w[
|
|
12
|
-
method path status duration_ms request_body response_body error_class error_message streaming
|
|
26
|
+
method path status http_status duration_ms request_body response_body error_class error_message streaming
|
|
27
|
+
endpoint logical_model generation_id success cost usage request_json response_json
|
|
13
28
|
].freeze
|
|
14
29
|
|
|
15
30
|
class << self
|
|
@@ -25,6 +40,60 @@ module SavvyOpenrouter
|
|
|
25
40
|
truncate_bytes(str, max_bytes)
|
|
26
41
|
end
|
|
27
42
|
|
|
43
|
+
def generation_id_from(response:, parsed_body:)
|
|
44
|
+
h = response.respond_to?(:headers) ? response.headers : nil
|
|
45
|
+
if h
|
|
46
|
+
raw = h["x-generation-id"] || h["X-Generation-Id"] || h["X-GENERATION-ID"]
|
|
47
|
+
gid = blank_to_nil(raw&.to_s)
|
|
48
|
+
return gid if gid
|
|
49
|
+
end
|
|
50
|
+
return unless parsed_body.is_a?(Hash)
|
|
51
|
+
|
|
52
|
+
blank_to_nil((parsed_body[:id] || parsed_body["id"]).to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def blank_to_nil(raw)
|
|
56
|
+
t = raw.to_s.strip
|
|
57
|
+
t.empty? ? nil : t
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Best-effort OpenRouter-style error.message for JSON error bodies (symbol or string keys).
|
|
61
|
+
def error_message_from_response_body(body)
|
|
62
|
+
return unless body.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
err = body[:error] || body["error"]
|
|
65
|
+
case err
|
|
66
|
+
when Hash
|
|
67
|
+
blank_to_nil((err[:message] || err["message"]).to_s)
|
|
68
|
+
when String
|
|
69
|
+
blank_to_nil(err)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def error_message_from_json_string(str)
|
|
74
|
+
return unless str.is_a?(String)
|
|
75
|
+
|
|
76
|
+
stripped = str.strip
|
|
77
|
+
return unless stripped.start_with?("{", "[")
|
|
78
|
+
|
|
79
|
+
parsed = JSON.parse(stripped, symbolize_names: true)
|
|
80
|
+
error_message_from_response_body(parsed) if parsed.is_a?(Hash)
|
|
81
|
+
rescue JSON::ParserError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cost_from_usage(usage)
|
|
86
|
+
return unless usage.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
u = usage.transform_keys(&:to_s)
|
|
89
|
+
v = u["cost"] || u["total_cost"]
|
|
90
|
+
return if v.nil?
|
|
91
|
+
|
|
92
|
+
BigDecimal(v.to_s)
|
|
93
|
+
rescue ArgumentError
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
28
97
|
private
|
|
29
98
|
|
|
30
99
|
def redact_secrets(str)
|
|
@@ -56,7 +125,15 @@ module SavvyOpenrouter
|
|
|
56
125
|
n.is_a?(Integer) && n.positive? ? n : DEFAULT_MAX_BODY_BYTES
|
|
57
126
|
end
|
|
58
127
|
|
|
59
|
-
|
|
128
|
+
def chat_attempts_final?
|
|
129
|
+
@config["chat_attempts"].to_s == "final"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def responses_attempts_final?
|
|
133
|
+
@config["responses_attempts"].to_s == "final"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# +attrs+ string-keyed hashes; column mapping selects and renames fields for +create!+.
|
|
60
137
|
def record(attrs)
|
|
61
138
|
return unless enabled?
|
|
62
139
|
|
|
@@ -74,14 +151,50 @@ module SavvyOpenrouter
|
|
|
74
151
|
def build_row(attrs)
|
|
75
152
|
cols = @config["columns"] || {}
|
|
76
153
|
attrs = Configuration.stringify_keys_static(attrs)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
154
|
+
lim = max_body_limit
|
|
155
|
+
|
|
156
|
+
cols.each_with_object({}) do |(source_key, column_name), acc|
|
|
157
|
+
sk = source_key.to_s
|
|
158
|
+
next if RESERVED_CONFIG_KEYS.include?(sk)
|
|
159
|
+
next unless attrs.key?(sk)
|
|
160
|
+
|
|
161
|
+
acc[column_name.to_s] = coerce_value(sk, attrs[sk], lim)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
80
164
|
|
|
81
|
-
|
|
165
|
+
def coerce_value(source_key, val, lim)
|
|
166
|
+
case source_key
|
|
167
|
+
when "request_json", "response_json", "usage"
|
|
168
|
+
val.nil? ? nil : self.class.format_body_for_log(val, max_bytes: lim)
|
|
169
|
+
when "error_message"
|
|
170
|
+
val.nil? ? nil : self.class.format_body_for_log(val.to_s, max_bytes: lim)
|
|
171
|
+
when "success"
|
|
172
|
+
coerce_success(val)
|
|
173
|
+
when "cost"
|
|
174
|
+
coerce_cost(val)
|
|
175
|
+
when "http_status", "status"
|
|
176
|
+
val.is_a?(Integer) ? val : Integer(val, exception: false) || val
|
|
177
|
+
else
|
|
178
|
+
val
|
|
82
179
|
end
|
|
83
180
|
end
|
|
84
181
|
|
|
182
|
+
def coerce_success(val)
|
|
183
|
+
return nil if val.nil?
|
|
184
|
+
return val if [true, false].include?(val)
|
|
185
|
+
|
|
186
|
+
val ? true : false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def coerce_cost(val)
|
|
190
|
+
return nil if val.nil?
|
|
191
|
+
return val if val.is_a?(BigDecimal)
|
|
192
|
+
|
|
193
|
+
BigDecimal(val.to_s)
|
|
194
|
+
rescue ArgumentError, TypeError
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
|
|
85
198
|
def constantize_model(name)
|
|
86
199
|
raise NameError, "blank model" if name.empty?
|
|
87
200
|
raise NameError, "invalid model name #{name.inspect}" if name.include?("..") || /[^A-Za-z0-9_:]/.match?(name)
|
|
@@ -25,6 +25,16 @@ module SavvyOpenrouter
|
|
|
25
25
|
class Client
|
|
26
26
|
attr_reader :config, :connection
|
|
27
27
|
|
|
28
|
+
# Optional DB-backed request logging; see README (+api_call_log+).
|
|
29
|
+
def api_call_logger
|
|
30
|
+
connection.api_call_logger
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Merge active +with_call_context+ stack attrs and persist one row (e.g. structured-output failure after HTTP 200).
|
|
34
|
+
def record_api_call(attrs)
|
|
35
|
+
connection.record_manual_api_call(attrs)
|
|
36
|
+
end
|
|
37
|
+
|
|
28
38
|
def initialize(config_path: nil, **options)
|
|
29
39
|
@config = Configuration.new(config_path: config_path, **options)
|
|
30
40
|
@connection = Connection.new(@config)
|
|
@@ -5,7 +5,8 @@ require "yaml"
|
|
|
5
5
|
module SavvyOpenrouter
|
|
6
6
|
class Configuration
|
|
7
7
|
attr_accessor :api_key, :base_url, :default_model, :http_referer, :app_title
|
|
8
|
-
attr_reader :defaults, :video_defaults, :responses_defaults, :api_call_log, :chat_retries
|
|
8
|
+
attr_reader :defaults, :video_defaults, :responses_defaults, :api_call_log, :chat_retries, :responses_retries,
|
|
9
|
+
:file_parser_pdf_engine
|
|
9
10
|
|
|
10
11
|
alias llm_model default_model
|
|
11
12
|
alias llm_model= default_model=
|
|
@@ -30,6 +31,8 @@ module SavvyOpenrouter
|
|
|
30
31
|
@responses_defaults = {}
|
|
31
32
|
@api_call_log = {}
|
|
32
33
|
@chat_retries = {}
|
|
34
|
+
@responses_retries = {}
|
|
35
|
+
@file_parser_pdf_engine = nil
|
|
33
36
|
load_from_env!
|
|
34
37
|
yaml_path = config_path || self.class.discover_config_file
|
|
35
38
|
merge_hash!(self.class.load_file(yaml_path)) if yaml_path
|
|
@@ -44,6 +47,7 @@ module SavvyOpenrouter
|
|
|
44
47
|
nil
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity -- single YAML merge entry point
|
|
47
51
|
def merge_hash!(hash)
|
|
48
52
|
return unless hash.is_a?(Hash)
|
|
49
53
|
|
|
@@ -61,7 +65,10 @@ module SavvyOpenrouter
|
|
|
61
65
|
assign_api_call_log(hash["api_call_log"]) if hash.key?("api_call_log")
|
|
62
66
|
assign_chat_retries(hash["chat_retries"]) if hash.key?("chat_retries")
|
|
63
67
|
assign_chat_retries(hash["completion_retries"]) if hash.key?("completion_retries")
|
|
68
|
+
assign_responses_retries(hash["responses_retries"]) if hash.key?("responses_retries")
|
|
69
|
+
assign_file_parser_pdf_engine(hash["file_parser_pdf_engine"]) if hash.key?("file_parser_pdf_engine")
|
|
64
70
|
end
|
|
71
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
65
72
|
|
|
66
73
|
def merge_chat_body(body)
|
|
67
74
|
body = stringify_keys(body)
|
|
@@ -94,6 +101,8 @@ module SavvyOpenrouter
|
|
|
94
101
|
@default_model = ENV.fetch("OPENROUTER_DEFAULT_MODEL", nil)
|
|
95
102
|
@http_referer = ENV.fetch("OPENROUTER_HTTP_REFERER", nil)
|
|
96
103
|
@app_title = ENV.fetch("OPENROUTER_APP_TITLE", nil)
|
|
104
|
+
env_pdf = ENV.fetch("OPENROUTER_FILE_PARSER_PDF_ENGINE", nil)
|
|
105
|
+
@file_parser_pdf_engine = env_pdf.strip if env_pdf && !env_pdf.strip.empty?
|
|
97
106
|
end
|
|
98
107
|
|
|
99
108
|
def apply_options!(options)
|
|
@@ -117,6 +126,8 @@ module SavvyOpenrouter
|
|
|
117
126
|
elsif opts.key?(:completion_retries)
|
|
118
127
|
assign_chat_retries(opts.delete(:completion_retries))
|
|
119
128
|
end
|
|
129
|
+
assign_responses_retries(opts.delete(:responses_retries)) if opts.key?(:responses_retries)
|
|
130
|
+
assign_file_parser_pdf_engine(opts.delete(:file_parser_pdf_engine)) if opts.key?(:file_parser_pdf_engine)
|
|
120
131
|
|
|
121
132
|
return if opts.empty?
|
|
122
133
|
|
|
@@ -152,5 +163,28 @@ module SavvyOpenrouter
|
|
|
152
163
|
raise ArgumentError, "chat_retries must be a Hash or false"
|
|
153
164
|
end
|
|
154
165
|
end
|
|
166
|
+
|
|
167
|
+
def assign_responses_retries(value)
|
|
168
|
+
case value
|
|
169
|
+
when false, nil
|
|
170
|
+
@responses_retries = {}
|
|
171
|
+
when Hash
|
|
172
|
+
h = self.class.stringify_keys_static(value)
|
|
173
|
+
h["on"] = self.class.stringify_keys_static(h["on"]) if h["on"].is_a?(Hash)
|
|
174
|
+
@responses_retries = h
|
|
175
|
+
else
|
|
176
|
+
raise ArgumentError, "responses_retries must be a Hash or false"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def assign_file_parser_pdf_engine(value)
|
|
181
|
+
@file_parser_pdf_engine =
|
|
182
|
+
if value.nil?
|
|
183
|
+
nil
|
|
184
|
+
else
|
|
185
|
+
s = value.to_s.strip
|
|
186
|
+
s.empty? ? nil : s
|
|
187
|
+
end
|
|
188
|
+
end
|
|
155
189
|
end
|
|
156
190
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "connection_instrumentation"
|
|
4
|
+
require_relative "connection_api_call_recording"
|
|
5
|
+
require_relative "connection_api_call_recording_transport"
|
|
4
6
|
|
|
5
7
|
require "faraday"
|
|
6
8
|
require "json"
|
|
@@ -10,14 +12,19 @@ require "uri"
|
|
|
10
12
|
module SavvyOpenrouter
|
|
11
13
|
class Connection
|
|
12
14
|
include Instrumentation
|
|
15
|
+
include ApiCallRecording
|
|
16
|
+
include ApiCallRecordingTransport
|
|
13
17
|
|
|
14
18
|
DEFAULT_SUCCESS = [200, 201, 202, 204].freeze
|
|
15
19
|
|
|
16
|
-
attr_reader :config
|
|
20
|
+
attr_reader :config, :api_call_logger
|
|
17
21
|
|
|
18
22
|
def initialize(config)
|
|
19
23
|
@config = config
|
|
20
24
|
@api_call_logger = ApiCallLogger.new(config.api_call_log)
|
|
25
|
+
@call_context_stack = []
|
|
26
|
+
@pending_deferred_chat_log = nil
|
|
27
|
+
@pending_deferred_responses_log = nil
|
|
21
28
|
base = normalize_base(config.base_url)
|
|
22
29
|
headers = build_headers
|
|
23
30
|
@conn = Faraday.new(url: base, headers: headers) do |faraday|
|
|
@@ -154,6 +161,38 @@ module SavvyOpenrouter
|
|
|
154
161
|
raise
|
|
155
162
|
end
|
|
156
163
|
|
|
164
|
+
def with_call_context(context = {})
|
|
165
|
+
parent = @call_context_stack.last || {}
|
|
166
|
+
merged = parent.merge(stringify_body(context.is_a?(Hash) ? context : {}))
|
|
167
|
+
@call_context_stack.push(merged)
|
|
168
|
+
yield
|
|
169
|
+
ensure
|
|
170
|
+
@call_context_stack.pop
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Record a logical failure after HTTP success (e.g. invalid structured JSON). Merges +context+ with +attrs+.
|
|
174
|
+
def record_manual_api_call(attrs)
|
|
175
|
+
ctx = stringify_body(@call_context_stack.last || {})
|
|
176
|
+
merged = ctx.merge(Configuration.stringify_keys_static(attrs))
|
|
177
|
+
@api_call_logger.record(merged)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def flush_deferred_chat_log!
|
|
181
|
+
attrs = @pending_deferred_chat_log
|
|
182
|
+
@pending_deferred_chat_log = nil
|
|
183
|
+
return if attrs.nil?
|
|
184
|
+
|
|
185
|
+
@api_call_logger.record(attrs)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def flush_deferred_responses_log!
|
|
189
|
+
attrs = @pending_deferred_responses_log
|
|
190
|
+
@pending_deferred_responses_log = nil
|
|
191
|
+
return if attrs.nil?
|
|
192
|
+
|
|
193
|
+
@api_call_logger.record(attrs)
|
|
194
|
+
end
|
|
195
|
+
|
|
157
196
|
def stream_post(path, body, &)
|
|
158
197
|
ensure_api_key!
|
|
159
198
|
uri = join_uri(path)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SavvyOpenrouter
|
|
4
|
+
class Connection
|
|
5
|
+
# JSON Faraday exchanges + timing helpers for {api_call_log}.
|
|
6
|
+
module ApiCallRecording
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def record_faraday_json(method:, rel_path:, params:, request_body:, response:, duration_ms:)
|
|
10
|
+
return if suppress_api_call_log?
|
|
11
|
+
return unless @api_call_logger.enabled?
|
|
12
|
+
|
|
13
|
+
attrs = merge_call_log_context(
|
|
14
|
+
faraday_json_attrs(method: method, rel_path: rel_path, params: params, request_body: request_body,
|
|
15
|
+
response: response, duration_ms: duration_ms)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if defer_chat_completions_log?(method: method, rel_path: rel_path)
|
|
19
|
+
@pending_deferred_chat_log = attrs
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if defer_responses_log?(method: method, rel_path: rel_path)
|
|
24
|
+
@pending_deferred_responses_log = attrs
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@api_call_logger.record(attrs)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def faraday_json_attrs(method:, rel_path:, params:, request_body:, response:, duration_ms:)
|
|
32
|
+
lim = @api_call_logger.max_body_limit
|
|
33
|
+
body = response.body
|
|
34
|
+
usage = usage_hash_from_body(body)
|
|
35
|
+
status = response.status
|
|
36
|
+
success = status.is_a?(Integer) && (200..299).include?(status)
|
|
37
|
+
gid = ApiCallLogger.generation_id_from(response: response, parsed_body: body)
|
|
38
|
+
cost = usage ? ApiCallLogger.cost_from_usage(usage) : nil
|
|
39
|
+
lm = extract_logical_model_from_request(request_body)
|
|
40
|
+
error_message =
|
|
41
|
+
if success
|
|
42
|
+
nil
|
|
43
|
+
else
|
|
44
|
+
ApiCallLogger.error_message_from_response_body(body)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
"method" => method,
|
|
49
|
+
"path" => full_url(rel_path, params),
|
|
50
|
+
"status" => status,
|
|
51
|
+
"http_status" => status,
|
|
52
|
+
"duration_ms" => duration_ms.round(3),
|
|
53
|
+
"request_body" => ApiCallLogger.format_body_for_log(request_body, max_bytes: lim),
|
|
54
|
+
"response_body" => ApiCallLogger.format_body_for_log(body, max_bytes: lim),
|
|
55
|
+
"request_json" => request_body,
|
|
56
|
+
"response_json" => body,
|
|
57
|
+
"error_class" => nil,
|
|
58
|
+
"error_message" => error_message,
|
|
59
|
+
"streaming" => false,
|
|
60
|
+
"success" => success,
|
|
61
|
+
"generation_id" => gid,
|
|
62
|
+
"usage" => usage,
|
|
63
|
+
"cost" => cost,
|
|
64
|
+
"logical_model" => lm
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def usage_hash_from_body(body)
|
|
69
|
+
return unless body.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
u = body[:usage] || body["usage"]
|
|
72
|
+
u.is_a?(Hash) ? u : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def extract_logical_model_from_request(request_body)
|
|
76
|
+
case request_body
|
|
77
|
+
when Hash
|
|
78
|
+
m = request_body[:model] || request_body["model"]
|
|
79
|
+
s = m.to_s.strip
|
|
80
|
+
s.empty? ? nil : s
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def full_url(rel_path, params)
|
|
85
|
+
u = join_uri(rel_path)
|
|
86
|
+
if params && !params.empty?
|
|
87
|
+
flat = params.transform_keys(&:to_s)
|
|
88
|
+
u.query = URI.encode_www_form(flat)
|
|
89
|
+
end
|
|
90
|
+
u.to_s
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def monotonic_ms
|
|
94
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def elapsed_ms(started)
|
|
98
|
+
monotonic_ms - started
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|