legion-llm 0.13.0 → 0.14.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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +26 -0
  3. data/CHANGELOG.md +180 -0
  4. data/CLAUDE.md +23 -17
  5. data/README.md +34 -15
  6. data/REFACTOR-HANDOFF.md +299 -0
  7. data/docs/work/planning/p1-results.md +125 -0
  8. data/docs/work/planning/p2-results.md +101 -0
  9. data/docs/work/planning/p3-results.md +133 -0
  10. data/docs/work/planning/p4-results.md +129 -0
  11. data/docs/work/planning/p5-results.md +86 -0
  12. data/legion-llm.gemspec +1 -1
  13. data/lib/legion/llm/api/client_translators/anthropic_messages.rb +6 -3
  14. data/lib/legion/llm/api/client_translators/openai_chat.rb +6 -3
  15. data/lib/legion/llm/api/client_translators/openai_responses.rb +29 -33
  16. data/lib/legion/llm/api/client_translators/shared_extractors.rb +49 -0
  17. data/lib/legion/llm/api/error_translator.rb +71 -0
  18. data/lib/legion/llm/api/inventory_admin.rb +42 -0
  19. data/lib/legion/llm/api/namespaces/anthropic/messages.rb +9 -1
  20. data/lib/legion/llm/api/namespaces/helpers.rb +29 -0
  21. data/lib/legion/llm/api/namespaces/native/routing.rb +4 -23
  22. data/lib/legion/llm/api/namespaces/openai/chat/completions.rb +12 -4
  23. data/lib/legion/llm/api/namespaces/openai/responses.rb +17 -13
  24. data/lib/legion/llm/api/native/models.rb +5 -2
  25. data/lib/legion/llm/api/native/providers.rb +16 -14
  26. data/lib/legion/llm/api/native/routing.rb +4 -23
  27. data/lib/legion/llm/api/native/tiers.rb +5 -5
  28. data/lib/legion/llm/api/shared_helpers.rb +23 -0
  29. data/lib/legion/llm/api/stream_assembler.rb +88 -5
  30. data/lib/legion/llm/api.rb +2 -0
  31. data/lib/legion/llm/call/daemon_client.rb +1 -1
  32. data/lib/legion/llm/call/embeddings.rb +81 -46
  33. data/lib/legion/llm/call/lex_llm_adapter.rb +9 -0
  34. data/lib/legion/llm/call/providers.rb +0 -18
  35. data/lib/legion/llm/call/registry.rb +2 -2
  36. data/lib/legion/llm/call/structured_output.rb +1 -1
  37. data/lib/legion/llm/compat.rb +40 -3
  38. data/lib/legion/llm/context/compressor.rb +1 -1
  39. data/lib/legion/llm/context/curator.rb +2 -2
  40. data/lib/legion/llm/errors.rb +75 -0
  41. data/lib/legion/llm/fleet/dispatcher.rb +1 -1
  42. data/lib/legion/llm/helper.rb +10 -10
  43. data/lib/legion/llm/hooks/budget_guard.rb +1 -1
  44. data/lib/legion/llm/hooks/rag_guard.rb +1 -1
  45. data/lib/legion/llm/hooks/reciprocity.rb +2 -2
  46. data/lib/legion/llm/hooks/reflection.rb +2 -2
  47. data/lib/legion/llm/inference/context_accounting.rb +27 -7
  48. data/lib/legion/llm/inference/executor/context_window.rb +21 -5
  49. data/lib/legion/llm/inference/executor/escalation.rb +263 -360
  50. data/lib/legion/llm/inference/executor/payload_builder.rb +126 -0
  51. data/lib/legion/llm/inference/executor/routing.rb +60 -44
  52. data/lib/legion/llm/inference/executor/tool_injection.rb +1 -1
  53. data/lib/legion/llm/inference/executor.rb +12 -71
  54. data/lib/legion/llm/inference/native_tool_loop.rb +6 -101
  55. data/lib/legion/llm/inference/prompt.rb +7 -8
  56. data/lib/legion/llm/inference/request.rb +5 -2
  57. data/lib/legion/llm/inference/route_attempts.rb +4 -36
  58. data/lib/legion/llm/inference/steps/confidence_scoring.rb +1 -1
  59. data/lib/legion/llm/inference/steps/gaia_advisory.rb +5 -5
  60. data/lib/legion/llm/inference/steps/mcp_discovery.rb +1 -1
  61. data/lib/legion/llm/inference.rb +39 -16
  62. data/lib/legion/llm/inventory/capabilities.rb +48 -0
  63. data/lib/legion/llm/inventory/discovery/memory_gate.rb +55 -0
  64. data/lib/legion/llm/inventory/discovery/system.rb +138 -0
  65. data/lib/legion/llm/inventory/discovery.rb +565 -0
  66. data/lib/legion/llm/inventory/settings_observer.rb +61 -0
  67. data/lib/legion/llm/inventory/sweeper.rb +56 -0
  68. data/lib/legion/llm/inventory.rb +217 -458
  69. data/lib/legion/llm/metering/tokens.rb +2 -2
  70. data/lib/legion/llm/router/availability.rb +6 -159
  71. data/lib/legion/llm/router/health_tracker.rb +101 -41
  72. data/lib/legion/llm/router.rb +97 -572
  73. data/lib/legion/llm/scheduling.rb +1 -1
  74. data/lib/legion/llm/settings.rb +52 -32
  75. data/lib/legion/llm/skills/base.rb +1 -1
  76. data/lib/legion/llm/skills/disk_loader.rb +1 -1
  77. data/lib/legion/llm/skills/external_discovery.rb +2 -2
  78. data/lib/legion/llm/tools/confidence.rb +5 -5
  79. data/lib/legion/llm/tools/dispatcher.rb +1 -1
  80. data/lib/legion/llm/transport/message.rb +1 -1
  81. data/lib/legion/llm/types/message.rb +1 -1
  82. data/lib/legion/llm/version.rb +1 -1
  83. data/lib/legion/llm.rb +17 -12
  84. metadata +18 -14
  85. data/lib/legion/llm/capabilities.rb +0 -46
  86. data/lib/legion/llm/discovery/memory_gate.rb +0 -53
  87. data/lib/legion/llm/discovery/rule_generator.rb +0 -327
  88. data/lib/legion/llm/discovery/system.rb +0 -136
  89. data/lib/legion/llm/discovery.rb +0 -703
  90. data/lib/legion/llm/router/arbitrage.rb +0 -138
  91. data/lib/legion/llm/router/candidates.rb +0 -263
  92. data/lib/legion/llm/router/escalation/chain.rb +0 -51
  93. data/lib/legion/llm/router/escalation/tracker.rb +0 -76
  94. data/lib/legion/llm/router/registry_lookup.rb +0 -121
  95. data/lib/legion/llm/router/rule.rb +0 -134
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eaa596812e2320baa0d8ae31b44225c80e9ad9b54e62531b3cd1c7640ff09fe6
4
- data.tar.gz: a98b90ae07c7040fcd8a13adccf1d07037f253cc845cdc4dc55b65f434805ce0
3
+ metadata.gz: dac2aadf427ccf4c6e902242ed392c3f9f0f5a3e0ddaeeb6d6c262e3926c87b1
4
+ data.tar.gz: 48ccd139e47330107147d7745ec7e34c7a1de879cc8939d2d1fd33322d04b336
5
5
  SHA512:
