legion-llm 0.15.2 → 0.16.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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/legion-llm.gemspec +2 -2
  4. data/lib/legion/llm/api/client_translators/anthropic_messages.rb +4 -0
  5. data/lib/legion/llm/api/client_translators/openai_chat.rb +5 -1
  6. data/lib/legion/llm/api/client_translators/openai_responses.rb +5 -1
  7. data/lib/legion/llm/api/error_translator.rb +19 -0
  8. data/lib/legion/llm/api/model_catalog.rb +254 -0
  9. data/lib/legion/llm/api/namespaces/anthropic/messages/batches.rb +1 -3
  10. data/lib/legion/llm/api/namespaces/anthropic/messages/count_tokens.rb +61 -1
  11. data/lib/legion/llm/api/namespaces/anthropic/messages.rb +10 -2
  12. data/lib/legion/llm/api/namespaces/helpers.rb +6 -5
  13. data/lib/legion/llm/api/namespaces/native/inference.rb +2 -0
  14. data/lib/legion/llm/api/namespaces/native/offerings.rb +2 -2
  15. data/lib/legion/llm/api/namespaces/native/routing.rb +4 -2
  16. data/lib/legion/llm/api/namespaces/native/tiers.rb +2 -2
  17. data/lib/legion/llm/api/namespaces/openai/audio/speech.rb +2 -0
  18. data/lib/legion/llm/api/namespaces/openai/audio/transcriptions.rb +2 -0
  19. data/lib/legion/llm/api/namespaces/openai/audio/translations.rb +2 -0
  20. data/lib/legion/llm/api/namespaces/openai/batches.rb +5 -2
  21. data/lib/legion/llm/api/namespaces/openai/chat/completions.rb +9 -2
  22. data/lib/legion/llm/api/namespaces/openai/completions.rb +7 -3
  23. data/lib/legion/llm/api/namespaces/openai/embeddings.rb +39 -15
  24. data/lib/legion/llm/api/namespaces/openai/images.rb +6 -0
  25. data/lib/legion/llm/api/namespaces/openai/models.rb +36 -21
  26. data/lib/legion/llm/api/namespaces/openai/moderations.rb +2 -0
  27. data/lib/legion/llm/api/namespaces/openai/responses.rb +9 -2
  28. data/lib/legion/llm/api/native/helpers.rb +1 -2
  29. data/lib/legion/llm/api/native/inference.rb +2 -3
  30. data/lib/legion/llm/api/native/instances.rb +14 -1
  31. data/lib/legion/llm/api/native/offerings.rb +120 -2
  32. data/lib/legion/llm/api/native/providers.rb +17 -14
  33. data/lib/legion/llm/api/native/routing.rb +4 -2
  34. data/lib/legion/llm/api/native/tiers.rb +68 -51
  35. data/lib/legion/llm/api/openai/embeddings.rb +2 -2
  36. data/lib/legion/llm/api/routing_error_mapper.rb +209 -0
  37. data/lib/legion/llm/api/shared_helpers.rb +10 -5
  38. data/lib/legion/llm/api/stream_assembler.rb +1 -1
  39. data/lib/legion/llm/api/translators/openai_response.rb +37 -18
  40. data/lib/legion/llm/cache.rb +29 -0
  41. data/lib/legion/llm/call/dispatch.rb +29 -21
  42. data/lib/legion/llm/call/embeddings.rb +394 -220
  43. data/lib/legion/llm/call/selection_dispatch.rb +166 -0
  44. data/lib/legion/llm/call/structured_output.rb +62 -73
  45. data/lib/legion/llm/context/curator.rb +6 -16
  46. data/lib/legion/llm/errors.rb +22 -0
  47. data/lib/legion/llm/helper.rb +7 -4
  48. data/lib/legion/llm/inference/attempt_context.rb +85 -0
  49. data/lib/legion/llm/inference/embed_pipeline.rb +23 -124
  50. data/lib/legion/llm/inference/executor/escalation.rb +211 -610
  51. data/lib/legion/llm/inference/executor/routing.rb +45 -409
  52. data/lib/legion/llm/inference/executor/tool_injection.rb +15 -0
  53. data/lib/legion/llm/inference/executor.rb +48 -2
  54. data/lib/legion/llm/inference/prompt.rb +31 -16
  55. data/lib/legion/llm/inference/request.rb +79 -34
  56. data/lib/legion/llm/inference/route_attempts.rb +68 -0
  57. data/lib/legion/llm/inference/routing_session.rb +139 -0
  58. data/lib/legion/llm/inference/steps/classification.rb +5 -1
  59. data/lib/legion/llm/inference/steps/confidence_scoring.rb +0 -17
  60. data/lib/legion/llm/inference/steps/debate.rb +8 -4
  61. data/lib/legion/llm/inference/steps/tier_assigner.rb +1 -1
  62. data/lib/legion/llm/inference.rb +175 -238
  63. data/lib/legion/llm/inventory/discovery.rb +1 -46
  64. data/lib/legion/llm/inventory.rb +119 -0
  65. data/lib/legion/llm/quality/shadow_eval.rb +40 -7
  66. data/lib/legion/llm/router/body_model_hint_policy.rb +97 -0
  67. data/lib/legion/llm/router/candidate_evaluation.rb +104 -0
  68. data/lib/legion/llm/router/candidate_evaluator.rb +393 -0
  69. data/lib/legion/llm/router/header_constraints.rb +150 -0
  70. data/lib/legion/llm/router/input_bound.rb +151 -0
  71. data/lib/legion/llm/router/outcome_classifier.rb +154 -0
  72. data/lib/legion/llm/router/ranker.rb +224 -0
  73. data/lib/legion/llm/router/rejection_diagnostics.rb +273 -0
  74. data/lib/legion/llm/router/request_requirements.rb +162 -0
  75. data/lib/legion/llm/router/required_capabilities.rb +144 -0
  76. data/lib/legion/llm/router/settings_snapshot.rb +351 -0
  77. data/lib/legion/llm/router/settings_state.rb +100 -0
  78. data/lib/legion/llm/router.rb +110 -194
  79. data/lib/legion/llm/routing_context.rb +55 -0
  80. data/lib/legion/llm/settings/api.rb +39 -0
  81. data/lib/legion/llm/settings.rb +47 -48
  82. data/lib/legion/llm/skills/base.rb +1 -1
  83. data/lib/legion/llm/version.rb +1 -1
  84. data/lib/legion/llm.rb +21 -1
  85. metadata +24 -8
  86. data/lib/legion/llm/inventory/sweeper.rb +0 -56
  87. data/lib/legion/llm/router/availability.rb +0 -25
  88. data/lib/legion/llm/router/health_tracker.rb +0 -427
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63f3720eceeff21e7c4d1ca9c13858256b93bd2744d0ae3fc1bd0c7452b40d1a
4
- data.tar.gz: 686e8646720ba92310f58ab7e675d380003e89e465a7b02711b916b38ee89953
3
+ metadata.gz: c593f0ce469cb6391a8aecfea4bbccfb99220e598616e980bc005594fe789972
4
+ data.tar.gz: 2cb436d417122b94e0aa9eb02775aeee25047bf94ba7cb03ff8bf73f6677787f
5
5
  SHA512:
