llm_gateway 0.4.0 → 0.5.0

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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.pi/skills/live-provider-testing/SKILL.md +183 -0
  3. data/.pi/skills/options-development/SKILL.md +131 -0
  4. data/CHANGELOG.md +17 -0
  5. data/README.md +16 -0
  6. data/Rakefile +1 -0
  7. data/lib/llm_gateway/adapters/adapter.rb +2 -35
  8. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +0 -2
  9. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +106 -27
  10. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +0 -33
  11. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -46
  12. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +48 -6
  13. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +3 -2
  14. data/lib/llm_gateway/adapters/groq/input_mapper.rb +44 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +89 -4
  16. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +0 -2
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +0 -6
  19. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +135 -72
  20. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +100 -10
  21. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +169 -170
  22. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +0 -1
  23. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +128 -68
  24. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +99 -10
  25. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +81 -271
  26. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +0 -1
  27. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +3 -3
  28. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +0 -5
  29. data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
  30. data/lib/llm_gateway/client.rb +10 -66
  31. data/lib/llm_gateway/clients/groq.rb +13 -1
  32. data/lib/llm_gateway/version.rb +1 -1
  33. data/lib/llm_gateway.rb +2 -8
  34. metadata +7 -10
  35. data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +0 -111
  36. data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +0 -110
  37. data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +0 -40
  38. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +0 -120
  39. data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +0 -47
  40. data/lib/llm_gateway/adapters/stream_accumulator.rb +0 -91
  41. data/scripts/generate_handoff_live_fixture.rb +0 -169
  42. data/scripts/generate_handoff_media_fixture.rb +0 -167
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 43cc8958d5b5a190a2326517944bba603719ec1a2e7b2e621f275d9054a4d51b
4
- data.tar.gz: 578c4468ba81e12ccd73760b9c04e753cbd89bafe4b81f6c2074e22de2c7a66a
3
+ metadata.gz: ce9b9e4f2137a73474b1ed5f0876d8b1bf6185666ab8c756c3a3f67e99e9d86e
4
+ data.tar.gz: 5735832e4bd57946ffc0a251c5a3a0861af0fc12a989456e1f877675e08846ba
5
5
  SHA512:
