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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5429c1ab8b5a4a777f1e95511f1ff54a6b4c409026637a1732f090bf919fc1fc
4
- data.tar.gz: 40731fb4249d671e8ad33aaeab2de19708972cf75abc2a5d3d91e702d9e7dde4
3
+ metadata.gz: 0b72086a1800026ac12dae2c055b06051b61dec6ea35704d9ebe3a96aac9dee5
4
+ data.tar.gz: f2d1a708927e1b8fbaf57c10c648a4928cf19714bbae20ee0b4865b918c852b2
5
5
  SHA512:
6
- metadata.gz: 73104149df96fcbbc61b33719d924865335c98f355ac37f5701b0083005e8dca9f7fc38d7740722d70524a207a485474cfe7a7e03383cde1bf2818dbe91a8eb6
7
- data.tar.gz: 1d3f8011538f69c2e5e47aae61b7adbb28d262ab3e13c369214d7c6f442d347da1ce8ae36e05bdc36067624b732dc6b8d63d22c38df4e8d43de5d7334ae2854b
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 # GET, POST, …
72
- path: request_url # full URL including query string when present
73
- status: response_status # integer HTTP status (nil on transport failure before response)
74
- duration_ms: duration_ms # float milliseconds
75
- request_body: request_body # JSON-ish text; secrets redacted; truncated to max_body_bytes
76
- response_body: response_body # same treatment
77
- error_class: error_class # nil when Faraday returned a response
78
- error_message: error_message # transport errors or truncated exception message
79
- streaming: streaming # true for chat SSE streams
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
- Canonical keys on the **left** (`method`, `path`, …) are fixed by the gem; **right-hand** names are your database columns. Omit mappings you do not need. Set **`api_call_log: false`** (or omit `model` / `columns`) to disable.
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
- # +attrs+ uses canonical string keys (see CANONICAL_KEYS). Column mapping is applied before create.
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
- cols.each_with_object({}) do |(canonical, column_name), acc|
78
- next unless CANONICAL_KEYS.include?(canonical.to_s)
79
- next unless attrs.key?(canonical.to_s)
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
- acc[column_name.to_s] = attrs[canonical.to_s]
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