6
- metadata.gz: cf42fe3fc6d6128ca111f2d92488496079496357d03ddf5c8fdf45d1372d245fb19f997a941caff59b13a5c8c4ddf52ac1dcc28ad2aace7f5f5accd1fdb6c48d
7
- data.tar.gz: be0e5fbd569a12eb65a0952c58ee91a42f06955858a073c52e83ae1e2165fec4f595dd2677b3287ffc1b5cf99fb53ed8f8905849876e93cbb21b100ea4f47d02
6
+ metadata.gz: 2cb61a834758040085f1f8479119e1e36e3b77b7145fc1ee42b900454993f2356cac77ad4cd54898ded97380ae66bf905487e2ed2c26bf27a43cdf0f598ce6a7
7
+ data.tar.gz: 0ec4deffe1350de416f2f3f9366ebe97d20ac32154f76d25e603767b089e9155ed7896cb84ed43ceeb8306211318494c78ff8e3e8f58d0189b089b5db9e6d0d5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.16.3] - 2026-08-17
4
+
5
+ ### Changed
6
+ - **`llm.context_curation.tool_result_max_chars` 2,000 → 10,000.** The tool-result distillation cap is raised so larger tool outputs (file reads, command logs) survive curation intact before the heuristic summary kicks in.
7
+ - **Strict direct-first tier weights.** `llm.routing.tier_weights` is now `{ direct: 150, local: 120, fleet: 115, cloud: 110, frontier: 105 }` — a strict direct → local → fleet → cloud → frontier order with no ties. The previous default ranked frontier first (`{ direct: 105, local: 110, fleet: 110, cloud: 120, frontier: 150 }`, local/fleet tied at 110).
8
+
9
+ ### Removed
10
+ - **`llm.routing.last_resort_model` / `last_resort_provider` defaults.** No code path reads either key (verified); the stale `claude-sonnet-4-6` / `anthropic` last-resort fallback is gone.
11
+
12
+ ### Notes
13
+ - **Curation threshold audit (blocking ceiling / no-curation floor), mapped by behavior.** (1) The *no-curation floor* — `llm.context_curation.target_context_tokens`, at/below which drop-and-archive curation is a no-op (`context/curator.rb`) — is 120,000 → 120,000 (confirmed unchanged); the conversation auto-compact floor (`llm.conversation.summarize_threshold`, `inference/executor.rb`) sits at the same 120,000. (2) The *size-based blocking ceiling* that compacts the outgoing call — `llm.context_curation.context_window_threshold` (`inference/executor/context_window.rb`) — 0.90 → 0.85: the synchronous curation ceiling is now 85% of the model's context window (850k on the 1M-window vllm lanes).
14
+
15
+ ## [0.16.2] - 2026-08-17
16
+
17
+ ### Fixed
18
+ - **Status API repointed off the dead legacy store.** `/api/llm/providers` and `/instances` now read the per-instance health hash from `Legion::Settings` (4-key legacy shape keyed by config name, written by the discovery actors); `/tiers` and `/offerings` read the live `Inventory::Registry` snapshot. `routing_enabled?` is derived from the Registry instead of the hardcoded `false` stub.
19
+ - **Dispatch-boundary response normalization (latent 500 after a provider 200).** `Call::Dispatch.normalize_response` is public; the SSOT direct-dispatch path (`ssot_v3_direct_dispatch` in `route_attempts.rb`) wraps the raw provider `Message` in a canonical `Canonical::Response` at the executor consumption point, so the tool loop no longer NoMethodErrors on duck-typed `result[:tool_calls]`. Regression: `dispatch_message_normalization_spec.rb`.
20
+ - **SelectionDispatch logs the original dispatch error.** The `normalize` rescue records the original error's class + message (scrubbed), so a raising normalizer (e.g. a non-UTF-8 `ArgumentError` masked as `reason is not valid UTF-8`) can no longer hide the real error behind an infinite-retry 500.
21
+ - **Bounded 529 / typed rejection diagnostics.** A required capability that is `:unknown` on a settled (`:complete`) candidate set is now a terminal typed 400 instead of unbounded 529 `too_early` retries; tripped instances report before unknown (503, not 529); `too_early` is reserved for genuinely-`:initializing` candidates.
22
+ - **Pin-aware `fit_available` (release bar 6a).** A pinned tools+thinking request against an unserviceable provider no longer falls to unbounded 529 when pin-mismatched fit+available siblings exist — it returns the terminal typed 400.
23
+ - **Per-instance tuning keyed by config name.** `SettingsSnapshot` weight/`preferred_context` lookups used the derived `host:port/ak` id while config is keyed by name — silently inert. Tuning now resolves via the shared `SettingsCascade` keyed by the operator's config name.
24
+ - **Upper-exclusive binning seam restored.** `preferred_context_sieve` is back to `budget < max`; adjacent bins no longer double-match at the shared boundary.
25
+
26
+ ### Changed
27
+ - **`enable_*` keys are operator overrides consumed by the router.** The `enable_thinking`/`enable_tools`/`enable_streaming` cascade (provider → instance → model) is applied as a routing override — `true` satisfies the axis, `false` makes the candidate ineligible, unset falls back to provider evidence. Provider capability evidence itself is unchanged.
28
+ - **`legion-settings >= 1.4.2` floor.** The published 1.4.0 gem resolved nested `lex-llm-*` extensions to flat settings keys, leaving `settings[:instances]` nil so discovery actors saw zero instances; 1.4.2 ships the segments-based nested-path fix.
29
+ - **Fail-forward release bar locked in.** New 12-example frozen-config behavioral spec (`ssot_v3_fail_forward_release_bar_spec.rb`) drives the real router against the no-regression bar: fail forward, no config changes required, typed failures, recovery without restart.
30
+
31
+ ## [0.16.1] - 2026-08-13
32
+
33
+ ### Fixed
34
+ - **P1: native_dispatch_options dropped temperature and all generation params before provider dispatch in the native tool loop.** Root cause of the parallel tool-call dead stop: `native_dispatch_options` (tool_injection.rb) built the dispatch Hash from request fields but never included generation sampling params from `@request.generation`. The provider receives `opts[:temperature]` — when nil, vLLM/Ollama/any provider runs at its default temperature instead of the caller's explicit `temperature: 0`, producing nondeterministic output. For tool-call requests, this manifests as empty-argument tool calls (the model commits to tool call openers but generates EOS before argument tokens). Now propagates the full canonical generation params (temperature, top_p, top_k, frequency_penalty, presence_penalty, seed) via `apply_generation_params!`, using `.key?` to preserve explicit 0 values. Provider-agnostic fix at the executor boundary — all providers benefit.
35
+
36
+ ## [0.16.0] - 2026-08-13
37
+
38
+ ### Fixed
39
+ - **Never-recovering open circuit eliminated (routing incident).** An exact provider+instance whose circuit tripped could stay open permanently, yielding continual `no_lanes_available`. The legacy `HealthTracker` circuit engine (opened on error; its success-close path was dead behind a hardcoded gate) is deleted. The only health mechanism now is a normalized `instance_unavailable` mark on the exact instance via the Phase-1 Registry, cleared automatically when provider readiness republishes the instance (probe-cleared recovery). Transient provider errors (overload/timeout/429/5xx) stay request-local and never poison an instance. Locked in by `spec/legion/llm/router/ssot_v3_instance_recovery_regression_spec.rb`.
40
+
41
+ ### Changed
42
+ - **SSOT v3: one stateless selector.** `Router.next_lane(requirements:, exclusions:, snapshot:)` is the single selection path — a pure function of one Registry snapshot plus one settings snapshot. Removed every competing selector and legacy fallback: `Router.request_lane` and its hard-filter helpers, `infer_provider_for_model`, `inventory_default_model`, the executor's old resolution chain, the `chat_single`/`chat_with_escalation` escalation cluster, and the `pipeline_enabled?` routing gate — `dispatch_chat` now always routes through the pipeline. Exhaustion/empty returns a typed, retriable Rejection (`service_unavailable`/`too_early`, 503/425/529 + Retry-After), never `nil` or a fabricated default.
43
+ - **Dead modules removed.** Deleted `Router::Availability` and `Inventory::Sweeper` (unreachable).
44
+
45
+ ### Notes
46
+ - Tracked follow-ups (out of scope for this change): `lex-llm-*` provider gems auto-publishing to the Phase-1 Registry at boot — unblocks removing the residual `Call::Registry`/`Call::Providers` boot layer; and wiring GAIA `preferred_provider`/`preferred_model` into `RequestRequirements` pins on the SSOT path (`CandidateEvaluator` already enforces pins).
47
+
3
48
  ## [0.15.2] - 2026-08-04