6
- metadata.gz: 622f708bedb092c0d74793c847a642d2450be3344fa3af12591ddd7bbab49f688aed20246d315d904b687c6f6aa1a68f08888cf1d74c3598ad2873ee9a7960bf
7
- data.tar.gz: b12bda099f89b25bf091b7ce0cef631545dda546e33f3d52e2e71dee055892242155652b5353f0d0a595809ab58294574835d84f15e381e494e900516f12575b
6
+ metadata.gz: afd52f4ead29acf7a612a06456e203e295534d2cb2275a7ea99be5840da39a821f4727402687bd9c3696bc0081c12f09861aa1b7ad135f986054625c68341422
7
+ data.tar.gz: 9c033b13f91e9315aadedca98cb61e32c01584a4e6cbe4f05b3782eb84287d24e50dbbc5e1bc127d14bc362d2aac262a589b810614a01d432d02f72acb3013a7
@@ -0,0 +1,183 @@
1
+ ---
2
+ name: live-provider-testing
3
+ description: Use when adding or updating llm_gateway live provider integration tests, stream tests, recorded handoff fixtures, or handoff tests that replay outputs across provider/model pairs.
4
+ ---
5
+
6
+ # Live Provider Testing
7
+
8
+ Use this skill when working on live integration tests under `test/integration/live/`, especially tests that validate multiple provider/model pairs, record final stream outputs, or create handoff tests from those recordings.
9
+
10
+ ## Core pattern
11
+
12
+ Live provider tests use a shared `PAIRS` constant and generate one test per provider/model pair.
13
+
14
+ ```ruby
15
+ PAIRS = [
16
+ { provider: "openai_apikey_completions", model: "gpt-5.1" },
17
+ { provider: "anthropic_apikey_messages", model: "claude-sonnet-4-20250514" },
18
+ { provider: "openai_apikey_responses", model: "gpt-5.4" },
19
+ { provider: "anthropic_oauth_messages", model: "claude-sonnet-4-20250514" },
20
+ { provider: "openai_oauth_codex", model: "gpt-5.4" }
21
+ ].freeze
22
+ ```
23
+
24
+ Define tests by iterating over `PAIRS`, not by manually repeating calls:
25
+
26
+ ```ruby
27
+ def self.define_stream_tests_for(provider:, model:)
28
+ test "live_text_streaming_#{provider}_#{model}" do
29
+ with_vcr_adapter(provider:, model:) do |adapter|
30
+ response = basic_streaming_text_test(adapter)
31
+ record_live_handoff_result(test_file: __FILE__, provider:, model:, result: response)
32
+ end
33
+ end
34
+ end
35
+
36
+ PAIRS.each do |pair|
37
+ define_stream_tests_for(provider: pair[:provider], model: pair[:model])
38
+ end
39
+ ```
40
+
41
+ Always include `LiveTestHelper` and reset configuration in teardown:
42
+
43
+ ```ruby
44
+ include LiveTestHelper
45
+
46
+ def teardown
47
+ LlmGateway.reset_configuration!
48
+ end
49
+ ```
50
+
51
+ ## Running adapters
52
+
53
+ Use `with_vcr_adapter(provider:, model:)` for live/VCR-backed provider tests. It handles:
54
+
55
+ - provider configuration
56
+ - API key and OAuth credential lookup
57
+ - VCR cassette naming
58
+ - replay tokens for OAuth providers
59
+ - authentication skips
60
+
61
+ Do not construct provider clients directly inside live tests unless the test specifically targets client construction.
62
+
63
+ ## Stream tests that record outputs
64
+
65
+ The stream tests currently record final results for later handoff tests:
66
+
67
+ - `test/integration/live/stream_image_test.rb`
68
+ - `test/integration/live/stream_reasoning_test.rb`
69
+ - `test/integration/live/stream_test.rb`
70
+
71
+ Their helper methods should return the final `AssistantMessage` response after assertions pass. Generated tests then call:
72
+
73
+ ```ruby
74
+ record_live_handoff_result(test_file: __FILE__, provider:, model:, result: response)
75
+ ```
76
+
77
+ This writes JSON under:
78
+
79
+ ```text
80
+ test/fixtures/handoff/{source_test_name_without_rb}/{provider_model}.json
81
+ ```
82
+
83
+ Example:
84
+
85
+ ```text
86
+ test/fixtures/handoff/stream_test/openai_apikey_completions_gpt-5.1.json
87
+ ```
88
+
89
+ The JSON file is keyed by the current Minitest test name with the `test_` prefix removed, so multiple generated tests for the same pair can share one pair file.
90
+
91
+ ## Handoff tests from recorded stream outputs
92
+
93
+ Handoff stream tests are separate files and must not modify the existing handoff tests:
94
+
95
+ - Existing handoff tests to leave alone:
96
+ - `test/integration/live/handoff_test.rb`
97
+ - `test/integration/live/handoff_media_test.rb`
98
+ - Stream handoff tests:
99
+ - `test/integration/live/handoff_stream_image_test.rb`
100
+ - `test/integration/live/handoff_stream_reasoning_test.rb`
101
+ - `test/integration/live/handoff_stream_test.rb`
102
+
103
+ Each stream handoff test should:
104
+
105
+ 1. Read `PAIRS` from the source stream test file.
106
+ 2. Load all recorded output JSON files from the matching fixture directory.
107
+ 3. Send those recorded outputs to each provider/model pair.
108
+ 4. Assert that the receiving model understood the previous outputs.
109
+
110
+ Important: the prompt must not interpolate `records.length` or otherwise tell the model the count. The model should infer the count from the supplied JSON/transcript. It is fine for the assertion to compare against `records.length.to_s`.
111
+
112
+ Example prompt style:
113
+
114
+ ```ruby
115
+ prompt = <<~PROMPT
116
+ You are receiving recorded final outputs from previous image streaming tests.
117
+ Each output describes the same image. Read the JSON, infer what all previous assistants saw,
118
+ and answer in one short sentence. Include the shape, color, and the number of recorded outputs
119
+ as an Arabic numeral.
120
+
121
+ #{JSON.pretty_generate(records)}
122
+ PROMPT
123
+ ```
124
+
125
+ Example assertions:
126
+
127
+ ```ruby
128
+ text = response.content.select { |block| block.type == "text" }.map(&:text).join(" ").downcase
129
+ assert_includes text, "red"
130
+ assert_includes text, "circle"
131
+ assert_includes text, records.length.to_s
132
+ ```
133
+
134
+ ## Reading pairs from source tests
135
+
136
+ When creating a handoff test from a source stream test, keep the provider matrix coupled to the source test by reading its `PAIRS` definition:
137
+
138
+ ```ruby
139
+ SOURCE_TEST_PATH = File.expand_path("stream_image_test.rb", __dir__)
140
+ PAIRS = eval(File.read(SOURCE_TEST_PATH).match(/PAIRS = (\[.*?\])\s*\.freeze/m)[1]).freeze
141
+ ```
142
+
143
+ Only use this pattern for test code where the source file is trusted repository code.
144
+
145
+ ## Fixture loading pattern
146
+
147
+ Handoff tests should skip cleanly if recordings do not exist:
148
+
149
+ ```ruby
150
+ def load_recorded_outputs
151
+ skip "Missing fixture directory at #{FIXTURE_DIR}. Run stream_image_test live tests first." unless Dir.exist?(FIXTURE_DIR)
152
+
153
+ Dir.glob(File.join(FIXTURE_DIR, "*.json")).sort.map do |path|
154
+ {
155
+ pair: File.basename(path, ".json"),
156
+ result: JSON.parse(File.read(path))
157
+ }
158
+ end.tap do |records|
159
+ skip "No recorded outputs in #{FIXTURE_DIR}. Run stream_image_test live tests first." if records.empty?
160
+ end
161
+ end
162
+ ```
163
+
164
+ ## Validation expectations
165
+
166
+ Use assertions that prove semantic handoff, not exact wording:
167
+
168
+ - Image handoff: assert the response mentions `red`, `circle`, and the inferred fixture count.
169
+ - Reasoning handoff: assert the response mentions `69` and the inferred fixture count.
170
+ - General stream handoff: assert the response mentions the inferred fixture count and expected tool/math results such as `42`, `714`, and `887`.
171
+
172
+ Avoid brittle assertions against full response text.
173
+
174
+ ## Syntax and test checks
175
+
176
+ After editing Ruby tests, at minimum run syntax checks:
177
+
178
+ ```bash
179
+ ruby -c test/integration/live/<file>.rb
180
+ ruby -c test/utils/live_test_helper.rb
181
+ ```
182
+
183
+ Run the actual live tests only when requested or when credentials/VCR setup is available, since they may require API keys, OAuth credentials, and network access.
@@ -0,0 +1,131 @@
1
+ ---
2
+ name: options-development
3
+ description: Use when developing or updating provider option mappers in llm_gateway from an API reference URL and optional API hint. Guides managed option mapping, valid option whitelists, source comments, tests, and client behavior boundaries.
4
+ ---
5
+
6
+ # Options Development
7
+
8
+ Use this skill when the user asks to build, update, or audit an LLM provider/API option mapper. The user should provide:
9
+
10
+ - an API reference URL, e.g. `https://platform.claude.com/docs/en/api/messages/create`
11
+ - a hint identifying the API when a provider has multiple APIs, e.g. `Anthropic Messages`, `OpenAI Responses`, `OpenAI Chat Completions`, `Groq Chat Completions`, `OpenAI Codex`
12
+
13
+ ## Goal
14
+
15
+ Keep option handling split into clear layers:
16
+
17
+ 1. **Managed options**: library-owned options that should work consistently across clients.
18
+ 2. **Option mapper**: translates managed options to provider/API option names and shapes. If an option is not managed but is a valid provider option, pass it through. Every mapper must validate the final transformed hash against a whitelist of valid provider options and reject unknown keys.
19
+ 3. **Client behavior**: clients receive already-mapped options and must not do additional option mapping. A client should behave exactly like the provider API docs for its endpoint.
20
+ 4. **Tools and transcript**: option mappers must not map tools, transcript, messages, or system content.
21
+ 5. **Current limitation**: mapped options are currently pushed only into the stream path; do not broaden this unless the task explicitly asks for architecture work.
22
+
23
+ ## Current managed options
24
+
25
+ - `reasoning`
26
+ - supported values: `"none"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`
27
+ - mapped differently depending on the provider/API
28
+ - `max_completion_tokens`
29
+ - default is usually `20_480`
30
+ - Anthropic maps this to `max_tokens`
31
+ - OpenAI Responses maps this to `max_output_tokens`
32
+ - `response_format`
33
+ - examples: `"text"`, `{ type: "json_object" }`, `{ type: "json_schema" }`
34
+ - `cache_key`
35
+ - OpenAI maps this to `prompt_cache_key`
36
+ - `cache_retention`
37
+ - OpenAI values: `"short"`, `"long"`, `"none"`
38
+ - Anthropic passes this through as `cache_retention`
39
+ - `temperature`
40
+ - Groq defaults this to `0`
41
+
42
+ These are **not** options and must not be handled by option mappers:
43
+
44
+ - `message` / `messages` / transcript
45
+ - `tools`
46
+ - `system`
47
+
48
+ ## Repository locations
49
+
50
+ Option mappers live under `lib/llm_gateway/adapters/`, including:
51
+
52
+ - `lib/llm_gateway/adapters/anthropic_option_mapper.rb`
53
+ - `lib/llm_gateway/adapters/groq/option_mapper.rb`
54
+ - `lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb`
55
+ - `lib/llm_gateway/adapters/openai/responses/option_mapper.rb`
56
+ - `lib/llm_gateway/adapters/openai_codex/option_mapper.rb`
57
+
58
+ Tests live under `test/unit/options/`.
59
+
60
+ ## Required workflow
61
+
62
+ 1. **Identify mapper and tests**
63
+ - Use the API hint to find the corresponding option mapper and test file.
64
+ - Inspect the related adapter/client only to verify mapping boundaries; do not move mapping into clients.
65
+
66
+ 2. **Read the provider API reference**
67
+ - Fetch/read the provided URL if network access is available, for example with `curl -L <url>`.
68
+ - Extract every request-body option accepted by the endpoint.
69
+ - Exclude non-option structural fields such as messages/input/transcript, tools, and system/developer instructions.
70
+ - If docs are not fetchable, say so and ask the user for the relevant request parameter list before coding.
71
+
72
+ 3. **Add/maintain a source comment at the option mapper**
73
+ - Near the valid-option whitelist in the mapper, add a comment containing:
74
+ - source URL
75
+ - API name/hint
76
+ - date accessed
77
+ - the full list of valid option keys copied from the API reference
78
+ - Keep this comment updated whenever the whitelist changes.
79
+
80
+ 4. **Implement managed option mapping in the mapper only**
81
+ - Match the coding style and structure of the closest existing mapper before changing behavior. For example, Anthropic and OpenAI Chat Completions use named default constants, `VALID_OPTIONS`, `MANAGED_OPTIONS`, a `map` method that builds `mapped_options`, explicit normalizer helpers, and `validate_options!` near the mapper.
82
+ - Prefer `mapped_options = options.reject { |key, _| MANAGED_OPTIONS.include?(key) }` when a mapper has multiple managed aliases; this makes alias removal explicit and keeps pass-through provider-native options obvious.
83
+ - Remove managed aliases after mapping so the final hash contains only provider-native option keys.
84
+ - Preserve valid provider-native options that are not managed.
85
+ - Apply provider-specific defaults only in the mapper.
86
+ - Unless the user explicitly asks for default behavior changes, do not modify existing defaults.
87
+ - Do not map tools, transcript/messages, or system.
88
+
89
+ 5. **Whitelist after transformation**
90
+ - Define a `VALID_OPTIONS`/equivalent whitelist from the API reference.
91
+ - After all transformations, reject any final key not in the whitelist.
92
+ - Prefer raising `ArgumentError` with a useful message listing unknown option keys and/or valid keys.
93
+ - Validate the returned hash, not merely the input hash, so bad mapped output is caught.
94
+
95
+ 6. **Tests**
96
+ - Match the structure and naming style of the closest existing provider/API option test before adding cases. For Anthropic and OpenAI Chat Completions, prefer a compact set of broad tests:
97
+ - one adapter-boundary test named like `passes mapped managed options and provider-native options through adapter to client`
98
+ - one unknown provider option rejection test
99
+ - one structural field rejection test
100
+ - one superset/final output mapper test
101
+ - Prefer testing option behavior at the adapter boundary by stubbing the provider client and asserting the exact options passed to the client's request/stream method. This verifies that managed options are mapped before the client and that valid provider-native options pass through the adapter layer unchanged.
102
+ - Keep pure mapper tests for validation/error behavior, final-output superset assertions, and small normalization helpers when useful, but avoid relying only on direct `OptionMapper.map(...)` assertions for passthrough behavior.
103
+ - Fake clients in adapter-boundary stream tests should yield enough realistic stream chunks for the adapter's stream mapper and accumulator to complete without errors; do not yield only terminal/usage chunks if the mapper expects started content/tool state.
104
+ - Add/update tests for:
105
+ - each managed option mapping relevant to the provider/API
106
+ - pass-through of valid provider-native options together with representative managed options in the same adapter-level test
107
+ - rejection of unknown options after transformation
108
+ - no handling of tools/messages/system in the mapper
109
+ - provider-specific defaults, if any
110
+ - For adapter-level option tests:
111
+ - instantiate the real adapter with a fake/stub client for the target provider/API
112
+ - call the public adapter method (`stream` today) with managed and provider-native options
113
+ - capture the keyword args received by the client method
114
+ - assert the final provider-native option hash, including mapped managed options and unchanged provider-native options
115
+ - ensure fake clients provide whatever minimal stream/result events are needed so the adapter can complete without network or VCR
116
+ - After making option-mapper changes, run the targeted option tests, then the broader test suite if practical.
117
+ - If VCR/cassette-backed tests fail and option changes are the only code changes, do not immediately re-record or mutate cassettes. First explain why the VCR is failing (for example: request body changed because a new/default option is now sent, unknown option rejection changed control flow, or provider-native option passthrough altered the recorded request). Ask for confirmation before taking further VCR steps.
118
+
119
+ ## Implementation checklist
120
+
121
+ Before finishing, verify:
122
+
123
+ - [ ] mapper has source/API/date/full-valid-option-list comment
124
+ - [ ] mapper maps all relevant managed options and deletes aliases
125
+ - [ ] valid provider-native options pass through unchanged
126
+ - [ ] final returned options are whitelist-validated
127
+ - [ ] clients do not perform additional option mapping
128
+ - [ ] tools/transcript/messages/system are not mapped as options
129
+ - [ ] defaults were not modified unless explicitly requested
130
+ - [ ] tests cover mapping, pass-through, rejection, and defaults
131
+ - [ ] tests were run after option changes; any VCR failure was explained before further cassette work
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.5.0](https://github.com/Hyper-Unearthing/llm_gateway/tree/v0.5.0) (2026-05-20)
4
+
5
+ [Full Changelog](https://github.com/Hyper-Unearthing/llm_gateway/compare/v0.4.0...v0.5.0)
6
+
7
+ **Merged pull requests:**
8
+
9
+ - Refactor stream mapper accumulation [\#61](https://github.com/Hyper-Unearthing/llm_gateway/pull/61) ([billybonks](https://github.com/billybonks))
10
+ - feat\(groq\): add stream support for groq [\#60](https://github.com/Hyper-Unearthing/llm_gateway/pull/60) ([billybonks](https://github.com/billybonks))
11
+ - test\(feat\): allow options to be passed in model pairs [\#59](https://github.com/Hyper-Unearthing/llm_gateway/pull/59) ([billybonks](https://github.com/billybonks))
12
+ - Focus streaming and Claude client tests [\#58](https://github.com/Hyper-Unearthing/llm_gateway/pull/58) ([billybonks](https://github.com/billybonks))
13
+ - feat\(test\): automatically delete unused vcrs [\#57](https://github.com/Hyper-Unearthing/llm_gateway/pull/57) ([billybonks](https://github.com/billybonks))
14
+ - refactor: handoff test [\#56](https://github.com/Hyper-Unearthing/llm_gateway/pull/56) ([billybonks](https://github.com/billybonks))
15
+ - Refactor/options clients [\#55](https://github.com/Hyper-Unearthing/llm_gateway/pull/55) ([billybonks](https://github.com/billybonks))
16
+ - burn: all the old code [\#54](https://github.com/Hyper-Unearthing/llm_gateway/pull/54) ([billybonks](https://github.com/billybonks))
17
+ - test: only skip actual auth errors [\#53](https://github.com/Hyper-Unearthing/llm_gateway/pull/53) ([billybonks](https://github.com/billybonks))
18
+ - test: dont try refresh token when using vcr only when regenerating [\#52](https://github.com/Hyper-Unearthing/llm_gateway/pull/52) ([billybonks](https://github.com/billybonks))
19
+
3
20
  ## [v0.4.0](https://github.com/Hyper-Unearthing/llm_gateway/tree/v0.4.0) (2026-05-17)
4
21
 
5
22
  [Full Changelog](https://github.com/Hyper-Unearthing/llm_gateway/compare/v0.3.0...v0.4.0)
data/README.md CHANGED
@@ -628,3 +628,19 @@ If your app refreshes tokens in the background, rebuild the adapter (or recreate
628
628
  - Rebuild client/provider state with latest access token for future calls.
629
629
 
630
630
  In short: library executes refresh mechanics; your app owns token lifecycle persistence and operational policy.
631
+
632
+ ## Contributing
633
+
634
+ ### Recording VCR cassettes
635
+
636
+ Live integration tests use VCR cassettes stored under `test/fixtures/vcr_cassettes`. To record a new cassette, run the target test with real provider credentials available in your environment or `.env`:
637
+
638
+ ```bash
639
+ bundle exec ruby -Itest test/integration/live/stream_test.rb
640
+ ```
641
+
642
+ Cassette names are derived from the test file and test name, with VCR sanitizing path segments such as `stream_test.rb` to `stream_test_rb`.
643
+
644
+ For OAuth-backed providers (`anthropic_oauth_messages`, `openai_oauth_codex`), the live test helper only loads real OAuth credentials while the cassette is being recorded. Once the cassette exists, replay uses placeholder tokens/account IDs so the test suite can run without local OAuth state. API-key providers still require the relevant API key when recording. Sensitive authorization headers and selected response headers are redacted before cassettes are written.
645
+
646
+ Some tests pass `redact_request_body: true` to `with_vcr_adapter`; those cassettes match on method and URI only and replace large request bodies with `"<huge prompt body redacted>"`.
data/Rakefile CHANGED
@@ -3,6 +3,7 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "minitest/test_task"
5
5
 
6
+ ENV["LLM_GATEWAY_DELETE_UNUSED_VCR_CASSETTES"] ||= "1"
6
7
  Minitest::TestTask.create
7
8
 
8
9
  require "rubocop/rake_task"
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "stream_accumulator"
4
3
  require_relative "structs"
5
4
 
6
5
  module LlmGateway
@@ -12,23 +11,6 @@ module LlmGateway
12
11
  @client = client
13
12
  end
14
13
 
15
- def chat(message, tools: nil, system: nil, **options)
16
- normalized_input = map_input({
17
- messages: sanitize_messages(normalize_messages(message)),
18
- tools: tools,
19
- system: normalize_system(system)
20
- })
21
-
22
- result = perform_chat(
23
- normalized_input[:messages],
24
- tools: normalized_input[:tools],
25
- system: normalized_input[:system],
26
- **map_options(options)
27
- )
28
-
29
- map_output(result)
30
- end
31
-
32
14
  def stream(message, tools: nil, system: nil, **options, &block)
33
15
  raise LlmGateway::Errors::MissingMapperForProvider, "No stream_mapper configured" unless stream_mapper
34
16
 
@@ -38,7 +20,6 @@ module LlmGateway
38
20
  system: normalize_system(system)
39
21
  })
40
22
 
41
- accumulator = ::StreamAccumulator.new
42
23
  mapper = stream_mapper.new
43
24
 
44
25
  perform_stream(
@@ -47,13 +28,11 @@ module LlmGateway
47
28
  system: normalized_input[:system],
48
29
  **map_options(options)
49
30
  ) do |chunk|
50
- event = mapper.map(chunk)
51
- accumulator.push(event)
52
- block.call(event) if block && event
31
+ mapper.map(chunk, &block)
53
32
  end
54
33
 
55
34
  AssistantMessage.new(
56
- accumulator.result.merge(
35
+ mapper.result.merge(
57
36
  provider: LlmGateway::Client.provider_id_from_client(client),
58
37
  api: api_name
59
38
  )
@@ -92,10 +71,6 @@ module LlmGateway
92
71
  nil
93
72
  end
94
73
 
95
- def output_mapper
96
- raise NotImplementedError, "#{self.class} must implement #output_mapper"
97
- end
98
-
99
74
  def file_output_mapper
100
75
  nil
101
76
  end
@@ -108,18 +83,10 @@ module LlmGateway
108
83
  input_mapper.map(input)
109
84
  end
110
85
 
111
- def map_output(output)
112
- output_mapper.map(output)
113
- end
114
-
115
86
  def map_options(options)
116
87
  option_mapper.map(options)
117
88
  end
118
89
 
119
- def perform_chat(messages, tools:, system:, **options)
120
- client.chat(messages, tools: tools, system: system, **options)
121
- end
122
-
123
90
  def perform_stream(messages, tools:, system:, **options, &block)
124
91
  client.stream(messages, tools: tools, system: system, **options, &block)
125
92
  end
@@ -11,8 +11,6 @@ module LlmGateway
11
11
 
12
12
  def input_sanitizer = InputMessageSanitizer
13
13
 
14
- def output_mapper = Anthropic::OutputMapper
15
-
16
14
  def file_output_mapper = Anthropic::FileOutputMapper
17
15
 
18
16
  def option_mapper = AnthropicOptionMapper
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "bidirectional_message_mapper"
4
-
5
3
  module LlmGateway
6
4
  module Adapters
7
5
  module Anthropic
@@ -14,42 +12,123 @@ module LlmGateway
14
12
  }
15
13
  end
16
14
 
17
- private
15
+ def self.map_content(content)
16
+ content = { type: "text", text: content } unless content.is_a?(Hash)
17
+
18
+ case content[:type]
19
+ when "text"
20
+ map_text_content(content)
21
+ when "file"
22
+ map_file_content(content)
23
+ when "image"
24
+ map_image_content(content)
25
+ when "tool_use"
26
+ map_tool_use_content(content)
27
+ when "tool_result"
28
+ map_tool_result_content(content)
29
+ when "thinking", "reasoning"
30
+ map_reasoning_content(content)
31
+ else
32
+ content
33
+ end
34
+ end
35
+
36
+ class << self
37
+ private
18
38
 
19
- def self.map_messages(messages)
20
- return messages unless messages
39
+ def map_messages(messages)
40
+ return messages unless messages
21
41
 
22
- message_mapper = BidirectionalMessageMapper.new(LlmGateway::DIRECTION_IN)
42
+ messages.map do |msg|
43
+ msg = msg.merge(role: "user") if msg[:role] == "developer"
23
44
 
24
- messages.map do |msg|
25
- msg = msg.merge(role: "user") if msg[:role] == "developer"
45
+ content = if msg[:content].is_a?(Array)
46
+ msg[:content].map { |content| map_content(content) }
47
+ else
48
+ [ map_content(msg[:content]) ]
49
+ end
26
50
 
27
- content = if msg[:content].is_a?(Array)
28
- msg[:content].map do |content|
29
- message_mapper.map_content(content)
30
- end
51
+ {
52
+ role: msg[:role],
53
+ content: content
54
+ }
55
+ end
56
+ end
57
+
58
+ def map_system(system)
59
+ if !system || system.empty?
60
+ nil
61
+ elsif system.length == 1 && system.first[:role] == "system"
62
+ mapped = { type: "text", text: system.first[:content] }
63
+ mapped[:cache_control] = system.first[:cache_control] if system.first[:cache_control]
64
+ [ mapped ]
31
65
  else
32
- [ message_mapper.map_content(msg[:content]) ]
66
+ system
33
67
  end
68
+ end
34
69
 
70
+ def map_text_content(content)
71
+ result = {
72
+ type: "text",
73
+ text: content[:text]
74
+ }
75
+ result[:cache_control] = content[:cache_control] if content[:cache_control]
76
+ result
77
+ end
78
+
79
+ def map_file_content(content)
35
80
  {
36
- role: msg[:role],
37
- content: content
81
+ type: "document",
82
+ source: {
83
+ data: content[:data],
84
+ type: "text",
85
+ media_type: content[:media_type]
86
+ }
38
87
  }
39
88
  end
40
- end
41
89
 
42
- def self.map_system(system)
43
- if !system || system.empty?
44
- nil
45
- elsif system.length == 1 && system.first[:role] == "system"
46
- # If we have a single system message, convert to Claude format
47
- mapped = { type: "text", text: system.first[:content] }
48
- mapped[:cache_control] = system.first[:cache_control] if system.first[:cache_control]
49
- [ mapped ]
50
- else
51
- # For multiple messages or non-standard format, pass through
52
- system
90
+ def map_image_content(content)
91
+ {
92
+ type: "image",
93
+ source: {
94
+ data: content[:data],
95
+ type: "base64",
96
+ media_type: content[:media_type]
97
+ }
98
+ }
99
+ end
100
+
101
+ def map_tool_use_content(content)
102
+ {
103
+ type: "tool_use",
104
+ id: content[:id],
105
+ name: content[:name],
106
+ input: content[:input]
107
+ }
108
+ end
109
+
110
+ def map_tool_result_content(content)
111
+ mapped_content = content[:content]
112
+ if mapped_content.is_a?(Array)
113
+ mapped_content = mapped_content.map do |item|
114
+ item.is_a?(Hash) ? map_content(item.transform_keys(&:to_sym)) : item
115
+ end
116
+ end
117
+
118
+ {
119
+ type: "tool_result",
120
+ tool_use_id: content[:tool_use_id],
121
+ content: mapped_content
122
+ }
123
+ end
124
+
125
+ def map_reasoning_content(content)
126
+ result = {
127
+ type: "thinking",
128
+ thinking: content[:reasoning]
129
+ }
130
+ result[:signature] = content[:signature] unless content[:signature].nil?
131
+ result
53
132
  end
54
133
  end
55
134
  end
@@ -12,39 +12,6 @@ module LlmGateway
12
12
  )
13
13
  end
14
14
  end
15
-
16
- class OutputMapper
17
- def self.map(data)
18
- {
19
- id: data[:id],
20
- model: data[:model],
21
- usage: data[:usage],
22
- choices: map_choices(data)
23
- }
24
- end
25
-
26
- private
27
-
28
- def self.map_choices(data)
29
- message_mapper = BidirectionalMessageMapper.new(LlmGateway::DIRECTION_OUT)
30
-
31
- content = if data[:content].is_a?(Array)
32
- data[:content].map do |content|
33
- message_mapper.map_content(content)
34
- end
35
- else
36
- data[:content] ? [ message_mapper.map_content(data[:content]) ] : []
37
- end
38
-
39
- # Claude returns content directly at root level, not in a choices array
40
- # We need to construct the choices array from the full response data
41
- [ {
42
- content: content, # Use content directly from Claude response
43
- finish_reason: data[:stop_reason],
44
- role: "assistant"
45
- } ]
46
- end
47
- end
48
15
  end
49
16
  end
50
17
  end