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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8137e975dade1520d62e20932114c3001f00f068ef088a58e1ffb461c1ba7473
|
|
4
|
+
data.tar.gz: 6aedce626d20af2e52b5b109c595a7b36a87c1861275d6bcce5d64cf52dfbd7c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca74a1076e38ef38c008d8562744cc88625ca409c93e3ce3474c8413c04cb10fc830a7fc69811578e6d2dee4f1b659b2339319cd151abf2062f446d4df02b0c0
|
|
7
|
+
data.tar.gz: 30a6e7c41a40710944ad41a2ea9d3886961d4f6ef9e2f6ef7f902c339cb1b7d4e63705a63e2f74163ee06f173cb7ba66f5c2e48c09d20829838f24894e752d9d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.4.2] - 2026-07-14
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **`Models#first_ranked_free_text_model`:** passes **`max_price: 0`** to **`GET /models`** (OpenRouter’s free-price filter) while still requiring zero prompt and completion pricing. Temporary free models with an **`expiration_date`** remain eligible when they rank first. Integration expectations updated for current top free ids; smoke chat model switched off the removed `inclusionai/ring-2.6-1t:free` id.
|
|
8
|
+
|
|
9
|
+
## [0.4.1] - 2026-05-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`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.
|
|
14
|
+
- **`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).
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
- README: PDF engine configuration.
|
|
19
|
+
|
|
20
|
+
## [0.4.0] - 2026-05-15
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`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`).
|
|
25
|
+
- **`require "savvy_openrouter/request_plugins"`:** **`prepare_chat_body!`** / **`prepare_responses_body!`** inject OpenRouter **response-healing** and **file-parser** (Cloudflare PDF) for chat when applicable.
|
|
26
|
+
- **`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.
|
|
27
|
+
- **`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.
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
- README: patterns, request plugins, `responses_retries`, `responses_attempts`.
|
|
32
|
+
|
|
33
|
+
## [0.3.0] - 2026-05-15
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- **`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.
|
|
38
|
+
- **`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).
|
|
39
|
+
- **`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.
|
|
40
|
+
- **`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.
|
|
41
|
+
- **`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.
|
|
42
|
+
|
|
43
|
+
### Documentation
|
|
44
|
+
|
|
45
|
+
- README and install template: expanded `api_call_log` examples and new options.
|
|
46
|
+
|
|
3
47
|
## [0.2.0] - 2026-05-09
|
|
4
48
|
|
|
5
49
|
### 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**
|
|
@@ -151,14 +214,14 @@ OpenRouter documents the legacy `plugins: [{ id: "web" }]` approach as deprecate
|
|
|
151
214
|
client.models.list(category: "programming", output_modalities: "text")
|
|
152
215
|
```
|
|
153
216
|
|
|
154
|
-
OpenRouter returns models in **curated rank order** within each filtered result set—that order is not “highest `context_length` wins.” To approximate the **top free text model** for a category, call **`first_ranked_free_text_model`**, which keeps API order and returns the **first** model whose **`pricing.prompt`** and **`pricing.completion`** both parse to zero:
|
|
217
|
+
OpenRouter returns models in **curated rank order** within each filtered result set—that order is not “highest `context_length` wins.” To approximate the **top free text model** for a category, call **`first_ranked_free_text_model`**, which calls **`GET /models`** with **`max_price=0`** (and text modality), keeps API order, and returns the **first** model whose **`pricing.prompt`** and **`pricing.completion`** both parse to zero. Temporary free models (those with an **`expiration_date` / “going away” badge) are **included** on purpose when they rank first:
|
|
155
218
|
|
|
156
219
|
```ruby
|
|
157
220
|
client.models.first_ranked_free_text_model(category: "programming")
|
|
158
221
|
client.models.first_ranked_free_text_model(category: "roleplay")
|
|
159
222
|
```
|
|
160
223
|
|
|
161
|
-
**Use a stored model id for normal traffic.** Resolving the free model calls **`GET /models`** (one request). Each chat turn is **`POST /chat/completions`** (another). If you call `first_ranked_free_text_model` (or `list` and pick) on **every** user message, you pay **two** API calls per turn—list plus chat. Instead, resolve **once** (at deploy, in a Rake task, or on a long TTL), **remember** the returned **`id`** (environment variable, database, cache, or `default_model` in `savvy_openrouter.yml`), and pass that string to **`client.chat.completions(model: ...)`** for ongoing requests. Refresh the stored id when you want to pick up a new “top free” model after OpenRouter changes their list.
|
|
224
|
+
**Use a stored model id for normal traffic.** Resolving the free model calls **`GET /models`** (one request). Each chat turn is **`POST /chat/completions`** (another). If you call `first_ranked_free_text_model` (or `list` and pick) on **every** user message, you pay **two** API calls per turn—list plus chat. Instead, resolve **once** (at deploy, in a Rake task, or on a long TTL), **remember** the returned **`id`** (environment variable, database, cache, or `default_model` in `savvy_openrouter.yml`), and pass that string to **`client.chat.completions(model: ...)`** for ongoing requests. Refresh the stored id when you want to pick up a new “top free” model after OpenRouter changes their list—especially if the chosen model lists an expiration date.
|
|
162
225
|
|
|
163
226
|
That heuristic stays aligned with OpenRouter’s listing **as long as their ranking and pricing rows stay as they are**; it is not a separate benchmark score from the JSON (there is no `rating` field on each model). For chat-specific knobs such as **`tools`**, **`tool_choice`**, and **`response_format`** (including JSON schema), pass them on **`client.chat.completions`**; global YAML **`defaults`** also merge into embeddings and other resources, so prefer per-call args for tool and schema defaults unless you only use chat endpoints.
|
|
164
227
|
|
|
@@ -219,7 +282,7 @@ bin/setup
|
|
|
219
282
|
bundle exec rake # RSpec + RuboCop
|
|
220
283
|
```
|
|
221
284
|
|
|
222
|
-
Integration tests live in `spec/integration/` and are tagged `:integration`. When `OPENROUTER_API_KEY` is set, they call the live API (WebMock allows net connect only for those examples). Smoke chat examples use the free model `
|
|
285
|
+
Integration tests live in `spec/integration/` and are tagged `:integration`. When `OPENROUTER_API_KEY` is set, they call the live API (WebMock allows net connect only for those examples). Smoke chat examples use the free model `nvidia/nemotron-3-super-120b-a12b:free`; **`spec/integration/free_model_rank_spec.rb`** asserts curated “first free” model ids for `programming` and `roleplay` against the live models list (these examples can fail if OpenRouter changes ordering or pricing).
|
|
223
286
|
|
|
224
287
|
## Contributing
|
|
225
288
|
|
|
@@ -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)
|