6
- metadata.gz: a7611f997b2163792aa4f29d8ca2e3b8c10ec11af08ff8d89fae578ab0b0a138fbd9ad4957a5c42b793c30e959ab4c17290f01972cd0d8de8128e7e79873e28c
7
- data.tar.gz: 3b0acc643ffe9c06c6d07df9c5dce9a54d79351878354e30c1cc22fb5c5001debf9ac529ddeb873868ff713f455665c6e5fe992558d70ef376151f9b368b7086
6
+ metadata.gz: 27c3269042cf0b060f0e402fad967f048319a2a98a425d4b68f39569d07e41842505bf947f32ae62a52125a0c81143e4d1fcf076bee53e247fd0cfe76a9e7f6a
7
+ data.tar.gz: 1fcd99c1a3007b858bc033f57a1cd3ada1fd96e2729d597fd65f8ac8eeb406295f1b73a2d4bcea31793ca6669b0bba545c32ac86474e2c9f1a5adcc60f9550f2
data/.rubocop.yml CHANGED
@@ -12,6 +12,31 @@ Legion/RescueLogging/NoCapture:
12
12
  Legion/ConstantSafety/InheritParam:
13
13
  Enabled: false
14
14
 
15
+ # rubocop-legion 0.1.9 cops — enabled as of P0 but deferred enforcement:
16
+ # these four cops flag broad pre-existing patterns in the codebase that
17
+ # predate the SSOT refactor. They gate NEW code from P1 onward; existing
18
+ # violations are cleaned up within each phase as the code they govern is
19
+ # rewritten. Do not add new violations; do not suppress them with inline
20
+ # rubocop:disable without a tracking comment.
21
+ #
22
+ # TODO(P1): enable Legion/Llm/TaxonomyEnum repo-wide after lane taxonomy
23
+ # is established and all :type/:tier/:circuit_state literals updated.
24
+ # TODO(P1): enable Legion/Llm/RescueLogLevel repo-wide after back-compat
25
+ # :debug rescue handlers are audited and leveled up.
26
+ # TODO(P1): enable Legion/Llm/NoLoopDo repo-wide after cache/drain loops
27
+ # are converted to bounded iteration.
28
+ Legion/Llm/TaxonomyEnum:
29
+ Enabled: false
30
+ Legion/Llm/RescueLogLevel:
31
+ Enabled: false
32
+ Legion/Llm/NoLoopDo:
33
+ Enabled: false
34
+ # SettingsAccessPath is enabled for lib/ only — specs legitimately write
35
+ # settings via the loader path to set up test fixtures.
36
+ Legion/Llm/SettingsAccessPath:
37
+ Exclude:
38
+ - 'spec/**/*'
39
+
15
40
  AllCops:
