silas 0.6.0 → 0.6.2
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 +73 -0
- data/README.md +7 -3
- data/app/helpers/silas/inbox/trace_helper.rb +9 -1
- data/app/views/layouts/silas/inbox.html.erb +2 -2
- data/app/views/silas/inbox/sessions/_cost.html.erb +3 -1
- data/app/views/silas/inbox/sessions/show.html.erb +1 -1
- data/app/views/silas/inbox/turns/_header.html.erb +31 -25
- data/app/views/silas/inbox/turns/_turn.html.erb +5 -3
- data/docs/configuration.md +4 -0
- data/docs/connections.md +3 -0
- data/docs/inbox-and-api.md +7 -0
- data/docs/providers.md +171 -0
- data/docs/tutorial.md +6 -2
- data/lib/generators/silas/install/templates/ruby_llm.rb +3 -1
- data/lib/silas/connection.rb +16 -0
- data/lib/silas/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a34eec0f8444f42ef91b1db155bc2786c28ce0de5ed345d9e63b39af9b29756
|
|
4
|
+
data.tar.gz: 94c9b0464629b6f23eab8f4811e5dd63cdec63d480388cf01c8b0b9111a43376
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 62c5e530ccc6cfbc3cbfa7b1388b72e53545172acc054b7565930eef56541f8e839846435435585db3fcc3161d40994345b0da5e24fcdc0de1710988efc7ab35
|
|
7
|
+
data.tar.gz: e8c83c1733efd76810fe2d0cd5e5e57c8b1d929d1a5556d685e376c704108c550494f07e6eb332d6968e458f1bfbb85b5fc024016d1f951dfb1d6a351b6b4683
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,78 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.2 (2026-07-26)
|
|
4
|
+
|
|
5
|
+
The providers guide and the remaining community files.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`docs/providers.md` — Providers & gateways.** How a model id picks a
|
|
10
|
+
provider (and why Silas stamps the resolved provider on every step row),
|
|
11
|
+
the OpenRouter recipe — one key, 300+ models, slash-form ids, with cost
|
|
12
|
+
lines and `compact_at` following the registry entry for the route you
|
|
13
|
+
actually run — plus OpenAI-compatible gateways (LiteLLM, Vercel AI
|
|
14
|
+
Gateway) via `openai_api_base`/`openrouter_api_base`, the
|
|
15
|
+
Bedrock/Vertex/Azure key map, local runtimes with the honest
|
|
16
|
+
cost-unavailable story, and `around_model_call` for failover. Every
|
|
17
|
+
mechanical claim verified against ruby_llm 1.16 source and a live
|
|
18
|
+
resolution run; Vercel endpoint details from their docs as of 2026-07-07.
|
|
19
|
+
Ships in the gem; linked from the configuration reference, the README,
|
|
20
|
+
and the installer's `ruby_llm.rb` initializer comment.
|
|
21
|
+
- **`CODE_OF_CONDUCT.md`** (Contributor Covenant 2.1), linked from
|
|
22
|
+
CONTRIBUTING and the README.
|
|
23
|
+
- **A pull request template** mirroring CONTRIBUTING's actual gates: both
|
|
24
|
+
stores, RuboCop, the chaos gate when the loop is touched, the
|
|
25
|
+
templates-smoke run when generators change, and a durability-notes
|
|
26
|
+
section for replay-path changes.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- README's status line claimed 0.5.x.
|
|
31
|
+
|
|
32
|
+
## 0.6.1 (2026-07-26)
|
|
33
|
+
|
|
34
|
+
Two live-inbox fixes found by driving real apps, a security hardening pass,
|
|
35
|
+
and the polished brand kit.
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Polished brand geometry everywhere.** Lamps sit on a shared r4.8 circle
|
|
40
|
+
(proceed and held aspects now one geometry), the favicon is redrawn
|
|
41
|
+
natively at 32px with a heavier housing (it reads at 16px now), and the
|
|
42
|
+
wordmark lockup is cropped to the ink — re-outlined to font-independent
|
|
43
|
+
paths on the new geometry. The inbox layout's favicon and header mark
|
|
44
|
+
carry the new coordinates, as do the templates' landing pages.
|
|
45
|
+
- The docs gained real product imagery: an 18-frame park→approve→clear GIF
|
|
46
|
+
captured from a genuine template-app run, the held approval card, and the
|
|
47
|
+
signal board — on the site landing, inbox-and-api, and tutorial pages.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- **The turn pill now flips to held live.** Turbo `replace` swaps the target
|
|
52
|
+
element itself, but the header's and cost line's broadcast-target ids lived
|
|
53
|
+
on wrapper divs in the parent views — so the FIRST status broadcast
|
|
54
|
+
destroyed the target and every later one (including the park that reads
|
|
55
|
+
**held**) silently no-opped until a reload. The partials now carry their own
|
|
56
|
+
root ids, with regression specs asserting a replace re-emits its target.
|
|
57
|
+
- **The first broadcast of a fresh worker no longer dies on lazy routes.**
|
|
58
|
+
Rails 8.1's lazy route set only retries a missing url helper when the app
|
|
59
|
+
routes were *just* loaded; in a worker's first Turbo render the engine's
|
|
60
|
+
helper module could predate its route draw, killing the job silently
|
|
61
|
+
(`undefined method 'cancel_inbox_turn_path'`). `silas_engine_path` now
|
|
62
|
+
forces the draw once on miss.
|
|
63
|
+
|
|
64
|
+
### Security
|
|
65
|
+
|
|
66
|
+
- **Connections refuse credentials over plaintext http.** A connection with
|
|
67
|
+
`auth:` configured and an `http://` URL to a remote host now fails loudly at
|
|
68
|
+
parse time (localhost exempt; unparseable URLs with auth fail closed).
|
|
69
|
+
Finding from the pre-release security audit — which otherwise confirmed the
|
|
70
|
+
posture: fail-closed HMAC webhook verification with constant-time compare
|
|
71
|
+
and a replay window, expiring signed approval tokens, deny-by-default
|
|
72
|
+
inbox/API auth enforced at the base controllers, argv-array sandbox exec
|
|
73
|
+
(no shell interpolation), timing-safe MCP token compare, restricted ERB
|
|
74
|
+
bindings, no interpolated SQL, Brakeman and bundler-audit clean.
|
|
75
|
+
|
|
3
76
|
## 0.6.0 (2026-07-26)
|
|
4
77
|
|
|
5
78
|
The brand release: the Signals inbox, the docs surface (site + gem-shipped
|
data/README.md
CHANGED
|
@@ -100,9 +100,11 @@ release): [guarantees](https://danielstpaul.github.io/silas/guarantees).
|
|
|
100
100
|
|
|
101
101
|
## Status
|
|
102
102
|
|
|
103
|
-
Early (0.
|
|
103
|
+
Early (0.6.x) and moving fast. Requires Rails >= 8.1 (Active Job
|
|
104
104
|
Continuations) and Solid Queue >= 1.2; any model provider
|
|
105
|
-
[RubyLLM](https://rubyllm.com) supports
|
|
105
|
+
[RubyLLM](https://rubyllm.com) supports — Anthropic direct, OpenRouter,
|
|
106
|
+
OpenAI-compatible gateways, local runtimes
|
|
107
|
+
([providers guide](https://github.com/danielstpaul/silas/blob/main/docs/providers.md)).
|
|
106
108
|
|
|
107
109
|
## Community
|
|
108
110
|
|
|
@@ -113,7 +115,9 @@ Questions, ideas, and bug reports →
|
|
|
113
115
|
|
|
114
116
|
See [CONTRIBUTING.md](https://github.com/danielstpaul/silas/blob/main/CONTRIBUTING.md)
|
|
115
117
|
— including the short list of things Silas deliberately doesn't do, and the
|
|
116
|
-
chaos gate that protects the durability contract.
|
|
118
|
+
chaos gate that protects the durability contract. Everyone interacting here
|
|
119
|
+
is expected to follow the
|
|
120
|
+
[code of conduct](https://github.com/danielstpaul/silas/blob/main/CODE_OF_CONDUCT.md).
|
|
117
121
|
|
|
118
122
|
## Security
|
|
119
123
|
|
|
@@ -65,7 +65,15 @@ module Silas
|
|
|
65
65
|
# bare broadcast renderer doesn't have — so broadcast-rendered partials
|
|
66
66
|
# build paths from the engine's own route set + the discovered mount.
|
|
67
67
|
def silas_engine_path(helper, *args)
|
|
68
|
-
Silas::Engine.routes.url_helpers
|
|
68
|
+
helpers = Silas::Engine.routes.url_helpers
|
|
69
|
+
# Rails 8.1's lazy route set: in a worker's FIRST broadcast render the
|
|
70
|
+
# engine's helper module can predate its route draw, and the lazy
|
|
71
|
+
# method_missing only retries when the app routes were *just* loaded —
|
|
72
|
+
# otherwise it raises NoMethodError and the Turbo job dies silently
|
|
73
|
+
# (observed: the first turn broadcast of a fresh worker). Force the
|
|
74
|
+
# draw once on miss; every later call takes the fast path.
|
|
75
|
+
Rails.application.reload_routes! unless helpers.respond_to?(helper)
|
|
76
|
+
helpers.public_send(helper, *args, script_name: Silas::Inbox.mount_path)
|
|
69
77
|
end
|
|
70
78
|
end
|
|
71
79
|
end
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title><%= content_for?(:title) ? yield(:title) : "Silas Inbox" %></title>
|
|
7
7
|
<%# The night tile, inlined — the engine ships no assets and keeps shipping none. %>
|
|
8
|
-
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230F1013'/%3E%
|
|
8
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230F1013'/%3E%3Ccircle cx='16' cy='16' r='10.8' fill='none' stroke='%23E9EBEF' stroke-width='2.5'/%3E%3Ccircle cx='12.18' cy='19.82' r='2.8' fill='%23F2F4F8'/%3E%3Ccircle cx='19.82' cy='12.18' r='2.8' fill='%23F2F4F8'/%3E%3C/svg%3E">
|
|
9
9
|
<% if Silas::Inbox.streaming_available? %><%= turbo_include_tags rescue nil %><% end %>
|
|
10
10
|
<style>
|
|
11
11
|
/* Direction "Signals" (see docs/conventions.md and the brand book).
|
|
@@ -160,7 +160,7 @@
|
|
|
160
160
|
<%# The mark: a position-light signal, diagonal = "proceed". Bezel in
|
|
161
161
|
ink, lamps in the accent — so the lamps are white at night and
|
|
162
162
|
route-blue on paper, per the one-token-may-change-hue rule. %>
|
|
163
|
-
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" role="img" aria-hidden="true"><circle cx="12" cy="12" r="9.6" fill="none" style="stroke: var(--ink)" stroke-width="2.2"/><circle cx="8.
|
|
163
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" role="img" aria-hidden="true"><circle cx="12" cy="12" r="9.6" fill="none" style="stroke: var(--ink)" stroke-width="2.2"/><circle cx="8.61" cy="15.39" r="2.5" style="fill: var(--accent)"/><circle cx="15.39" cy="8.61" r="2.5" style="fill: var(--accent)"/></svg><span>silas</span>
|
|
164
164
|
<% end %>
|
|
165
165
|
</span>
|
|
166
166
|
<span class="spacer"></span>
|
|
@@ -1 +1,3 @@
|
|
|
1
|
-
|
|
1
|
+
<%# Root id = the broadcast target (replaced after every completed step); it
|
|
2
|
+
must be in the partial or the first replace erases the target. %>
|
|
3
|
+
<span class="cost" id="silas-session-<%= session.id %>-cost"><%= cost_label(Silas::Inbox::Cost.for_session(session)) %></span>
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<span class="muted">#<%= @session.id %></span>
|
|
13
13
|
</div>
|
|
14
14
|
<div class="muted"><%= @session.channel.presence || "direct" %> · started <%= silas_relative_time(@session.created_at) %></div>
|
|
15
|
-
|
|
15
|
+
<%= render "silas/inbox/sessions/cost", session: @session %>
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
18
|
<%# 04: everything awaiting a person sits ABOVE the movement — the operator
|
|
@@ -1,28 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
<%=
|
|
9
|
-
|
|
1
|
+
<%# The root id IS the broadcast target (Broadcastable replaces
|
|
2
|
+
silas-turn-<id>-header on every status change). It must live HERE, not on
|
|
3
|
+
a wrapper in the parent: Turbo `replace` swaps the target element itself,
|
|
4
|
+
so a partial without the id would erase the target on first use. %>
|
|
5
|
+
<div id="silas-turn-<%= turn.id %>-header">
|
|
6
|
+
<div class="turn-head">
|
|
7
|
+
<span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
|
|
8
|
+
<%= status_pill(turn.status) %>
|
|
9
|
+
<% if turn.active? && !turn.cancel_requested_at %>
|
|
10
|
+
<%# silas_engine_path — this partial is broadcast-rendered through the
|
|
11
|
+
host renderer on every turn status change; bare engine helpers (and
|
|
12
|
+
the scope-dependent mounted proxy) 500 there. %>
|
|
13
|
+
<%= form_with url: silas_engine_path(:cancel_inbox_turn_path, turn), method: :post, class: "inline" do %>
|
|
14
|
+
<button class="btn-cancel" title="Honored at the next step boundary">Cancel</button>
|
|
15
|
+
<% end %>
|
|
16
|
+
<% elsif turn.cancel_requested_at && turn.active? %>
|
|
17
|
+
<span class="muted">cancel requested…</span>
|
|
10
18
|
<% end %>
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
</div>
|
|
20
|
+
<% if turn.budget_parked? %>
|
|
21
|
+
<div class="approval">
|
|
22
|
+
<h3>Budget reached — <%= turn.failure_reason %></h3>
|
|
23
|
+
<div class="muted">Parked at zero compute. Raise the limit and the turn resumes
|
|
24
|
+
by replaying completed work from rows — no re-calls, no re-effects.</div>
|
|
25
|
+
<%= form_with url: silas_engine_path(:raise_budget_inbox_turn_path, turn), method: :post, class: "topup-form" do %>
|
|
26
|
+
<input name="value" type="number" step="any" min="0" required class="topup-input"
|
|
27
|
+
placeholder="<%= turn.failure_reason == "max_cost" ? "new limit in dollars, e.g. 2.50" : "new limit, e.g. 200000" %>">
|
|
28
|
+
<button class="btn approve">Raise & resume</button>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
<% elsif turn.failure_reason.present? %>
|
|
32
|
+
<div class="muted">failed: <%= turn.failure_reason %></div>
|
|
13
33
|
<% end %>
|
|
14
34
|
</div>
|
|
15
|
-
<% if turn.budget_parked? %>
|
|
16
|
-
<div class="approval">
|
|
17
|
-
<h3>Budget reached — <%= turn.failure_reason %></h3>
|
|
18
|
-
<div class="muted">Parked at zero compute. Raise the limit and the turn resumes
|
|
19
|
-
by replaying completed work from rows — no re-calls, no re-effects.</div>
|
|
20
|
-
<%= form_with url: silas_engine_path(:raise_budget_inbox_turn_path, turn), method: :post, class: "topup-form" do %>
|
|
21
|
-
<input name="value" type="number" step="any" min="0" required class="topup-input"
|
|
22
|
-
placeholder="<%= turn.failure_reason == "max_cost" ? "new limit in dollars, e.g. 2.50" : "new limit, e.g. 200000" %>">
|
|
23
|
-
<button class="btn approve">Raise & resume</button>
|
|
24
|
-
<% end %>
|
|
25
|
-
</div>
|
|
26
|
-
<% elsif turn.failure_reason.present? %>
|
|
27
|
-
<div class="muted">failed: <%= turn.failure_reason %></div>
|
|
28
|
-
<% end %>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<div class="turn" id="<%= dom_id(turn) %>">
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
<%# The header partial carries its own broadcast-target id: Turbo `replace`
|
|
3
|
+
swaps the TARGET ELEMENT itself, so if the id lived on a wrapper here,
|
|
4
|
+
the first status broadcast would destroy it and every later transition
|
|
5
|
+
(including the park that flips the pill to held) would silently no-op. %>
|
|
6
|
+
<%= render "silas/inbox/turns/header", turn: turn %>
|
|
5
7
|
<div id="silas-turn-<%= turn.id %>-steps">
|
|
6
8
|
<%= render partial: "silas/inbox/steps/step", collection: turn.steps, as: :step %>
|
|
7
9
|
</div>
|
data/docs/configuration.md
CHANGED
|
@@ -18,6 +18,10 @@ end
|
|
|
18
18
|
| `around_model_call` | `nil` | Wrap every model call — e.g. `->(ctx, &call) { RubyLLM::Resilience.chain(:anthropic) { call.() } }`. |
|
|
19
19
|
| `queue_name` | `:default` | Active Job queue for agent turns. |
|
|
20
20
|
|
|
21
|
+
Which provider serves a turn is decided by the model id — OpenRouter,
|
|
22
|
+
OpenAI-compatible gateways, Bedrock/Vertex/Azure, and local runtimes are all
|
|
23
|
+
recipes in [providers](providers.md).
|
|
24
|
+
|
|
21
25
|
## The loop
|
|
22
26
|
|
|
23
27
|
| Option | Default | Meaning |
|
data/docs/connections.md
CHANGED
|
@@ -29,6 +29,9 @@ through the same Ledger.
|
|
|
29
29
|
join your database transaction, so the honest ceiling is `at_most_once`
|
|
30
30
|
(an ambiguous crash parks in-doubt for a person) or `idempotent` (you're
|
|
31
31
|
asserting the remote op is safe to repeat).
|
|
32
|
+
- **Credentials require https.** A connection with `auth:` configured and a
|
|
33
|
+
plaintext `http://` URL fails loudly at parse time (localhost is exempt for
|
|
34
|
+
local development servers).
|
|
32
35
|
- **Transport is HTTP** (v1). Filename is the namespace: `crm.yml` →
|
|
33
36
|
`crm__*`.
|
|
34
37
|
|
data/docs/inbox-and-api.md
CHANGED
|
@@ -13,6 +13,13 @@ hoisted to the top of the session, a full audit trail (every tool call's
|
|
|
13
13
|
arguments and result, who cleared what and why), cancel, raise-budget, and
|
|
14
14
|
per-session token/cost accounting priced from RubyLLM's model registry.
|
|
15
15
|
|
|
16
|
+
<img src="https://raw.githubusercontent.com/danielstpaul/silas/main/docs/img/silas-inbox-held.png" width="740"
|
|
17
|
+
alt="A session held at the signal: the amber approval card for issue_refund hoisted above the trace, with the turn marked HELD and a held-at-the-signal stub in place">
|
|
18
|
+
|
|
19
|
+
A £64 refund holding at the signal: the card carries the exact arguments, the
|
|
20
|
+
turn costs nothing while it waits, and the trace below keeps a one-line stub
|
|
21
|
+
where the invocation held.
|
|
22
|
+
|
|
16
23
|
It's **deny-by-default** — invisible until you wire auth:
|
|
17
24
|
|
|
18
25
|
```ruby
|
data/docs/providers.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Providers & gateways
|
|
2
|
+
|
|
3
|
+
Silas has exactly one inference seam: the `:ruby_llm` adapter. Every model
|
|
4
|
+
call goes through [RubyLLM](https://rubyllm.com), so every provider RubyLLM
|
|
5
|
+
speaks — Anthropic, OpenAI, Gemini, Bedrock, Vertex AI, Azure, Mistral,
|
|
6
|
+
DeepSeek, Perplexity, xAI, OpenRouter, local runtimes — is available to your
|
|
7
|
+
agents with zero Silas-specific glue. Keys live in
|
|
8
|
+
`config/initializers/ruby_llm.rb`; **which provider serves a turn is decided
|
|
9
|
+
by the model id**. Nothing in `app/agent/` changes when the provider does.
|
|
10
|
+
|
|
11
|
+
## How a model id picks a provider
|
|
12
|
+
|
|
13
|
+
RubyLLM ships a model registry (1,100+ entries, refreshed upstream from
|
|
14
|
+
[models.dev](https://models.dev)). Silas resolves the agent's `model:` — from
|
|
15
|
+
`agent.yml`, falling back to `config.default_model` — against that registry;
|
|
16
|
+
the matching entry names the provider, and the adapter builds that provider's
|
|
17
|
+
client. The same entry supplies the numbers Silas runs on, per
|
|
18
|
+
(model, provider):
|
|
19
|
+
|
|
20
|
+
- **pricing** — the cost lines in the inbox and the `max_cost` budget;
|
|
21
|
+
- **context window** — the `compact_at` compaction threshold.
|
|
22
|
+
|
|
23
|
+
So switching provider is switching model id. A model the registry doesn't
|
|
24
|
+
know fails fast at the first step with the fix in the error
|
|
25
|
+
(`RubyLLM.models.refresh!`, or pick a registry model). And because a
|
|
26
|
+
registry's tie-breaks can change across upgrades, Silas stamps the resolved
|
|
27
|
+
provider on every step row — historical cost lines price against the
|
|
28
|
+
(model, provider) that actually served them, forever.
|
|
29
|
+
|
|
30
|
+
## Direct providers
|
|
31
|
+
|
|
32
|
+
The installer's `config/initializers/ruby_llm.rb` maps environment keys in —
|
|
33
|
+
RubyLLM never reads provider keys from the environment by itself:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
RubyLLM.configure do |c|
|
|
37
|
+
c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every provider follows the same pattern (`openai_api_key`,
|
|
42
|
+
`gemini_api_key`, …— the full list is in
|
|
43
|
+
[RubyLLM's configuration docs](https://rubyllm.com/configuration/)). For
|
|
44
|
+
cloud-platform shops the "enterprise gateway" is usually just the native
|
|
45
|
+
provider:
|
|
46
|
+
|
|
47
|
+
| Platform | Keys |
|
|
48
|
+
|---|---|
|
|
49
|
+
| AWS Bedrock | `bedrock_api_key`, `bedrock_secret_key`, `bedrock_region` (+ optional `bedrock_session_token`) |
|
|
50
|
+
| GCP Vertex AI | `vertexai_project_id`, `vertexai_location` (+ optional `vertexai_service_account_key`) |
|
|
51
|
+
| Azure OpenAI | `azure_api_base`, `azure_api_key` (or `azure_ai_auth_token`) |
|
|
52
|
+
|
|
53
|
+
Verify any of this with `bin/rails silas:doctor` — it reports which providers
|
|
54
|
+
have credentials configured and resolves your default model with its price:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
✓ provider credentials — anthropic
|
|
58
|
+
✓ model claude-sonnet-4-5 — anthropic · $3/$15 per MTok
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## OpenRouter: one key, 300+ models
|
|
62
|
+
|
|
63
|
+
[OpenRouter](https://openrouter.ai) is a first-class RubyLLM provider, and
|
|
64
|
+
the registry ships its catalog (341 models as of ruby_llm 1.16) — one key
|
|
65
|
+
buys your agents Claude, GPT, Gemini, Llama, DeepSeek and the rest, billed
|
|
66
|
+
in one place.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# config/initializers/ruby_llm.rb
|
|
70
|
+
RubyLLM.configure do |c|
|
|
71
|
+
c.openrouter_api_key = ENV["OPENROUTER_API_KEY"]
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Routed models use slash-form ids — `creator/model`, exactly as OpenRouter
|
|
76
|
+
lists them:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
# app/agent/agent.yml
|
|
80
|
+
model: anthropic/claude-sonnet-4.5 # via OpenRouter
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
or globally:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
Silas.configure do |c|
|
|
87
|
+
c.default_model = "anthropic/claude-sonnet-4.5"
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The two id families never collide: `claude-sonnet-4-5` is Anthropic direct,
|
|
92
|
+
`anthropic/claude-sonnet-4.5` is the same model via OpenRouter, and each
|
|
93
|
+
resolves to its own registry entry. That per-route entry is the one your
|
|
94
|
+
cost lines and compaction thresholds follow — the route you run, not the
|
|
95
|
+
origin provider. Concretely, in the shipped registry the direct entry lists
|
|
96
|
+
a 200K context window while OpenRouter's route lists 1M, so `compact_at`
|
|
97
|
+
triggers where the route actually overflows.
|
|
98
|
+
|
|
99
|
+
`silas:doctor` confirms the whole chain:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
✓ provider credentials — openrouter
|
|
103
|
+
✓ model anthropic/claude-sonnet-4.5 — openrouter · $3/$15 per MTok
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## OpenAI-compatible gateways
|
|
107
|
+
|
|
108
|
+
Self-hosted and enterprise gateways (LiteLLM, Vercel AI Gateway, an internal
|
|
109
|
+
proxy) mostly speak the OpenAI chat-completions dialect. Two shapes:
|
|
110
|
+
|
|
111
|
+
**The gateway serves OpenAI model ids** (`gpt-5.2`, …) — point the OpenAI
|
|
112
|
+
provider at it:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
RubyLLM.configure do |c|
|
|
116
|
+
c.openai_api_key = ENV["GATEWAY_API_KEY"]
|
|
117
|
+
c.openai_api_base = "https://gateway.internal/v1"
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**The gateway serves models.dev slash ids** (`anthropic/claude-sonnet-4.5`,
|
|
122
|
+
…, as Vercel's AI Gateway does) — repoint the OpenRouter provider, which
|
|
123
|
+
already speaks plain chat-completions against exactly those ids:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
RubyLLM.configure do |c|
|
|
127
|
+
c.openrouter_api_key = ENV["AI_GATEWAY_API_KEY"]
|
|
128
|
+
c.openrouter_api_base = "https://ai-gateway.vercel.sh/v1"
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Either way the model id must still resolve in the registry — the registry
|
|
133
|
+
entry is where Silas gets pricing and the context window, and a gateway
|
|
134
|
+
that bills differently can be corrected per model with the
|
|
135
|
+
`config.model_prices` override ([configuration](configuration.md)).
|
|
136
|
+
|
|
137
|
+
Two gateway footnotes, both cheap to check with one real turn:
|
|
138
|
+
|
|
139
|
+
- RubyLLM renders system messages as role `developer` (OpenAI's current
|
|
140
|
+
dialect) through these providers. OpenRouter normalises it; if your
|
|
141
|
+
gateway insists on `system`, set `c.openai_use_system_role = true`.
|
|
142
|
+
- Streaming must pass through as SSE. If the operator inbox shows a turn
|
|
143
|
+
completing without live text, the gateway buffered the stream.
|
|
144
|
+
|
|
145
|
+
## Local runtimes
|
|
146
|
+
|
|
147
|
+
Ollama and GPUStack are RubyLLM providers too (`ollama_api_base`,
|
|
148
|
+
`gpustack_api_base`/`gpustack_api_key`). Local models aren't in the shipped
|
|
149
|
+
registry, so refresh it after configuring — `RubyLLM.models.refresh!` asks
|
|
150
|
+
every configured provider for its live model list and merges the results —
|
|
151
|
+
then use the id it lists. Local models carry no registry pricing: the inbox
|
|
152
|
+
shows their token counts with "cost unavailable", and a `max_cost` budget
|
|
153
|
+
can't bind (only priced tokens count toward it) — list the model in
|
|
154
|
+
`config.model_prices` to restore both.
|
|
155
|
+
|
|
156
|
+
## Failover and wrapping
|
|
157
|
+
|
|
158
|
+
Provider outages, rate-limit retries and model failover belong at the
|
|
159
|
+
inference seam, not in your tools. `config.around_model_call` wraps every
|
|
160
|
+
model call the loop makes:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
Silas.configure do |c|
|
|
164
|
+
c.around_model_call = ->(ctx, &call) do
|
|
165
|
+
RubyLLM::Resilience.chain(:anthropic) { call.() }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Whatever runs inside still lands in the same durable step — a failover
|
|
171
|
+
retry that succeeds is recorded exactly like a first-try success.
|
data/docs/tutorial.md
CHANGED
|
@@ -15,8 +15,12 @@ rails new desk -m https://raw.githubusercontent.com/danielstpaul/silas/main/temp
|
|
|
15
15
|
cd desk && bin/dev
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Open <http://localhost:3000> — the **signal board
|
|
19
|
-
|
|
18
|
+
Open <http://localhost:3000> — the **signal board**:
|
|
19
|
+
|
|
20
|
+
<img src="https://raw.githubusercontent.com/danielstpaul/silas/main/docs/img/silas-board.png" width="740"
|
|
21
|
+
alt="The desk's signal board: aspect tiles for held/working/clear/in doubt, the keyless demo banner, the agent card, and try-it prompts">
|
|
22
|
+
|
|
23
|
+
Then *operator inbox →* and start a session:
|
|
20
24
|
|
|
21
25
|
> The walnut monitor stand (order R-1002) arrived cracked.
|
|
22
26
|
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
#
|
|
4
4
|
# export ANTHROPIC_API_KEY=sk-ant-...
|
|
5
5
|
#
|
|
6
|
-
# Any provider RubyLLM supports works the same way (openai_api_key, etc.)
|
|
6
|
+
# Any provider RubyLLM supports works the same way (openai_api_key, etc.) —
|
|
7
|
+
# OpenRouter, OpenAI-compatible gateways, and local runtimes are covered in
|
|
8
|
+
# the Silas guide docs/providers.md (shipped in the gem: `bundle show silas`).
|
|
7
9
|
RubyLLM.configure do |c|
|
|
8
10
|
# Silas never uses RubyLLM's acts_as_* ActiveRecord mixins (it owns its own
|
|
9
11
|
# durable schema), so opt into the new API to silence the legacy deprecation
|
data/lib/silas/connection.rb
CHANGED
|
@@ -51,6 +51,22 @@ module Silas
|
|
|
51
51
|
raise Error, "connection #{name}: transport #{transport.inspect} unsupported (v1: http)" unless TRANSPORTS.include?(transport)
|
|
52
52
|
raise Error, "connection #{name}: approval #{approval.inspect} invalid" unless APPROVALS.include?(approval)
|
|
53
53
|
raise Error, "connection #{name}: effect #{effect.inspect} invalid" unless EFFECTS.include?(effect)
|
|
54
|
+
# Never send a credential over plaintext: an auth'd connection must be
|
|
55
|
+
# https (loopback exempt for local development servers). Boot-time and
|
|
56
|
+
# loud, like every other connection misconfiguration.
|
|
57
|
+
if @auth["type"].present? && plaintext_remote?
|
|
58
|
+
raise Error, "connection #{name}: refusing to send credentials over plaintext http — " \
|
|
59
|
+
"use https (localhost/127.0.0.1 are exempt)"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def plaintext_remote?
|
|
64
|
+
uri = URI(url)
|
|
65
|
+
# Scheme case-insensitively (HTTP:// is still plaintext); URI#host keeps
|
|
66
|
+
# brackets on IPv6 literals, so the loopback exemption lists both forms.
|
|
67
|
+
uri.scheme&.downcase == "http" && !%w[localhost 127.0.0.1 ::1 [::1]].include?(uri.host)
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
true # an unparseable url with auth configured fails closed
|
|
54
70
|
end
|
|
55
71
|
|
|
56
72
|
# One remote tool, resolved. Quacks like a resolved Silas::Tool for the
|
data/lib/silas/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: silas
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Daniel St Paul
|
|
@@ -177,6 +177,7 @@ files:
|
|
|
177
177
|
- docs/guarantees.md
|
|
178
178
|
- docs/inbox-and-api.md
|
|
179
179
|
- docs/memory.md
|
|
180
|
+
- docs/providers.md
|
|
180
181
|
- docs/sandbox.md
|
|
181
182
|
- docs/tools.md
|
|
182
183
|
- docs/tutorial.md
|