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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/legion-llm.gemspec +2 -2
- data/lib/legion/llm/api/client_translators/anthropic_messages.rb +4 -0
- data/lib/legion/llm/api/client_translators/openai_chat.rb +5 -1
- data/lib/legion/llm/api/client_translators/openai_responses.rb +5 -1
- data/lib/legion/llm/api/error_translator.rb +19 -0
- data/lib/legion/llm/api/model_catalog.rb +254 -0
- data/lib/legion/llm/api/namespaces/anthropic/messages/batches.rb +1 -3
- data/lib/legion/llm/api/namespaces/anthropic/messages/count_tokens.rb +61 -1
- data/lib/legion/llm/api/namespaces/anthropic/messages.rb +10 -2
- data/lib/legion/llm/api/namespaces/helpers.rb +6 -5
- data/lib/legion/llm/api/namespaces/native/inference.rb +2 -0
- data/lib/legion/llm/api/namespaces/native/offerings.rb +2 -2
- data/lib/legion/llm/api/namespaces/native/routing.rb +4 -2
- data/lib/legion/llm/api/namespaces/native/tiers.rb +2 -2
- data/lib/legion/llm/api/namespaces/openai/audio/speech.rb +2 -0
- data/lib/legion/llm/api/namespaces/openai/audio/transcriptions.rb +2 -0
- data/lib/legion/llm/api/namespaces/openai/audio/translations.rb +2 -0
- data/lib/legion/llm/api/namespaces/openai/batches.rb +5 -2
- data/lib/legion/llm/api/namespaces/openai/chat/completions.rb +9 -2
- data/lib/legion/llm/api/namespaces/openai/completions.rb +7 -3
- data/lib/legion/llm/api/namespaces/openai/embeddings.rb +39 -15
- data/lib/legion/llm/api/namespaces/openai/images.rb +6 -0
- data/lib/legion/llm/api/namespaces/openai/models.rb +36 -21
- data/lib/legion/llm/api/namespaces/openai/moderations.rb +2 -0
- data/lib/legion/llm/api/namespaces/openai/responses.rb +9 -2
- data/lib/legion/llm/api/native/helpers.rb +1 -2
- data/lib/legion/llm/api/native/inference.rb +2 -3
- data/lib/legion/llm/api/native/instances.rb +14 -1
- data/lib/legion/llm/api/native/offerings.rb +120 -2
- data/lib/legion/llm/api/native/providers.rb +17 -14
- data/lib/legion/llm/api/native/routing.rb +4 -2
- data/lib/legion/llm/api/native/tiers.rb +68 -51
- data/lib/legion/llm/api/openai/embeddings.rb +2 -2
- data/lib/legion/llm/api/routing_error_mapper.rb +209 -0
- data/lib/legion/llm/api/shared_helpers.rb +10 -5
- data/lib/legion/llm/api/stream_assembler.rb +1 -1
- data/lib/legion/llm/api/translators/openai_response.rb +37 -18
- data/lib/legion/llm/cache.rb +29 -0
- data/lib/legion/llm/call/dispatch.rb +29 -21
- data/lib/legion/llm/call/embeddings.rb +394 -220
- data/lib/legion/llm/call/selection_dispatch.rb +166 -0
- data/lib/legion/llm/call/structured_output.rb +62 -73
- data/lib/legion/llm/context/curator.rb +6 -16
- data/lib/legion/llm/errors.rb +22 -0
- data/lib/legion/llm/helper.rb +7 -4
- data/lib/legion/llm/inference/attempt_context.rb +85 -0
- data/lib/legion/llm/inference/embed_pipeline.rb +23 -124
- data/lib/legion/llm/inference/executor/escalation.rb +211 -610
- data/lib/legion/llm/inference/executor/routing.rb +45 -409
- data/lib/legion/llm/inference/executor/tool_injection.rb +15 -0
- data/lib/legion/llm/inference/executor.rb +48 -2
- data/lib/legion/llm/inference/prompt.rb +31 -16
- data/lib/legion/llm/inference/request.rb +79 -34
- data/lib/legion/llm/inference/route_attempts.rb +68 -0
- data/lib/legion/llm/inference/routing_session.rb +139 -0
- data/lib/legion/llm/inference/steps/classification.rb +5 -1
- data/lib/legion/llm/inference/steps/confidence_scoring.rb +0 -17
- data/lib/legion/llm/inference/steps/debate.rb +8 -4
- data/lib/legion/llm/inference/steps/tier_assigner.rb +1 -1
- data/lib/legion/llm/inference.rb +175 -238
- data/lib/legion/llm/inventory/discovery.rb +1 -46
- data/lib/legion/llm/inventory.rb +119 -0
- data/lib/legion/llm/quality/shadow_eval.rb +40 -7
- data/lib/legion/llm/router/body_model_hint_policy.rb +97 -0
- data/lib/legion/llm/router/candidate_evaluation.rb +104 -0
- data/lib/legion/llm/router/candidate_evaluator.rb +393 -0
- data/lib/legion/llm/router/header_constraints.rb +150 -0
- data/lib/legion/llm/router/input_bound.rb +151 -0
- data/lib/legion/llm/router/outcome_classifier.rb +154 -0
- data/lib/legion/llm/router/ranker.rb +224 -0
- data/lib/legion/llm/router/rejection_diagnostics.rb +273 -0
- data/lib/legion/llm/router/request_requirements.rb +162 -0
- data/lib/legion/llm/router/required_capabilities.rb +144 -0
- data/lib/legion/llm/router/settings_snapshot.rb +351 -0
- data/lib/legion/llm/router/settings_state.rb +100 -0
- data/lib/legion/llm/router.rb +110 -194
- data/lib/legion/llm/routing_context.rb +55 -0
- data/lib/legion/llm/settings/api.rb +39 -0
- data/lib/legion/llm/settings.rb +47 -48
- data/lib/legion/llm/skills/base.rb +1 -1
- data/lib/legion/llm/version.rb +1 -1
- data/lib/legion/llm.rb +21 -1
- metadata +24 -8
- data/lib/legion/llm/inventory/sweeper.rb +0 -56
- data/lib/legion/llm/router/availability.rb +0 -25
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c593f0ce469cb6391a8aecfea4bbccfb99220e598616e980bc005594fe789972
|
|
4
|
+
data.tar.gz: 2cb436d417122b94e0aa9eb02775aeee25047bf94ba7cb03ff8bf73f6677787f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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::
|
|
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::
|
|
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:
|
|
24
|
-
auto_rules_populated:
|
|
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
|
})
|