4
49
 
5
50
  ### Fixed
data/legion-llm.gemspec CHANGED
@@ -35,10 +35,10 @@ Gem::Specification.new do |spec|
35
35
  spec.add_dependency 'legion-cache', '>= 1.4.2'
36
36
  spec.add_dependency 'legion-json', '>= 1.2.0'
37
37
  spec.add_dependency 'legion-logging', '>= 1.2.8'
38
- spec.add_dependency 'legion-settings', '>= 1.4.0'
38
+ spec.add_dependency 'legion-settings', '>= 1.4.2'
39
39
  spec.add_dependency 'legion-transport', '>= 1.4.14'
40
40
  spec.add_dependency 'lex-knowledge'
41
- spec.add_dependency 'lex-llm', '>= 0.6.3'
41
+ spec.add_dependency 'lex-llm', '>= 0.7.0'
42
42
  spec.add_dependency 'pdf-reader'
43
43
  spec.add_dependency 'sinatra-contrib', '>= 2.0'
44
44
  spec.add_dependency 'tzinfo', '>= 2.0'
@@ -101,6 +101,10 @@ module Legion
101
101
  messages: messages,
102
102
  system: canonical_request.system,
103
103
  routing: canonical_request.routing,
104
+ # Body-model routing hint (SSOT v3 D19): the sole input to
105
+ # BodyModelHintPolicy. The trusted X-Legion-Model pin in
106
+ # `routing` supersedes it — RequestRequirements keeps that order.
107
+ client_model: canonical_request.metadata[:client_model],
104
108
  tools: tool_defs,
