axn-ruby_llm 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 567165990e6185d68aab836344f8aaf5f1b2891c0c6fd19442aab5908c1fa335
4
- data.tar.gz: 326731c8da66d1ac1ca92bcc5bf8b4c43132aaa42580f10ba97d7d98218911ee
3
+ metadata.gz: 1c013cfd72ea5be2662e93875a9f0f01b8d8761cfe008aabed920bb3ee4ab8d1
4
+ data.tar.gz: 2977d6ab65433f53e59f019c796a9a10adc016efa290990ee3596d4b0680df4f
5
5
  SHA512:
6
- metadata.gz: 6b0ba66b042f93f747523524bbf2eb33c00f8a2ad2d21f81525c2429c211896512aa96cfd2e749aec9682de68497b7d6a27e609e5bb0b2a4e5b0aeec6385a78e
7
- data.tar.gz: d2d928acbe79f0c3c25757d0db23135cfa5abaad6e3c5b4d5602f2074a70ce8a6331a6583c296e96c7191b151f783448b646b6091d0dc6288ebba88a7ef158e6
6
+ metadata.gz: 532fa712897c472636dff0085d9df2b9696f0a355e88db3f18978da9128b6d4c9d0a7251170b8f84b9354e55c7fd83406b0d0ab7863b4291b1f255dfc9d7c5b1
7
+ data.tar.gz: 8f77714a318a5bbf674d467bd2a0d5bb4b82c6f4d199d0faf84dc82a5d298fba9825aa083d2a71ca79c89e67d147cf150d02f2d044d6a573f88a936602782173
data/CHANGELOG.md CHANGED
@@ -1,5 +1,111 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0] - 2026-09-22
4
+
5
+ RubyLLM 2.0 is a breaking rewrite (renamed Tool DSL, restructured error hierarchy, a usage ledger
6
+ replacing per-message token/cost readers, `RubyLLM::Schema` moved to the separate `schematist` gem)
7
+ with no compatibility shim for 1.x, so this is a hard cut: `ruby_llm ">= 2.0", "< 3.0"` replaces the
8
+ previous `">= 1.15", "< 2.0"` floor, and 1.x is no longer supported by this gem.
9
+
10
+ This release was developed against ruby_llm's `2.0.0.rc4` release candidate (as `0.3.0.rc1`, never
11
+ published as a final release) and raised to the `2.0.0` GA floor once RubyLLM released it
12
+ (2026-09-18) — re-diffed first and confirmed every path this gem touches (`Tool`, the
13
+ Gemini/Anthropic/Chat-Completions protocols) is byte-identical between rc4 and GA.
14
+
15
+ ### Breaking
16
+
17
+ - **Tool DSL renamed to match `RubyLLM::Tool` 2.0.** Internal to `Axn::RubyLLM.wrap`'s generated
18
+ tool class (`params`/`with_params` → `parameters`/`provider_options`); not observable unless you
19
+ subclassed or introspected a wrapped tool directly. `Tool#params_schema` is now
20
+ `#parameters_schema`, and `Tool#parameters` (the empty `Parameter` DSL reader) is now
21
+ `#declared_parameters`.
22
+ - **`provider_params:` renamed to `provider_options:`** (the `wrap` kwarg and the
23
+ `configure(:ruby_llm)` setting), matching RubyLLM 2.0's own `Tool.provider_options`. A leftover
24
+ `provider_params:` raises `ArgumentError` with a pointer, the same hard-error pattern as the
25
+ pre-existing `render_as:` → `present_as:` rename.
26
+ - **`halt_after:` removed outright** — the `wrap` kwarg, the `configure(:ruby_llm)` setting, and the
27
+ `RubyLLM::Tool::Halt`-wrapping behavior. RubyLLM 2.0 deleted `Tool::Halt`; the conversation loop is
28
+ now caller-controlled (`chat.step` / `chat.complete?`). See RubyLLM's Agentic Workflows guide for
29
+ the replacement pattern.
30
+ - **`json:` input removed from `Axn::RubyLLM::Ask`.** There is no protocol-agnostic JSON-mode
31
+ request field in RubyLLM 2.0 (OpenAI's `response_format: {type: "json_object"}` is Chat
32
+ Completions-only, and OpenAI now defaults to the Responses API). `schema:` is the replacement —
33
+ it now additionally accepts a plain Hash (already supported, previously undocumented) or an Axn
34
+ class (new: forwards `axn_class.output_schema`), alongside a `Schematist::Schema` class/instance.
35
+ The Axn-class form makes adjustments axn's own `output_schema` has no reason to make on its own,
36
+ each confirmed against a real provider (Anthropic, live) rather than assumed from docs alone
37
+ (which turned out unreliable on this point — a docs summary claimed `minLength` was also
38
+ unsupported by Anthropic; a flat schema with `minLength: 1` on a String field succeeded live
39
+ regardless, so only what's actually confirmed failing is adjusted):
40
+ - Injects `additionalProperties: false` on every fixed-shape object node — required
41
+ *unconditionally* by Anthropic's structured output (confirmed live: without it, the request
42
+ fails with `"For 'object' type, 'additionalProperties' must be explicitly set to false"`) and
43
+ by OpenAI's strict mode — while leaving a map's own `additionalProperties` (its value schema)
44
+ untouched.
45
+ - Strips `minProperties`/`maxProperties` from every object *schema node* — axn emits
46
+ `minProperties: 1` by default on a nested fixed-shape `Hash` field, and Anthropic's schema
47
+ validator rejects it outright (confirmed live: `"For 'object' type, property 'minProperties' is
48
+ not supported"`). Gated on the node actually being an object schema (declaring `type`), not on
49
+ the key name alone — a code-review catch: the recursive pass walks every Hash in the schema,
50
+ but `properties` is a name-to-schema *map*, so an Axn exposing a field literally named
51
+ `minProperties` would otherwise have that field silently dropped while `required` still named
52
+ it, producing an invalid schema (confirmed live before the fix).
53
+ - Pins `strict: false`, since RubyLLM's own strict-inference would otherwise turn on for the
54
+ common case of every property being required, and OpenAI's *full* strict mode additionally
55
+ requires every property to appear in `required` even when conceptually optional, which axn's
56
+ reflection doesn't promise.
57
+
58
+ **Known limitation, confirmed live, not worked around:** a `Hash` map field (`type: Hash, of:
59
+ {...}`) doesn't survive this path against every provider. Anthropic's structured output rejects
60
+ `additionalProperties` set to anything but the literal `false` (confirmed live:
61
+ `"'additionalProperties: object' is not supported. Please set 'additionalProperties' to false"`),
62
+ and OpenAI's strict mode has the same restriction by design — neither provider's structured-output
63
+ feature represents dynamic/arbitrary keys, only a fixed shape, so there's no schema-legal way to
64
+ route around it. Use a fixed-shape `Hash` (`shape:`) instead, or pass your own schema for that
65
+ provider if you specifically need a map. Pass a `Schematist::Schema` instead of an Axn class if you
66
+ need OpenAI's *full* strict-mode guarantee.
67
+ - **`RubyLLM::Schema` is `Schematist::Schema` in RubyLLM 2.0** — this gem does not shim the old
68
+ name. Any `schema:` class you declare must subclass `Schematist::Schema`.
69
+ - **`Axn::RubyLLM.configuration` / `.reset_configuration!` removed** — these were deprecated in
70
+ 0.2.0 for `.config` / `.reset_config!` and scheduled for removal in 0.3.0 regardless of the
71
+ RubyLLM 2.0 port; landing in the same release since both are breaking-change cuts.
72
+
73
+ ### Changed
74
+
75
+ - **Token and cost exposures now read RubyLLM 2.0's own usage ledger** (`Chat#tokens` /
76
+ `Chat#cost`) instead of summing `Message#input_tokens`/`#cache_read_tokens`/etc. across
77
+ `chat.messages` by hand. Exposure names (`input_tokens`, `output_tokens`, `cache_read_tokens`,
78
+ `cache_write_tokens`, `prompt_tokens`, `cost`, `cost_breakdown`) are unchanged, but the totals now
79
+ additionally include failed retries and no-message provider attempts within a tool loop — strictly
80
+ more accurate than the previous per-message sum, which only ever saw messages that made it onto
81
+ the chat.
82
+ - **`Message#model_id` reads are now `Message#model`**; `response_model` in OTel attributes follows.
83
+ - **`with_params(temperature:)` is now `with_temperature(temperature)`.** RubyLLM 2.0 sends the
84
+ temperature you set verbatim; 1.x sometimes rewrote it to `1.0` or dropped it for models that
85
+ didn't support it. A model that now rejects your value raises `RubyLLM::BadRequestError` instead
86
+ of silently ignoring it.
87
+ - **`Ask`'s `KNOWN_ERROR_CLASSES` grew three entries** — `RubyLLM::ModelRegistryError`,
88
+ `RubyLLM::PendingToolCallsError`, `RubyLLM::CancelledError` — new in RubyLLM 2.0, so their
89
+ messages surface instead of falling into the generic `"LLM request failed"` bucket.
90
+ - **Removed ~170 lines of Gemini schema workarounds** (`normalize_nullable_types`,
91
+ `annotate_object_constraints`, and six helpers) from the tool adapter. RubyLLM 2.0's Gemini
92
+ protocol reads a tool's schema via `parametersJsonSchema` — the wire form verbatim — rather than
93
+ rebuilding each property from the fixed whitelist that dropped array-valued `type`,
94
+ `additionalProperties`, and `min`/`maxProperties`. A wrapped tool's schema is now passed to every
95
+ provider unmodified.
96
+ - **The gemspec now declares `faraday` directly** (previously arrived only transitively through
97
+ `ruby_llm`), since `ask.rb` rescues `::Faraday::Error` directly.
98
+ - **`axn` floor bumped to `0.1.0-alpha.6.1`** (from `0.1.0-alpha.6`). No new API is required — this
99
+ just picks up alpha.6.1's logger-raise `best_effort` hardening and `model:`/`Result#declared_fields`
100
+ fixes.
101
+
102
+ ### Fixed
103
+
104
+ - **`stub_axn_ruby_llm`'s message double no longer needs `RubyLLM.models.find` stubbed.** The test
105
+ helper's `chat.cost`/`chat.tokens` stubs mirror the real 2.0 ledger reads directly, removing a
106
+ layer of indirection through a stubbed model registry lookup that the production code no longer
107
+ performs either.
108
+
3
109
  ## [0.2.1] - 2026-09-03
