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.
- checksums.yaml +4 -4
- data/.pi/skills/live-provider-testing/SKILL.md +183 -0
- data/.pi/skills/options-development/SKILL.md +131 -0
- data/CHANGELOG.md +17 -0
- data/README.md +16 -0
- data/Rakefile +1 -0
- data/lib/llm_gateway/adapters/adapter.rb +2 -35
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +0 -2
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +106 -27
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +0 -33
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -46
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +48 -6
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +3 -2
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +44 -0
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +89 -4
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +0 -2
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +0 -6
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +135 -72
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +100 -10
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +169 -170
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +0 -1
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +128 -68
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +99 -10
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +81 -271
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +0 -1
- data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +3 -3
- data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +0 -5
- data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
- data/lib/llm_gateway/client.rb +10 -66
- data/lib/llm_gateway/clients/groq.rb +13 -1
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +2 -8
- metadata +7 -10
- data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +0 -111
- data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +0 -110
- data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +0 -40
- data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +0 -120
- data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +0 -47
- data/lib/llm_gateway/adapters/stream_accumulator.rb +0 -91
- data/scripts/generate_handoff_live_fixture.rb +0 -169
- data/scripts/generate_handoff_media_fixture.rb +0 -167
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce9b9e4f2137a73474b1ed5f0876d8b1bf6185666ab8c756c3a3f67e99e9d86e
|
|
4
|
+
data.tar.gz: 5735832e4bd57946ffc0a251c5a3a0861af0fc12a989456e1f877675e08846ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
39
|
+
def map_messages(messages)
|
|
40
|
+
return messages unless messages
|
|
21
41
|
|
|
22
|
-
|
|
42
|
+
messages.map do |msg|
|
|
43
|
+
msg = msg.merge(role: "user") if msg[:role] == "developer"
|
|
23
44
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
msg[:
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|