105
109
  tool_choice: canonical_request.tool_choice,
106
110
  caller: server_caller,
@@ -95,6 +95,10 @@ module Legion
95
95
  messages: messages,
96
96
  system: canonical_request.system,
97
97
  routing: canonical_request.routing,
98
+ # Body-model routing hint (SSOT v3 D19): the sole input to
99
+ # BodyModelHintPolicy. The trusted X-Legion-Model pin in
100
+ # `routing` supersedes it — RequestRequirements keeps that order.
101
+ client_model: canonical_request.metadata[:client_model],
98
102
  tools: tool_defs,
99
103
  tool_choice: canonical_request.tool_choice,
100
104
  caller: server_caller,
@@ -305,7 +309,7 @@ module Legion
305
309
  # 'tool_calls' on the final done covers it.
306
310
  end
307
311
 
308
- def on_tool_call_abort(block_index:, reason:) # rubocop:disable Lint/UnusedMethodArgument
312
+ def on_tool_call_abort(**)
309
313
  nil
310
314
  end
311
315
 
@@ -97,6 +97,10 @@ module Legion
97
97
  messages: messages,
98
98
  system: canonical_request.system,
99
99
  routing: canonical_request.routing,
100
+ # Body-model routing hint (SSOT v3 D19): the sole input to
101
+ # BodyModelHintPolicy. The trusted X-Legion-Model pin in
102
+ # `routing` supersedes it — RequestRequirements keeps that order.
103
+ client_model: canonical_request.metadata[:client_model],
100
104
  tools: tool_defs,
101
105
  tool_choice: canonical_request.tool_choice,
102
106
  caller: server_caller,
@@ -395,7 +399,7 @@ module Legion
395
399
  @output_items[state[:output_index]] = completed
396
400
  end
397
401
 
398
- def on_tool_call_abort(block_index:, reason:) # rubocop:disable Lint/UnusedMethodArgument
402
+ def on_tool_call_abort(**)
399
403
  nil
400
404
  end
401
405
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/helper'
4
+ require 'legion/llm/api/routing_error_mapper'
4
5
 
5
6
  module Legion
6
7
  module LLM
@@ -10,6 +11,8 @@ module Legion
10
11
  # NoLaneAvailable → 400 (filters excluded everything; caller can fix the request)
11
12
  # EscalationExhausted → 503 + Retry-After (tried lanes, all failed; transient upstream degradation)
12
13
  # InvalidHeader → 400 (x-legion-* header carries unrecognized value; caller can fix)
14
+ # RoutingRejected → SSOT v3 §18 dialect status table (native 425 / openai 503 /
15
+ # anthropic 529 for too_early, etc.) via RoutingErrorMapper.
13
16
  #
14
17
  # Included into API Helpers so every inference route gets the mapping without
15
18
  # duplicating rescue clauses. Must be included BEFORE the route-level rescue
@@ -65,6 +68,22 @@ module Legion
65
68
  status 400
66
69
  Legion::JSON.dump(body)
67
70
  end
71
+
72
+ # SSOT v3 §18 / D16: render a typed Routing::Rejection carried by
73
+ # Errors::RoutingRejected through the dialect status/header/body table.
74
+ # dialect is :native | :openai | :anthropic. RoutingErrorMapper owns the
75
+ # status divergence (native 425 vs openai 503 vs anthropic 529 for
76
+ # too_early) and the Retry-After header for retryable kinds.
77
+ def translate_routing_rejected(error, dialect:, operation:)
78
+ handle_exception(error, level: :warn, handled: true, operation: operation)
79
+ response = Legion::LLM::API::RoutingErrorMapper.call(
80
+ rejection: error.rejection, dialect: dialect
81
+ )
82
+ response.headers.each { |k, v| headers k => v }
83
+ content_type :json
84
+ status response.status
85
+ Legion::JSON.dump(response.body)
86
+ end
68
87
  end
69
88
  end