4
110
 
5
111
  ### Added
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # axn-ruby_llm
2
2
 
3
- Call LLMs from [Axn](https://github.com/teamshares/axn) actions using [RubyLLM](https://github.com/crmne/ruby_llm), with declarative error handling, optional JSON mode, configurable defaults, and cost/token tracking — and wrap any Axn as a `RubyLLM::Tool` a chat can call.
3
+ Call LLMs from [Axn](https://github.com/teamshares/axn) actions using [RubyLLM](https://github.com/crmne/ruby_llm), with declarative error handling, schema-based structured output, configurable defaults, and cost/token tracking — and wrap any Axn as a `RubyLLM::Tool` a chat can call.
4
+
5
+ > **RubyLLM 2.0 required.** As of `0.3.0`, this gem requires `ruby_llm >= 2.0, < 3.0` and no longer supports RubyLLM 1.x — see [CHANGELOG.md](CHANGELOG.md) for the full breaking-change rundown if you're upgrading from an earlier `axn-ruby_llm` release.
4
6
 
5
7
  Part of the `axn-*` extension ecosystem — see also [axn-mcp](https://github.com/teamshares/axn-mcp).
6
8
 
@@ -12,7 +14,7 @@ Four things you'd otherwise hand-build:
12
14
 
13
15
  2. **Production gating.** A single `c.enabled = -> { Rails.env.production? }` in an initializer stubs every LLM call in non-prod environments — no per-callsite guards needed. The stub is typed (`stubbed: true`, `input_tokens: 0`, etc.) so downstream code doesn't need to branch on it either.
14
16
 
15
- 3. **Cost/token tracking, exposed automatically.** Every call exposes `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `prompt_tokens` (the total), `cost`, and `cost_breakdown` without you doing the `RubyLLM.models.find` lookup manually. If your app uses OpenTelemetry, these values are also set as attributes on the existing `axn.call` span — no configuration required.
17
+ 3. **Cost/token tracking, exposed automatically.** Every call exposes `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `prompt_tokens` (the total), `cost`, and `cost_breakdown`, read straight off RubyLLM's own usage ledger (`Chat#tokens` / `Chat#cost`) — no manual model lookup, and a tool-call loop's retries and multiple round-trips are already aggregated for you. If your app uses OpenTelemetry, these values are also set as attributes on the existing `axn.call` span — no configuration required.
16
18
 
17
19
  4. **Author-once tools.** `Axn::RubyLLM.wrap` turns any Axn into a `RubyLLM::Tool` your chat can call — reuse the same Axn classes you already expose through [axn-mcp](https://github.com/teamshares/axn-mcp), or plain Axns, with no rewrite. The tool's name, JSON Schema, and argument validation all come from the Axn's own contract.
18
20
 
@@ -52,13 +54,6 @@ result = Axn::RubyLLM.ask(
52
54
  )
53
55
  result.response # => "The team decided to..."
54
56
 
55
- # JSON mode
56
- result = Axn::RubyLLM.ask(
57
- prompt: build_extraction_prompt(doc),
58
- json: true
59
- )
60
- result.response # => { "company" => "Acme", "founded" => 1999 }
61
-
62
57
  # With system prompt and model override
63
58
  result = Axn::RubyLLM.ask(
64
59
  prompt: user_message,
@@ -71,10 +66,14 @@ result = Axn::RubyLLM.ask(
71
66
 
72
67
  ### Structured output via schema
73
68
 
74
- Pass `schema:` to enable provider-enforced structured output (e.g. OpenAI strict mode) via `RubyLLM::Chat#with_schema`. The result's `response` is the parsed Hash.
69
+ Pass `schema:` to enable provider-enforced structured output (e.g. OpenAI strict mode) via `RubyLLM::Chat#with_schema`. The result's `response` is the parsed Hash — read via `RubyLLM::Message#parsed` under the hood, not `#content` (which is now always the raw JSON text).
70
+
71
+ `schema:` accepts any of three forms:
72
+
73
+ **A [`Schematist::Schema`](https://github.com/crmne/schematist) class or instance** — anything `RubyLLM::Chat#with_schema` itself accepts:
75
74
 
76
75
  ```ruby
77
- class CompanyMatch < RubyLLM::Schema
76
+ class CompanyMatch < Schematist::Schema
78
77
  integer :company_id, description: "ID of the matched company, or null"
79
78
  number :confidence, description: "0.0–1.0"
80
79
  string :reasoning
@@ -87,11 +86,41 @@ result = Axn::RubyLLM.ask(
87
86
  result.response # => { "company_id" => 42, "confidence" => 0.92, "reasoning" => "..." }
88
87
  ```
89
88
 
90
- `schema:` accepts a [`ruby_llm-schema`](https://github.com/crmne/ruby_llm-schema) class or instance anything `RubyLLM::Chat#with_schema` accepts, including a raw JSON Schema hash. The `ruby_llm-schema` gem is recommended but not required; declare it in your own Gemfile if you want the DSL. When `schema:` is set, `json: true` is ignored.
89
+ The `schematist` gem (installed automatically by RubyLLM 2.0) is recommended but not required; declare it in your own Gemfile if you want the DSL.
90
+
91
+ **A raw JSON Schema Hash**, passed straight through unchanged:
92
+
93
+ ```ruby
94
+ Axn::RubyLLM.ask(prompt: "...", schema: { type: "object", properties: { answer: { type: "string" } } })
95
+ ```
96
+
97
+ **An Axn class**, via its own `output_schema` reflection — the same contract you'd already write with `exposes`, no separate schema to maintain:
98
+
99
+ ```ruby
100
+ class CompanyMatch
101
+ include Axn
102
+ exposes :company_id, type: Integer, allow_nil: true
103
+ exposes :confidence, type: Float
104
+ exposes :reasoning, type: String
105
+ end
106
+
107
+ result = Axn::RubyLLM.ask(prompt: "...", schema: CompanyMatch)
108
+ result.response # => { "company_id" => 42, "confidence" => 0.92, "reasoning" => "..." }
109
+ ```
110
+
111
+ A few adjustments happen automatically here, confirmed against real provider calls (Anthropic live; OpenAI by its documented contract):
112
+
113
+ - `additionalProperties: false` is injected on every fixed-shape object node — required unconditionally by Anthropic's structured output and by OpenAI's strict mode, and axn's `exposes` contract has no reason to emit it on its own.
114
+ - `minProperties`/`maxProperties` are stripped — axn emits `minProperties: 1` by default on a nested fixed-shape `Hash` field, and Anthropic's schema validator rejects it outright (confirmed live).
115
+ - `strict: false` is always sent, rather than left to RubyLLM's own strict-inference (which would otherwise turn on for the common case of every property being required).
116
+
117
+ You get the declared shape, required keys, and "no extra keys" enforcement; you don't get OpenAI's *full* strict-mode guarantee, which additionally requires every property to appear in `required` — even conceptually optional ones, via a nullable type — which axn's reflection doesn't promise. Pass a `Schematist::Schema` instead if you need that.
118
+
119
+ > **A `Hash` map field (`type: Hash, of: {...}`) doesn't survive this path against every provider.** Confirmed live: Anthropic's structured output rejects `additionalProperties` set to anything but the literal `false` (`"additionalProperties: object' is not supported. Please set 'additionalProperties' to false"`), and OpenAI's strict mode has the same restriction by design — neither provider's structured-output feature represents dynamic/arbitrary keys, only a fixed shape. This isn't something the adapter can paper over (there's no schema-legal way to say "arbitrary keys, but still typed" in either provider's strict mode), so a map field is left as-is and the provider's own rejection surfaces as the request's error. Use a fixed-shape `Hash` (`shape:`) instead, or pass your own schema Hash / `Schematist::Schema` if you specifically need a map with that provider.
91
120
 
92
121
  ### Token counts and cost
93
122
 
94
- Every successful result exposes token usage and cost:
123
+ Every successful result exposes token usage and cost, read off RubyLLM's own usage ledger (`Chat#tokens` / `Chat#cost`) — which already sums every provider attempt for the call, including a tool loop's multiple round-trips and any retries:
95
124
 
96
125
  ```ruby
97
126
  result = Axn::RubyLLM.ask(prompt: "...")
@@ -103,14 +132,14 @@ result.prompt_tokens # => 512 (input_tokens + cache_read_tokens + cache_wr
103
132
  result.output_tokens # => 78
104
133
  result.cost # => 0.00056 (Float USD total; nil if RubyLLM has no pricing for the model)
105
134
 
106
- # Full breakdown — RubyLLM::Cost struct with per-tier pricing
135
+ # Full breakdown — RubyLLM::Cost, RubyLLM's own aggregated-cost object
107
136
  result.cost_breakdown # => #<Cost input: 0.0004, output: 0.00016, cache_read: 0.0, ..., total: 0.00056>
108
137
 
109
138
  # Raw RubyLLM::Message for thinking tokens, raw provider data, etc.
110
139
  result.raw_message # => #<RubyLLM::Message ...>
111
140
  ```
112
141
 
113
- `cost` and `cost_breakdown` are both `nil` when RubyLLM lacks pricing for the model (e.g. unknown/custom endpoints). Token counts are nil only if the provider did not return them. `prompt_tokens` is nil only if all three input token fields are nil.
142
+ `cost` is `nil` when RubyLLM lacks pricing for the model (e.g. unknown/custom endpoints); `cost_breakdown` itself is still a `Cost` object in that case (only its component readers are `nil`). Token counts are nil only if the provider did not return them. `prompt_tokens` is nil only if all three input token fields are nil.
114
143
 
115
144
  ### Errors
116
145
 
@@ -119,8 +148,8 @@ Errors are handled via Axn's declarative `error` DSL. Every failure shares a con
119
148
  - `RubyLLM::RateLimitError` (HTTP 429, provider-agnostic) → `"LLM request failed: Rate limit reached: <message>"`
120
149
  - `RubyLLM::OverloadedError` / `ServiceUnavailableError` / `ServerError` (5xx, transient) → `"LLM request failed: Provider temporarily unavailable, try again later: <message>"`
121
150
  - `RubyLLM::ContextLengthExceededError` → `"LLM request failed: Prompt exceeds the model's context window: <message>"` (message retains the provider's token counts)
122
- - `schema:` set but LLM returned non-JSON → `"LLM request failed: Schema response was not valid JSON"`
123
- - Any other known RubyLLM error — `RubyLLM::Error` (auth, bad request, payment, etc.), `RubyLLM::ConfigurationError`, `ModelNotFoundError`, `PromptNotFoundError`, `InvalidRoleError`, `InvalidToolChoiceError`, `UnsupportedAttachmentError` — or `Faraday::Error` (network/transport failure) → `"LLM request failed: <message>"`
151
+ - `schema:` set but LLM returned non-JSON, or valid JSON that isn't an object → `"LLM request failed: Response was not valid JSON"` (malformed JSON text) or `"LLM request failed: Schema response was not valid JSON"` (valid JSON, wrong shape)
152
+ - Any other known RubyLLM error — `RubyLLM::Error` (auth, bad request, payment, etc.), `RubyLLM::ConfigurationError`, `ModelNotFoundError`, `ModelRegistryError`, `PromptNotFoundError`, `InvalidRoleError`, `InvalidToolChoiceError`, `PendingToolCallsError`, `CancelledError`, `UnsupportedAttachmentError` — or `Faraday::Error` (network/transport failure) → `"LLM request failed: <message>"`
124
153
  - Any other `StandardError` (i.e. not a recognized RubyLLM/network failure — most likely a bug) → `"LLM request failed"`, with no exception detail leaked into the message
125
154
 
126
155
  ## Tool adapter — wrap any Axn as a RubyLLM::Tool
@@ -153,20 +182,19 @@ result.response # => "Created widget Sprocket (id: 42)."
153
182
 
154
183
  `tools:` accepts a mix of **bare Axn classes** (wrapped automatically) and **already-wrapped tools** from `Axn::RubyLLM.wrap` (a class, or an instance that closed over `ambient_context:` — see below). Pass `tools: Axn::RubyLLM.tools` to expose everything registered under the `:ruby_llm` adapter (see [Enumerating tools](#enumerating-tools-from-the-registry)). The same Axn classes you expose through [axn-mcp](https://github.com/teamshares/axn-mcp) work here unchanged.
155
184
 
156
- > **Token/cost in a tool loop:** a tool call makes multiple model round-trips inside one `ask`. The token counts, `cost`, and `cost_breakdown` are **summed across every turn**, so they reflect the whole call not just the final response. (`raw_message` is still the final response.)
185
+ > **Token/cost in a tool loop:** a tool call makes multiple model round-trips inside one `ask`. The token counts, `cost`, and `cost_breakdown` come from RubyLLM's own usage ledger (`Chat#tokens` / `Chat#cost`), which already aggregates **every turn** so they reflect the whole call, not just the final response. (`raw_message` is still the final response.)
157
186
 
158
187
  `Axn::RubyLLM.wrap` is also available directly if you're driving `RubyLLM.chat` yourself rather than going through `ask` — see [Using wrapped tools with RubyLLM directly](#using-wrapped-tools-with-rubyllm-directly).
159
188
 
160
189
  The tool's name, description, and JSON Schema parameters come straight from the Axn's own contract — the same `description`/`expects`/`exposes` you'd write for any Axn — so a minimal class just works (the tool name defaults from the class name: `CreateWidget` → `create_widget`). Arguments the model supplies are run through axn core's tool `Invoker`: wire types are coerced, and any contract violation — a missing required field, an out-of-schema argument, a wrong type, or a value outside an `inclusion` set (validated at **full depth**, not just the top-level type) — comes back to the model as a clean, correctable `{ error: "Invalid tool arguments: <reason>" }`, and does **not** page `on_exception` as though it were a bug. A model-supplied `ambient_context` is stripped before the Axn runs, so a prompt-injected context can never override the caller's — the wrap's own `ambient_context:` (below) is injected instead. The `Invoker` also stamps every call (including any nested sub-Axn) with the `invoked_via: :ruby_llm` dimension, so a Datadog dashboard can query tool-driven traffic separately from ordinary direct `.call`s — see [OpenTelemetry](#opentelemetry) below.
161
190
 
162
- On success, `execute` returns the exposed values (via `Axn::RubyLLM.serialize_exposed`, honoring `reject_opaque_exposed_values` — see below) as a JSON **string**, not a Hash — `RubyLLM::Chat#handle_tool_calls` only passes a `Content`/`Content::Raw` return through as-is, and otherwise sends `tool_payload.to_s`, which for a Hash produces Ruby's inspect syntax rather than JSON; on failure, `{ error: result.error }`. The same `CreateWidget` class can be wrapped for other transports (e.g. `Axn::MCP.wrap`) with no changes — the contract is declared once.
191
+ On success, `execute` returns the exposed values (via `Axn::RubyLLM.serialize_exposed`, honoring `reject_opaque_exposed_values` — see below) as a JSON **string**, not a Hash — `RubyLLM::Tool.split_result` sends a returned String through as-is, but a returned Hash/Array is `#to_json`'d via Ruby's own dispatch, which may not match axn's own serialization contract (Symbol keys/values, `BigDecimal`, `Time`, opaque-value rejection); serializing ourselves keeps the wire form aligned with what `output_schema` advertises. On failure, `{ error: result.error }`. The same `CreateWidget` class can be wrapped for other transports (e.g. `Axn::MCP.wrap`) with no changes — the contract is declared once.
163
192
 
164
193
  Options, settable either per-call via `wrap` keywords or once on the Axn via axn's namespaced per-class `configure(:ruby_llm) { |c| ... }` (a `wrap` keyword wins when both are present, then the class-level `configure(:ruby_llm)` value, then this gem's own `Axn::RubyLLM.configure { |c| ... }` global, then the default below):
165
194
 
166
195
  | Option | Effect |
167
196
  |---|---|
168
- | `halt_after:` | When `true`, wraps a successful payload in `RubyLLM::Tool::Halt` to stop the agent loop after this call. Default `false`. |
169
- | `provider_params:` | Hash deep-merged into the tool definition sent to the provider (via RubyLLM's `with_params`) — an escape hatch for provider-specific tool fields RubyLLM doesn't model first-class (e.g. OpenAI `strict` function calling, Anthropic tool `cache_control`). Keys mirror that provider's tool shape. Default `{}`. |
197
+ | `provider_options:` | Hash merged into the tool definition sent to the provider (via RubyLLM's `Tool.provider_options`) an escape hatch for provider-specific tool fields RubyLLM doesn't model first-class (e.g. OpenAI `strict` function calling, Anthropic tool `cache_control`). Keys mirror that provider's tool shape. Default `{}`. |
170
198
  | `present_as:` | `:structured` (default) returns the exposed values as a JSON string; `:message` returns `result.message` instead. Same knob as axn-mcp's `present_as:`. |
171
199
 
172
200
  Set a default once on the Axn with `configure(:ruby_llm)`, and still override per call:
@@ -174,14 +202,16 @@ Set a default once on the Axn with `configure(:ruby_llm)`, and still override pe
174
202
  ```ruby
175
203
  class CreateWidget
176
204
  include Axn
177
- configure(:ruby_llm) { |c| c.halt_after = true } # default for this tool
205
+ configure(:ruby_llm) { |c| c.present_as = :message } # default for this tool
178
206
  # ...
179
207
  end
180
208
 
181
- Axn::RubyLLM.wrap(CreateWidget) # halts after running
182
- Axn::RubyLLM.wrap(CreateWidget, halt_after: false) # per-call override
209
+ Axn::RubyLLM.wrap(CreateWidget) # returns result.message
210
+ Axn::RubyLLM.wrap(CreateWidget, present_as: :structured) # per-call override
183
211
  ```
184
212
 
213
+ > **No more `halt_after:`.** RubyLLM 2.0 removed `Tool::Halt` along with the rest of the auto-halting machinery — the conversation loop is now caller-controlled. If you need a tool call to stop the loop, drive it yourself: `loop { chat.step; break if chat.complete? || done_condition }`. See RubyLLM's [Agentic Workflows](https://rubyllm.com/agentic-workflows/) guide.
214
+
185
215
  `configure(:ruby_llm)` needs no `include` beyond `Axn` — every Axn gets it for free (core's namespaced per-class config). It's usually written in the class body as above, but since it's a plain class method you can also call it from outside — e.g. `SomeThirdPartyAxn.configure(:ruby_llm) { |c| ... }` in an initializer, to configure an Axn you don't own. Namespacing is what lets **one base Axn be configured for multiple adapters at once**, each in its own namespace, without collision even when two adapters share a setting name (both this gem and axn-mcp expose `present_as`):
186
216
 
187
217
  ```ruby
@@ -200,14 +230,14 @@ Pass `ambient_context:` to close over explicit caller context (e.g. `current_use
200
230
  Axn::RubyLLM.wrap(CreateWidget, ambient_context: { company_id: current_company.id })
201
231
  ```
202
232
 
203
- Passing `ambient_context:` returns a tool **instance** (closing over that context) rather than the tool class, since `chat.with_tool` accepts either.
233
+ Passing `ambient_context:` returns a tool **instance** (closing over that context) rather than the tool class, since `chat.with_tools` accepts either.
204
234
 
205
235
  ### Using wrapped tools with RubyLLM directly
206
236
 
207
- `Axn::RubyLLM.ask(tools:)` covers the common single-call case. When you're driving `RubyLLM.chat` yourself — multi-turn conversations, streaming, or anything else beyond `ask` — register wrapped tools with RubyLLM's own `with_tool` / `with_tools`, which accept a `RubyLLM::Tool` class or instance:
237
+ `Axn::RubyLLM.ask(tools:)` covers the common single-call case. When you're driving `RubyLLM.chat` yourself — multi-turn conversations, streaming, or anything else beyond `ask` — register wrapped tools with RubyLLM's own `with_tools`, which accepts one or many `RubyLLM::Tool` classes/instances:
208
238
 
209
239
  ```ruby
210
- chat = RubyLLM.chat.with_tool(Axn::RubyLLM.wrap(CreateWidget))
240
+ chat = RubyLLM.chat.with_tools(Axn::RubyLLM.wrap(CreateWidget))
211
241
  chat.ask("Create a widget called Sprocket")
212
242
 
213
243
  # or register everything under the :ruby_llm adapter at once:
@@ -283,13 +313,13 @@ So the adapter guards that mapping step (only — the Axn call already reports i
283
313
 
284
314
  ### Schema reflection — provider notes
285
315
 
286
- The advertised tool schema is axn's reflected `input_schema`. A few things worth knowing when you care how it lands at a specific provider (Gemini is the strictest it runs a mandatory OpenAPI-subset converter; OpenAI and Anthropic pass the schema through as-is):
316
+ The advertised tool schema is axn's reflected `input_schema`, passed through to RubyLLM **unmodified** the adapter does no per-provider schema rewriting. RubyLLM 2.0's Gemini protocol reads a tool's schema via `parametersJsonSchema`, the wire form verbatim, rather than rebuilding each property from a fixed whitelist (as its 1.x converter did), so nullable fields, `additionalProperties`, and entry-count bounds all reach Gemini the same way they reach OpenAI and Anthropic.
317
+
318
+ A few things worth knowing about what axn itself reflects, independent of RubyLLM version:
287
319
 
288
- - **Nullable/optional fields**handled. axn reflects a nullable field as an array-valued `type` (`["integer", "null"]`); the adapter rewrites that to the equivalent `anyOf` form, because Gemini's converter can't read array-valued types and would otherwise collapse the field to `STRING`. No action needed on your part.
289
- - **Array fields — declare `of:`.** `expects :ids, type: Array` reflects to `{type: array}` with no `items`, so the element type isn't advertised (Gemini then assumes `string`; OpenAI *strict* mode requires `items`). Declare the element type — `expects :ids, type: Array, of: Integer` — to advertise `items` correctly.
320
+ - **Array fields — declare `of:`.** `expects :ids, type: Array` reflects to `{type: array}` with no `items`, so the element type isn't advertised (some providers then assume `string`; OpenAI *strict* mode requires `items`). Declare the element type `expects :ids, type: Array, of: Integer` to advertise `items` correctly.
290
321
  - **Enums are advertised — use the top-level `inclusion:` key.** `expects :color, type: String, inclusion: %w[red green blue]` (a bare Array, or the long form `inclusion: { in: %w[red green blue] }`) reflects to `{ type: "string", enum: ["red", "green", "blue"] }`, so the model is told the allowed set (an `optional:` field keeps `null` in the enum; a dynamic `in: -> { ... }` is correctly skipped rather than guessed). Note this is the **top-level `inclusion:` option**, not `validate: { inclusion: ... }` — `validate:` is axn's custom-callable hook (it needs `with:`), so that spelling neither enforces nor reflects the set.
291
- - **Hash maps and entry counts** — handled. A map (`expects :scores, type: Hash, of: { keys: String, values: Integer }`) reflects to `additionalProperties`, and a Hash's entry-count bounds to `minProperties`/`maxProperties`. Gemini's converter carries none of the three, so a map would otherwise arrive as an empty `{type: OBJECT}` no error, but the model never learns what the values must be, so its guess is rejected at runtime instead. The adapter restates the constraint in that node's `description` (“An object mapping arbitrary keys to integer values. This object must not be empty.”), which Gemini does forward; a structured value type carries its compact JSON Schema alongside. The real keys are left in place for OpenAI/Anthropic, which enforce them, so the prose is redundant there rather than load-bearing. Your own `description:` is kept and the generated sentences appended after it. No action needed on your part.
292
- - **Conditional expectations** (`expects :token, if: :use_token`) reflect to a JSON Schema `allOf`/`if`/`then` clause. Gemini's converter ignores it — the field degrades to plain-optional (safe: a valid call is never wrongly rejected, but the conditional isn't conveyed to the model). This gem doesn't set OpenAI's `strict` mode, so OpenAI tolerates `allOf` by default; if you opt into strict via `provider_params`, OpenAI will reject `allOf`. Either way, encoding the rule in `description:` is the portable option.
322
+ - **Conditional expectations** (`expects :token, if: :use_token`) reflect to a JSON Schema `allOf`/`if`/`then` clause. Whether a given provider's tool-schema handling honors that clause is between RubyLLM and the provider, not something this adapter controls check the provider's own function-calling docs if it matters for your case. Encoding the rule in `description:` remains the portable option regardless.
293
323
 
294
324
  ## Testing
295
325
 
@@ -305,7 +335,7 @@ it "summarizes the thread" do
305
335
  end
306
336
  ```
307
337
 
308
- The response is the only required argument — pass it positionally (as above) or as `response:`. A Hash response is auto-JSON-serialized for `json: true` calls; pass `schema:` to route a Hash through the schema path unparsed (and to assert the exact schema class). Token counts and cost default to zero and can be set explicitly to exercise cost/usage logic:
338
+ The response is the only required argument — pass it positionally (as above) or as `response:`. Pass `schema:` to route a Hash response through the schema path (stubbing `raw_message.parsed` so `result.response` gets the Hash back unparsed, matching what a real `schema:` call returns). Token counts and cost default to zero and can be set explicitly to exercise cost/usage logic:
309
339
 
310
340
  ```ruby
311
341
  stub_axn_ruby_llm({ "company_id" => 42 }, schema: CompanyMatch)
@@ -349,8 +379,8 @@ When disabled, `Axn::RubyLLM.ask` returns a **success** result with obvious stub
349
379
 
350
380
  | Field | Stubbed value |
351
381
  |---|---|
352
- | `response` | `"stubbed response value"` (plain) / `{ "stubbed" => true }` (`json: true` or `schema:`) |
353
- | `raw_message` | Stub struct with `.content`, `.input_tokens`, `.output_tokens`, `.cache_read_tokens`, `.cache_write_tokens`, `.model_id` |
382
+ | `response` | `"stubbed response value"` (plain) / `{ "stubbed" => true }` (`schema:`) |
383
+ | `raw_message` | Stub struct with `.content`, `.tokens` (a real `RubyLLM::Tokens`, all zero), `.model`, `.parsed` |
354
384
  | `input_tokens` / `output_tokens` / `cache_read_tokens` / `cache_write_tokens` / `prompt_tokens` | `0` |
355
385
  | `cost` | `0.0` |
356
386
  | `cost_breakdown` | `nil` |
@@ -6,7 +6,6 @@ module Axn
6
6
  include Axn
7
7
 
8
8
  expects :prompt
9
- expects :json, type: :boolean, default: false
10
9
  expects :schema, optional: true
11
10
  expects :model, optional: true
12
11
  expects :system_prompt, optional: true
@@ -24,22 +23,36 @@ module Axn
24
23
  exposes :cost_breakdown, allow_nil: true
25
24
  exposes :stubbed, type: :boolean, default: false
26
25
 
27
- StubMessage = Data.define(:content, :input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens, :model_id)
26
+ # Shape-compatible with a real ::RubyLLM::Message on the disabled path: `.content` is the raw
27
+ # text (JSON when `schema:` is set, matching 2.0's read-only String #content), `.tokens` is a
28
+ # real ::RubyLLM::Tokens (so `.input`/`.output`/`.cache_read`/`.cache_write` all resolve), and
29
+ # `.parsed` mirrors Message#parsed (memoized JSON.parse over #content).
30
+ StubMessage = Data.define(:content, :tokens, :model) do
31
+ def parsed
32
+ return if content.nil? || content.empty?
33
+
34
+ JSON.parse(content)
35
+ end
36
+ end
28
37
 
29
38
  # RubyLLM wraps HTTP-response-level provider errors (4xx/5xx) under RubyLLM::Error, but its
30
- # non-HTTP errors (bad config, missing model/prompt/role, unsupported attachment) subclass
31
- # StandardError directly -- so RubyLLM::Error alone misses them. Connection-level failures
32
- # (timeout, DNS, refused) never reach RubyLLM at all and surface as raw Faraday errors. All
33
- # three are "known" failure shapes safe to surface verbatim; anything outside this is a bug
34
- # and must not leak its message into a user-facing result.
39
+ # non-HTTP errors (bad config, missing model/prompt/role, unsupported attachment, a stale model
40
+ # registry, an unresolved pending-tool-call/approval loop state) subclass StandardError
41
+ # directly -- so RubyLLM::Error alone misses them. Connection-level failures (timeout, DNS,
42
+ # refused) never reach RubyLLM at all and surface as raw Faraday errors. All these are "known"
43
+ # failure shapes safe to surface verbatim; anything outside this is a bug and must not leak its
44
+ # message into a user-facing result.
35
45
  KNOWN_ERROR_CLASSES = [
36
46
  ::RubyLLM::Error,
37
47
  ::Faraday::Error,
38
48
  ::RubyLLM::ConfigurationError,
39
49
  ::RubyLLM::ModelNotFoundError,
50
+ ::RubyLLM::ModelRegistryError,
40
51
  ::RubyLLM::PromptNotFoundError,
41
52
  ::RubyLLM::InvalidRoleError,
42
53
  ::RubyLLM::InvalidToolChoiceError,
54
+ ::RubyLLM::PendingToolCallsError,
55
+ ::RubyLLM::CancelledError,
43
56
  ::RubyLLM::UnsupportedAttachmentError,
44
57
  ].freeze
45
58
  KNOWN_ERROR = ->(exception:) { KNOWN_ERROR_CLASSES.any? { |k| exception.is_a?(k) } }
@@ -79,20 +92,20 @@ module Axn
79
92
  expose(
80
93
  response: parsed_response,
81
94
  raw_message: llm_response,
82
- input_tokens: sum_across(:input_tokens),
83
- output_tokens: sum_across(:output_tokens),
84
- cache_read_tokens: sum_across(:cache_read_tokens),
85
- cache_write_tokens: sum_across(:cache_write_tokens),
95
+ input_tokens: token_usage.input,
96
+ output_tokens: token_usage.output,
97
+ cache_read_tokens: token_usage.cache_read,
98
+ cache_write_tokens: token_usage.cache_write,
86
99
  prompt_tokens: total_input_tokens,
87
100
  cost_breakdown:,
88
101
  cost: cost_breakdown&.total,
89
102
  stubbed: false,
90
103
  )
91
104
  record_otel_attributes!(
92
- input_tokens: sum_across(:input_tokens),
93
- output_tokens: sum_across(:output_tokens),
105
+ input_tokens: token_usage.input,
106
+ output_tokens: token_usage.output,
94
107
  cost: cost_breakdown&.total,
95
- response_model: response_message&.model_id,
108
+ response_model: llm_response&.model,
96
109
  stubbed: false,
97
110
  )
98
111
  rescue ::RubyLLM::RateLimitError => e
@@ -104,10 +117,12 @@ module Axn
104
117
  def disabled? = !Axn::RubyLLM.enabled?
105
118
 
106
119
  def stubbed_exposures
107
- content = schema || json ? { "stubbed" => true } : "stubbed response value"
120
+ parsed_content = schema ? { "stubbed" => true } : nil
121
+ content = parsed_content ? parsed_content.to_json : "stubbed response value"
122
+ zero_tokens = ::RubyLLM::Tokens.new(input: 0, output: 0, cache_read: 0, cache_write: 0)
108
123
  {
109
- response: content,
110
- raw_message: StubMessage.new(content:, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, model_id: "stubbed"),
124
+ response: parsed_content || content,
125
+ raw_message: StubMessage.new(content:, tokens: zero_tokens, model: "stubbed"),
111
126
  input_tokens: 0,
112
127
  output_tokens: 0,
113
128
  cache_read_tokens: 0,
@@ -120,99 +135,38 @@ module Axn
120
135
  end
121
136
 
122
137
  def parsed_response
123
- return halted_response if halted?
124
-
125
- if schema
126
- # with_schema makes RubyLLM parse the response into a Hash on success
127
- return llm_response.content if llm_response.content.is_a?(Hash)
128
-
129
- fail! "Schema response was not valid JSON"
130
- end
131
- json ? JSON.parse(llm_response.content) : llm_response.content
132
- end
138
+ return llm_response.content unless schema
133
139
 
134
- # A halted tool (halt_after:) short-circuits the model's final turn, so with_schema/json never
135
- # parsed a model response the "response" is the tool's own payload (Halt#content). For a
136
- # :structured tool that's JSON text, so parse it to honor the Hash contract a schema:/json:
137
- # caller expects; fall back to the raw string for a :message tool or an unparseable payload.
138
- def halted_response
139
- content = llm_response.content
140
- return content unless (schema || json) && content.is_a?(String)
141
-
142
- JSON.parse(content)
143
- rescue JSON::ParserError
144
- content
145
- end
140
+ # with_schema makes RubyLLM parse the response into JSON text on success; #parsed memoizes
141
+ # JSON.parse over #content and raises JSON::ParserError on malformed JSON (caught by the
142
+ # declared `error "Response was not valid JSON", if: JSON::ParserError` handler above).
143
+ parsed = llm_response.parsed
144
+ return parsed if parsed.is_a?(Hash)
146
145
 
147
- # A tool call makes multiple model round-trips inside one `ask`; every assistant turn is
148
- # accumulated on the chat and reports its OWN usage, so sum across them for the true per-call
149
- # totals rather than just the final turn's. Non-response messages (the user prompt, tool
150
- # results) carry no tokens — they contribute 0 to the token sums, and RubyLLM::Cost.aggregate
151
- # ignores them (no `tokens?`) — so summing over every message is correct, and a plain (no-tool)
152
- # ask (one assistant turn) is a no-op.
153
- def usage_messages
154
- chat.messages
146
+ fail! "Schema response was not valid JSON"
155
147
  end
156
148
 
157
- # nil only when NO turn reported the field (preserving the "nil if the provider didn't return it"
158
- # contract); otherwise the summed count, treating a missing turn as 0.
159
- def sum_across(field)
160
- values = usage_messages.map(&field)
161
- values.all?(&:nil?) ? nil : values.sum(&:to_i)
162
- end
149
+ # Every provider attempt this chat has made -- including retries and fallback attempts that
150
+ # produced no message -- aggregated by RubyLLM itself (Chat#tokens / Chat#cost), rather than
151
+ # summed by hand across chat.messages. A tool call makes multiple model round-trips inside one
152
+ # `ask`; this still reflects the whole call, not just the final response.
153
+ memo def token_usage = chat.tokens
154
+ memo def cost_breakdown = chat.cost
163
155
 
156
+ # nil only when NO turn reported the field (preserving the "nil if the provider didn't return
157
+ # it" contract); otherwise the summed count, treating a missing component as 0.
164
158
  def total_input_tokens
165
- vals = usage_messages.flat_map { |m| [m.input_tokens, m.cache_read_tokens, m.cache_write_tokens] }
159
+ vals = [token_usage.input, token_usage.cache_read, token_usage.cache_write]
166
160
  vals.all?(&:nil?) ? nil : vals.sum(&:to_i)
167
161
  end
168
162
 
169
- memo def cost_breakdown
170
- return nil unless model_info
171
-
172
- # chat.messages always includes the user prompt (chat.ask appends it before completing) and,
173
- # in a tool loop, the tool-result messages -- none of which carry token usage. Keep only the
174
- # token-bearing (billable) costs BEFORE the one?-vs-aggregate decision: a normal single-turn
175
- # call then preserves the response's OWN Cost (with its tokens/model) via costs.one?, instead
176
- # of being forced through aggregate -- which returns a Cost with nil tokens/model -- by the
177
- # ever-present user message. `select(&:tokens?)` mirrors Cost.aggregate's own billable filter,
178
- # so the multi-turn total is unchanged.
179
- costs = usage_messages.map { |message| message.cost(model: model_info) }.select(&:tokens?)
180
- return nil if costs.empty?
181
-
182
- # One billable turn → its own Cost (identical to the pre-tool-loop non-tool call). Multiple →
183
- # RubyLLM::Cost.aggregate sums the per-tier costs into a single breakdown.
184
- costs.one? ? costs.first : ::RubyLLM::Cost.aggregate(costs)
185
- end
186
-
187
- memo def model_info
188
- return nil unless response_message&.model_id
189
-
190
- ::RubyLLM.models.find(response_message.model_id)
191
- rescue ::RubyLLM::ModelNotFoundError
192
- nil
193
- end
194
-
195
163
  memo def llm_response = chat.ask(prompt)
196
164
 
197
- # When a wrapped tool halts the loop (halt_after:), chat.ask returns a ::RubyLLM::Tool::Halt
198
- # carrying the tool payload as #content, not a Message — and a Halt has no #model_id. Read the
199
- # model (for cost lookup + OTel) from the last assistant turn accumulated on the chat in that
200
- # case; for a normal response, llm_response IS that final message. Token/cost SUMS already read
201
- # chat.messages, so only the model-id reads needed this indirection.
202
- def response_message
203
- return llm_response unless halted?
204
-
205
- chat.messages.reverse.find { |message| message.role == :assistant }
206
- end
207
-
208
- def halted? = llm_response.is_a?(::RubyLLM::Tool::Halt)
209
-
210
165
  memo def chat
211
166
  ::RubyLLM.chat(model: resolved_model).tap do |c|
212
167
  c.with_instructions(system_prompt) if system_prompt
213
- c.with_schema(schema) if schema
214
- c.with_params(response_format: { type: "json_object" }) if json && !schema
215
- c.with_params(temperature:) if temperature
168
+ c.with_schema(resolved_schema) if schema
169
+ c.with_temperature(temperature) if temperature
216
170
  c.with_tools(*resolved_tools) if resolved_tools.any?
217
171
  end
218
172
  end
@@ -221,6 +175,78 @@ module Axn
221
175
  model || Axn::RubyLLM.config.default_model
222
176
  end
223
177
 
178
+ # `schema:` accepts a raw JSON Schema Hash (passed through unchanged -- Chat#with_schema
179
+ # already normalizes a bare Hash), a Schematist::Schema class/instance (likewise passed
180
+ # through -- with_schema itself checks for #to_json_schema), or an Axn class: the same
181
+ # reflection the tool adapter already uses for input (`input_schema`), mirrored here for
182
+ # output. `output_schema` is axn's own public JSON Schema Hash for its `exposes` contract.
183
+ #
184
+ # Adjustments axn's own `exposes` contract has no reason to make on its own -- each one
185
+ # confirmed live against a real model, not just read off docs (a docs summary claimed
186
+ # `minLength` was ALSO unsupported by Anthropic; a flat schema with `minLength: 1` on a
187
+ # String field succeeded live regardless, so only what's actually confirmed failing is
188
+ # stripped, nothing broader):
189
+ #
190
+ # - `additionalProperties: false` on every fixed-shape object node. Anthropic's
191
+ # `output_config.format.schema` REQUIRES this unconditionally -- confirmed live
192
+ # ("output_config.format.schema: For 'object' type, 'additionalProperties' must be
193
+ # explicitly set to false"), and it deletes any `strict:` key before validating
194
+ # (protocols/anthropic/chat.rb#build_output_config), so there is no "non-strict" escape
195
+ # hatch there. OpenAI's strict mode has the same requirement (its own docs / community
196
+ # reports). Injected on every object node that declares `properties` and has no
197
+ # `additionalProperties` of its own -- which excludes a map (`type: Hash, of: {...}`),
198
+ # whose `additionalProperties` already names its value schema and must stay that way.
199
+ # - `minProperties`/`maxProperties` stripped from every object node. axn emits
200
+ # `minProperties: 1` on any fixed-shape object (a nested `type: Hash, shape: {...}` field,
201
+ # or a map) by default -- Anthropic's schema validator rejects it outright, confirmed live
202
+ # ("output_config.format.schema: For 'object' type, property 'minProperties' is not
203
+ # supported"). No prose-restatement fallback (the kind PRO-3172's now-deleted Gemini
204
+ # workaround used) -- that machinery existed for a fixed-whitelist converter Gemini no
205
+ # longer has (see tool_adapter.rb); reintroducing it here for a narrower, less common case
206
+ # (a nested fixed-shape object's entry-count bound) isn't worth the complexity back.
207
+ # - `strict: false`, pinned rather than left to RubyLLM's own inference. Chat#with_schema's
208
+ # strict_schema? (chat_completions/chat.rb) infers `strict: true` whenever every property
209
+ # is required -- the common case for an Axn's output contract (see the README example) --
210
+ # and OpenAI's *full* strict mode additionally requires every property to be listed in
211
+ # `required` even when conceptually optional (via a nullable type), which axn's reflection
212
+ # doesn't promise. Pinned rather than relying on the `additionalProperties: false` fix
213
+ # above to make strict inference merely harmless.
214
+ def resolved_schema
215
+ return schema unless schema.is_a?(::Class) && schema.respond_to?(:output_schema)
216
+
217
+ { name: schema.name || "response", schema: sanitize_output_schema(schema.output_schema), strict: false }
218
+ end
219
+
220
+ # Builds a new Hash/Array throughout rather than mutating -- axn may hand back a memoized
221
+ # output_schema, and mutating it would corrupt every other reader.
222
+ #
223
+ # Every adjustment is gated on `object_node` -- the recursion walks every Hash in the
224
+ # schema, but not every Hash IS a schema node. `properties` is a name-to-schema map, so an
225
+ # Axn with a field literally named `minProperties`/`maxProperties`/`additionalProperties`
226
+ # puts a Hash at exactly the key this pass reads; an ungated delete/injection would corrupt
227
+ # that container instead of a schema node's own keywords (confirmed live: an Axn exposing
228
+ # `minProperties` had that field silently dropped from `properties` while `required` still
229
+ # named it -- an invalid schema). The `properties` container itself never carries `type`, so
230
+ # gating on `object_node` -- true only for an actual object-schema node -- keeps the pass off
231
+ # it entirely.
232
+ def sanitize_output_schema(node)
233
+ case node
234
+ when Hash
235
+ rebuilt = node.transform_values { |value| sanitize_output_schema(value) }
236
+ object_node = rebuilt[:type] == "object" || Array(rebuilt[:type]).include?("object")
237
+ if object_node
238
+ rebuilt.delete(:minProperties)
239
+ rebuilt.delete(:maxProperties)
240
+ rebuilt[:additionalProperties] = false if rebuilt.key?(:properties) && !rebuilt.key?(:additionalProperties)
241
+ end
242
+ rebuilt
243
+ when Array
244
+ node.map { |value| sanitize_output_schema(value) }
245
+ else
246
+ node
247
+ end
248
+ end
249
+
224
250
  # `tools:` accepts a mix of bare Axn classes (wrapped here, so callers can pass their own Axns
225
251
  # straight in) and already-wrapped `::RubyLLM::Tool`s -- a class or an instance, the latter being
226
252
  # how you pass a tool that closed over explicit context via `Axn::RubyLLM.wrap(axn, ambient_context:)`.
@@ -14,8 +14,7 @@ module Axn
14
14
  #
15
15
  # Usage in a spec:
16
16
  # stub_axn_ruby_llm("Here is a summary.")
17
- # stub_axn_ruby_llm({ "key" => "value" }) # auto-JSON-serialized for json: true calls
18
- # stub_axn_ruby_llm({ "k" => "v" }, schema: MySchema) # Hash passed through unparsed
17
+ # stub_axn_ruby_llm({ "k" => "v" }, schema: MySchema) # Hash passed through as `parsed`
19
18
  # stub_axn_ruby_llm("...", input_tokens: 100, output_tokens: 50, cost: 0.0023)
20
19
  # stub_axn_ruby_llm("...", cache_read_tokens: 500, cache_write_tokens: 200)
21
20
  # stub_axn_ruby_llm(response: "...") # keyword form still works
@@ -28,63 +27,44 @@ module Axn
28
27
  raise ArgumentError, "stub_axn_ruby_llm requires a response (positionally or as `response:`)" if response.equal?(UNSET)
29
28
 
30
29
  resolved_model_id = model || Axn::RubyLLM.config.default_model
31
- llm_message = _stub_axn_ruby_llm_message(response, resolved_model_id, input_tokens, output_tokens,
32
- cache_read_tokens:, cache_write_tokens:, schema:)
33
- chat_instance = _stub_axn_ruby_llm_chat(model, llm_message, schema:)
34
- _stub_axn_ruby_llm_cost(llm_message, resolved_model_id, cost)
35
- chat_instance
30
+ llm_message = _stub_axn_ruby_llm_message(response, resolved_model_id, schema:)
31
+ _stub_axn_ruby_llm_chat(model, llm_message, input_tokens:, output_tokens:,
32
+ cache_read_tokens:, cache_write_tokens:, cost:)
36
33
  end
37
34
 
38
35
  private
39
36
 
40
- def _stub_axn_ruby_llm_message(response, model_id, input_tokens, output_tokens, cache_read_tokens:,
41
- cache_write_tokens:, schema:)
42
- content = if schema
43
- response
44
- elsif response.is_a?(Hash)
45
- response.to_json
46
- else
47
- response.to_s
48
- end
49
- instance_double(::RubyLLM::Message,
50
- content:, input_tokens:, output_tokens:,
51
- cache_read_tokens:, cache_write_tokens:, model_id:)
37
+ # `content` mirrors real ::RubyLLM::Message#content (a read-only String, JSON text when
38
+ # `schema:` is set); `parsed` mirrors #parsed (the Hash `schema:` callers actually want back
39
+ # via Ask's `parsed_response`, which reads `.parsed` -- not `.content` -- once schema is set).
40
+ def _stub_axn_ruby_llm_message(response, model_id, schema:)
41
+ content = schema ? response.to_json : response.to_s
42
+ parsed = schema ? response : nil
43
+ instance_double(::RubyLLM::Message, content:, parsed:, model: model_id)
52
44
  end
53
45
 
54
- def _stub_axn_ruby_llm_chat(model, llm_message, schema:)
46
+ def _stub_axn_ruby_llm_chat(model, llm_message, input_tokens:, output_tokens:,
47
+ cache_read_tokens:, cache_write_tokens:, cost:)
55
48
  chat_instance = instance_double(::RubyLLM::Chat)
56
49
  if model
57
50
  allow(::RubyLLM).to receive(:chat).with(model:).and_return(chat_instance)
58
51
  else
59
52
  allow(::RubyLLM).to receive(:chat).and_return(chat_instance)
60
53
  end
61
- %i[with_instructions with_params with_tools].each do |method|
54
+ %i[with_instructions with_schema with_temperature with_provider_options with_tools].each do |method|
62
55
  allow(chat_instance).to receive(method).and_return(chat_instance)
63
56
  end
64
- # Always stub with_schema so specs don't blow up if production code passes schema:
65
- # even when the helper is called without schema:. Use a tight matcher when schema
66
- # is known so the stub still validates the correct class is passed.
67
- if schema
68
- allow(chat_instance).to receive(:with_schema).with(schema).and_return(chat_instance)
69
- else
70
- allow(chat_instance).to receive(:with_schema).and_return(chat_instance)
71
- end
72
57
  allow(chat_instance).to receive(:ask).and_return(llm_message)
73
- # Ask sums usage across the chat's assistant turns; a stubbed call is single-turn.
74
- allow(chat_instance).to receive(:messages).and_return([llm_message])
75
- chat_instance
76
- end
77
-
78
- def _stub_axn_ruby_llm_cost(llm_message, model_id, cost)
79
- model_info = instance_double("RubyLLM::Model")
80
- allow(::RubyLLM.models).to receive(:find).with(model_id).and_return(model_info)
81
- # Default to zero cost so specs exercise the "model found, cost computed" path.
58
+ # Ask reads usage off the chat's own ledger (Chat#tokens / Chat#cost), not per-message --
59
+ # a stubbed call is single-turn, so the ledger is just these values directly.
60
+ allow(chat_instance).to receive(:tokens).and_return(
61
+ instance_double(::RubyLLM::Tokens, input: input_tokens, output: output_tokens,
62
+ cache_read: cache_read_tokens, cache_write: cache_write_tokens),
63
+ )
64
+ # Default to zero cost so specs exercise the "cost computed" path.
82
65
  # Pass cost: explicitly to assert a specific value.
83
- cost_total = cost || 0.0
84
- # tokens?: true — the stubbed message is a billable assistant turn, and Ask's cost_breakdown
85
- # keeps only token-bearing (tokens?) costs before its one?-vs-aggregate decision.
86
- cost_struct = instance_double(::RubyLLM::Cost, total: cost_total, tokens?: true)
87
- allow(llm_message).to receive(:cost).with(model: model_info).and_return(cost_struct)
66
+ allow(chat_instance).to receive(:cost).and_return(instance_double(::RubyLLM::Cost, total: cost || 0.0))
67
+ chat_instance
88
68
  end
89
69
  end
90
70
  end
@@ -3,15 +3,14 @@
3
3
  module Axn
4
4
  module RubyLLM
5
5
  # Namespaced per-class config (axn's `Axn::Configurable`, PRO-2880): any Axn — with no
6
- # adapter-specific mixin required — can declare `configure(:ruby_llm) { |c| c.halt_after = true }`
6
+ # adapter-specific mixin required — can declare `configure(:ruby_llm) { |c| c.present_as = :message }`
7
7
  # to set these per-class, alongside e.g. `configure(:mcp) { ... }` for a different adapter on the
8
8
  # same class, without the two colliding. `wrap` resolves them via `resolve_override_for`, which
9
9
  # falls back to this module's own global `config` (`Axn::RubyLLM.configure { |c| ... }`) and then
10
10
  # to each setting's default — the same class-override-then-global-then-default chain a flat
11
11
  # `overridable: true` accessor would give a single-adapter consumer.
12
12
  config_namespace :ruby_llm
13
- setting :halt_after, default: false, overridable: true
14
- setting :provider_params, default: {}, overridable: true
13
+ setting :provider_options, default: {}, overridable: true
15
14
  setting :present_as, default: :structured, one_of: %i[structured message], overridable: true
16
15
  # `Axn::Tools::AdapterSerialization` (extended onto Axn::RubyLLM in ruby_llm.rb, which is required
17
16
  # before this file reopens the module) owns this setting's declaration so the three adapters can't
@@ -35,13 +34,13 @@ module Axn
35
34
  ADAPTER_FAILURE_MESSAGE = "The tool could not produce a valid response"
36
35
 
37
36
  class << self
38
- def wrap(axn_class, halt_after: nil, provider_params: nil, present_as: nil, render_as: NOT_SET, ambient_context: NOT_SET)
37
+ def wrap(axn_class, provider_options: nil, present_as: nil, render_as: NOT_SET, provider_params: NOT_SET, ambient_context: NOT_SET)
39
38
  validate_present_as_kwargs!(present_as, render_as)
39
+ validate_provider_options_kwargs!(provider_params)
40
40
 
41
41
  tool_class = build_tool_class(
42
42
  axn_class,
43
- halt_after: halt_after.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :halt_after) : halt_after,
44
- provider_params: provider_params.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :provider_params) : provider_params,
43
+ provider_options: provider_options.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :provider_options) : provider_options,
45
44
  present_as: present_as.nil? ? Axn::RubyLLM.resolve_override_for(axn_class, :present_as) : present_as,
46
45
  ambient_context:,
47
46
  )
@@ -69,6 +68,17 @@ module Axn
69
68
  raise ArgumentError, "present_as must be one of :structured, :message; got #{present_as.inspect}#{hint}"
70
69
  end
71
70
 
71
+ # `provider_params:` was renamed to `provider_options:` (PRO-3467) to match RubyLLM 2.0's own
72
+ # `Tool.provider_options`, which replaced `with_params` for tool-level provider metadata.
73
+ # Same hard-error treatment as `render_as:` above: pre-1.0, never silently shimmed.
74
+ def validate_provider_options_kwargs!(provider_params)
75
+ return if provider_params.equal?(NOT_SET)
76
+
77
+ raise ArgumentError,
78
+ "`provider_params:` was renamed to `provider_options:` " \
79
+ "(e.g. `Axn::RubyLLM.wrap(..., provider_options: { ... })`)."
80
+ end
81
+
72
82
  # `guard_tool_response`'s `on_error`: the transport-native error response, plus the operator's
73
83
  # only pointer to WHY (the tool-facing text stays generic -- see ADAPTER_FAILURE_MESSAGE).
74
84
  # Mirrors axn-openapi's dispatcher hint / axn-mcp's Invocation guard: the config pointer lives
@@ -101,7 +111,7 @@ module Axn
101
111
  { error: ADAPTER_FAILURE_MESSAGE }
102
112
  end
103
113
 
104
- def build_tool_class(axn_class, halt_after:, provider_params:, present_as:, ambient_context:)
114
+ def build_tool_class(axn_class, provider_options:, present_as:, ambient_context:)
105
115
  # Core's canonical, provider-safe tool_name (PRO-2921): strips configured leading prefixes,
106
116
  # snake_cases with single underscores, restricts to [a-z0-9_], and is never blank (anonymous
107
117
  # -> "tool"). Pass the `:ruby_llm` adapter key so a per-adapter `tool ruby_llm: { name: }`
@@ -111,8 +121,14 @@ module Axn
111
121
  # different name, so provider tool calls / forced choices on the declared name wouldn't
112
122
  # match. Absent an override it's identical to the zero-arg name (Axn::MCP.wrap passes `:mcp`
113
123
  # the same way -- the author-once point).
124
+ #
125
+ # Passed through unmodified (PRO-3467): RubyLLM 2.0's Gemini protocol reads a tool's schema
126
+ # via `parametersJsonSchema` -- the wire form verbatim, with no whitelist converter in the
127
+ # way -- so the array-valued-`type` / additionalProperties / min-maxProperties workarounds
128
+ # 1.x needed here are gone along with the fixed-property Gemini schema converter they patched
129
+ # around.
114
130
  tool_name = axn_class.tool_name(:ruby_llm)
115
- input_schema = normalize_nullable_types(annotate_object_constraints(axn_class.input_schema))
131
+ input_schema = axn_class.input_schema
116
132
  # Built HERE, not inside `define_method(:execute)`: `self` in the executed block is the
117
133
  # ::RubyLLM::Tool instance, which has no access to this module's private helpers. Closing
118
134
  # over the lambda from build_tool_class's scope binds it to ToolAdapter instead.
@@ -120,10 +136,10 @@ module Axn
120
136
 
121
137
  Class.new(::RubyLLM::Tool) do
122
138
  description(axn_class.description) if axn_class.description
123
- params(input_schema)
124
- with_params(**provider_params) if provider_params.any?
139
+ parameters(input_schema)
140
+ provider_options(provider_options) if provider_options.any?
125
141
 
126
- define_method(:name) { tool_name }
142
+ define_singleton_method(:tool_name) { tool_name }
127
143
 
128
144
  define_method(:execute) do |**args|
129
145
  # Run the Axn through axn core's tool Invoker (PRO-2943): input types are coerced from the
@@ -164,200 +180,26 @@ module Axn
164
180
  # exceptions -- double-guarding would double-report on_exception), and the block's
165
181
  # return value is #execute's.
166
182
  Axn::RubyLLM.guard_tool_response(axn_class, on_error: on_serialization_failure) do
167
- # RubyLLM::Chat#handle_tool_calls only treats a Content/Content::Raw return as-is; any
168
- # other object (including a plain Hash) gets `#to_s`'d before being sent to the
169
- # provider -- which for a Hash produces Ruby's inspect syntax (`{"k"=>"v"}`), not
170
- # JSON. Serialize structured payloads ourselves so the wire form is always valid JSON.
183
+ # RubyLLM::Tool.split_result (called from Chat#add_tool_result_message) sends a String
184
+ # through as-is but `#to_json`'s a returned Hash/Array only via its OWN #to_json
185
+ # dispatch, not necessarily matching how axn would serialize it (Symbol keys/values,
186
+ # BigDecimal, Time, opaque-value rejection). Serialize structured payloads ourselves so
187
+ # the wire form always reflects axn's own serialization contract, not Ruby's default.
171
188
  #
172
189
  # `serialize_exposed` (not `Serialization.render` directly) resolves
173
190
  # reject_opaque_exposed_values PER CALL off the result's own action class, so a
174
191
  # per-tool `configure(:ruby_llm)` override is honored and a config change reaches
175
192
  # already-wrapped tools. `present_as` stays a wrap-time kwarg: it's adapter-owned, not
176
193
  # part of the shared mixin, and `wrap` accepts it as an explicit override.
177
- payload = if present_as == :message
178
- result.message
179
- else
180
- Axn::RubyLLM.serialize_exposed(result).to_json
181
- end
182
- halt_after ? halt(payload) : payload
194
+ if present_as == :message
195
+ result.message
196
+ else
197
+ Axn::RubyLLM.serialize_exposed(result).to_json
198
+ end
183
199
  end
184
200
  end
185
201
  end
186
202
  end
187
-
188
- # axn reflects a nullable/optional field as a JSON Schema array-valued `type`
189
- # (e.g. `["integer", "null"]`). That's valid JSON Schema and OpenAI/Anthropic consume it
190
- # fine, but RubyLLM's Gemini converter only recognizes anyOf-form nullability: it does
191
- # `param_type_for_gemini(type)` with `type.to_s.downcase`, so an array `type` matches no
192
- # case and falls through to STRING -- silently dropping both the declared type and the
193
- # nullability. Rewrite every array-valued `type` into the equivalent `anyOf: [{type: ...}]`,
194
- # which Gemini's `normalize_any_of_schema` collapses back to the real type + nullable, and
195
- # which the other providers accept unchanged. Purely a wire-shape change: the admitted value
196
- # set is identical, and the adapter's own validator (json_types_for) already reads anyOf.
197
- #
198
- # Builds new Hashes/Arrays throughout rather than mutating -- axn may hand back a memoized
199
- # input_schema, and mutating it would corrupt every other reader.
200
- def normalize_nullable_types(node)
201
- case node
202
- when Hash
203
- rebuilt = node.to_h { |key, value| [key, normalize_nullable_types(value)] }
204
- if rebuilt[:type].is_a?(Array)
205
- types = rebuilt.delete(:type)
206
- rebuilt[:anyOf] = types.map { |type| { type: } }
207
- end
208
- rebuilt
209
- when Array
210
- node.map { |value| normalize_nullable_types(value) }
211
- else
212
- node
213
- end
214
- end
215
-
216
- # PRO-3172. RubyLLM's Gemini converter rebuilds every property from a fixed whitelist
217
- # (`convert_property`: description/enum/format/nullable/maximum/minimum/multipleOf, plus
218
- # properties/required and, for an array, items/minItems/maxItems). Three keys axn emits for a
219
- # Hash fall outside it: `additionalProperties` -- a map's value contract, from
220
- # `of: { keys:, values: }` -- and `minProperties`/`maxProperties`, its entry-count bounds.
221
- # All three are dropped with no error raised, so a map reaches Gemini as a bare
222
- # `{type: OBJECT, properties: {}}`: the model never learns what the values must be, sends
223
- # whatever it likes, and the Invoker rejects the call. The constraint degrades from
224
- # schema-enforced to runtime-rejected, costing a wasted round trip plus a recovery the model
225
- # has to work out for itself.
226
- #
227
- # Gemini's Schema proto has no equivalent to translate any of them to -- but `description` IS
228
- # copied through, so restate them as prose there. Applied unconditionally rather than only for
229
- # Gemini: the adapter has no provider to branch on (a wrapped tool class outlives the choice
230
- # of chat), and on OpenAI/Anthropic -- which take `params_schema` verbatim and so still get
231
- # the enforceable keys themselves -- the extra sentence is merely redundant, never wrong.
232
- #
233
- # Same non-mutation rule as normalize_nullable_types, for the same reason: axn may hand back
234
- # a memoized input_schema, so build new Hashes/Arrays throughout.
235
- def annotate_object_constraints(node)
236
- case node
237
- when Hash
238
- rebuilt = node.to_h { |key, value| [key, annotate_object_constraints(value)] }
239
- # Read the sentences off the ORIGINAL node, not `rebuilt`: map_sentence may dump the value
240
- # subschema as JSON, and the original is the copy that has no generated prose in it yet.
241
- # The JSON clause goes LAST: it ends in a brace rather than a period, so anything appended
242
- # after it would read as a run-on (and a period placed right after `}` risks being read as
243
- # part of the JSON itself).
244
- return rebuilt unless object_node?(node)
245
-
246
- sentences = [map_sentence(node), entry_count_sentence(node), value_schema_clause(node)].compact
247
- return rebuilt if sentences.empty?
248
-
249
- # merge (rather than assignment into a fresh Hash) so an author-supplied description keeps
250
- # its original position in the node; the generated sentences follow the author's text.
251
- rebuilt.merge(description: [node[:description], *sentences].compact.join(" "))
252
- when Array
253
- node.map { |value| annotate_object_constraints(value) }
254
- else
255
- node
256
- end
257
- end
258
-
259
- # A map's value contract as prose. A bare type reads as a plain word -- "integer", "string or
260
- # integer" -- which says everything the schema does; anything structured is named by its
261
- # top-level type here and spelled out exactly by value_schema_clause below.
262
- def map_sentence(node)
263
- values = map_values(node)
264
- return nil unless values
265
-
266
- # `additionalProperties` governs only the keys `properties` does NOT match, so a map that
267
- # also declares a `shape:` carries both on one node -- and Gemini keeps `properties`, which
268
- # makes "arbitrary keys" actively wrong in that case.
269
- lead = if node[:properties].is_a?(Hash) && node[:properties].any?
270
- "Keys other than those listed map to"
271
- else
272
- "An object mapping arbitrary keys to"
273
- end
274
-
275
- phrase = bare_type_phrase(values)
276
- phrase ? "#{lead} #{phrase} values." : "#{lead} values."
277
- end
278
-
279
- # A structured value type -- an array's `items`, a nested map, a constrained scalar -- would
280
- # need hand-written English grammar to render as prose, which degrades fast with nesting
281
- # depth, so carry it as compact JSON Schema instead: exact at any depth, and a form models
282
- # read natively. Skipped when the type word alone already said everything.
283
- def value_schema_clause(node)
284
- values = map_values(node)
285
- return nil if values.nil? || bare_type?(values)
286
-
287
- "Each value must match this JSON Schema: #{JSON.generate(values)}"
288
- end
289
-
290
- # The recursion above walks every Hash in the schema, but not every Hash IS a schema node --
291
- # `properties` is a name-to-schema map, so an Axn with a field named `additionalProperties` or
292
- # `minProperties` puts a Hash (or an Integer) at exactly the key this pass reads. Without this
293
- # gate, such a container was itself annotated, injecting a `description` key into `properties`
294
- # and thereby advertising a phantom parameter named "description" -- which the model might then
295
- # send and the Invoker would reject as undeclared. Requiring a declared object type also keeps
296
- # object prose off a string/array node that carries these keys for any other reason.
297
- def object_node?(node)
298
- type = node[:type]
299
- type == "object" || (type.is_a?(Array) && type.include?("object"))
300
- end
301
-
302
- # A map's value schema, or nil if this node isn't a map. axn omits `additionalProperties`
303
- # entirely rather than emitting an empty one, and never emits the boolean form.
304
- def map_values(node)
305
- values = node[:additionalProperties]
306
- values if values.is_a?(Hash) && values.any?
307
- end
308
-
309
- # True when a schema constrains nothing beyond the type itself -- exactly the case a type word
310
- # conveys in full, with no JSON clause needed.
311
- def bare_type?(schema)
312
- case schema.keys
313
- when [:type] then true
314
- when [:anyOf] then schema[:anyOf].all? { |entry| entry.is_a?(Hash) && entry.keys == [:type] }
315
- else false
316
- end
317
- end
318
-
319
- # "integer"; "integer or null" (axn's array-valued nullable type -- annotation runs BEFORE
320
- # normalize_nullable_types rewrites it to anyOf); "string or integer" (a union's anyOf). nil
321
- # when no type is declared at all, which sends the caller to the JSON-only phrasing.
322
- def bare_type_phrase(schema)
323
- types = if schema[:type].is_a?(String)
324
- [schema[:type]]
325
- elsif schema[:type].is_a?(Array)
326
- schema[:type]
327
- elsif schema[:anyOf].is_a?(Array)
328
- schema[:anyOf].filter_map { |entry| entry[:type] if entry.is_a?(Hash) }
329
- end
330
-
331
- return nil unless types.is_a?(Array) && types.any? && types.all?(String)
332
-
333
- types.uniq.join(" or ")
334
- end
335
-
336
- # minProperties/maxProperties. Gemini forwards an ARRAY's minItems/maxItems but has no OBJECT
337
- # equivalent, so an entry-count bound is lost whether or not the node is also a map -- a plain
338
- # `expects :meta, type: Hash` already reflects `minProperties: 1` from axn's non-blank default.
339
- def entry_count_sentence(node)
340
- min = node[:minProperties]
341
- max = node[:maxProperties]
342
- # A zero minimum admits the empty object, i.e. constrains nothing -- reporting it as
343
- # "must not be empty" below would state the opposite of what the schema allows.
344
- min = nil unless min.is_a?(Integer) && min.positive?
345
- max = nil unless max.is_a?(Integer)
346
- return nil unless min || max
347
-
348
- bound = if min && max && min == max then "exactly #{entry_count(min)}"
349
- elsif min && max then "between #{min} and #{entry_count(max)}"
350
- elsif max then "at most #{entry_count(max)}"
351
- elsif min > 1 then "at least #{entry_count(min)}"
352
- end
353
-
354
- # A bare `minProperties: 1` is just non-emptiness, and reads far better said that way.
355
- bound ? "This object must have #{bound}." : "This object must not be empty."
356
- end
357
-
358
- def entry_count(count)
359
- "#{count} #{count == 1 ? "entry" : "entries"}"
360
- end
361
203
  end
362
204
  end
363
205
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Axn
4
4
  module RubyLLM
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/axn/ruby_llm.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "delegate"
4
3
  require "ruby_llm"
5
4
  require "axn"
6
5
 
@@ -33,16 +32,6 @@ module Axn
33
32
 
34
33
  mount_axn :ask, Ask
35
34
 
36
- # Backward-compatible view of `config` returned by the deprecated `configuration` alias. The
37
- # pre-DSL `Configuration#enabled?` invoked a callable gate (`enabled = -> { ... }`); the
38
- # DSL-generated `config.enabled?` returns an assigned Proc as-is (always truthy). Delegate
39
- # everything to `config`, but restore the callable-resolving `enabled?` (via the module-level
40
- # `enabled?`) so a compatibility caller's production gate still resolves correctly during the
41
- # deprecation window instead of silently reading as enabled. Removed with the alias in 0.3.0.
42
- class DeprecatedConfigProxy < SimpleDelegator
43
- def enabled? = Axn::RubyLLM.enabled?
44
- end
45
-
46
35
  class << self
47
36
  # `enabled` accepts a Boolean OR a callable — the documented production-gating idiom is
48
37
  # `c.enabled = -> { Rails.env.production? }`. axn's Configurable used to invoke an assigned
@@ -54,30 +43,6 @@ module Axn
54
43
  value = config.enabled
55
44
  value.respond_to?(:call) ? !!value.call : !!value
56
45
  end
57
-
58
- # DEPRECATED backward-compatible aliases for the pre-DSL API. The
59
- # Axn::Configurable DSL standardizes on `.config` / `reset_config!`.
60
- # These keep older callers working but emit a deprecation warning and
61
- # are scheduled for removal in the next minor version (see DEPRECATIONS.md).
62
- def configuration
63
- _warn_deprecated_alias("Axn::RubyLLM.configuration", "Axn::RubyLLM.config")
64
- DeprecatedConfigProxy.new(config)
65
- end
66
-
67
- def reset_configuration!
68
- _warn_deprecated_alias("Axn::RubyLLM.reset_configuration!", "Axn::RubyLLM.reset_config!")
69
- reset_config!
70
- end
71
-
72
- private
73
-
74
- def _warn_deprecated_alias(old, new)
75
- warn(
76
- "[axn-ruby_llm] DEPRECATION: #{old} is deprecated and will be removed in the next minor version; use #{new} instead.",
77
- category: :deprecated,
78
- uplevel: 2,
79
- )
80
- end
81
46
  end
82
47
  end
83
48
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: axn-ruby_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.0.pre.alpha.6
18
+ version: 0.1.0.pre.alpha.6.1
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: 0.2.0
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: 0.1.0.pre.alpha.6
28
+ version: 0.1.0.pre.alpha.6.1
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: 0.2.0
@@ -35,22 +35,36 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: '1.15'
38
+ version: '2.0'
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
- version: '2.0'
41
+ version: '3.0'
42
42
  type: :runtime
43
43
  prerelease: false
44
44
  version_requirements: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '1.15'
48
+ version: '2.0'
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
- version: '2.0'
51
+ version: '3.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: faraday
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 1.10.0
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 1.10.0
52
66
  description: Call LLMs from Axn actions using RubyLLM, with structured error handling,
53
- optional JSON mode, and cost/token tracking.
67
+ schema-based structured output, and cost/token tracking.
54
68
  email:
55
69
  - kali@teamshares.com
56
70
  executables: []