activeagent 1.9.2 → 1.9.3
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 +202 -0
- data/README.md +3 -0
- data/lib/active_agent/base.rb +2 -2
- data/lib/active_agent/evals/publisher.rb +19 -12
- data/lib/active_agent/evals/report.rb +12 -62
- data/lib/active_agent/evals/report_html.rb +85 -10
- data/lib/active_agent/evals/report_markdown.rb +202 -0
- data/lib/active_agent/evals.rb +1 -0
- data/lib/active_agent/providers/_base_provider.rb +2 -0
- data/lib/active_agent/providers/anthropic_provider.rb +1 -0
- data/lib/active_agent/providers/common/responses/base.rb +15 -2
- data/lib/active_agent/providers/common/usage.rb +4 -2
- data/lib/active_agent/providers/open_ai/chat_provider.rb +39 -0
- data/lib/active_agent/providers/open_ai/responses_provider.rb +3 -0
- data/lib/active_agent/providers/opper/_types.rb +16 -0
- data/lib/active_agent/providers/opper/options.rb +38 -0
- data/lib/active_agent/providers/opper_provider.rb +72 -0
- data/lib/active_agent/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7ecc64cad33188dd3cdfc563b553e49d0a05d42192698365a467061e6bb6d59
|
|
4
|
+
data.tar.gz: c3923cee8e401e17e3170c28f2fc83d8c290d70c5a4bc4d301951031d7d9cf43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 66e3244ce04d56625f880e26c18cbe3f353f35eca2f4dd5922a1adafb1d542ee5216065401e7ddbbe88cccc1c40336076ab6d4a30d54a5360846cad595ae9717
|
|
7
|
+
data.tar.gz: b023e5fe43f6674b7040086cc5a47c19fffc4bf3e919d0bb0ab0138d5c4839d3463f09e5a66d7047e4e28262628465a066b343ee76a503732b803ddc5d270bff
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,208 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.9.3] - 2026-10-21
|
|
9
|
+
|
|
10
|
+
Releases `activeagent` and `actionagent` 1.9.3, with the matching browser
|
|
11
|
+
sidecar: browser upload and code conversations (the sandbox browser's live
|
|
12
|
+
view takes files from the person driving it, and a Claude Code or Codex
|
|
13
|
+
session is a conversation with the sandbox's browser beside the chat and a
|
|
14
|
+
hand-off either way, Codex on this machine's own login), evaluation run
|
|
15
|
+
reports exported as JSON, HTML and Markdown with Copy as Markdown, an agent
|
|
16
|
+
builder that offers only tools that work, a model leaderboard on the
|
|
17
|
+
Evaluations page, complete trace window statistics, a judge pin that runs
|
|
18
|
+
on its own provider, Opper as a provider and Parallel Search in the MCP
|
|
19
|
+
catalog, and streaming fixes (unindexed tool call fragments, the finish
|
|
20
|
+
reason of streamed and Responses generations, Anthropic cache-token usage).
|
|
21
|
+
The framework, the dashboard engine and `@activeagents/browser-sidecar`
|
|
22
|
+
move to 1.9.3 together.
|
|
23
|
+
|
|
24
|
+
Upgrading: migration 028 adds the conversation columns to `code_sessions`.
|
|
25
|
+
A host runs `bin/rails generate action_agent:install --skip` then
|
|
26
|
+
`bin/rails db:migrate`, after 1.9.1's 027. Upgrade the sidecar with the
|
|
27
|
+
engine: the file chooser relay and the per-turn browser tokens are in the
|
|
28
|
+
1.9.3 sidecar. On a single-tenant install the new report export
|
|
29
|
+
(`GET <mount>/api/evaluation_reports/:run_id`) answers 401 until
|
|
30
|
+
`ActionAgent.ingest_api_key` is set. A `judge_model` pin written
|
|
31
|
+
`anthropic/…` or `openai/…` now runs on that native provider; an install
|
|
32
|
+
that reached such a model through OpenRouter pins `openrouter/anthropic/…`.
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **Export evaluation run reports as JSON, HTML and Markdown** (`activeagent`,
|
|
37
|
+
`actionagent`). `GET <mount>/api/evaluation_reports/:run_id`, with the
|
|
38
|
+
ingest key and scoped to the tenant like the POST (and refused on a
|
|
39
|
+
single-tenant install that set no key), returns a complete run
|
|
40
|
+
as the version-1 envelope the collector takes, so a run moves to another
|
|
41
|
+
install with one POST and posting it back is an identical retry (200);
|
|
42
|
+
`?format=html` returns the run's report page and `?format=md` its
|
|
43
|
+
Markdown, and `?download=1` an attachment of any of them.
|
|
44
|
+
An imported run exports under its published `run_id`, a dashboard run
|
|
45
|
+
under `dashboard-run-<id>-<created_at>`. `ActiveAgent::Evals::Publisher.envelope`
|
|
46
|
+
builds the envelope for both the publisher and the export. The dashboard's
|
|
47
|
+
run report adds `report.json` and `report.md` (one piece with
|
|
48
|
+
`?part=verdict|fix_items|scenario`), and `?download=1` saves any format
|
|
49
|
+
as `<evaluation>-run-<n>.<ext>`; the run report view and the suite's run
|
|
50
|
+
header get *Download HTML / JSON / Markdown*. New rake tasks:
|
|
51
|
+
`action_agent:evaluations:export RUN=… [EVALUATION=…] FORMAT=json|html|md [FILE=…]`
|
|
52
|
+
and `action_agent:evaluations:import FILE=… [ACCOUNT_ID=…]`.
|
|
53
|
+
|
|
54
|
+
- **Copy as Markdown** (`activeagent`, `actionagent`). `Report#to_markdown`
|
|
55
|
+
now has the HTML page's content in GitHub-flavored Markdown: the verdict's
|
|
56
|
+
rationale, What to fix from `Report#fix_items` (replacing the old
|
|
57
|
+
Recommendations list), and every scenario's answers in a `<details>` block
|
|
58
|
+
with a per-model table; table cells escape `|` and line breaks. Its pieces
|
|
59
|
+
are public — `verdict_markdown`, `fix_items_markdown(model:)`,
|
|
60
|
+
`fix_item_markdown(item)`, `scenario_markdown(key)` — and the dashboard
|
|
61
|
+
and the standalone HTML page copy each of them: the whole report, the
|
|
62
|
+
verdict with the comparison, What to fix for the filtered model, a fix
|
|
63
|
+
card, and a scenario. The HTML page now carries one inline script for
|
|
64
|
+
those buttons, with a clipboard fallback.
|
|
65
|
+
|
|
66
|
+
- **Upload files to a page through the sandbox browser's live view**
|
|
67
|
+
(`actionagent`, `@activeagents/browser-sidecar`). A file chooser the page
|
|
68
|
+
opens while someone drives is answered from the driver's own machine
|
|
69
|
+
(picker or drop), over the live-view WebSocket.
|
|
70
|
+
|
|
71
|
+
- **Claude Code and Codex sessions as Interactions, with chat and browser
|
|
72
|
+
handoff** (`actionagent`, `@activeagents/browser-sidecar`). A coding
|
|
73
|
+
session is now a conversation: a later message resumes the CLI session
|
|
74
|
+
(`--resume`, or `codex exec resume` for Codex), a message sent while a
|
|
75
|
+
turn runs waits for it, and
|
|
76
|
+
`POST /api/sandboxes/:sandbox_id/code_sessions/:id/messages` starts or
|
|
77
|
+
queues a turn under the new `:chat_code_session` permission.
|
|
78
|
+
`GET /api/interactions` lists agent, Claude Code and Codex interactions
|
|
79
|
+
together, and the dashboard's `/interactions/code-<id>` shows the turns
|
|
80
|
+
with a composer and the sandbox's browser beside the chat, with a hand
|
|
81
|
+
back to the agent. The sidecar mints a per-turn token for the browser
|
|
82
|
+
MCP, tells the agent once when a person took or returned control, lets
|
|
83
|
+
the agent ask for a person (`browser_request_person`), offers a person
|
|
84
|
+
the file chooser the agent left open, and names the waiting agent in the
|
|
85
|
+
live view. Every Claude Code session runs with `--strict-mcp-config`, and
|
|
86
|
+
every session gets a system note (on stdin, for Codex) that repository
|
|
87
|
+
content, tool output and pages are data.
|
|
88
|
+
|
|
89
|
+
- **Codex turns reach the sandbox browser, and Codex can run on this
|
|
90
|
+
machine's own login** (`actionagent`). A Codex turn now gets the same
|
|
91
|
+
per-turn browser token as a Claude Code turn: the sidecar's MCP server
|
|
92
|
+
goes in as `--config 'mcp_servers.browser.url="…"'`, with the token in an
|
|
93
|
+
environment variable Codex reads (`bearer_token_env_var`) rather than on
|
|
94
|
+
the command line, the server's tools pre-approved
|
|
95
|
+
(`default_tools_approval_mode="approve"`, since Codex would otherwise
|
|
96
|
+
refuse every tool not marked read-only under `approval_policy="never"`),
|
|
97
|
+
and is revoked when the turn ends; hand-offs and
|
|
98
|
+
`browser_request_person` work the same. `config.codex_auth = :local_login`
|
|
99
|
+
(with the `:local` backend) runs Codex on whatever `codex login` set up
|
|
100
|
+
for the dashboard's OS user, a ChatGPT plan included, with
|
|
101
|
+
`--ignore-user-config` so none of the user's own `config.toml` reaches a
|
|
102
|
+
session; the dashboard never reads the credential and only asks `codex
|
|
103
|
+
login status` whether there is one. `GET /api/sandboxes` adds `codex_auth`
|
|
104
|
+
and, in that mode, `codex_login`, the assistant's configuration a
|
|
105
|
+
`connections.codex`, and the Codex card in Settings shows the machine's
|
|
106
|
+
login instead of a key form. The default stays `:api_key`.
|
|
107
|
+
|
|
108
|
+
- **The agent builder offers only tools that work, and every built-in
|
|
109
|
+
capability has functions** (`actionagent`). The Configure step reads the
|
|
110
|
+
agent's tool roster (`GET /api/agents/tool_roster` for an agent not
|
|
111
|
+
created yet) instead of fixed chips, offers the MCP servers a dashboard
|
|
112
|
+
run can call, and names each one it cannot with the reason
|
|
113
|
+
(`MCPToolDispatcher.unreachable_reason`: a stdio server, no URL, missing
|
|
114
|
+
credentials). The six capabilities that gave the model nothing now have
|
|
115
|
+
functions: `terminal` (`run_command`), `filesystem` (`list_files`,
|
|
116
|
+
`read_file`, `write_file`), `edit` (`edit_file`) and `database`
|
|
117
|
+
(`query_database`, read-only) act in the run's checkout sandbox through
|
|
118
|
+
new `SandboxOrchestrator` verbs (on the `:local` backend; others return
|
|
119
|
+
`UnsupportedBackendError` to the model); `translate` (`translate_text`)
|
|
120
|
+
generates on the run's own provider; `slack` lists, reads and posts with
|
|
121
|
+
a bot token connected in Settings -> Integrations -> Slack (a `slack`
|
|
122
|
+
connection `ProviderKey`). Instruction sets are composed into the
|
|
123
|
+
instructions a run executes under. `run_command`, `write_file`,
|
|
124
|
+
`edit_file` and `slack_post_message` wait for approval by default: an
|
|
125
|
+
agent that gains `terminal`, `filesystem`, `edit` or `slack` — from the
|
|
126
|
+
builder, a template, the Tools tab or the agents API — has them on its
|
|
127
|
+
`approval_required_tools` unless the save names its own list, and the
|
|
128
|
+
Tools tab's Approval switch takes them off. An agent that enabled one of
|
|
129
|
+
those capabilities before this release keeps its list as it is. A host's
|
|
130
|
+
`ActionAgent.mcp_catalog` headers now reach the MCP client (they were
|
|
131
|
+
dropped).
|
|
132
|
+
|
|
133
|
+
- **Start an interaction with your own agent from Interactions and Run
|
|
134
|
+
Agents** (`actionagent`). Interactions has a "New interaction" picker and
|
|
135
|
+
Run Agents a "Your agents" list; both open the agent's runner on a fresh
|
|
136
|
+
conversation (`/agents/:id/run?new=1`). The runner lists the agent's tools
|
|
137
|
+
and MCP servers, and can run against a checkout sandbox (`sandbox_id`),
|
|
138
|
+
which the checkout tools need.
|
|
139
|
+
|
|
140
|
+
- **A model leaderboard on the Evaluations page** (`actionagent`). The Pass
|
|
141
|
+
rate tile listed each model's passes on a line of its own, a dozen lines
|
|
142
|
+
for seven models; it now shows the overall rate, the leading model and a
|
|
143
|
+
"compare N models" toggle, which opens a leaderboard below the tiles: one
|
|
144
|
+
row per model with its pooled passes and rate, and one column per counted
|
|
145
|
+
evaluation (`—` where the model did not run). Models rank by the lower
|
|
146
|
+
bound of the Wilson score interval (95%), so a lucky 7/8 does not outrank
|
|
147
|
+
75/173.
|
|
148
|
+
|
|
149
|
+
- **Opper as an OpenAI-compatible provider** (`activeagent`). `service:
|
|
150
|
+
"Opper"` generates and embeds through Opper's compatibility endpoint
|
|
151
|
+
(`https://api.opper.ai/v3/compat`), with the key from `OPPER_API_KEY`
|
|
152
|
+
when none is configured, as the Requesty provider does (#583).
|
|
153
|
+
|
|
154
|
+
- **Parallel Search in the MCP catalog** (`actionagent`). An opt-in,
|
|
155
|
+
keyless `parallel` server (`web_search`, `web_fetch`) over HTTP; queries
|
|
156
|
+
and URLs go to parallel.ai. Every MCP HTTP request now sends
|
|
157
|
+
`User-Agent: ActionAgent/<version>` unless a header names another (#575).
|
|
158
|
+
|
|
159
|
+
### Fixed
|
|
160
|
+
|
|
161
|
+
- **Trace totals and timeline buckets cover the whole filtered window**
|
|
162
|
+
(`actionagent`). `GET /api/traces` adds `total_count`, `returned_count`,
|
|
163
|
+
`truncated`, a `summary` (requests, errors, average latency, throughput
|
|
164
|
+
per minute) and zero-filled `buckets` computed over every trace the
|
|
165
|
+
filters match, independently of the capped detail list, and the Traces
|
|
166
|
+
view reports truncation and averages recorded request durations instead
|
|
167
|
+
of time-bucket means (part of #384).
|
|
168
|
+
|
|
169
|
+
- **A pinned evaluation judge runs on its own provider** (`actionagent`).
|
|
170
|
+
A `judge_model` pin selects the model's native provider, or the one its
|
|
171
|
+
qualifier names (`openrouter/anthropic/…`), instead of the first provider
|
|
172
|
+
with credentials; the qualifier is stripped before the request, the pin
|
|
173
|
+
wins over the credential resolver's default model, and a pinned route
|
|
174
|
+
with no credentials fails the run clearly when another provider is
|
|
175
|
+
configured. Native-to-gateway alias conversion stays out of scope (#431).
|
|
176
|
+
Upgrading: a pin written `anthropic/…` or `openai/…` now selects that
|
|
177
|
+
native provider, as candidate model names already did; an install that
|
|
178
|
+
reached such a model through OpenRouter pins `openrouter/anthropic/…`.
|
|
179
|
+
|
|
180
|
+
- **Streamed tool calls without an `index` are no longer split into
|
|
181
|
+
fragments** (`activeagent`). OpenAI-compatible servers (Gemini's
|
|
182
|
+
compatibility endpoint, some Ollama and OpenRouter backends) stream tool
|
|
183
|
+
calls without the `index` that ties argument fragments to their call, and
|
|
184
|
+
the Chat provider turned every fragment into its own tool call with
|
|
185
|
+
partial arguments. A fragment now continues the call with the same `id`,
|
|
186
|
+
or the last call when it has no `id` and no function name.
|
|
187
|
+
|
|
188
|
+
- **A streamed generation reports why it stopped** (`activeagent`).
|
|
189
|
+
`response.finish_reason` (and `stop_reason`), and the `finish_reason` of
|
|
190
|
+
the `prompt.active_agent` event, were `nil` for every streamed Chat and
|
|
191
|
+
Anthropic generation, since a stream has no response body to read it
|
|
192
|
+
from. The Chat provider now records the chunk's `finish_reason` and the
|
|
193
|
+
Anthropic provider the `message_delta`'s `stop_reason`, and the response
|
|
194
|
+
falls back to it.
|
|
195
|
+
|
|
196
|
+
- **OpenAI Responses generations report why they stopped** (`activeagent`).
|
|
197
|
+
`response.finish_reason` (and `stop_reason`) was `nil` for every
|
|
198
|
+
Responses generation, streamed or not. It is now
|
|
199
|
+
`incomplete_details.reason` when the response was cut short
|
|
200
|
+
(`max_output_tokens`, `content_filter`), else the response's `status`
|
|
201
|
+
(`completed`).
|
|
202
|
+
|
|
203
|
+
- **Anthropic usage with cache token fields no longer raises** (`activeagent`).
|
|
204
|
+
`Usage.from_provider_usage` recognised Anthropic's usage only by
|
|
205
|
+
`cache_creation` or `service_tier`; a payload carrying
|
|
206
|
+
`cache_read_input_tokens` or `cache_creation_input_tokens` without them
|
|
207
|
+
fell through to `Usage.new` and raised `UnknownAttributeError`. The cache
|
|
208
|
+
token fields now mark the payload as Anthropic's (#611).
|
|
209
|
+
|
|
8
210
|
## [1.9.2] - 2026-10-20
|
|
9
211
|
|
|
10
212
|
Releases `activeagent` and `actionagent` 1.9.2, with the matching browser
|
data/README.md
CHANGED
data/lib/active_agent/base.rb
CHANGED
|
@@ -26,7 +26,7 @@ module ActiveAgent
|
|
|
26
26
|
#
|
|
27
27
|
# @example Basic agent
|
|
28
28
|
# class MyAgent < ActiveAgent::Base
|
|
29
|
-
# generate_with :openai, model: "gpt-
|
|
29
|
+
# generate_with :openai, model: "gpt-5"
|
|
30
30
|
#
|
|
31
31
|
# def greet
|
|
32
32
|
# prompt instructions: "Greet the user warmly"
|
|
@@ -112,7 +112,7 @@ module ActiveAgent
|
|
|
112
112
|
# @return [void]
|
|
113
113
|
#
|
|
114
114
|
# @example
|
|
115
|
-
# generate_with :openai, model: "gpt-
|
|
115
|
+
# generate_with :openai, model: "gpt-5"
|
|
116
116
|
def self.generate_with(provider_reference, **agent_options)
|
|
117
117
|
self.prompt_provider = provider_reference
|
|
118
118
|
|
|
@@ -94,11 +94,7 @@ module ActiveAgent
|
|
|
94
94
|
# report may be a Report or its saved JSON hash. Full prompts, answers and
|
|
95
95
|
# tool results are included; applications should make publication opt-in.
|
|
96
96
|
def call(report:, run_id:, source:, agent_name:, suite:)
|
|
97
|
-
|
|
98
|
-
identities.each do |key, value|
|
|
99
|
-
raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
|
|
100
|
-
end
|
|
101
|
-
body = encode(identities.merge("version" => 1, "report" => report_hash(report)))
|
|
97
|
+
body = encode(self.class.envelope(report: report, run_id: run_id, source: source, agent_name: agent_name, suite: suite))
|
|
102
98
|
raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
|
|
103
99
|
|
|
104
100
|
deliver("retain the report and run_id for retry") do
|
|
@@ -143,6 +139,24 @@ module ActiveAgent
|
|
|
143
139
|
end
|
|
144
140
|
end
|
|
145
141
|
|
|
142
|
+
# The version-1 envelope a collector stores: the four identities, the
|
|
143
|
+
# version and the report's hash. What #call sends, and what a
|
|
144
|
+
# collector's export of a stored run returns, so the export can be
|
|
145
|
+
# posted again. report may be a Report or its saved JSON hash.
|
|
146
|
+
#
|
|
147
|
+
# Raises ArgumentError for an identity that is not a nonempty string,
|
|
148
|
+
# or a report that is neither.
|
|
149
|
+
def self.envelope(report:, run_id:, source:, agent_name:, suite:)
|
|
150
|
+
identities = { "run_id" => run_id, "source" => source, "agent_name" => agent_name, "suite" => suite }
|
|
151
|
+
identities.each do |key, value|
|
|
152
|
+
raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
|
|
153
|
+
end
|
|
154
|
+
hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
|
|
155
|
+
raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
|
|
156
|
+
|
|
157
|
+
identities.merge("version" => 1, "report" => hash)
|
|
158
|
+
end
|
|
159
|
+
|
|
146
160
|
private
|
|
147
161
|
|
|
148
162
|
# Posts +body+ to the endpoint with the bearer key and the delivery timeouts.
|
|
@@ -176,13 +190,6 @@ module ActiveAgent
|
|
|
176
190
|
raise Error.new("Evaluation delivery failed (#{e.class}); #{guidance}", retryable: true)
|
|
177
191
|
end
|
|
178
192
|
|
|
179
|
-
def report_hash(report)
|
|
180
|
-
hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
|
|
181
|
-
raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
|
|
182
|
-
|
|
183
|
-
hash
|
|
184
|
-
end
|
|
185
|
-
|
|
186
193
|
# Returns the envelope as JSON. A report holding invalid UTF-8, NaN,
|
|
187
194
|
# Infinity or nesting over JSON's depth limit raises a non-retryable
|
|
188
195
|
# Error, without the generator's message, which can quote the report.
|
|
@@ -8,6 +8,7 @@ module ActiveAgent
|
|
|
8
8
|
# Markdown, or a self-contained HTML page (ReportHtml).
|
|
9
9
|
class Report
|
|
10
10
|
include ReportHtml
|
|
11
|
+
include ReportMarkdown
|
|
11
12
|
|
|
12
13
|
# The judge a verdict names when the framework ranked the models by
|
|
13
14
|
# pass rate itself, no judge having been available to rule on them.
|
|
@@ -307,24 +308,6 @@ module ActiveAgent
|
|
|
307
308
|
JSON.pretty_generate(to_h, *args)
|
|
308
309
|
end
|
|
309
310
|
|
|
310
|
-
def to_markdown
|
|
311
|
-
scenario_count = @results.map { |result| result.scenario.key }.uniq.size
|
|
312
|
-
lines = [ "# Evaluation — #{scenario_count} scenario#{'s' unless scenario_count == 1} × #{@models.size} model#{'s' unless @models.size == 1}", "" ]
|
|
313
|
-
label = @judge_label || @judge&.label
|
|
314
|
-
lines << (label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone.")
|
|
315
|
-
lines << ""
|
|
316
|
-
lines.concat(summary_table)
|
|
317
|
-
lines << ""
|
|
318
|
-
lines.concat(total_lines)
|
|
319
|
-
lines << "**Best model: #{winner}**" if winner
|
|
320
|
-
lines << ""
|
|
321
|
-
lines.concat(comparison_lines)
|
|
322
|
-
lines.concat(matrix_table)
|
|
323
|
-
lines.concat(detail_lines)
|
|
324
|
-
lines.concat(recommendation_lines)
|
|
325
|
-
lines.join("\n")
|
|
326
|
-
end
|
|
327
|
-
|
|
328
311
|
private
|
|
329
312
|
|
|
330
313
|
# Whether a model summary's cost is an estimate — any replay priced
|
|
@@ -559,7 +542,7 @@ module ActiveAgent
|
|
|
559
542
|
latency = stats["avg_duration_ms"] ? "#{stats['avg_duration_ms']} ms" : "—"
|
|
560
543
|
percentiles = stats["p50_duration_ms"] ? "#{stats['p50_duration_ms']} / #{stats['p95_duration_ms']} ms" : "—"
|
|
561
544
|
cells = [
|
|
562
|
-
"`#{label}`",
|
|
545
|
+
cell("`#{label}`"),
|
|
563
546
|
Format.passes(stats["passed"], stats["scenarios"], style: :markdown),
|
|
564
547
|
Format.score(stats["avg_score"]),
|
|
565
548
|
latency,
|
|
@@ -568,7 +551,7 @@ module ActiveAgent
|
|
|
568
551
|
Format.money(stats["cost"], estimated: estimated_cost?(stats)),
|
|
569
552
|
per_thousand_cell(stats),
|
|
570
553
|
(judge_cell(stats) if judged),
|
|
571
|
-
faults.presence || "—"
|
|
554
|
+
cell(faults.presence || "—")
|
|
572
555
|
].compact
|
|
573
556
|
"| #{cells.join(' | ')} |"
|
|
574
557
|
end
|
|
@@ -617,13 +600,13 @@ module ActiveAgent
|
|
|
617
600
|
lines = [ "## Comparison", "", "| Model | Pass rate | #{confidence} | Scenarios |", "|---|---|---|---|" ]
|
|
618
601
|
comparison["ranking"].each do |label|
|
|
619
602
|
stats = comparison["models"][label]
|
|
620
|
-
lines << "| `#{label}` | #{rate_text(stats['pass_rate'])} | #{interval_text(stats['interval'])} | #{stats['scenarios']} |"
|
|
603
|
+
lines << "| #{cell("`#{label}`")} | #{rate_text(stats['pass_rate'])} | #{interval_text(stats['interval'])} | #{stats['scenarios']} |"
|
|
621
604
|
end
|
|
622
605
|
lines += [ "", "| Pair | Difference | #{confidence} | Shared scenarios | Decisive |", "|---|---|---|---|---|" ]
|
|
623
606
|
comparison["pairs"].each do |pair|
|
|
624
607
|
a, b = pair["models"]
|
|
625
608
|
difference = pair["difference"] ? format("%+.1f points", pair["difference"]) : "—"
|
|
626
|
-
lines << "| `#{a}` vs `#{b}` | #{difference} | #{interval_text(pair['interval'])} | #{pair['scenarios']} | #{pair['decisive'] ? 'yes' : 'no'} |"
|
|
609
|
+
lines << "| #{cell("`#{a}` vs `#{b}`")} | #{difference} | #{interval_text(pair['interval'])} | #{pair['scenarios']} | #{pair['decisive'] ? 'yes' : 'no'} |"
|
|
627
610
|
end
|
|
628
611
|
lines += [ "", comparison["conclusion"].to_s, "",
|
|
629
612
|
"_#{confidence} from #{Format.delimited(method['resamples'])} resamples of whole scenarios (seed #{method['seed']})._", "" ]
|
|
@@ -642,16 +625,16 @@ module ActiveAgent
|
|
|
642
625
|
# cost across every model, and in brackets what the judge spent on it.
|
|
643
626
|
def matrix_table
|
|
644
627
|
labels = @models.map(&:label)
|
|
645
|
-
header = [ "| Scenario | #{labels.map { |label| "`#{label}`" }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
|
|
628
|
+
header = [ "| Scenario | #{labels.map { |label| cell("`#{label}`") }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
|
|
646
629
|
rows = @results.group_by { |result| result.scenario.key }.map do |key, cohort|
|
|
647
630
|
cells = labels.map do |label|
|
|
648
631
|
result = cohort.find { |candidate| candidate.label == label }
|
|
649
632
|
next "—" unless result
|
|
650
633
|
|
|
651
634
|
mark = result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
|
|
652
|
-
[ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" ")
|
|
635
|
+
cell([ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" "))
|
|
653
636
|
end
|
|
654
|
-
"| `#{key}` #{
|
|
637
|
+
"| #{cell("`#{key}` #{cohort.first.scenario.prompt.truncate(70)}")} | #{cells.join(' | ')} | #{scenario_cost_cell(key)} |"
|
|
655
638
|
end
|
|
656
639
|
header + rows
|
|
657
640
|
end
|
|
@@ -664,30 +647,11 @@ module ActiveAgent
|
|
|
664
647
|
text
|
|
665
648
|
end
|
|
666
649
|
|
|
667
|
-
# A prompt may contain " | " (ScenarioParser keeps it),
|
|
668
|
-
# otherwise
|
|
650
|
+
# A prompt may contain " | " (ScenarioParser keeps it), and an answer
|
|
651
|
+
# or a label a line break, either of which would otherwise break the
|
|
652
|
+
# table row.
|
|
669
653
|
def cell(text)
|
|
670
|
-
text.to_s.gsub("|") { "\\|" }
|
|
671
|
-
end
|
|
672
|
-
|
|
673
|
-
def detail_lines
|
|
674
|
-
lines = [ "", "## Answers", "" ]
|
|
675
|
-
@results.each do |result|
|
|
676
|
-
lines << "### `#{result.scenario.key}` · `#{result.label}` · #{result.status}" \
|
|
677
|
-
"#{" · score #{Format.score(result.score)}" if result.score}#{" · #{answer_cost(result)}" if result.replay.cost}"
|
|
678
|
-
lines << ""
|
|
679
|
-
lines << "> #{result.scenario.prompt}"
|
|
680
|
-
lines << ""
|
|
681
|
-
if result.replay.tool_calls.any?
|
|
682
|
-
lines << "Tools: #{result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(', ')}"
|
|
683
|
-
end
|
|
684
|
-
lines << "Fault: #{result.fault.tr('_', ' ')} — #{result.summary} #{result.recommendation}" if result.fault
|
|
685
|
-
lines << "Error: #{result.replay.error}" if result.replay.error
|
|
686
|
-
lines << ""
|
|
687
|
-
lines << (result.replay.answer.presence || "_(no answer)_").to_s.truncate(1_500)
|
|
688
|
-
lines << ""
|
|
689
|
-
end
|
|
690
|
-
lines
|
|
654
|
+
text.to_s.gsub(/\s*\r?\n\s*/, " ").gsub("|") { "\\|" }
|
|
691
655
|
end
|
|
692
656
|
|
|
693
657
|
# "~$0.0243 · judge ~$0.0015": what one answer cost, and what judging
|
|
@@ -698,20 +662,6 @@ module ActiveAgent
|
|
|
698
662
|
text += " · judge #{Format.money(judge_cost, estimated: estimated_usage?(result.judge_usage))}" if judge_cost
|
|
699
663
|
text
|
|
700
664
|
end
|
|
701
|
-
|
|
702
|
-
# Renders after `detail_lines`, whose trailing blank line separates the two sections.
|
|
703
|
-
def recommendation_lines
|
|
704
|
-
return [] if recommendations.empty?
|
|
705
|
-
|
|
706
|
-
lines = [ "## Recommendations", "" ]
|
|
707
|
-
recommendations.each do |entry|
|
|
708
|
-
lines << "- **#{entry['fault'].tr('_', ' ')}** ×#{entry['count']} (#{entry['scenario_keys'].join(', ')}): #{entry['recommendation']}"
|
|
709
|
-
entry["suggested_tools"].each do |tool|
|
|
710
|
-
lines << " - suggested tool `#{tool['name']}`: #{tool['description']}"
|
|
711
|
-
end
|
|
712
|
-
end
|
|
713
|
-
lines << ""
|
|
714
|
-
end
|
|
715
665
|
end
|
|
716
666
|
end
|
|
717
667
|
end
|
|
@@ -5,10 +5,15 @@ require "cgi"
|
|
|
5
5
|
module ActiveAgent
|
|
6
6
|
module Evals
|
|
7
7
|
# Report#to_html: the run as one self-contained page on the dashboard's
|
|
8
|
-
# design system (DesignTokens) — inline styles, no
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
8
|
+
# design system (DesignTokens) — inline styles, no external assets — so a
|
|
9
|
+
# run's outcome can be archived next to a CI run or handed to a teammate,
|
|
10
|
+
# and so the dashboard can serve the same page for a persisted run.
|
|
11
|
+
#
|
|
12
|
+
# Its one inline script (COPY_SCRIPT) backs the "Copy as Markdown"
|
|
13
|
+
# buttons: the whole report, the verdict, What to fix (for the model the
|
|
14
|
+
# filter shows), each fix card and each scenario. The Markdown each copies
|
|
15
|
+
# is ReportMarkdown's, embedded in a hidden textarea, so the page copies
|
|
16
|
+
# exactly what the dashboard and the CLI write.
|
|
12
17
|
#
|
|
13
18
|
# Same content as Report#to_markdown, laid out the way the dashboard's
|
|
14
19
|
# suite card is: header and stat tiles, the MODELS panel with the judge's
|
|
@@ -50,6 +55,8 @@ module ActiveAgent
|
|
|
50
55
|
#{html_footer}
|
|
51
56
|
</section>
|
|
52
57
|
</main>
|
|
58
|
+
#{markdown_store("md-report", to_markdown)}
|
|
59
|
+
<script>#{COPY_SCRIPT}</script>
|
|
53
60
|
</body>
|
|
54
61
|
</html>
|
|
55
62
|
HTML
|
|
@@ -237,7 +244,7 @@ module ActiveAgent
|
|
|
237
244
|
|
|
238
245
|
<<~HEADER
|
|
239
246
|
<header>
|
|
240
|
-
<h1>#{h(title)}</h1>
|
|
247
|
+
<div class="title-row"><h1>#{h(title)}</h1>#{copy_button('md-report', label: 'Copy as Markdown', title: 'Copy the whole report as GitHub-flavored Markdown')}</div>
|
|
241
248
|
<div class="chips">#{chips.join}</div>
|
|
242
249
|
</header>
|
|
243
250
|
HEADER
|
|
@@ -320,7 +327,8 @@ module ActiveAgent
|
|
|
320
327
|
|
|
321
328
|
<<~PANEL
|
|
322
329
|
<div class="panel">
|
|
323
|
-
<div class="panel-head"><span class="micro">Models</span><span class="right">judged by #{h(judged_by)}</span
|
|
330
|
+
<div class="panel-head"><span class="micro">Models</span><span class="right">judged by #{h(judged_by)}</span>#{copy_button('md-verdict', title: 'Copy the verdict and the model comparison as Markdown')}</div>
|
|
331
|
+
#{markdown_store('md-verdict', verdict_markdown)}
|
|
324
332
|
#{html_comparison_table if comparing?}
|
|
325
333
|
#{html_intervals}
|
|
326
334
|
#{blocks.join}
|
|
@@ -612,12 +620,13 @@ module ActiveAgent
|
|
|
612
620
|
expects = scenario.expected_tools.any? ? %(<span class="exp">expects <b>#{h(scenario.expected_tools.join(' or '))}</b></span>) : ""
|
|
613
621
|
labels = @models.map(&:label)
|
|
614
622
|
cards = cohort.sort_by { |result| labels.index(result.label) || labels.size }.map { |result| html_result_card(result) }
|
|
623
|
+
md_id = "md-#{anchor(scenario)}"
|
|
615
624
|
|
|
616
625
|
<<~BLOCK
|
|
617
626
|
<div id="#{h(anchor(scenario))}">
|
|
618
627
|
<details>
|
|
619
628
|
<summary><span class="chev">></span><span class="key">#{h(scenario.key)}</span><span class="prompt">#{h(scenario.prompt)}</span>#{expects}</summary>
|
|
620
|
-
<div class="drill">#{cards.join}</div>
|
|
629
|
+
<div class="drill">#{cards.join}<div class="drill-copy">#{copy_button(md_id, title: "Copy #{scenario.key} as Markdown")}#{markdown_store(md_id, scenario_markdown(scenario.key, answer_limit: ANSWER_LIMIT))}</div></div>
|
|
621
630
|
</details>
|
|
622
631
|
</div>
|
|
623
632
|
BLOCK
|
|
@@ -670,14 +679,16 @@ module ActiveAgent
|
|
|
670
679
|
"#{plural(faulted.map { |result| result.scenario.key }.uniq.size, 'scenario')}"
|
|
671
680
|
body =
|
|
672
681
|
if items.any?
|
|
673
|
-
%(<div class="fixes">#{items.map { |item| html_fix_card(item) }.join}</div>)
|
|
682
|
+
%(<div class="fixes">#{items.each_with_index.map { |item, index| html_fix_card(item, index) }.join}</div>)
|
|
674
683
|
else
|
|
675
684
|
%(<div class="nothing">[+] nothing to fix</div>)
|
|
676
685
|
end
|
|
677
686
|
|
|
678
687
|
<<~FIXES
|
|
679
688
|
<section class="section fix-section" aria-label="Recommendations">
|
|
680
|
-
<div class="section-head"><span class="micro">What to fix</span><span class="meta">#{h(meta)}</span
|
|
689
|
+
<div class="section-head"><span class="micro">What to fix</span><span class="meta">#{h(meta)}</span>#{copy_button('md-fixes', model_filtered: comparing? && items.any?, title: 'Copy What to fix as Markdown, for the model the filter shows')}</div>
|
|
690
|
+
#{markdown_store('md-fixes', fix_items_markdown)}
|
|
691
|
+
#{@models.each_index.map { |index| markdown_store("md-fixes-m#{index}", fix_items_markdown(model: @models[index].label)) }.join if comparing? && items.any?}
|
|
681
692
|
#{html_fix_filter(items) if comparing? && items.any?}
|
|
682
693
|
#{body}
|
|
683
694
|
</section>
|
|
@@ -706,7 +717,7 @@ module ActiveAgent
|
|
|
706
717
|
labels.filter_map { |label| (index = @models.index(model_by_label(label))) && "m#{index}" }.join(" ")
|
|
707
718
|
end
|
|
708
719
|
|
|
709
|
-
def html_fix_card(item)
|
|
720
|
+
def html_fix_card(item, index)
|
|
710
721
|
tone = item["kind"] == "instruction" ? "info" : "error"
|
|
711
722
|
glyph = tone == "info" ? "[i]" : "[!]"
|
|
712
723
|
title = fault_name(item["fault"]) + (item["count"].to_i > 1 ? " ×#{item['count']}" : "")
|
|
@@ -719,6 +730,7 @@ module ActiveAgent
|
|
|
719
730
|
parts << html_fix_server(item["server"]) if item["server"]
|
|
720
731
|
parts << %(<div class="note">#{h(item['note'])}</div>) if item["note"].present?
|
|
721
732
|
parts << html_fix_action(item["action"]) if item["action"]
|
|
733
|
+
parts << %(<div class="fix-copy">#{copy_button("md-fix-#{index}", title: 'Copy this fix as Markdown')}#{markdown_store("md-fix-#{index}", fix_item_markdown(item))}</div>)
|
|
722
734
|
models = fix_model_tokens(item)
|
|
723
735
|
%(<div class="fix"#{%( data-models="#{models}") if models.present?}>#{parts.join}</div>)
|
|
724
736
|
end
|
|
@@ -771,6 +783,19 @@ module ActiveAgent
|
|
|
771
783
|
"#{scenarios} · #{models}"
|
|
772
784
|
end
|
|
773
785
|
|
|
786
|
+
# --- Copy as Markdown -------------------------------------------------
|
|
787
|
+
|
|
788
|
+
# A button COPY_SCRIPT answers by copying the textarea +id+ names; with
|
|
789
|
+
# +model_filtered+, the one for the model the fix filter has checked.
|
|
790
|
+
def copy_button(id, label: "Copy as Markdown", title: nil, model_filtered: false)
|
|
791
|
+
%(<button type="button" class="copy-md" data-md="#{h(id)}"#{' data-md-model' if model_filtered}) +
|
|
792
|
+
%(#{%( title="#{h(title)}") if title}>#{h(label)}</button>)
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def markdown_store(id, markdown)
|
|
796
|
+
%(<textarea class="md-store" id="#{h(id)}" hidden readonly>#{h(markdown)}</textarea>)
|
|
797
|
+
end
|
|
798
|
+
|
|
774
799
|
# --- footer ----------------------------------------------------------
|
|
775
800
|
|
|
776
801
|
# The run's terms: the judge, the criteria and the pass mark, what it
|
|
@@ -804,6 +829,48 @@ module ActiveAgent
|
|
|
804
829
|
|
|
805
830
|
# Colors only through the token variables; radii 4 badges · 6 chips ·
|
|
806
831
|
# 8 controls · 10 nested panels · 12 cards · 999 bars; no shadows.
|
|
832
|
+
# Copies the Markdown a button names: the async Clipboard API where the
|
|
833
|
+
# page may use it, else a selected textarea and the copy command, which
|
|
834
|
+
# still works on an http:// host and in the dashboard's frame.
|
|
835
|
+
COPY_SCRIPT = <<~JS.gsub(/\n\s*/, "").freeze
|
|
836
|
+
document.addEventListener("click", function (event) {
|
|
837
|
+
var button = event.target.closest && event.target.closest("button.copy-md");
|
|
838
|
+
if (!button) return;
|
|
839
|
+
event.preventDefault();
|
|
840
|
+
var id = button.getAttribute("data-md");
|
|
841
|
+
if (button.hasAttribute("data-md-model")) {
|
|
842
|
+
var picked = document.querySelector('input[name="fix-model"]:checked');
|
|
843
|
+
if (picked && picked.value !== "all") id += "-" + picked.value;
|
|
844
|
+
}
|
|
845
|
+
var store = document.getElementById(id);
|
|
846
|
+
if (!store) return;
|
|
847
|
+
var text = store.value;
|
|
848
|
+
var label = button.getAttribute("data-label") || button.textContent;
|
|
849
|
+
button.setAttribute("data-label", label);
|
|
850
|
+
var done = function (ok) {
|
|
851
|
+
button.textContent = ok ? "Copied" : "Copy failed";
|
|
852
|
+
setTimeout(function () { button.textContent = label; }, 1500);
|
|
853
|
+
};
|
|
854
|
+
var legacy = function () {
|
|
855
|
+
var area = document.createElement("textarea");
|
|
856
|
+
area.value = text;
|
|
857
|
+
area.style.position = "fixed";
|
|
858
|
+
area.style.opacity = "0";
|
|
859
|
+
document.body.appendChild(area);
|
|
860
|
+
area.select();
|
|
861
|
+
var ok = false;
|
|
862
|
+
try { ok = document.execCommand("copy"); } catch (error) {}
|
|
863
|
+
document.body.removeChild(area);
|
|
864
|
+
done(ok);
|
|
865
|
+
};
|
|
866
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
867
|
+
navigator.clipboard.writeText(text).then(function () { done(true); }, legacy);
|
|
868
|
+
} else {
|
|
869
|
+
legacy();
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
JS
|
|
873
|
+
|
|
807
874
|
STYLES = <<~CSS.freeze
|
|
808
875
|
* { box-sizing: border-box; }
|
|
809
876
|
html, body { margin: 0; min-height: 100%; }
|
|
@@ -913,6 +980,14 @@ module ActiveAgent
|
|
|
913
980
|
.action { display: flex; align-items: center; gap: 10px; margin-top: auto; padding-top: 2px; flex-wrap: wrap; }
|
|
914
981
|
.btn { display: inline-block; padding: 6px 12px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--color-text-cell); border: 1px solid var(--color-border-strong); background: transparent; white-space: nowrap; }
|
|
915
982
|
.btn:hover { background: var(--color-hover); color: var(--color-text-primary); text-decoration: none; }
|
|
983
|
+
.copy-md { font-family: var(--font-mono); font-size: 11px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--color-border); background: transparent; color: var(--color-text-secondary); cursor: pointer; white-space: nowrap; }
|
|
984
|
+
.copy-md:hover { background: var(--color-hover); color: var(--color-text-primary); }
|
|
985
|
+
.title-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
|
986
|
+
.section-head .copy-md, .panel-head .copy-md { margin-left: auto; }
|
|
987
|
+
.panel-head .right + .copy-md { margin-left: 0; }
|
|
988
|
+
.drill-copy, .fix-copy { display: flex; justify-content: flex-end; }
|
|
989
|
+
.drill-copy { grid-column: 1 / -1; }
|
|
990
|
+
@media print { .copy-md { display: none; } }
|
|
916
991
|
.hint { font-family: var(--font-mono); font-size: 11px; color: var(--color-text-muted); white-space: nowrap; }
|
|
917
992
|
.matrix { border: 1px solid var(--color-border-light); border-radius: 10px; overflow-x: auto; }
|
|
918
993
|
.mx { display: grid; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--color-border-light); }
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# Report#to_markdown: the run as GitHub-flavored Markdown with the same
|
|
6
|
+
# content as Report#to_html — the per-model table and the cost, the
|
|
7
|
+
# verdict with the model comparison, the scenario matrix, every answer
|
|
8
|
+
# (one `<details>` block per scenario, since GitHub strips the HTML
|
|
9
|
+
# page's styles), and What to fix from Report#fix_items — so it can be
|
|
10
|
+
# pasted into a pull request or an issue.
|
|
11
|
+
#
|
|
12
|
+
# Each piece is public on its own, for the pieces people paste one at a
|
|
13
|
+
# time: #fix_item_markdown, #fix_items_markdown (optionally for one
|
|
14
|
+
# model), #verdict_markdown and #scenario_markdown. The whole report is
|
|
15
|
+
# built from them, so a piece copied alone reads as it does in the report.
|
|
16
|
+
#
|
|
17
|
+
# Table cells escape `|` and fold line breaks; text set inside the HTML
|
|
18
|
+
# of a `<details>` block escapes `<`, `>` and `&`.
|
|
19
|
+
module ReportMarkdown
|
|
20
|
+
# How much of an answer the whole report quotes; a scenario copied on
|
|
21
|
+
# its own quotes the whole answer.
|
|
22
|
+
MARKDOWN_ANSWER_LIMIT = 1_500
|
|
23
|
+
|
|
24
|
+
def to_markdown
|
|
25
|
+
lines = [ "# #{markdown_title}", "" ]
|
|
26
|
+
lines << judged_by_line
|
|
27
|
+
lines << ""
|
|
28
|
+
lines.concat(summary_table)
|
|
29
|
+
lines << ""
|
|
30
|
+
lines.concat(total_lines)
|
|
31
|
+
lines.concat(verdict_lines)
|
|
32
|
+
lines.concat(comparison_lines)
|
|
33
|
+
lines.concat(matrix_table)
|
|
34
|
+
lines.concat([ "", "## Answers", "" ])
|
|
35
|
+
scenario_keys.each { |key| lines << scenario_markdown(key, details: true, answer_limit: MARKDOWN_ANSWER_LIMIT) << "" }
|
|
36
|
+
lines << fix_items_markdown(heading: "##")
|
|
37
|
+
"#{lines.join("\n").rstrip}\n"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# One fix item (Report#fix_items) as a heading and its body: the fault
|
|
41
|
+
# and how often, the scenarios and models it covers, the
|
|
42
|
+
# recommendation, the judge's proposed instruction change as a quote,
|
|
43
|
+
# the tools involved, the server that serves them, the note and where
|
|
44
|
+
# in the dashboard the fix lives.
|
|
45
|
+
def fix_item_markdown(item, heading: "###")
|
|
46
|
+
item = item.to_h.stringify_keys
|
|
47
|
+
title = item["fault"].to_s.tr("_", " ") + (item["count"].to_i > 1 ? " ×#{item['count']}" : "")
|
|
48
|
+
lines = [ "#{heading} #{item['kind'] == 'instruction' ? 'ℹ️' : '❗'} #{title}", "" ]
|
|
49
|
+
|
|
50
|
+
scope = [ "Scenarios: #{Array(item['scenario_keys']).map { |key| "`#{key}`" }.join(', ')}" ]
|
|
51
|
+
scope << "models: #{Array(item['models']).map { |label| "`#{label}`" }.join(', ')}" if Array(item["models"]).any?
|
|
52
|
+
scope << "judge suggestion" if item["kind"] == "instruction"
|
|
53
|
+
lines << scope.join(" · ") << ""
|
|
54
|
+
lines << item["recommendation"].to_s.strip << "" if item["recommendation"].present?
|
|
55
|
+
lines.concat(quoted(item["quote"]).push("")) if item["quote"].present?
|
|
56
|
+
tools = Array(item["tools"])
|
|
57
|
+
if tools.any?
|
|
58
|
+
lines << "**#{item['tools_label'].presence || 'tools'}:** " + tools.map { |tool| tool_text(tool) }.join(", ")
|
|
59
|
+
lines << ""
|
|
60
|
+
end
|
|
61
|
+
if (server = item["server"])
|
|
62
|
+
state = server["status"] == "enabled" ? "enabled for #{@agent_name}" : "#{server['status'].presence || 'unknown'} · not enabled for #{@agent_name}"
|
|
63
|
+
lines << "Served by **#{server['name'].presence || server['key']}** (#{state})" << ""
|
|
64
|
+
end
|
|
65
|
+
lines << "_#{item['note'].to_s.strip}_" << "" if item["note"].present?
|
|
66
|
+
if (action = item["action"])
|
|
67
|
+
lines << "Fix: #{[ action['label'], action['hint'] ].compact_blank.join(' — ')}" << ""
|
|
68
|
+
end
|
|
69
|
+
lines.join("\n").rstrip
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# What to fix: every fix item, or with +model+ (a model label) the ones
|
|
73
|
+
# attributed to that model, as the report's model filter shows them.
|
|
74
|
+
def fix_items_markdown(model: nil, heading: "##")
|
|
75
|
+
items = fix_items
|
|
76
|
+
items = items.select { |item| Array(item["models"]).empty? || item["models"].include?(model) } if model.present?
|
|
77
|
+
lines = [ "#{heading} What to fix#{" for `#{model}`" if model.present?}", "" ]
|
|
78
|
+
if items.empty?
|
|
79
|
+
lines << "_Nothing to fix._"
|
|
80
|
+
else
|
|
81
|
+
sub = "#{heading}#"
|
|
82
|
+
lines << items.map { |item| fix_item_markdown(item, heading: sub) }.join("\n\n")
|
|
83
|
+
end
|
|
84
|
+
lines.join("\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The verdict — the best model, why, and who picked it — then the
|
|
88
|
+
# per-model table and, comparing models, the comparison with its
|
|
89
|
+
# intervals.
|
|
90
|
+
def verdict_markdown(heading: "##")
|
|
91
|
+
lines = [ "#{heading} Verdict", "", judged_by_line, "" ]
|
|
92
|
+
lines.concat(verdict_lines)
|
|
93
|
+
lines.concat(summary_table)
|
|
94
|
+
lines << ""
|
|
95
|
+
lines.concat(total_lines)
|
|
96
|
+
lines.concat(comparison_lines.map { |line| line.start_with?("## ") ? line.sub("## ", "#{heading}# ") : line })
|
|
97
|
+
lines.join("\n").rstrip
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One scenario: its prompt and expectations, a table of each model's
|
|
101
|
+
# status, score, tool calls, fault and cost, then each model's answer.
|
|
102
|
+
# With +details+ it is a `<details>` block whose summary names the key,
|
|
103
|
+
# the prompt and each model's mark, the way the report folds it.
|
|
104
|
+
# Raises KeyError for a key the report has no result for.
|
|
105
|
+
def scenario_markdown(key, details: false, answer_limit: nil)
|
|
106
|
+
cohort = @results.select { |result| result.scenario.key == key.to_s }
|
|
107
|
+
raise KeyError, "no results for scenario #{key}" if cohort.empty?
|
|
108
|
+
|
|
109
|
+
scenario = cohort.first.scenario
|
|
110
|
+
labels = @models.map(&:label)
|
|
111
|
+
cohort = cohort.sort_by { |result| labels.index(result.label) || labels.size }
|
|
112
|
+
body = []
|
|
113
|
+
body << "**Prompt**" << "" << quoted(scenario.prompt).join("\n") << ""
|
|
114
|
+
expectations = expectation_lines(scenario)
|
|
115
|
+
body.concat(expectations).push("") if expectations.any?
|
|
116
|
+
body << "| Model | Status | Score | Tools | Fault | Cost |" << "|---|---|---|---|---|---|"
|
|
117
|
+
cohort.each do |result|
|
|
118
|
+
tools = result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(", ")
|
|
119
|
+
body << "| #{[ "`#{result.label}`", status_text(result), (result.score ? Format.score(result.score) : '—'), tools.presence || '—',
|
|
120
|
+
result.fault&.tr('_', ' ') || '—', (result.replay.cost ? answer_cost(result) : '—') ].map { |text| cell(text) }.join(' | ')} |"
|
|
121
|
+
end
|
|
122
|
+
body << ""
|
|
123
|
+
cohort.each do |result|
|
|
124
|
+
body << "**`#{result.label}`** — #{status_text(result)}"
|
|
125
|
+
body << ""
|
|
126
|
+
body << "Fault: #{result.fault.tr('_', ' ')} — #{[ result.summary, result.recommendation ].compact.join(' ')}" << "" if result.fault
|
|
127
|
+
body << "Error: #{result.replay.error}" << "" if result.replay.error
|
|
128
|
+
answer = result.replay.answer.to_s
|
|
129
|
+
answer = answer.truncate(answer_limit) if answer_limit
|
|
130
|
+
body << (answer.present? ? quoted(answer).join("\n") : "_(no answer)_") << ""
|
|
131
|
+
end
|
|
132
|
+
text = body.join("\n").rstrip
|
|
133
|
+
return "### Scenario `#{scenario.key}`\n\n#{text}" unless details
|
|
134
|
+
|
|
135
|
+
marks = cohort.map { |result| "#{status_mark(result)} #{result.label}" }.join(" · ")
|
|
136
|
+
summary = "<code>#{html_text(scenario.key)}</code> #{html_text(scenario.prompt.truncate(80))} — #{html_text(marks)}"
|
|
137
|
+
"<details>\n<summary>#{summary}</summary>\n\n#{text}\n\n</details>"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def markdown_title
|
|
143
|
+
scenarios = scenario_keys.size
|
|
144
|
+
"Evaluation — #{scenarios} scenario#{'s' unless scenarios == 1} × #{@models.size} model#{'s' unless @models.size == 1}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def scenario_keys
|
|
148
|
+
@results.map { |result| result.scenario.key }.uniq
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def judged_by_line
|
|
152
|
+
label = @judge_label || @judge&.label
|
|
153
|
+
label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone."
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# "**Best model: gpt-5-mini**" with the verdict's rationale under it
|
|
157
|
+
# and, when a judge (not the pass-rate ranking) wrote it, who did.
|
|
158
|
+
def verdict_lines
|
|
159
|
+
return [] unless winner
|
|
160
|
+
|
|
161
|
+
lines = [ "**Best model: #{winner}**", "" ]
|
|
162
|
+
rationale = verdict["rationale"].to_s.strip
|
|
163
|
+
judge = verdict["judge"].presence
|
|
164
|
+
rationale += " _(#{judge == Report::PASS_RATE_JUDGE ? 'ranked by pass rate' : "judged by #{judge}"})_" if judge && rationale.present?
|
|
165
|
+
lines << rationale << "" if rationale.present?
|
|
166
|
+
lines
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def expectation_lines(scenario)
|
|
170
|
+
lines = []
|
|
171
|
+
lines << "Expects tools: #{scenario.expected_tools.map { |name| "`#{name}`" }.join(' or ')}" if scenario.expected_tools.any?
|
|
172
|
+
lines << "Expects the answer to contain: #{scenario.expected_patterns.map { |text| "`#{text}`" }.join(', ')}" if scenario.expected_patterns.any?
|
|
173
|
+
lines << "Expects the answer not to contain: #{scenario.forbidden_patterns.map { |text| "`#{text}`" }.join(', ')}" if scenario.forbidden_patterns.any?
|
|
174
|
+
lines.map { |line| "- #{line}" }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def status_mark(result)
|
|
178
|
+
result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def status_text(result)
|
|
182
|
+
"#{status_mark(result)} #{result.status}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def tool_text(tool)
|
|
186
|
+
tool = tool.is_a?(Hash) ? tool.stringify_keys : { "name" => tool.to_s }
|
|
187
|
+
note = tool["note"].presence
|
|
188
|
+
"`#{tool['name']}`#{" (#{note})" if note}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Each line of +text+ as a blockquote line, `<` and `>` escaped so an
|
|
192
|
+
# answer cannot open or close the HTML around it.
|
|
193
|
+
def quoted(text)
|
|
194
|
+
text.to_s.strip.split(/\r?\n/).map { |line| line.strip.empty? ? ">" : "> #{line.gsub('<', '<').gsub('>', '>')}" }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def html_text(text)
|
|
198
|
+
text.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub(/\s+/, " ")
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
data/lib/active_agent/evals.rb
CHANGED
|
@@ -27,6 +27,7 @@ require_relative "evals/judge"
|
|
|
27
27
|
require_relative "evals/result"
|
|
28
28
|
require_relative "evals/design_tokens"
|
|
29
29
|
require_relative "evals/report_html"
|
|
30
|
+
require_relative "evals/report_markdown"
|
|
30
31
|
require_relative "evals/report"
|
|
31
32
|
require_relative "evals/runner"
|
|
32
33
|
require_relative "evals/correlation"
|
|
@@ -132,6 +132,7 @@ module ActiveAgent
|
|
|
132
132
|
:stream_broadcaster, :streaming, # Callback (Streams)
|
|
133
133
|
:stream_completion_pending, # Callback (Streams)
|
|
134
134
|
:stream_completion_result, # Callback (Streams)
|
|
135
|
+
:stream_finish_reason, # Callback (Streams)
|
|
135
136
|
:tools_function, # Callback (Tools)
|
|
136
137
|
:usage_stack, # Usage Tracking
|
|
137
138
|
:stream_usage_index, # Usage Tracking (Streams)
|
|
@@ -607,6 +608,7 @@ module ActiveAgent
|
|
|
607
608
|
messages:,
|
|
608
609
|
raw_request: prompt_request_type.serialize(request),
|
|
609
610
|
raw_response: api_response,
|
|
611
|
+
streamed_finish_reason: stream_finish_reason,
|
|
610
612
|
usages: usage_stack,
|
|
611
613
|
**attributes
|
|
612
614
|
)
|
|
@@ -237,6 +237,7 @@ module ActiveAgent
|
|
|
237
237
|
when :message_delta
|
|
238
238
|
delta = Anthropic::Transforms.gem_to_hash(api_response_chunk.delta)
|
|
239
239
|
self.message_stack.last.merge!(delta)
|
|
240
|
+
self.stream_finish_reason = delta[:stop_reason] if delta[:stop_reason]
|
|
240
241
|
|
|
241
242
|
# Message Completed [Full Message]
|
|
242
243
|
when :message_stop
|
|
@@ -63,6 +63,16 @@ module ActiveAgent
|
|
|
63
63
|
# @return [Array<Usage>]
|
|
64
64
|
attribute :usages, default: -> { [] }, writable: false
|
|
65
65
|
|
|
66
|
+
# @!attribute [r] streamed_finish_reason
|
|
67
|
+
# Why a streamed generation stopped, as the provider reported it.
|
|
68
|
+
#
|
|
69
|
+
# A streamed generation has no response body to read it from, so
|
|
70
|
+
# the provider records it from the stream. {#finish_reason} falls
|
|
71
|
+
# back to it.
|
|
72
|
+
#
|
|
73
|
+
# @return [String, nil]
|
|
74
|
+
attribute :streamed_finish_reason, writable: false
|
|
75
|
+
|
|
66
76
|
# Initializes response with deep-duplicated attributes.
|
|
67
77
|
#
|
|
68
78
|
# Deep duplication prevents external modifications from affecting internal state.
|
|
@@ -180,14 +190,17 @@ module ActiveAgent
|
|
|
180
190
|
# response.stop_reason #=> "stop" (alias)
|
|
181
191
|
def finish_reason
|
|
182
192
|
@finish_reason ||= begin
|
|
183
|
-
return
|
|
193
|
+
return streamed_finish_reason unless raw_response
|
|
184
194
|
|
|
185
195
|
if raw_response.is_a?(Hash)
|
|
186
196
|
# OpenAI format: choices[0].finish_reason or choices[0].message.finish_reason
|
|
187
197
|
raw_response.dig(:choices, 0, :finish_reason) ||
|
|
188
198
|
raw_response.dig(:choices, 0, :message, :finish_reason) ||
|
|
189
199
|
# Anthropic format: stop_reason
|
|
190
|
-
raw_response[:stop_reason]
|
|
200
|
+
raw_response[:stop_reason] ||
|
|
201
|
+
# OpenAI Responses format: why it was cut short, else its status
|
|
202
|
+
raw_response.dig(:incomplete_details, :reason) ||
|
|
203
|
+
raw_response[:status]
|
|
191
204
|
end
|
|
192
205
|
end
|
|
193
206
|
end
|
|
@@ -335,8 +335,10 @@ module ActiveAgent
|
|
|
335
335
|
# Detect Ollama by presence of nanosecond duration fields
|
|
336
336
|
if usage.key?(:total_duration)
|
|
337
337
|
from_ollama(usage_hash)
|
|
338
|
-
# Detect Anthropic by presence of cache_creation or service_tier
|
|
339
|
-
|
|
338
|
+
# Detect Anthropic by presence of cache_creation or service_tier, or by its
|
|
339
|
+
# cache token fields (a usage payload does not always carry the former two)
|
|
340
|
+
elsif usage.key?(:cache_creation) || usage.key?(:service_tier) ||
|
|
341
|
+
usage.key?(:cache_read_input_tokens) || usage.key?(:cache_creation_input_tokens)
|
|
340
342
|
from_anthropic(usage_hash)
|
|
341
343
|
# Detect OpenAI Responses API by input_tokens/output_tokens with details
|
|
342
344
|
elsif usage.key?(:input_tokens) && usage.key?(:input_tokens_details)
|
|
@@ -117,6 +117,10 @@ module ActiveAgent
|
|
|
117
117
|
return if api_message.nil?
|
|
118
118
|
|
|
119
119
|
# If we have a delta, we need to update a message in the stack
|
|
120
|
+
if (finish_reason = api_message.try(:finish_reason))
|
|
121
|
+
self.stream_finish_reason = finish_reason.to_s
|
|
122
|
+
end
|
|
123
|
+
|
|
120
124
|
message = find_or_create_message(api_message.index)
|
|
121
125
|
message_merge_delta(message, api_message.delta.deep_to_h)
|
|
122
126
|
|
|
@@ -263,6 +267,39 @@ module ActiveAgent
|
|
|
263
267
|
message_stack.last
|
|
264
268
|
end
|
|
265
269
|
|
|
270
|
+
# Finds the streamed tool call an unindexed delta continues
|
|
271
|
+
#
|
|
272
|
+
# OpenAI-compatible servers (Gemini's compatibility endpoint, some Ollama
|
|
273
|
+
# and OpenRouter backends) omit the `index` that ties argument fragments
|
|
274
|
+
# to their call. A delta carrying an `id` continues the call with that
|
|
275
|
+
# id; one without an id or a function name continues the last call;
|
|
276
|
+
# anything else starts a new call.
|
|
277
|
+
#
|
|
278
|
+
# @param tool_calls [Array<Hash>] calls accumulated so far
|
|
279
|
+
# @param delta_item [Hash] unindexed tool call delta
|
|
280
|
+
# @return [Hash, nil] the call to merge into, or nil for a new call
|
|
281
|
+
def unindexed_tool_call_target(tool_calls, delta_item)
|
|
282
|
+
if delta_item[:id].present?
|
|
283
|
+
tool_calls.find { _1[:id] == delta_item[:id] }
|
|
284
|
+
elsif delta_item.dig(:function, :name).blank?
|
|
285
|
+
tool_calls.last
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Strips the fields a server repeats on every fragment (`id`, `type`,
|
|
290
|
+
# the function name), so merging does not concatenate them.
|
|
291
|
+
#
|
|
292
|
+
# @param hash_item [Hash] the call the fragment continues
|
|
293
|
+
# @param delta_item [Hash] unindexed tool call delta
|
|
294
|
+
# @return [Hash] the fragment to merge
|
|
295
|
+
def unindexed_tool_call_fragment(hash_item, delta_item)
|
|
296
|
+
fragment = delta_item.except(:id, :type)
|
|
297
|
+
if fragment[:function].is_a?(Hash) && fragment.dig(:function, :name) == hash_item.dig(:function, :name)
|
|
298
|
+
fragment = fragment.merge(function: fragment[:function].except(:name))
|
|
299
|
+
end
|
|
300
|
+
fragment
|
|
301
|
+
end
|
|
302
|
+
|
|
266
303
|
# Recursively merges delta changes into a hash structure
|
|
267
304
|
#
|
|
268
305
|
# Handles complex delta merging for OpenAI's streaming API, including
|
|
@@ -285,6 +322,8 @@ module ActiveAgent
|
|
|
285
322
|
else
|
|
286
323
|
hash[key] << delta_item
|
|
287
324
|
end
|
|
325
|
+
elsif key == :tool_calls && delta_item.is_a?(Hash) && (hash_item = unindexed_tool_call_target(hash[key], delta_item))
|
|
326
|
+
hash_merge_delta(hash_item, unindexed_tool_call_fragment(hash_item, delta_item))
|
|
288
327
|
else
|
|
289
328
|
hash[key] << delta_item
|
|
290
329
|
end
|
|
@@ -181,6 +181,9 @@ module ActiveAgent
|
|
|
181
181
|
# usage from, so it is recorded from the final event.
|
|
182
182
|
record_stream_usage(api_response_event.response.usage)
|
|
183
183
|
|
|
184
|
+
response = api_response_event.response
|
|
185
|
+
self.stream_finish_reason = (response.incomplete_details&.reason || response.status).to_s
|
|
186
|
+
|
|
184
187
|
# Completion, tool loop included, runs from stream_finished! once
|
|
185
188
|
# the stream has drained, so each turn's tool calls run once.
|
|
186
189
|
self.stream_completion_pending = true
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../open_ai/chat/_types"
|
|
4
|
+
require_relative "options"
|
|
5
|
+
|
|
6
|
+
module ActiveAgent
|
|
7
|
+
module Providers
|
|
8
|
+
module Opper
|
|
9
|
+
# ActiveModel type for casting and serializing Opper requests.
|
|
10
|
+
#
|
|
11
|
+
# Opper is OpenAI-compatible, so requests use the same shape as the
|
|
12
|
+
# OpenAI Chat API. This delegates entirely to OpenAI::Chat::RequestType.
|
|
13
|
+
RequestType = ActiveAgent::Providers::OpenAI::Chat::RequestType
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../open_ai/options"
|
|
4
|
+
|
|
5
|
+
module ActiveAgent
|
|
6
|
+
module Providers
|
|
7
|
+
module Opper
|
|
8
|
+
# Configuration options for the Opper provider.
|
|
9
|
+
#
|
|
10
|
+
# Extends OpenAI::Options, overriding the base URL to point at Opper's
|
|
11
|
+
# OpenAI-compatible gateway and resolving the API key from OPPER_API_KEY.
|
|
12
|
+
# Opper does not use organization or project identifiers.
|
|
13
|
+
#
|
|
14
|
+
# @example Basic configuration
|
|
15
|
+
# options = Options.new(api_key: ENV["OPPER_API_KEY"])
|
|
16
|
+
#
|
|
17
|
+
# @see https://docs.opper.ai
|
|
18
|
+
# @see https://platform.opper.ai Opper API Keys
|
|
19
|
+
class Options < ActiveAgent::Providers::OpenAI::Options
|
|
20
|
+
# @!attribute base_url
|
|
21
|
+
# @return [String] API endpoint (default: "https://api.opper.ai/v3/compat")
|
|
22
|
+
attribute :base_url, :string, as: "https://api.opper.ai/v3/compat"
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def resolve_api_key(kwargs)
|
|
27
|
+
kwargs[:api_key] ||
|
|
28
|
+
kwargs[:access_token] ||
|
|
29
|
+
ENV["OPPER_API_KEY"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Not used as part of Opper
|
|
33
|
+
def resolve_organization_id(kwargs) = nil
|
|
34
|
+
def resolve_project_id(kwargs) = nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require_relative "_base_provider"
|
|
2
|
+
|
|
3
|
+
require_gem!(:openai, __FILE__)
|
|
4
|
+
|
|
5
|
+
require_relative "open_ai_provider"
|
|
6
|
+
require_relative "opper/_types"
|
|
7
|
+
|
|
8
|
+
module ActiveAgent
|
|
9
|
+
module Providers
|
|
10
|
+
# Provides access to Opper's OpenAI-compatible LLM gateway.
|
|
11
|
+
#
|
|
12
|
+
# Extends the OpenAI provider to work with Opper's OpenAI-compatible API,
|
|
13
|
+
# enabling access to models from many providers through a single
|
|
14
|
+
# interface. Model ids are bare pool names (e.g. +claude-sonnet-4-6+);
|
|
15
|
+
# a +provider/model+ id (e.g. +anthropic/claude-sonnet-4-6+) pins a route.
|
|
16
|
+
#
|
|
17
|
+
# Opper is a plain OpenAI-compatible gateway: requests, responses and
|
|
18
|
+
# transforms are identical to the OpenAI Chat API, so this provider reuses
|
|
19
|
+
# OpenAI::Chat::RequestType and OpenAI::Chat::Transforms directly. The only
|
|
20
|
+
# Opper-specific configuration is the base URL and API key, which live in
|
|
21
|
+
# Opper::Options.
|
|
22
|
+
#
|
|
23
|
+
# @example Configuration in active_agent.yml
|
|
24
|
+
# opper:
|
|
25
|
+
# service: "Opper"
|
|
26
|
+
# api_key: <%= ENV["OPPER_API_KEY"] %>
|
|
27
|
+
# model: "claude-sonnet-4-6"
|
|
28
|
+
#
|
|
29
|
+
# @see OpenAI::ChatProvider
|
|
30
|
+
# @see https://docs.opper.ai
|
|
31
|
+
class OpperProvider < OpenAI::ChatProvider
|
|
32
|
+
# @return [String]
|
|
33
|
+
def self.service_name
|
|
34
|
+
"Opper"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Class]
|
|
38
|
+
def self.options_klass
|
|
39
|
+
Opper::Options
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [ActiveModel::Type::Value]
|
|
43
|
+
def self.prompt_request_type
|
|
44
|
+
OpenAI::Chat::RequestType.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [ActiveModel::Type::Value]
|
|
48
|
+
def self.embed_request_type
|
|
49
|
+
OpenAI::Embedding::RequestType.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
protected
|
|
53
|
+
|
|
54
|
+
# Executes an embedding request via Opper's OpenAI-compatible API.
|
|
55
|
+
#
|
|
56
|
+
# @param parameters [Hash]
|
|
57
|
+
# @return [Hash] symbolized API response
|
|
58
|
+
def api_embed_execute(parameters)
|
|
59
|
+
client.embeddings.create(**parameters).as_json.deep_symbolize_keys
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @see BaseProvider#api_response_normalize
|
|
63
|
+
# @param api_response [OpenAI::Models::ChatCompletion]
|
|
64
|
+
# @return [Hash] normalized response hash
|
|
65
|
+
def api_response_normalize(api_response)
|
|
66
|
+
return api_response unless api_response
|
|
67
|
+
|
|
68
|
+
OpenAI::Chat::Transforms.gem_to_hash(api_response)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/active_agent/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activeagent
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.9.
|
|
4
|
+
version: 1.9.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Justin Bowen
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: actionpack
|
|
@@ -467,6 +467,7 @@ files:
|
|
|
467
467
|
- lib/active_agent/evals/replay.rb
|
|
468
468
|
- lib/active_agent/evals/report.rb
|
|
469
469
|
- lib/active_agent/evals/report_html.rb
|
|
470
|
+
- lib/active_agent/evals/report_markdown.rb
|
|
470
471
|
- lib/active_agent/evals/result.rb
|
|
471
472
|
- lib/active_agent/evals/ruby_llm.rb
|
|
472
473
|
- lib/active_agent/evals/runner.rb
|
|
@@ -585,6 +586,9 @@ files:
|
|
|
585
586
|
- lib/active_agent/providers/open_router_provider.rb
|
|
586
587
|
- lib/active_agent/providers/openai_provider.rb
|
|
587
588
|
- lib/active_agent/providers/openrouter_provider.rb
|
|
589
|
+
- lib/active_agent/providers/opper/_types.rb
|
|
590
|
+
- lib/active_agent/providers/opper/options.rb
|
|
591
|
+
- lib/active_agent/providers/opper_provider.rb
|
|
588
592
|
- lib/active_agent/providers/requesty/_types.rb
|
|
589
593
|
- lib/active_agent/providers/requesty/options.rb
|
|
590
594
|
- lib/active_agent/providers/requesty_provider.rb
|