70
89
  end
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'legion/logging/helper'
5
+
6
+ module Legion
7
+ module LLM
8
+ module API
9
+ # Snapshot-only model catalog — §17.3 / D19 Copilot compatibility.
10
+ #
11
+ # .list → frozen Array<Hash> all models in the requested dialect
12
+ # .fetch → frozen Hash or nil single-model lookup; nil if not catalog-visible
13
+ #
14
+ # The catalog iterates the supplied snapshot once and NEVER calls
15
+ # Router.next_lane, Ranker, or any callable. Selection, lane weight,
16
+ # and availability do not determine which models appear in the compat
17
+ # view — only publication completion, supported-operation evidence, and
18
+ # the §9.5 model policy (from settings_snapshot.model_policy_for) do.
19
+ #
20
+ # X-Legion-Model is intentionally exempt from the body-hint policy
21
+ # evaluated here. This module handles only compat-view construction;
22
+ # it does NOT re-apply D19 body-hint logic during GET /v1/models listing.
23
+ module ModelCatalog
24
+ include Legion::Logging::Helper
25
+ extend Legion::Logging::Helper
26
+
27
+ VALID_DIALECTS = %i[native openai anthropic].freeze
28
+ OPENAI_OBJECT_TYPE = 'model'
29
+ DEFAULT_OWNED_BY = 'legion'
30
+ private_constant :VALID_DIALECTS, :OPENAI_OBJECT_TYPE, :DEFAULT_OWNED_BY
31
+
32
+ # ------------------------------------------------------------------ #
33
+ # Public API #
34
+ # ------------------------------------------------------------------ #
35
+
36
+ # Returns a frozen Array<Hash> of model objects for the requested dialect.
37
+ # dialect must be :native, :openai, or :anthropic; any other value raises
38
+ # ArgumentError immediately.
39
+ def self.list(snapshot:, settings_snapshot:, dialect:)
40
+ validate_dialect!(dialect)
41
+ log.debug("[llm][model_catalog] action=list dialect=#{dialect} " \
42
+ "snapshot_generation=#{snapshot.generation} " \
43
+ "settings_generation=#{settings_snapshot.generation}")
44
+ case dialect
45
+ when :native then native_list(snapshot: snapshot)
46
+ when :openai then compat_list(snapshot: snapshot, settings_snapshot: settings_snapshot, dialect: :openai)
47
+ when :anthropic then compat_list(snapshot: snapshot, settings_snapshot: settings_snapshot, dialect: :anthropic)
48
+ end
49
+ end
50
+
51
+ # Returns the frozen dialect model object for +id+, or nil when:
52
+ # - compat dialect: the model is not catalog-visible (body hint would be
53
+ # ignored or not whitelisted); auto-routing alias returns nil when the
54
+ # compat set is empty.
55
+ # - native dialect: the id does not match any offering in the snapshot.
56
+ def self.fetch(id:, snapshot:, settings_snapshot:, dialect:)
57
+ validate_dialect!(dialect)
58
+ list(snapshot: snapshot, settings_snapshot: settings_snapshot, dialect: dialect)
59
+ .find { |m| m[:id] == id.to_s }
60
+ end
61
+
62
+ # ------------------------------------------------------------------ #
63
+ # Private class methods #
64
+ # ------------------------------------------------------------------ #
65
+
66
+ # Raise ArgumentError unless dialect is one of the three accepted symbols.
67
+ def self.validate_dialect!(dialect)
68
+ return if VALID_DIALECTS.include?(dialect)
69
+
70
+ raise ArgumentError,
71
+ "[llm][model_catalog] dialect must be one of #{VALID_DIALECTS.inspect}, " \
72
+ "got #{dialect.inspect}"
73
+ end
74
+ private_class_method :validate_dialect!
75
+
76
+ # Native: complete diagnostic view of every offering.
77
+ # Every offering is included regardless of availability or policy so
78
+ # operators can see exactly what the registry holds for diagnostics.
79
+ # Enriched with the exact instance availability state and the publication
80
+ # state from the three Phase 1 snapshot enumerators.
81
+ def self.native_list(snapshot:)
82
+ pub_by_key = {}
83
+ inst_by_key = {}
84
+ snapshot.each_publication_status { |ps| pub_by_key[ps.instance_key] = ps }
85
+ snapshot.each_instance { |inst| inst_by_key[inst.instance_key] = inst }
86
+
87
+ entries = []
88
+ snapshot.each_offering do |offering|
89
+ ik = offering.instance_key
90
+ entries << {
91
+ id: offering.model.to_s,
92
+ offering_id: offering.offering_id.to_s,
93
+ provider_family: ik.provider_family.to_s,
94
+ instance_id: ik.instance_id.to_s,
95
+ tier: offering.tier.to_s,
96
+ supported_operations: offering.supported_operations.map(&:to_s).freeze,
97
+ unsupported_operations: offering.unsupported_operations.map(&:to_s).freeze,
98
+ unknown_operations: offering.unknown_operations.map(&:to_s).freeze,
99
+ publication_state: pub_by_key[ik]&.state&.to_s,
100
+ availability_state: inst_by_key[ik]&.availability&.state&.to_s, # rubocop:disable Style/SafeNavigationChainLength
101
+ publication_source: offering.publication_source.to_s,
102
+ metadata: offering.metadata
103
+ }.freeze
104
+ end
105
+ entries.freeze
106
+ end
107
+ private_class_method :native_list
108
+
109
+ # Compat: one entry per unique model that has at least one COMPLETE,
110
+ # policy-permitted offering with a SUPPORTED operation.
111
+ # Availability does NOT remove a model; an initializing claim with no
112
+ # offering does NOT manufacture one (§17.3 behavioural rule).
113
+ # Auto-routing aliases are appended only when the compat set is non-empty.
114
+ def self.compat_list(snapshot:, settings_snapshot:, dialect:)
115
+ # One-pass publication-status index (keyed by InstanceKey).
116
+ pub_by_key = {}
117
+ snapshot.each_publication_status { |ps| pub_by_key[ps.instance_key] = ps }
118
+
119
+ # Collect eligible unique model identifiers; first-seen provider wins
120
+ # for the owned_by field when the same model appears on multiple providers.
121
+ seen = {} # model_id (String) => provider_family (String)
122
+ snapshot.each_offering do |offering|
123
+ model_id = offering.model.to_s
124
+ next if seen.key?(model_id)
125
+
126
+ ik = offering.instance_key
127
+ ps = pub_by_key[ik]
128
+
129
+ # Publication must be complete (not initializing).
130
+ next unless ps&.state == :complete
131
+
132
+ # Must advertise at least one supported operation.
133
+ next if offering.supported_operations.empty?
134
+
135
+ # §9.5 fail-closed whitelist-AND-blacklist model policy.
136
+ next unless policy_permits?(offering: offering, settings_snapshot: settings_snapshot)
137
+
138
+ seen[model_id] = ik.provider_family.to_s
139
+ end
140
+
141
+ # Build sorted, frozen model entries for deterministic list order.
142
+ entries = seen.keys.sort.map do |model_id|
143
+ format_compat_entry(id: model_id, owned_by: seen[model_id], dialect: dialect)
144
+ end
145
+
146
+ # Auto-routing aliases are appended ONLY when the compat set is non-empty.
147
+ # Alias copilot-utility-small MUST return owned_by: 'legionio' via its
148
+ # configured alias metadata.
149
+ unless entries.empty?
150
+ settings_snapshot.auto_routing_model_aliases.each do |alias_id|
151
+ entries << format_alias_entry(
152
+ alias_id: alias_id,
153
+ settings_snapshot: settings_snapshot,
154
+ dialect: dialect
155
+ )
156
+ end
157
+ end
158
+
159
+ entries.freeze
160
+ end
161
+ private_class_method :compat_list
162
+
163
+ # §9.5 fail-closed primitive: case-insensitive literal substring matching.
164
+ # A nonempty effective whitelist must match the offering model.
165
+ # A matching blacklist always denies, including when the whitelist matched.
166
+ def self.policy_permits?(offering:, settings_snapshot:)
167
+ policy = settings_snapshot.model_policy_for(offering: offering)
168
+ whitelist = policy[:whitelist]
169
+ blacklist = policy[:blacklist]
170
+ model_lc = offering.model.to_s.downcase
171
+
172
+ # nonempty whitelist — at least one entry must match
173
+ return false if whitelist.any? && whitelist.none? { |e| model_lc.include?(e.downcase) }
174
+
175
+ # any blacklist match denies, even when whitelist also matched
176
+ return false if blacklist.any? { |e| model_lc.include?(e.downcase) }
177
+
178
+ true
179
+ end
180
+ private_class_method :policy_permits?
181
+
182
+ # Build a real-model compat entry. No limits for real models because they
183
+ # are deduplicated across providers and no single canonical context window
184
+ # is available without selecting a specific lane.
185
+ def self.format_compat_entry(id:, owned_by:, dialect:)
186
+ case dialect
187
+ when :openai then format_openai_entry(id: id, owned_by: owned_by)
188
+ when :anthropic then format_anthropic_entry(id: id)
189
+ end
190
+ end
191
+ private_class_method :format_compat_entry
192
+
193
+ # Build an auto-routing alias entry.
194
+ # Limits: alias metadata[:context_window/:max_output_tokens] first, then
195
+ # the registered llm.context_window / llm.max_output_tokens envelope.
196
+ # No field comes from a selected, max-weight, or first lane.
197
+ def self.format_alias_entry(alias_id:, settings_snapshot:, dialect:)
198
+ meta = settings_snapshot.auto_routing_model_alias_metadata[alias_id] || {}
199
+ owned_by = meta[:owned_by] || DEFAULT_OWNED_BY
200
+ created = meta[:created]
201
+ ctx = meta[:context_window] || Legion::Settings[:llm][:context_window]
202
+ max_out = meta[:max_output_tokens] || Legion::Settings[:llm][:max_output_tokens]
203
+ limits = { context_window: ctx, max_output_tokens: max_out }.freeze
204
+
205
+ case dialect
206
+ when :openai
207
+ format_openai_entry(id: alias_id, owned_by: owned_by, created: created, limits: limits)
208
+ when :anthropic
209
+ format_anthropic_entry(id: alias_id, created: created, limits: limits)
210
+ end
211
+ end
212
+ private_class_method :format_alias_entry
213
+
214
+ # OpenAI model object shape: { id:, object: 'model', created:, owned_by: }
215
+ # Limits are optional and included only for alias entries.
216
+ def self.format_openai_entry(id:, owned_by:, created: nil, limits: nil)
217
+ obj = {
218
+ id: id.to_s,
219
+ object: OPENAI_OBJECT_TYPE,
220
+ created: created || Time.now.to_i,
221
+ owned_by: owned_by.to_s
222
+ }
223
+ if limits.is_a?(Hash)
224
+ if limits[:context_window]
225
+ obj[:context_window] = limits[:context_window]
226
+ obj[:context_size] = limits[:context_window]
227
+ end
228
+ obj[:max_output_tokens] = limits[:max_output_tokens] if limits[:max_output_tokens]
229
+ end
230
+ obj.freeze
231
+ end
232
+ private_class_method :format_openai_entry
233
+
234
+ # Anthropic model object shape: { type: 'model', id:, display_name:, created_at: }
235
+ # Limits are optional and translated to max_input_tokens / max_tokens.
236
+ def self.format_anthropic_entry(id:, created: nil, limits: nil)
237
+ ts = created || Time.now.to_i
238
+ obj = {
239
+ type: 'model',
240
+ id: id.to_s,
241
+ display_name: id.to_s,
242
+ created_at: Time.at(ts).utc.strftime('%Y-%m-%dT%H:%M:%SZ')
243
+ }
244
+ if limits.is_a?(Hash)
245
+ obj[:max_input_tokens] = limits[:context_window] if limits[:context_window]
246
+ obj[:max_tokens] = limits[:max_output_tokens] if limits[:max_output_tokens]
247
+ end
248
+ obj.freeze
249
+ end
250
+ private_class_method :format_anthropic_entry
251
+ end
252
+ end
253
+ end
254
+ end
@@ -156,13 +156,11 @@ module Legion
156
156
  batch_store.delete(id)
