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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd68f6d368466b20fbb35f49628f166ac61dd60ace3a8cbd20bbc6e316c1d346
4
- data.tar.gz: 94d2a902cd7f364aa33200b1ea1f98446dabee38d9e9f7431df9cd96d1fc7f41
3
+ metadata.gz: 7a34eec0f8444f42ef91b1db155bc2786c28ce0de5ed345d9e63b39af9b29756
4
+ data.tar.gz: 94c9b0464629b6f23eab8f4811e5dd63cdec63d480388cf01c8b0b9111a43376
5
5
  SHA512:
6
- metadata.gz: c386608b076c2c3dfb23e369c819a7d7b1c4fc6122f0011ea80880a46b158b25549a75388a17de250b200a60e0ada311a73df28b47c67e4007cc9e2ebef8bf3f
7
- data.tar.gz: 4216b83c6925605076cb3742286699673e2a18dfd9eab3a3cc6e21479ee64e17de714737e5764acce0f147ca0023ced60ed0864b0836850c771dbab88ea84100
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.5.x) and moving fast. Requires Rails >= 8.1 (Active Job
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.public_send(helper, *args, script_name: Silas::Inbox.mount_path)
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%3Cg transform='translate(4,4)'%3E%3Ccircle cx='12' cy='12' r='9.6' fill='none' stroke='%23E9EBEF' stroke-width='2.2'/%3E%3Ccircle cx='8.4' cy='15.6' r='2.5' fill='%23F2F4F8'/%3E%3Ccircle cx='15.6' cy='8.4' r='2.5' fill='%23F2F4F8'/%3E%3C/g%3E%3C/svg%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.4" cy="15.6" r="2.5" style="fill: var(--accent)"/><circle cx="15.6" cy="8.4" r="2.5" style="fill: var(--accent)"/></svg><span>silas</span>
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
- <span class="cost"><%= cost_label(Silas::Inbox::Cost.for_session(session)) %></span>
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
- <div id="silas-session-<%= @session.id %>-cost"><%= render "silas/inbox/sessions/cost", session: @session %></div>
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
- <div class="turn-head">
2
- <span class="turn-input"><%= truncate(turn.input, length: 90) %></span>
3
- <%= status_pill(turn.status) %>
4
- <% if turn.active? && !turn.cancel_requested_at %>
5
- <%# silas_engine_path — this partial is broadcast-rendered through the
6
- host renderer on every turn status change; bare engine helpers (and
7
- the scope-dependent mounted proxy) 500 there. %>
8
- <%= form_with url: silas_engine_path(:cancel_inbox_turn_path, turn), method: :post, class: "inline" do %>
9
- <button class="btn-cancel" title="Honored at the next step boundary">Cancel</button>
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
- <% elsif turn.cancel_requested_at && turn.active? %>
12
- <span class="muted">cancel requested…</span>
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 &amp; 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 &amp; 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
- <div id="silas-turn-<%= turn.id %>-header">
3
- <%= render "silas/inbox/turns/header", turn: turn %>
4
- </div>
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>
@@ -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
 
@@ -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** — then *operator inbox →*
19
- and start a session:
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module Silas
2
- VERSION = "0.6.0"
2
+ VERSION = "0.6.2"
3
3
  end
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.0
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