16
41
  TargetRubyVersion: 3.4
17
42
  NewCops: enable
@@ -106,6 +131,7 @@ Naming/PredicateMethod:
106
131
  Enabled: false
107
132
  Metrics/ParameterLists:
108
133
  Max: 9
134
+ CountKeywordArgs: false
109
135
  Style/RedundantConstantBase:
110
136
  Exclude:
111
137
  - 'spec/**/*'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,185 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.14.3] - 2026-06-22
4
+
5
+ ### Fixed
6
+
7
+ - Context window enforcement now accounts for tool definition tokens when deciding whether to compact messages. Previously, 235 injected tools (~50k tokens) were invisible to the threshold check, causing context overflow on dispatch.
8
+ - Context overflow errors are no longer unconditionally terminal during escalation. When a lane with a larger context window exists, the executor retries on that lane instead of raising immediately.
9
+ - `X-Legion-Format: canonical` debug surface enabled by default (was gated to dev/lite mode only). The canonical format is a first-class output format, not a debug feature.
10
+ - Completion log now includes `context_tokens_saved=N` showing total tokens saved by curation, archival, thinking-strip, and context window compaction per request.
11
+ - Embedding dispatch now sends chunks individually instead of as an array, fixing intermittent `ContextOverflow` on RAG embedding queries with ollama.
12
+ - Truncated tool results now instruct the model to make smaller targeted requests instead of silently chopping content.
13
+ - `context_window_threshold` moved from hardcoded 0.90 to `context_curation_defaults` setting.
14
+
15
+ ### Changed
16
+
17
+ - `tool_result_max_dispatch_chars` raised from 2k to 5k (dispatch-time truncation of oversized tool results before provider call).
18
+ - `context_curation.tool_result_max_chars` lowered from 10k to 2k (curator distills tool results earlier, reducing context growth).
19
+ - Default `tier_weights` rebalanced: direct=105, local=110, fleet=110, cloud=120, frontier=150 (prefer higher-capability tiers when lane_weight tie-breaks).
20
+
21
+ ## [0.14.2] - 2026-06-20
22
+
23
+ ### Fixed
24
+
25
+ - Move auto-routing model aliases into `llm.routing.auto_routing_model_aliases`, so `legionio` and `auto` stay configurable rather than hard-coded.
26
+ - Ignore request-body `model` values as routing hints unless `llm.routing.allow_body_routing_hints` is explicitly enabled; auto-routing aliases still mean "you pick".
27
+ - Stop treating injected special tools as an implicit native-tools routing requirement when the client did not actually request tools.
28
+
29
+ ## [0.14.1] - 2026-06-20
30
+
31
+ ### Fixed
32
+
33
+ - Treat Bedrock region-prefixed model ids such as `us.anthropic.claude-sonnet-4-6` as equivalent to
34
+ the inventory's canonical `anthropic.claude-sonnet-4-6` lane during hard model filtering, so
35
+ routing no longer raises `NoLaneAvailable` for valid Bedrock requests.
36
+
37
+ ## [0.14.0] - 2026-06-19
38
+
39
+ ### Changed (BREAKING — internal API)
40
+
41
+ - **Inventory is now a single live `Concurrent::Map`** keyed by 5-part lane id
42
+ `tier:provider:instance:type:model`. The catalog is composed on write (by `lex-llm-*` discovery
43
+ actors via the `Inventory::ScopedRefresher` mixin), not recomposed on read. Per-request
44
+ `offerings_calls` collapses from ~4N to ≤1.
45
+ - **`Router.request_lane(**routing_payload)` is the single selection method.** `Router.resolve`,
46
+ `Router.resolve_chain`, `Router::Candidates`, `Router::EscalationChain`, `Arbitrage`, and the
47
+ full chain-building machinery are deleted.
48
+ - **`HealthTracker` writes lane health one-directionally.** The old request-time read API
49
+ (`circuit_state(provider:, instance:)`, `adjustment(...)`, `model_denied?(...)`) is deleted.
50
+ Health is now read from `lane[:health]`.
51
+ - **Dual error classes replace the old `EscalationExhausted`.** `Errors::NoLaneAvailable` (HTTP 400;
52
+ filters excluded all candidates from the start) and `Errors::EscalationExhausted` (HTTP 503 +
53
+ `Retry-After`; max attempts reached mid-flight) are the new error contract. Both inherit from
54
+ `LLMError`.
55
+ - **Embedding selection uses `Router.request_lane(type: :embedding, models: [pinned])`.** Strict
56
+ model pin — no cross-model failover. The bespoke embedding-selection machine is deleted.
57
+ - **`while remaining.positive?` loop replaces `loop do`.** The executor's request lifecycle is
58
+ bounded by construction; `loop do`, `retry`, `redo` are forbidden by the `NoLoopDo` rubocop cop.
59
+
60
+ ### Added
61
+
62
+ - `Inventory.write_lane(lane:, ttl:, **)` / `.delete_lane(id:, **)` / `.lane(id:, **)` /
63
+ `.lanes_for(provider:, instance:, type:, model:, **)` / `.lanes(**)` — kwargs-only public API.
64
+ - `Inventory::Sweeper` `::Every` actor — TTL safety net for dead-actor lane orphans.
65
+ - **RANKING v2:** `lane_weight = tier_w × provider_w × instance_w × model_w × health_mult`,
66
+ precomputed at write time, surfaced in `/api/llm/providers/<p>/models`. Operator-tunable via
67
+ settings; all weights default to 100.
68
+ - `Legion::Cache::Local` cooldown circuit for auth failures
69
+ (`llm_auth_failed:<credential_hash>` key). Short-circuits dispatch during the cooldown window
70
+ without tripping the instance circuit.
71
+ - `PayloadBuilder` single ingress site at `inference/executor/payload_builder.rb`. Validates
72
+ `x-legion-tiers`, `x-legion-providers`, `x-legion-instances`, `x-legion-models` headers against
73
+ frozen taxonomies. Unknown values → 400 with `error.type: invalid_header`.
74
+ - `StreamAssembler` mid-stream failover contract: `provider_failover_pending!(from:)` clears the
75
+ canonical buffer; `finalize` emits debug trailers (`x-legion-failover-from`, `-to`, `-count`)
76
+ only when failover occurred. No custom SSE event (N×N invariant 5).
77
+ - Admin endpoint `POST /api/llm/inventory/refresh` — operator-triggered catalog refresh.
78
+ - `:fleet` is a first-class tier in the `Taxonomies::TIERS` enum.
79
+
80
+ ### Deprecated
81
+
82
+ - `Router.populate_auto_rules(_)` — no-op stub. Removed in v0.15.0 after call sites in `lex-llm-*`
83
+ gems are cleaned up. Tracking issue: [#154](https://github.com/legion-io/legion-llm/issues/154).
84
+ Remove-stub issue: [#155](https://github.com/legion-io/legion-llm/issues/155).
85
+
86
+ ### Fixed
87
+
88
+ - `/v1/moderations` 500 error (missing `Call::Registry.providers` method).
89
+ - Compliance leak via discovery path: denied models could enter `/api/llm/offerings` because the
90
+ discovery feeder bypassed `lex-llm-*` whitelist/blacklist filtering. `Inventory.write_lane` is
91
+ now the single fail-closed choke point.
92
+ - Mid-stream provider failover now correctly clears the canonical buffer — no thinking tokens from
93
+ provider A leak into provider B's response context.
94
+
95
+ ### Removed
96
+
97
+ - `Legion::LLM::EscalationTracker` (dead code, zero callers).
98
+ - `Inventory#native_provider_offerings`, `discovery_offerings`, `dedupe_offerings`, `build_offering`,
99
+ `add_fleet_lane`, `compose_offerings` — replaced by `lex-llm-*` gem writers via the
100
+ `Inventory::ScopedRefresher` mixin.
101
+ - `Call::Registry.all_provider_families` (duplicate of `.available`).
102
+ - Hardcoded last-resort tier model literals.
103
+ - `Providers.inject_anthropic_cache_control!` — moved to `lex-llm-anthropic` translator (CLAUDE.md
104
+ invariant #3).
105
+ - `lib/legion/llm/discovery.rb`, `lib/legion/llm/capabilities.rb`, `lib/legion/llm/discovery/`
106
+ compat shim forwarders (module paths moved to `inventory/` tree in v0.13.x; shims deleted in
107
+ v0.14.0).
108
+ - `Router::Candidates`, `Router::Arbitrage`, `Router::EscalationChain` (all deleted; use
109
+ `Router.request_lane`).
110
+
111
+ ### Breaking change notes
112
+
113
+ - **Embedding single-instance HA:** single-instance Ollama (or any single embedding provider) will
114
+ produce 400 `NoLaneAvailable` during the ~5–10s restart window rather than silently retrying.
115
+ Use two instances for HA.
116
+ - **Rollback requires yanking the entire train.** `lex-llm 0.6.0`'s `ScopedRefresher` calls
117
+ `Inventory.write_lane` which does not exist on `legion-llm 0.13.x`. Yanking `legion-llm 0.14.0`
118
+ alone is insufficient — `lex-llm 0.6.0` and all 9 `lex-llm-*` paired versions must be yanked
119
+ together. See `docs/migration/0.14.0.md` for the 3am rollback procedure.
120
+
121
+ ---
122
+
123
+ ## [0.13.3] - 2026-06-18
124
+
125
+ ### Fixed
126
+
127
+ - **OpenAI Responses (`/v1/responses`) tool turns now terminate with `response.completed`.** A turn
128
+ carrying client-callable `function_call` items was emitting a non-standard `response.done` with
129
+ `status: requires_action` — Assistants-API vocabulary the Responses protocol has no concept of. Real
130
+ Responses clients wait for `response.completed`, so each tool turn surfaced to the client as
131
+ "stream disconnected before completion" and forced a reconnect/retry. The terminal event is now
132
+ always `response.completed` / `status: completed` with the `function_call` items in `output[]`
133
+ (streaming **and** non-streaming); `requires_action`/`action_required` removed. Server-executed
134
+ (LegionIO) tools were already `completed` and are unchanged. Specs updated to assert the protocol.
135
+ - **Router no longer manufactures escalation fallbacks the live catalog doesn't offer.**
136
+ `build_fallback_resolutions` enumerated registered instances and paired each with a default model
137
+ without checking the catalog offered it, producing dead candidates (a provider + a model it does
138
+ not serve) that availability rejected on every request — wasted work plus `resolution_unavailable`
139
+ log noise. Fallbacks are now gated against `Inventory` (the catalog SSOT) via
140
+ `fallback_model_offered?`, so an unoffered triple is never proposed.
141
+
142
+ ### Changed
143
+
144
+ - Removed the per-response `extract_thinking` INFO log spam (it fired 4–6× per request, once per
145
+ extraction site). The extraction is unchanged; only the diagnostic logging was dropped.
146
+
147
+ ## [0.13.2] - 2026-06-17
148
+
149
+ ### Fixed
150
+
151
+ - **Discovery no longer blocks the request path on a live network refresh.** `Discovery#discovered_models`
152
+ used to refresh synchronously once its 60s TTL lapsed — a serial, per-instance live fetch
153
+ (`adapter.offerings(live: true)`) on the request thread, so one unreachable/slow instance stalled
154
+ routing for its socket timeout (~20s, recurring ~once a minute). It surfaced as a fast `[pipeline][timing]`
155
+ with the time hidden in the `routing` step (which reads candidates via `model_available?`/`model_size`).
156
+ The request path now only **reads** the cache; refresh is owned by the provider `DiscoveryRefresh`
157
+ `::Every` actors (background) + the startup `Discovery.run` warm.
158
+
159
+ ### Changed
160
+
161
+ - **Discovered-models cache is a `Concurrent::Map` keyed by provider.** Each provider's refresh actor
162
+ writes its own key atomically (no read-modify-write across providers, no lock); reads flatten all
163
+ values lock-free. `@discovery_status` is likewise a `Concurrent::Map` (the `@discovery_mutex` is
164
+ removed). Dead read-path TTL machinery (`discovered_models_stale?`, `discovery_refresh_seconds`)
165
+ deleted; the `llm.discovery.refresh_seconds` setting is now inert (actors use their own interval).
166
+
167
+ ## [0.13.1] - 2026-06-17
168
+
169
+ ### Fixed
170
+
171
+ - **Streamed responses no longer leak Ruby object inspect strings to the client.** The
172
+ `StreamAssembler::ChunkAdapter` — the single chunk→wire normalizer — rendered provider value
173
+ objects with `.to_s` when they weren't plain strings, so the client SSE could carry
174
+ `#<Legion::Extensions::Llm::Thinking:0x…>` (Claude Code `/v1/messages`, via the legacy-chunk
175
+ `legacy_thinking` path that only checked `#content` while the legacy `Thinking` exposes `#text`)
176
+ or `[#<data …Canonical::ContentBlock…>]` (Codex `/v1/responses`, via a `text_delta` whose delta
177
+ arrived as a `ContentBlock` array). Both paths now unwrap to text and never `.to_s` a value
178
+ object onto the wire. The metering/audit ledger was already clean — only the streaming wire was
179
+ affected; the in-process matrix did not catch it because the `FakeProvider` emits canonical
180
+ chunks only (the documented provider-shape blind spot), so the regression is locked by direct
181
+ `StreamAssembler` specs.
182
+
3
183
  ## [0.13.0] - 2026-06-17
4
184
 
5
185
  Consolidated release. This single version bundles every change from `0.12.14` through `0.12.35`
data/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- # legion-llm (v0.13.0)
1
+ # legion-llm (v0.14.0)
2
2
 
3
3
  Core LegionIO gem: LLM routing, provider dispatch, the inference pipeline, and the
4
4
  OpenAI/Anthropic-compatible API surface. This file is loaded into **every** session — it is
@@ -31,8 +31,8 @@ push. If a regression breaks live e2e but not the matrix, the matrix is missing
31
31
  |------|------|
32
32
  | Facade (`start`, `chat`, `ask`, `embed`) | `lib/legion/llm.rb` |
33
33
  | **Single source of truth for the catalog** | `lib/legion/llm/inventory.rb` |
34
- | Router (`resolve`, `resolve_chain`, candidates) | `lib/legion/llm/router.rb`, `router/{candidates,availability,resolution,rule,health_tracker}.rb` |
35
- | Escalation / failover | `lib/legion/llm/router/escalation/`, `inference/executor/escalation.rb` |
34
+ | Router (`request_lane` — single selection) | `lib/legion/llm/router.rb`, `router/{availability,resolution,health_tracker}.rb` |
35
+ | Escalation history / failover | `lib/legion/llm/router/escalation/history.rb`, `inference/executor/escalation.rb` |
36
36
  | Pipeline executor (18 steps, streaming) | `lib/legion/llm/inference/executor.rb` (+ `executor/*.rb`) |
37
37
  | Pipeline steps | `lib/legion/llm/inference/steps/*.rb` |
38
38
  | Client API routes | `lib/legion/llm/api/openai/`, `api/anthropic/`, `api/native/` |
@@ -75,21 +75,27 @@ These have caused production incidents. They are also enforced by `rubocop-legio
75
75
  prompt; server-executed tools run server-side; client-passthrough tools surface as pending
76
76
  calls for the client. Simplest end-to-end check that the proxy contract holds in both formats.
77
77
 
78
- ## Routing rules (current behaviour)
79
-
80
- - **`Inventory.offerings` is THE catalog** (registration + liveness + health/circuit/denied).
81
- `Call::Registry`, `Discovery`, `HealthTracker` are *feeders*, never read directly for model facts
82
- by routing/availability/executor.
83
- - **Never dispatch a triple that isn't in the live catalog / isn't healthy.** There is no
84
- anthropic→qwen; the availability gate rejects models a provider doesn't offer. Fail over, don't
85
- hard-fail, unless the chain is genuinely empty.
86
- - **Multi-instance failover:** exhaust a provider's own instances before crossing providers.
87
- Account-scoped errors (credit/quota/payment) **deprioritize** the failing instance via its
88
- per-instance circuit (no model-deny) so the healthy sibling wins and auto-recovers on cooldown.
89
- Model-intrinsic errors skip all instances. Instance selection prefers closed half_open open.
78
+ ## Routing rules (RANKING v2 — current behaviour)
79
+
80
+ - **`Inventory` live `Concurrent::Map` is THE catalog.** Keyed by 5-part lane id
81
+ `tier:provider:instance:type:model`. Written by `lex-llm-*` discovery actors via the
82
+ `Inventory::ScopedRefresher` mixin. `HealthTracker` is the only other writer (owns `health`
83
+ block per lane). Everyone reads the same map, lock-free.
84
+ - **`Router.request_lane(**routing_payload)` is the single selection method.** Returns one lane
85
+ hash or `nil`. Hard filters soft filter (lane_weight ≤ 0 excluded) → max-weight bucket →
86
+ uniform sample. No pre-built chains.
87
+ - **Escalation = "ask again with the failed lane excluded."** Executor calls `request_lane` in a
88
+ `while remaining.positive?` loop, appending tried lane ids to `tried_lanes`. No `loop do`.
89
+ - **`lane_weight = tier_w × provider_w × instance_w × model_w × health_mult`.** Precomputed on
90
+ write. Negative = open circuit or policy-denied (excluded by soft filter). Surfaced in
91
+ `/api/llm/providers/<p>/models`. Tunable via `settings[:llm][:routing][:weights]`.
92
+ - **`:fleet` is a first-class tier** in `Taxonomies::TIERS`. Fleet lanes written by `lex-llm-*`
93
+ fleet workers appear alongside direct lanes.
94
+ - **`NoLaneAvailable` (400):** hard filters excluded everything before the first attempt.
95
+ **`EscalationExhausted` (503 + `Retry-After`):** max attempts reached mid-flight.
90
96
  - **Model policy is compliance.** `model_whitelist`/`model_blacklist` is honored at dispatch,
91
- fail-closed. A policy-denied model is **terminal** — never escalated, never trips circuits/denies.
92
- Enforced at the daemon layer here (`call/dispatch.rb` `enforce_model_policy!` →
97
+ fail-closed. A policy-denied model is **terminal** — never escalated, never trips circuits.
98
+ Enforced at the daemon layer (`call/dispatch.rb` `enforce_model_policy!` →
93
99
  `Errors::ModelNotAllowed`) and in each `lex-llm-*` provider.
94
100
 
95
101
  ## Coding constraints (enforced in review + cops)
data/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  <p align="center">
13
13
  <img alt="License" src="https://img.shields.io/badge/license-Apache--2.0-blue.svg">
14
14
  <img alt="Ruby" src="https://img.shields.io/badge/ruby-3.4%2B-CC342D.svg">
15
- <img alt="Version" src="https://img.shields.io/badge/version-0.13.0-informational.svg">
15
+ <img alt="Version" src="https://img.shields.io/badge/version-0.14.0-informational.svg">
16
16
  <img alt="Tests" src="https://img.shields.io/badge/tests-3200%2B%20examples%20·%200%20failures-success.svg">
17
17
  <img alt="RuboCop" src="https://img.shields.io/badge/rubocop-0%20offenses-success.svg">
18
18
  </p>
@@ -642,24 +642,43 @@ session = llm_session(tier: :local)
642
642
  | `capability` | `:basic`, `:moderate`, `:reasoning` | `:moderate` | Higher prefers larger/cloud models |
643
643
  | `cost` | `:minimize`, `:normal` | `:normal` | `:minimize` prefers local/fleet |
644
644
 
645
- #### Routing Resolution
645
+ #### Routing Resolution — RANKING v2
646
646
 
647
+ `Router.request_lane(**routing_payload)` returns one lane hash from the live `Inventory` catalog
648
+ or `nil`. The catalog is a `Concurrent::Map` of 5-part lane ids
649
+ (`tier:provider:instance:type:model`) populated by `lex-llm-*` discovery actors. No recomputation
650
+ on read.
651
+
652
+ **Selection algorithm:**
653
+ ```
654
+ 1. Hard filters applied (provider/instance/model/tier constraints from routing_payload).
655
+ 2. Soft filter: lanes with lane_weight ≤ 0 excluded (open circuit or policy-denied).
656
+ 3. Max-weight bucket selected (all lanes with the highest lane_weight value).
657
+ 4. One lane sampled uniformly within the bucket (seeded RNG for reproducibility).
658
+ 5. Returns the lane, or nil if no lanes survive filters.
659
+ ```
660
+
661
+ **RANKING v2 lane_weight formula:**
647
662
  ```
648
- 1. Caller passes intent: { privacy: :strict, capability: :basic }
649
- 2. Router merges with default_intent (fills missing dimensions)
650
- 3. Load rules from settings, filter by:
651
- a. Intent match (all `when` conditions must match)
652
- b. Schedule window (valid_from/valid_until, hours, days)
653
- c. Constraints (e.g., never_cloud strips cloud-tier rules)
654
- d. Discovery (Ollama model pulled? Model fits in available RAM?)
655
- e. Tier availability (is Ollama running? is Transport loaded?)
656
- 4. Score remaining candidates:
657
- effective_priority = rule.priority
658
- + health_tracker.adjustment(provider)
659
- + (1.0 - cost_multiplier) * 10
660
- 5. Return Resolution for highest-scoring candidate
663
+ lane_weight = tier_weight × provider_weight × instance_weight × model_weight × health_multiplier
661
664
  ```
662
665
 
666
+ All weights default to 100. The health multiplier is:
667
+ - `1.0` — closed circuit (full weight)
668
+ - `0.5` — half-open (reduced weight; cautious retry)
669
+ - `-100_000_000` — open circuit (effectively disabled; excluded by soft filter)
670
+
671
+ Weights are operator-tunable via settings and take effect immediately (no restart required).
672
+ Surfaced in `/api/llm/providers/<provider>/models` as `lane_weight`.
673
+
674
+ **Escalation:** "try again with the failed lane excluded." The executor calls `request_lane` in a
675
+ `while remaining.positive?` loop, appending each tried lane to `tried_lanes`. This replaces the
676
+ old pre-built escalation chain.
677
+
678
+ **Errors:**
679
+ - `Errors::NoLaneAvailable` (HTTP 400) — all filters excluded everything before the first attempt.
680
+ - `Errors::EscalationExhausted` (HTTP 503 + `Retry-After`) — attempts exhausted mid-flight.
681
+
663
682
  #### Settings
664
683
 
665
684
  Add routing configuration under the `llm` key: