axn-ruby_llm 0.1.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 +7 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.md +169 -0
- data/Rakefile +13 -0
- data/lib/axn/ruby_llm/ask.rb +134 -0
- data/lib/axn/ruby_llm/configuration.rb +20 -0
- data/lib/axn/ruby_llm/rspec.rb +78 -0
- data/lib/axn/ruby_llm/version.rb +7 -0
- data/lib/axn/ruby_llm.rb +29 -0
- data/lib/axn-ruby_llm.rb +3 -0
- metadata +95 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5c8aae7528ce13d8b34d96781ea472dec9059aaadd029176c8442da9a828c88c
|
|
4
|
+
data.tar.gz: 5d45458a6c80b9679fa3a59c12e9674264891788bbd586b26fb589763403bd19
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e42eb1a60816cf49c579ebe9343ffa53ff39eadb95a746cd624a4bc78a1209ef8f52f8c2f566566d8d47fc45b16da86161bb88e179814b77bd919c5af44b45af
|
|
7
|
+
data.tar.gz: 12ca3f1d02a06c8c0e0d1120a9d4b600b37dde32fed917020cd50ff129958e9509cf4b87bb0037f79e50e227669bccec4ccf3bfb09bfb80ae371842ac66fd70a
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-05-21
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- `Axn::RubyLLM::Ask` action — port of the `Actions::LLM::Ask` pattern from buyout-app, with parameterized model/system_prompt/temperature and opt-in JSON mode (default `false`).
|
|
8
|
+
- `Axn::RubyLLM.ask` / `ask!` module-level shortcuts.
|
|
9
|
+
- Structured output: pass `schema:` (a `RubyLLM::Schema` class, instance, or any JSON Schema hash) to enable provider-enforced structured output via `RubyLLM::Chat#with_schema`. Result returns a parsed Hash; non-JSON responses fail with `"Schema response was not valid JSON"`. Takes precedence over `json: true`.
|
|
10
|
+
- Result exposes `response`, `raw_message`, `input_tokens`, `output_tokens`, `cost` (Float USD total), and `cost_breakdown` (`RubyLLM::Cost` struct). Cost fields are nil when RubyLLM lacks pricing for the model.
|
|
11
|
+
- OpenTelemetry span enrichment: when an OTel SDK is loaded, every `Ask` call sets `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cost` (USD), and `axn.ruby_llm.stubbed` on the existing `axn.call` span. No configuration required; no-op if OTel is not loaded. Full LLM-level tracing (individual chat calls, tool calls, embeddings) requires [`opentelemetry-instrumentation-ruby_llm`](https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm) in your own Gemfile.
|
|
12
|
+
- Production gating: `Axn::RubyLLM.configure { |c| c.enabled = -> { ... } }` (Boolean or callable). When disabled, `Ask` returns a success result with stub content (`response: "stubbed response value"` for plain, `{ "stubbed" => true }` for json/schema; `raw_message` is an `Ask::StubMessage` Data instance; tokens/cost zeroed) and `result.stubbed == true`.
|
|
13
|
+
- Rate-limit handling rescues `RubyLLM::RateLimitError` (HTTP 429, provider-agnostic) and fails with `"Rate limit reached: <message>"`.
|
|
14
|
+
- `Axn::RubyLLM::RSpec::Helpers` — `stub_axn_ruby_llm` helper accepting `response:`, optional `model:`, `schema:`, `input_tokens:`, `output_tokens:`, `cost:`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Teamshares, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# axn-ruby_llm
|
|
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.
|
|
4
|
+
|
|
5
|
+
Part of the `axn-*` extension ecosystem — see also [axn-mcp](https://github.com/teamshares/axn-mcp).
|
|
6
|
+
|
|
7
|
+
### Why use this over calling RubyLLM directly?
|
|
8
|
+
|
|
9
|
+
Three things you'd otherwise build at every callsite:
|
|
10
|
+
|
|
11
|
+
1. **Structured error handling.** The Axn error DSL declaratively maps `RateLimitError`, `JSON::ParserError`, and generic `StandardError` to clean failure messages. Callers check `result.ok?` instead of wrapping every call in `begin/rescue`.
|
|
12
|
+
|
|
13
|
+
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
|
+
|
|
15
|
+
3. **Cost/token tracking, exposed automatically.** Every call exposes `input_tokens`, `output_tokens`, `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.
|
|
16
|
+
|
|
17
|
+
> **Scope note:** This gem covers the subset of RubyLLM functionality that [Teamshares](https://github.com/teamshares) uses internally — single-turn chat, structured output, and basic observability. It is intentionally minimal rather than a full-featured wrapper. Feedback and pull requests to extend it are very welcome.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "axn-ruby_llm"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Configure RubyLLM as normal (e.g. in `config/initializers/ruby_llm.rb`). The default model is `gpt-4o-mini`, but any [RubyLLM-supported provider](https://rubyllm.com/llms) works — just configure the appropriate API key and pass `model:` to override:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
RubyLLM.configure do |c|
|
|
31
|
+
c.openai_api_key = ENV["OPENAI_API_KEY"] # OpenAI
|
|
32
|
+
c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"] # or Anthropic, Gemini, etc.
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Optionally configure gem-level defaults:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
Axn::RubyLLM.configure do |c|
|
|
40
|
+
c.default_model = "gpt-4o-mini" # default; override with any RubyLLM model ID
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
result = Axn::RubyLLM.ask(
|
|
48
|
+
prompt: "Summarize this Slack thread: #{thread_text}"
|
|
49
|
+
)
|
|
50
|
+
result.response # => "The team decided to..."
|
|
51
|
+
|
|
52
|
+
# JSON mode
|
|
53
|
+
result = Axn::RubyLLM.ask(
|
|
54
|
+
prompt: build_extraction_prompt(doc),
|
|
55
|
+
json: true
|
|
56
|
+
)
|
|
57
|
+
result.response # => { "company" => "Acme", "founded" => 1999 }
|
|
58
|
+
|
|
59
|
+
# With system prompt and model override
|
|
60
|
+
result = Axn::RubyLLM.ask(
|
|
61
|
+
prompt: user_message,
|
|
62
|
+
system_prompt: "You are a concise financial analyst.",
|
|
63
|
+
model: "gpt-4o",
|
|
64
|
+
temperature: 0.2
|
|
65
|
+
)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The underlying action class is available as `Axn::RubyLLM::Ask` for cases where you need the full `Axn` interface (`call!`, `call_async`, instrumentation hooks, etc.).
|
|
69
|
+
|
|
70
|
+
### Structured output via schema
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class CompanyMatch < RubyLLM::Schema
|
|
76
|
+
integer :company_id, description: "ID of the matched company, or null"
|
|
77
|
+
number :confidence, description: "0.0–1.0"
|
|
78
|
+
string :reasoning
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
result = Axn::RubyLLM.ask(
|
|
82
|
+
prompt: "Which company is this thread about?\n\n#{thread_text}",
|
|
83
|
+
schema: CompanyMatch,
|
|
84
|
+
)
|
|
85
|
+
result.response # => { "company_id" => 42, "confidence" => 0.92, "reasoning" => "..." }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`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
|
+
|
|
90
|
+
### Token counts and cost
|
|
91
|
+
|
|
92
|
+
Every successful result exposes token usage and cost in two tiers:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
result = Axn::RubyLLM.ask(prompt: "...")
|
|
96
|
+
|
|
97
|
+
# Flat (common case)
|
|
98
|
+
result.input_tokens # => 412
|
|
99
|
+
result.output_tokens # => 78
|
|
100
|
+
result.cost # => 0.00056 (Float USD total; nil if RubyLLM has no pricing for the model)
|
|
101
|
+
|
|
102
|
+
# Resolved breakdown — RubyLLM::Cost struct
|
|
103
|
+
result.cost_breakdown # => #<Cost input: 0.0004, output: 0.00016, cache_read: 0.0, ..., total: 0.00056>
|
|
104
|
+
|
|
105
|
+
# Full escape hatch — the raw RubyLLM::Message for cache/thinking tokens, etc.
|
|
106
|
+
result.raw_message # => #<RubyLLM::Message ...>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`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.
|
|
110
|
+
|
|
111
|
+
Errors are handled via Axn's declarative `error` DSL:
|
|
112
|
+
- `JSON::ParserError` → result fails with `"Failed to parse JSON from LLM response"`
|
|
113
|
+
- `RubyLLM::RateLimitError` (HTTP 429, provider-agnostic) → result fails with `"Rate limit reached: <message>"`
|
|
114
|
+
- `schema:` set but LLM returned non-JSON → result fails with `"Schema response was not valid JSON"`
|
|
115
|
+
- Any other `StandardError` → result fails with `"LLM request failed: <message>"`
|
|
116
|
+
|
|
117
|
+
## Testing
|
|
118
|
+
|
|
119
|
+
In your specs, require the helpers and use `stub_axn_ruby_llm`:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
require "axn/ruby_llm/rspec"
|
|
123
|
+
|
|
124
|
+
it "summarizes the thread" do
|
|
125
|
+
stub_axn_ruby_llm(response: "The team agreed to ship on Friday.")
|
|
126
|
+
result = Axn::RubyLLM.ask(prompt: "...")
|
|
127
|
+
expect(result.response).to include("ship on Friday")
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## OpenTelemetry
|
|
132
|
+
|
|
133
|
+
If your app uses OpenTelemetry, `axn` already wraps every action in an `axn.call` span. This gem enriches that span with LLM-specific attributes automatically — no configuration required:
|
|
134
|
+
|
|
135
|
+
| Attribute | Value |
|
|
136
|
+
|---|---|
|
|
137
|
+
| `gen_ai.request.model` | The model requested |
|
|
138
|
+
| `gen_ai.response.model` | The model that responded |
|
|
139
|
+
| `gen_ai.usage.input_tokens` | Prompt token count |
|
|
140
|
+
| `gen_ai.usage.output_tokens` | Completion token count |
|
|
141
|
+
| `gen_ai.usage.cost` | USD total (non-standard; useful for spend filtering) |
|
|
142
|
+
| `axn.ruby_llm.stubbed` | `true` when production gating returned a stub |
|
|
143
|
+
|
|
144
|
+
For LLM-level tracing (individual `RubyLLM.chat` calls, tool calls, embeddings, prompt content), add [`opentelemetry-instrumentation-ruby_llm`](https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm) to your own Gemfile and configure it per its README. It is not a dependency of this gem.
|
|
145
|
+
|
|
146
|
+
## Production gating
|
|
147
|
+
|
|
148
|
+
Set `Configuration#enabled` to gate LLM calls — useful for skipping spend in non-production environments. Accepts a Boolean or a callable (evaluated per call):
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
Axn::RubyLLM.configure do |c|
|
|
152
|
+
c.enabled = -> { Rails.env.production? }
|
|
153
|
+
# c.enabled = false # always stub
|
|
154
|
+
# c.enabled = true # default; always run
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
When disabled, `Ask` returns a **success** result with obvious stub content, so callers don't need per-callsite branching:
|
|
159
|
+
|
|
160
|
+
| Field | Stubbed value |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `response` | `"stubbed response value"` (plain) / `{ "stubbed" => true }` (`json: true` or `schema:`) |
|
|
163
|
+
| `raw_message` | `Ask::StubMessage` Data instance with `.content`, `.input_tokens`, `.output_tokens`, `.model_id` |
|
|
164
|
+
| `input_tokens` / `output_tokens` | `0` |
|
|
165
|
+
| `cost` | `0.0` |
|
|
166
|
+
| `cost_breakdown` | `nil` |
|
|
167
|
+
| `stubbed` | `true` |
|
|
168
|
+
|
|
169
|
+
Check `result.stubbed` if you need to branch on it (e.g. skip downstream writes that would otherwise persist stub LLM output). The Axn result's `message` is `"disabled - returning stubbed values"` for the same purpose.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
require "rubocop/rake_task"
|
|
6
|
+
|
|
7
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
8
|
+
|
|
9
|
+
RuboCop::RakeTask.new
|
|
10
|
+
|
|
11
|
+
task default: %i[spec rubocop]
|
|
12
|
+
|
|
13
|
+
Rake::Task["build"].enhance([:default])
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module RubyLLM
|
|
5
|
+
class Ask
|
|
6
|
+
include Axn
|
|
7
|
+
|
|
8
|
+
expects :prompt
|
|
9
|
+
expects :json, type: :boolean, default: false
|
|
10
|
+
expects :schema, optional: true
|
|
11
|
+
expects :model, optional: true
|
|
12
|
+
expects :system_prompt, optional: true
|
|
13
|
+
expects :temperature, optional: true
|
|
14
|
+
|
|
15
|
+
exposes :response
|
|
16
|
+
exposes :raw_message
|
|
17
|
+
exposes :input_tokens, allow_nil: true
|
|
18
|
+
exposes :output_tokens, allow_nil: true
|
|
19
|
+
exposes :cost, allow_nil: true
|
|
20
|
+
exposes :cost_breakdown, allow_nil: true
|
|
21
|
+
exposes :stubbed, type: :boolean, default: false
|
|
22
|
+
|
|
23
|
+
StubMessage = Data.define(:content, :input_tokens, :output_tokens, :model_id)
|
|
24
|
+
|
|
25
|
+
error prefix: "LLM request failed: "
|
|
26
|
+
error "Failed to parse JSON from LLM response", if: JSON::ParserError
|
|
27
|
+
|
|
28
|
+
before do
|
|
29
|
+
if disabled?
|
|
30
|
+
exposures = stubbed_exposures
|
|
31
|
+
record_otel_attributes!(
|
|
32
|
+
input_tokens: exposures[:input_tokens],
|
|
33
|
+
output_tokens: exposures[:output_tokens],
|
|
34
|
+
cost: exposures[:cost],
|
|
35
|
+
response_model: nil,
|
|
36
|
+
stubbed: true,
|
|
37
|
+
)
|
|
38
|
+
done!("disabled - returning stubbed values", **exposures)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call
|
|
43
|
+
expose(
|
|
44
|
+
response: parsed_response,
|
|
45
|
+
raw_message: llm_response,
|
|
46
|
+
input_tokens: llm_response.input_tokens,
|
|
47
|
+
output_tokens: llm_response.output_tokens,
|
|
48
|
+
cost_breakdown:,
|
|
49
|
+
cost: cost_breakdown&.total,
|
|
50
|
+
stubbed: false,
|
|
51
|
+
)
|
|
52
|
+
record_otel_attributes!(
|
|
53
|
+
input_tokens: llm_response.input_tokens,
|
|
54
|
+
output_tokens: llm_response.output_tokens,
|
|
55
|
+
cost: cost_breakdown&.total,
|
|
56
|
+
response_model: llm_response.model_id,
|
|
57
|
+
stubbed: false,
|
|
58
|
+
)
|
|
59
|
+
rescue ::RubyLLM::RateLimitError => e
|
|
60
|
+
fail! "Rate limit reached: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def disabled? = !Axn::RubyLLM.configuration.enabled?
|
|
66
|
+
|
|
67
|
+
def stubbed_exposures
|
|
68
|
+
content = schema || json ? { "stubbed" => true } : "stubbed response value"
|
|
69
|
+
{
|
|
70
|
+
response: content,
|
|
71
|
+
raw_message: StubMessage.new(content:, input_tokens: 0, output_tokens: 0, model_id: "stubbed"),
|
|
72
|
+
input_tokens: 0,
|
|
73
|
+
output_tokens: 0,
|
|
74
|
+
cost: 0.0,
|
|
75
|
+
cost_breakdown: nil,
|
|
76
|
+
stubbed: true,
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def parsed_response
|
|
81
|
+
if schema
|
|
82
|
+
# with_schema makes RubyLLM parse the response into a Hash on success
|
|
83
|
+
return llm_response.content if llm_response.content.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
fail! "Schema response was not valid JSON"
|
|
86
|
+
end
|
|
87
|
+
json ? JSON.parse(llm_response.content) : llm_response.content
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def cost_breakdown
|
|
91
|
+
return nil unless model_info
|
|
92
|
+
|
|
93
|
+
llm_response.cost(model: model_info)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
memo def model_info
|
|
97
|
+
::RubyLLM.models.find(llm_response.model_id)
|
|
98
|
+
rescue ::RubyLLM::ModelNotFoundError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
memo def llm_response = chat.ask(prompt)
|
|
103
|
+
|
|
104
|
+
memo def chat
|
|
105
|
+
::RubyLLM.chat(model: resolved_model).tap do |c|
|
|
106
|
+
c.with_instructions(system_prompt) if system_prompt
|
|
107
|
+
c.with_schema(schema) if schema
|
|
108
|
+
c.with_params(response_format: { type: "json_object" }) if json && !schema
|
|
109
|
+
c.with_params(temperature:) if temperature
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolved_model
|
|
114
|
+
model || Axn::RubyLLM.configuration.default_model
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def record_otel_attributes!(input_tokens:, output_tokens:, cost:, response_model:, stubbed:)
|
|
118
|
+
return unless defined?(::OpenTelemetry::Trace)
|
|
119
|
+
|
|
120
|
+
span = ::OpenTelemetry::Trace.current_span
|
|
121
|
+
return unless span&.context&.valid?
|
|
122
|
+
|
|
123
|
+
span.set_attribute("gen_ai.request.model", resolved_model) if resolved_model
|
|
124
|
+
span.set_attribute("gen_ai.response.model", response_model) if response_model
|
|
125
|
+
span.set_attribute("gen_ai.usage.input_tokens", input_tokens) if input_tokens
|
|
126
|
+
span.set_attribute("gen_ai.usage.output_tokens", output_tokens) if output_tokens
|
|
127
|
+
span.set_attribute("gen_ai.usage.cost", cost) if cost
|
|
128
|
+
span.set_attribute("axn.ruby_llm.stubbed", stubbed) unless stubbed.nil?
|
|
129
|
+
rescue StandardError
|
|
130
|
+
# never let telemetry break the action
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module RubyLLM
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULT_MODEL = "gpt-4o-mini"
|
|
7
|
+
|
|
8
|
+
attr_accessor :default_model, :enabled
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@default_model = DEFAULT_MODEL
|
|
12
|
+
@enabled = true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def enabled?
|
|
16
|
+
enabled.respond_to?(:call) ? !!enabled.call : !!enabled
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "axn-ruby_llm"
|
|
4
|
+
|
|
5
|
+
module Axn
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module RSpec
|
|
8
|
+
module Helpers
|
|
9
|
+
# Stubs RubyLLM so that Ask returns a canned response.
|
|
10
|
+
#
|
|
11
|
+
# Usage in a spec:
|
|
12
|
+
# stub_axn_ruby_llm(response: "Here is a summary.")
|
|
13
|
+
# stub_axn_ruby_llm(response: { "key" => "value" }) # auto-JSON-serialized for json: true calls
|
|
14
|
+
# stub_axn_ruby_llm(response: { "k" => "v" }, schema: MySchema) # Hash passed through unparsed
|
|
15
|
+
# stub_axn_ruby_llm(response: "...", input_tokens: 100, output_tokens: 50, cost: 0.0023)
|
|
16
|
+
#
|
|
17
|
+
# Returns the chat instance double for further assertions if needed.
|
|
18
|
+
def stub_axn_ruby_llm(response:, model: nil, schema: nil, input_tokens: nil, output_tokens: nil, cost: nil)
|
|
19
|
+
resolved_model_id = model || Axn::RubyLLM.configuration.default_model
|
|
20
|
+
llm_message = _stub_axn_ruby_llm_message(response, resolved_model_id, input_tokens, output_tokens, schema:)
|
|
21
|
+
chat_instance = _stub_axn_ruby_llm_chat(model, llm_message, schema:)
|
|
22
|
+
_stub_axn_ruby_llm_cost(llm_message, resolved_model_id, cost)
|
|
23
|
+
chat_instance
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def _stub_axn_ruby_llm_message(response, model_id, input_tokens, output_tokens, schema:)
|
|
29
|
+
content = if schema
|
|
30
|
+
response
|
|
31
|
+
elsif response.is_a?(Hash)
|
|
32
|
+
response.to_json
|
|
33
|
+
else
|
|
34
|
+
response.to_s
|
|
35
|
+
end
|
|
36
|
+
instance_double(::RubyLLM::Message, content:, input_tokens:, output_tokens:, model_id:)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def _stub_axn_ruby_llm_chat(model, llm_message, schema:)
|
|
40
|
+
chat_instance = instance_double(::RubyLLM::Chat)
|
|
41
|
+
if model
|
|
42
|
+
allow(::RubyLLM).to receive(:chat).with(model:).and_return(chat_instance)
|
|
43
|
+
else
|
|
44
|
+
allow(::RubyLLM).to receive(:chat).and_return(chat_instance)
|
|
45
|
+
end
|
|
46
|
+
allow(chat_instance).to receive(:with_instructions).and_return(chat_instance)
|
|
47
|
+
allow(chat_instance).to receive(:with_params).and_return(chat_instance)
|
|
48
|
+
# Always stub with_schema so specs don't blow up if production code passes schema:
|
|
49
|
+
# even when the helper is called without schema:. Use a tight matcher when schema
|
|
50
|
+
# is known so the stub still validates the correct class is passed.
|
|
51
|
+
if schema
|
|
52
|
+
allow(chat_instance).to receive(:with_schema).with(schema).and_return(chat_instance)
|
|
53
|
+
else
|
|
54
|
+
allow(chat_instance).to receive(:with_schema).and_return(chat_instance)
|
|
55
|
+
end
|
|
56
|
+
allow(chat_instance).to receive(:ask).and_return(llm_message)
|
|
57
|
+
chat_instance
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def _stub_axn_ruby_llm_cost(llm_message, model_id, cost)
|
|
61
|
+
model_info = instance_double("RubyLLM::Model")
|
|
62
|
+
allow(::RubyLLM.models).to receive(:find).with(model_id).and_return(model_info)
|
|
63
|
+
# Default to zero cost so specs exercise the "model found, cost computed" path.
|
|
64
|
+
# Pass cost: explicitly to assert a specific value.
|
|
65
|
+
cost_total = cost || 0.0
|
|
66
|
+
cost_struct = instance_double(::RubyLLM::Cost, total: cost_total)
|
|
67
|
+
allow(llm_message).to receive(:cost).with(model: model_info).and_return(cost_struct)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if defined?(RSpec)
|
|
75
|
+
RSpec.configure do |config|
|
|
76
|
+
config.include Axn::RubyLLM::RSpec::Helpers
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/axn/ruby_llm.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
require "axn"
|
|
5
|
+
|
|
6
|
+
require_relative "ruby_llm/version"
|
|
7
|
+
require_relative "ruby_llm/configuration"
|
|
8
|
+
require_relative "ruby_llm/ask"
|
|
9
|
+
|
|
10
|
+
module Axn
|
|
11
|
+
module RubyLLM
|
|
12
|
+
class << self
|
|
13
|
+
def configuration
|
|
14
|
+
@configuration ||= Configuration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield configuration
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reset_configuration!
|
|
22
|
+
@configuration = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ask(**) = Ask.call(**)
|
|
26
|
+
def ask!(**) = Ask.call!(**)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/axn-ruby_llm.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: axn-ruby_llm
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kali Donovan
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: axn
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0.pre.alpha.4.2
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: 0.2.0
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 0.1.0.pre.alpha.4.2
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: 0.2.0
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: ruby_llm
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '1.0'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '2.0'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '1.0'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '2.0'
|
|
52
|
+
description: Call LLMs from Axn actions using RubyLLM, with structured error handling,
|
|
53
|
+
optional JSON mode, and cost/token tracking.
|
|
54
|
+
email:
|
|
55
|
+
- kali@teamshares.com
|
|
56
|
+
executables: []
|
|
57
|
+
extensions: []
|
|
58
|
+
extra_rdoc_files: []
|
|
59
|
+
files:
|
|
60
|
+
- CHANGELOG.md
|
|
61
|
+
- LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- Rakefile
|
|
64
|
+
- lib/axn-ruby_llm.rb
|
|
65
|
+
- lib/axn/ruby_llm.rb
|
|
66
|
+
- lib/axn/ruby_llm/ask.rb
|
|
67
|
+
- lib/axn/ruby_llm/configuration.rb
|
|
68
|
+
- lib/axn/ruby_llm/rspec.rb
|
|
69
|
+
- lib/axn/ruby_llm/version.rb
|
|
70
|
+
homepage: https://github.com/teamshares/axn-ruby_llm
|
|
71
|
+
licenses:
|
|
72
|
+
- MIT
|
|
73
|
+
metadata:
|
|
74
|
+
homepage_uri: https://github.com/teamshares/axn-ruby_llm
|
|
75
|
+
source_code_uri: https://github.com/teamshares/axn-ruby_llm
|
|
76
|
+
changelog_uri: https://github.com/teamshares/axn-ruby_llm/blob/main/CHANGELOG.md
|
|
77
|
+
rubygems_mfa_required: 'true'
|
|
78
|
+
rdoc_options: []
|
|
79
|
+
require_paths:
|
|
80
|
+
- lib
|
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - ">="
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: 3.2.1
|
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '0'
|
|
91
|
+
requirements: []
|
|
92
|
+
rubygems_version: 3.6.8
|
|
93
|
+
specification_version: 4
|
|
94
|
+
summary: RubyLLM wrapper for Axn actions
|
|
95
|
+
test_files: []
|