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 +4 -4
- data/CHANGELOG.md +106 -0
- data/README.md +65 -35
- data/lib/axn/ruby_llm/ask.rb +122 -96
- data/lib/axn/ruby_llm/rspec.rb +23 -43
- data/lib/axn/ruby_llm/tool_adapter.rb +37 -195
- data/lib/axn/ruby_llm/version.rb +1 -1
- data/lib/axn/ruby_llm.rb +0 -35
- metadata +22 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1c013cfd72ea5be2662e93875a9f0f01b8d8761cfe008aabed920bb3ee4ab8d1
|
|
4
|
+
data.tar.gz: 2977d6ab65433f53e59f019c796a9a10adc016efa290990ee3596d4b0680df4f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
|
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 <
|
|
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
|
-
|
|
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
|
|
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`
|
|
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`
|
|
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::
|
|
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
|
-
| `
|
|
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.
|
|
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)
|
|
182
|
-
Axn::RubyLLM.wrap(CreateWidget,
|
|
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.
|
|
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 `
|
|
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.
|
|
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
|
|
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
|
-
- **
|
|
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
|
-
- **
|
|
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:`.
|
|
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 }` (`
|
|
353
|
-
| `raw_message` | Stub struct with `.content`, `.
|
|
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` |
|
data/lib/axn/ruby_llm/ask.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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:
|
|
83
|
-
output_tokens:
|
|
84
|
-
cache_read_tokens:
|
|
85
|
-
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:
|
|
93
|
-
output_tokens:
|
|
105
|
+
input_tokens: token_usage.input,
|
|
106
|
+
output_tokens: token_usage.output,
|
|
94
107
|
cost: cost_breakdown&.total,
|
|
95
|
-
response_model:
|
|
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
|
-
|
|
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:,
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 =
|
|
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(
|
|
214
|
-
c.
|
|
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:)`.
|
data/lib/axn/ruby_llm/rspec.rb
CHANGED
|
@@ -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({ "
|
|
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,
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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,
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
chat_instance
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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.
|
|
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 :
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
124
|
-
|
|
139
|
+
parameters(input_schema)
|
|
140
|
+
provider_options(provider_options) if provider_options.any?
|
|
125
141
|
|
|
126
|
-
|
|
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#
|
|
168
|
-
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
data/lib/axn/ruby_llm/version.rb
CHANGED
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.
|
|
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: '
|
|
38
|
+
version: '2.0'
|
|
39
39
|
- - "<"
|
|
40
40
|
- !ruby/object:Gem::Version
|
|
41
|
-
version: '
|
|
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: '
|
|
48
|
+
version: '2.0'
|
|
49
49
|
- - "<"
|
|
50
50
|
- !ruby/object:Gem::Version
|
|
51
|
-
version: '
|
|
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
|
-
|
|
67
|
+
schema-based structured output, and cost/token tracking.
|
|
54
68
|
email:
|
|
55
69
|
- kali@teamshares.com
|
|
56
70
|
executables: []
|