157
157
  end
158
158
 
159
- # rubocop:disable Lint/UnusedMethodArgument
160
- def list_batches(limit:, before_id: nil, after_id: nil)
159
+ def list_batches(limit:, **)
161
160
  all = batch_store.values.map { |e| e[:batch] }.sort_by { |b| b[:created_at] }.reverse
162
161
  all = all.first(limit.clamp(1, 100))
163
162
  { data: all, has_more: false, first_id: all.first&.dig(:id), last_id: all.last&.dig(:id) }
164
163
  end
165
- # rubocop:enable Lint/UnusedMethodArgument
166
164
 
167
165
  def batch_results(id)
168
166
  batch_store.dig(id, :results) || []
@@ -13,6 +13,7 @@ module Legion
13
13
  module Messages
14
14
  module CountTokens
15
15
  extend Sinatra::Extension
16
+ extend Legion::Logging::Helper
16
17
 
17
18
  post '/count_tokens' do
18
19
  require_llm!
@@ -26,7 +27,7 @@ module Legion
26
27
  Legion::JSON.dump({ type: 'error', error: { type: 'invalid_request_error', message: "missing required fields: #{missing.join(', ')}" } })
27
28
  end
28
29
 
29
- result = Legion::LLM::TokenEstimation.estimate(
30
+ result = CountTokens.count_tokens_result(
30
31
  messages: body[:messages],
31
32
  model: body[:model],
32
33
  system: body[:system],
@@ -40,6 +41,65 @@ module Legion
40
41
  handle_exception(e, level: :error, handled: true, operation: 'llm.ns.anthropic.count_tokens')
41
42
  anthropic_error('api_error', e.message, status_code: 500)
42
43
  end
44
+
45
+ # SSOT v3 §20 (count_tokens row): choose the EXACT lane first, then use
46
+ # that lane's callable tokenizer. `model` is a required, caller-supplied
47
+ # value — never a default. When the SSOT registry publishes a
48
+ # count_tokens lane for the requested model, the exact selected
49
+ # callable's tokenizer is authoritative; otherwise (cold registry, no
50
+ # count_tokens lane, or a callable without a tokenizer) fall back to the
51
+ # provider-neutral TokenEstimation.
52
+ def self.count_tokens_result(messages:, model:, system:, tools:)
53
+ lane_count = count_tokens_via_selected_lane(messages: messages, model: model, system: system, tools: tools)
54
+ return lane_count if lane_count
55
+
56
+ Legion::LLM::TokenEstimation.estimate(messages: messages, model: model, system: system, tools: tools)
57
+ end
58
+
59
+ def self.count_tokens_via_selected_lane(messages:, model:, system:, tools:)
60
+ snapshot = Legion::Extensions::Llm::Inventory::Registry.snapshot
61
+ return nil unless snapshot.generation.positive?
62
+
63
+ request = Legion::LLM::Inference::Request.build(
64
+ messages: messages,
65
+ system: system,
66
+ routing: { model: model },
67
+ tools: tools || []
68
+ )
69
+ requirements = Legion::LLM::Router::RequestRequirements.build(
70
+ request: request,
71
+ operation: :count_tokens,
72
+ required_capabilities: Legion::LLM::Router::RequiredCapabilities.call(request: request, operation: :count_tokens),
73
+ estimated_input_bound: 0,
74
+ required_output_tokens: 0
75
+ )
76
+ session = Legion::LLM::Inference::RoutingSession.new(request: request, requirements: requirements)
77
+ attempt = session.next_attempt(snapshot: snapshot)
78
+ return nil if attempt.is_a?(Legion::Extensions::Llm::Routing::Rejection)
79
+
80
+ dispatch = Legion::LLM::Call::SelectionDispatch.call(
81
+ attempt_context: attempt,
82
+ arguments: { messages: messages }
83
+ )
84
+ return nil unless dispatch.success?
85
+
86
+ normalize_token_count(dispatch.value)
87
+ rescue ::NoMethodError, ::ArgumentError, ::NotImplementedError
88
+ # Programming errors are never swallowed — re-raise.
89
+ raise
90
+ rescue StandardError => e
91
+ # Non-programming errors during lane selection/tokenizer dispatch fall
92
+ # back to provider-neutral estimation.
93
+ log.warn "[llm][ns][anthropic][count_tokens] action=lane_tokenizer_fallback error=#{e.class}: #{e.message}"
94
+ nil
95
+ end
96
+
97
+ def self.normalize_token_count(value)
98
+ return value if value.is_a?(Hash) && (value.key?(:input_tokens) || value.key?('input_tokens'))
99
+ return { input_tokens: value } if value.is_a?(Integer)
100
+
101
+ nil
102
+ end
43
103
  end
44
104
  end
45
105
  end
@@ -6,6 +6,7 @@ require 'sinatra/namespace'
6
6
  require 'legion/logging/helper'
7
7
  require 'legion/llm/api/client_translators/anthropic_messages'
8
8
  require 'legion/llm/api/stream_assembler'
9
+ require 'legion/llm/api/routing_error_mapper'
9
10
  require 'legion/llm/api/debug_formats'
10
11
 
11
12
  module Legion
@@ -48,6 +49,11 @@ module Legion
48
49
  echo_request = Legion::LLM::API::DebugFormats.echo_request?(env)
49
50
 
50
51
  if streaming
52
+ # SSOT v3 §19: select + acquire the exact lane BEFORE opening SSE.
53
+ # A routing rejection raises Errors::RoutingRejected here (rescued
54
+ # below → RoutingErrorMapper) instead of an SSE error after headers.
55
+ preflight_lane = executor.stream_preflight!
56
+
51
57
  content_type 'text/event-stream'
52
58
  headers 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive',
53
59
  'X-Accel-Buffering' => 'no', 'X-Legion-Conversation-Id' => conv_id
@@ -65,9 +71,9 @@ module Legion
65
71
  request_id: request_id,
66
72
  model: model,
67
73
  input_tokens: estimate_input_tokens(inference_request.messages),
68
- initial_lane: { id: 'unknown:pending' }
74
+ initial_lane: preflight_lane || { id: 'unknown:pending' }
69
75
  )
70
- pipeline_response = executor.call_stream do |chunk|
76
+ pipeline_response = executor.call_stream(stream_observer: assembler) do |chunk|
71
77
  assembler.push(chunk)
72
78
  end
73
79
  assembler.finalize(pipeline_response)
@@ -121,6 +127,8 @@ module Legion
121
127
  translate_escalation_exhausted(e, operation: 'llm.ns.anthropic.messages.exhausted', client: :anthropic)
122
128
  rescue Legion::LLM::Errors::InvalidHeader => e
123
129
  translate_invalid_header(e, operation: 'llm.ns.anthropic.messages.invalid_header', client: :anthropic)
130
+ rescue Legion::LLM::Errors::RoutingRejected => e
131
+ translate_routing_rejected(e, dialect: :anthropic, operation: 'llm.ns.anthropic.messages.routing_rejected')
124
132
  rescue Legion::LLM::AuthError => e
125
133
  handle_exception(e, level: :error, handled: true, operation: 'llm.ns.anthropic.messages.auth')
126
134
  anthropic_error('authentication_error', e.message, status_code: 401)
@@ -13,11 +13,12 @@ module Legion
13
13
  include Legion::LLM::API::SharedHelpers
14
14
  include Legion::LLM::API::ErrorTranslator
15
15
 
16
- def openai_error(message, type: 'server_error', code: nil, status_code: 500)
16
+ # OpenAI error envelope — ALWAYS carries all four keys (message, type,
17
+ # param, code); param/code are null when not applicable.
18
+ def openai_error(message, type: 'server_error', code: nil, param: nil, status_code: 500)
17
19
  content_type :json
18
20
  status status_code
19
- body = { error: { message: message, type: type } }
20
- body[:error][:code] = code if code
21
+ body = { error: { message: message, type: type, param: param, code: code } }
21
22
  Legion::JSON.dump(body)
22
23
  end
23
24
 
@@ -48,7 +49,6 @@ module Legion
48
49
  # Called by inference routes before building the executor request.
49
50
  def validate_legion_routing_headers!(rack_env)
50
51
  http_headers = rack_env.select { |k, _| k.start_with?('HTTP_') }
51
- return unless defined?(Legion::LLM::Inference::Executor::PayloadBuilder)
52
52
 
53
53
  # Build a flat header map from Rack env (HTTP_X_LEGION_TIERS → x-legion-tiers)
54
54
  mapped = http_headers.each_with_object({}) do |(k, v), h|
@@ -65,7 +65,8 @@ module Legion
65
65
  end
66
66
  rescue Legion::LLM::Errors::InvalidHeader
67
67
  raise
68
- rescue StandardError
68
+ rescue StandardError => e
69
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.api.validate_legion_routing_headers')
69
70
  nil
70
71
  end
71
72
 
@@ -241,6 +241,8 @@ module Legion
241
241
  payload.compact!
242
242
  json_response(payload, status_code: 200)
243
243
  end
244
+ rescue Legion::LLM::Errors::RoutingRejected => e
245
+ translate_routing_rejected(e, dialect: :native, operation: 'llm.api.inference.routing_rejected')
244
246
  rescue Legion::LLM::AuthError => e
245
247
  handle_exception(e, level: :error, handled: true, operation: 'llm.api.inference.auth', request_id: request_id)
246
248
  json_error('auth_error', e.message, status_code: 401)
@@ -21,7 +21,7 @@ module Legion
21
21
  require_llm!
22
22
 
23
23
  filters = Legion::LLM::API::Native::Offerings.request_filters(params)
24
- raw_offerings = Legion::LLM::Inventory.offerings(filters)
24
+ raw_offerings = Legion::LLM::API::Native::Offerings.snapshot_offerings(filters)
25
25
  grouped = Legion::LLM::API::Native::Offerings.group_offerings(raw_offerings)
26
26
 
27
27
  json_response({
@@ -38,7 +38,7 @@ module Legion
38
38
  log.debug("[llm][api][namespaces][offerings] action=get_offering id=#{offering_id}")
39
39
  require_llm!
40
40
 
41
- offering = Legion::LLM::Inventory.offerings(offering_id: offering_id).first
41
+ offering = Legion::LLM::API::Native::Offerings.snapshot_offering(offering_id)
42
42
  halt json_error('offering_not_found', "Offering '#{offering_id}' not found", status_code: 404) unless offering
43
43
 
44
44
  json_response({ offering: offering })
@@ -19,9 +19,11 @@ module Legion
19
19
  log.debug('[llm][api][namespaces][routing] action=list_rules')
20
20
  require_llm!
21
21
 
22
+ # SSOT has no routing toggle or rule list: routing is enabled
23
+ # whenever the Registry holds at least one complete publication.
22
24
  json_response({
23
- routing_enabled: false,
24
- auto_rules_populated: false,
25
+ routing_enabled: Legion::LLM::Router.routing_enabled?,
26
+ auto_rules_populated: Legion::LLM::Router.auto_rules_populated?,
25
27
  rules: [],
26
28
  summary: { total: 0, auto: 0, manual: 0 }
27
29
  })