lex-llm-vllm 0.3.17 → 0.4.5
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/.rubocop.yml +26 -5
- data/CHANGELOG.md +92 -0
- data/Gemfile +2 -0
- data/lex-llm-vllm.gemspec +5 -2
- data/lib/legion/extensions/llm/vllm/actors/discovery_refresh.rb +15 -128
- data/lib/legion/extensions/llm/vllm/actors/fleet_worker.rb +19 -13
- data/lib/legion/extensions/llm/vllm/callable.rb +189 -0
- data/lib/legion/extensions/llm/vllm/helpers/offering_builder.rb +223 -0
- data/lib/legion/extensions/llm/vllm/provider.rb +273 -234
- data/lib/legion/extensions/llm/vllm/runners/discovery_refresh/claim_activation.rb +102 -0
- data/lib/legion/extensions/llm/vllm/runners/discovery_refresh/health_display.rb +107 -0
- data/lib/legion/extensions/llm/vllm/runners/discovery_refresh/http.rb +150 -0
- data/lib/legion/extensions/llm/vllm/runners/discovery_refresh/instance_lifecycle.rb +89 -0
- data/lib/legion/extensions/llm/vllm/runners/discovery_refresh/probing.rb +109 -0
- data/lib/legion/extensions/llm/vllm/runners/discovery_refresh.rb +70 -0
- data/lib/legion/extensions/llm/vllm/runners/fleet_worker.rb +20 -4
- data/lib/legion/extensions/llm/vllm/translator.rb +559 -480
- data/lib/legion/extensions/llm/vllm/version.rb +1 -1
- data/lib/legion/extensions/llm/vllm.rb +6 -14
- metadata +13 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb587332ecdb4d2a8e4535d1b03250b3a63e7a8ce5171dc5198c11ad40549bef
|
|
4
|
+
data.tar.gz: ec906ffff9f3d1a0496a1d0043d25902c08031d6cd613a239847ca97474e4def
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd7215d64cda75590f936d6f6a4e39d3669826ee00639e765f426e602cf6a465c61a62bb0655126cb608552d70692eac2469def71a429bb4c885899cb9d9ce95
|
|
7
|
+
data.tar.gz: 275be07c6a109418995c759a2c9fcf5a67b567cda41335a9bda5e3a0ca723f4cc9fe8436840b04edcdd0f403c1cb7864b475eb407644d22562b1ea64bba22384
|
data/.rubocop.yml
CHANGED
|
@@ -12,18 +12,39 @@ Metrics/BlockLength:
|
|
|
12
12
|
Exclude:
|
|
13
13
|
- "*.gemspec"
|
|
14
14
|
- spec/**/*
|
|
15
|
+
# Aligned with the sibling lex-llm-* providers (vertex/ollama/bedrock): the
|
|
16
|
+
# rubocop defaults for AbcSize/CyclomaticComplexity/PerceivedComplexity (7/8/17)
|
|
17
|
+
# are too strict for provider dispatch + error-classification code, so they are
|
|
18
|
+
# disabled fleet-wide. Length is still bounded (Max 350).
|
|
15
19
|
Metrics/ClassLength:
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
Max: 350
|
|
21
|
+
Metrics/ModuleLength:
|
|
22
|
+
Max: 350
|
|
18
23
|
Metrics/MethodLength:
|
|
19
24
|
Enabled: false
|
|
25
|
+
Metrics/ParameterLists:
|
|
26
|
+
Enabled: false
|
|
27
|
+
Metrics/AbcSize:
|
|
28
|
+
Enabled: false
|
|
29
|
+
Metrics/CyclomaticComplexity:
|
|
30
|
+
Enabled: false
|
|
31
|
+
Metrics/PerceivedComplexity:
|
|
32
|
+
Enabled: false
|
|
33
|
+
Lint/DuplicateBranch:
|
|
34
|
+
Enabled: false
|
|
20
35
|
RSpec/ExampleLength:
|
|
21
|
-
|
|
36
|
+
Enabled: false
|
|
22
37
|
RSpec/MultipleExpectations:
|
|
23
38
|
Enabled: false
|
|
24
|
-
|
|
39
|
+
# Boundary stubs (Faraday Connection / provider HTTP) require stubbing the
|
|
40
|
+
# module under test and any-instance, and the lifecycle spec carries a small
|
|
41
|
+
# cast of memoized helpers — aligned with sibling lex-llm-* specs.
|
|
42
|
+
RSpec/SubjectStub:
|
|
43
|
+
Enabled: false
|
|
44
|
+
RSpec/AnyInstance:
|
|
45
|
+
Enabled: false
|
|
46
|
+
RSpec/MultipleMemoizedHelpers:
|
|
25
47
|
Enabled: false
|
|
26
48
|
Layout/LineLength:
|
|
27
49
|
Exclude:
|
|
28
50
|
- spec/**/*
|
|
29
|
-
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,97 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.4.5] - 2026-08-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Remove synthetic-default discovery suppression and its warning throttle.** The `default` instance now follows the same endpoint-based discovery path as every other configured instance; no synthetic-default skip warning is emitted.
|
|
7
|
+
|
|
8
|
+
## [0.4.4] - 2026-08-17
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Single actor registration — the provider module no longer extends Core at file level.** The boot-time recursive submodule walk (gated on `respond_to?(:autobuild)`) no longer sees the provider at preload and skips it, so the gem's own top-level extension load is the sole actor registration — this eliminates the twin-actor double-claim (FencedPublisherError) the double build produced under SSOT v3's Inventory::Registry claim tokens.
|
|
12
|
+
- **Multi-message requests carrying the prompt-cache `cache_control` key no longer fail before HTTP.** legion-llm's prompt-cache step injects `cache_control: {type: :ephemeral}` into every ≥2-message request; the canonical message bridge raised `ArgumentError: unknown keyword: :cache_control` in `Message.from_hash` before any HTTP was sent, so every multi-message vLLM request 500ed. The bridge now projects onto the known member set, so the transport-only key is dropped and never leaks onto the wire.
|
|
13
|
+
- **Non-UTF-8 (ASCII-8BIT) dispatch error messages no longer mask the original error.** `RecordSupport.sanitized_reason` now coerces to valid UTF-8 instead of raising `ValidationError`, so a real provider error is no longer turned into an unclassifiable retriable 500.
|
|
14
|
+
- **Adds dispatch-boundary regression specs** — 2-message `cache_control` sync render, canonical member projection, and the full provider render-to-parse path — proven to fail pre-fix.
|
|
15
|
+
- **The synthetic-default skip warn now fires once per boot instead of every discovery tick.** The `action=skip_instance instance=default reason=synthetic_default` WARN fired on every 300s discovery tick — the fleet's noisiest log line, since a provider being unconfigured-for-default is the normal state. It is now throttled to a single warning at first occurrence (the operator signal: `instances.default` is still the unmodified template; set a real config to publish it).
|
|
16
|
+
|
|
17
|
+
## [0.4.3] - 2026-08-16
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Instance identity is now the operator's config NAME** — the discovery
|
|
21
|
+
runner previously keyed instances by the derived `host:port/ak:<digest>`
|
|
22
|
+
string. The derived id silently inerted the router's `instances.<name>`
|
|
23
|
+
settings lookups (per-instance tuning, weight, preferred context windows)
|
|
24
|
+
and collapsed distinct config names that share an endpoint. Discovery now
|
|
25
|
+
publishes `InstanceKey.instance_id` = the config name and carries the
|
|
26
|
+
derived `host:port/ak:<digest>` in the secondary `physical_id` field
|
|
27
|
+
(dedup/diagnostics only — it never participates in identity). Two config
|
|
28
|
+
names pointing at the same endpoint stay distinct instances; an endpoint or
|
|
29
|
+
API-key move under a stable name re-claims the instance so the captured
|
|
30
|
+
callable tracks the new endpoint. Zero config changes required.
|
|
31
|
+
- **Embedding models now authoritatively exclude chat** — an embedding model
|
|
32
|
+
(`type: embedding` or `capabilities: [embedding]` in the vLLM catalog)
|
|
33
|
+
published `chat: :supported`, so a plain chat request could be misrouted to
|
|
34
|
+
an embedding-only instance. The offering builder now branches operation
|
|
35
|
+
evidence on model type (matching bedrock): embedding models publish
|
|
36
|
+
`chat`/`stream_chat`/`count_tokens` and the non-embedding media operations
|
|
37
|
+
as `:unsupported` and `embed` as `:supported`; chat models are unchanged
|
|
38
|
+
(`chat`/`stream_chat` `:supported`, `embed` `:unsupported`).
|
|
39
|
+
- **`tools` capability evidence was permanently `:unknown`** — `resolve_bool_cap`
|
|
40
|
+
returned `:unknown` for every configuration (absent, `enable_tools: true`, or
|
|
41
|
+
`false`), so the router's candidate evaluator never saw a ready candidate for
|
|
42
|
+
any request requiring the `tools` capability and rejected every tool-using
|
|
43
|
+
request (e.g. Claude Code `/v1/messages`) with typed `too_early` (425/529)
|
|
44
|
+
indefinitely. vLLM serves tool calling as an engine capability for every chat
|
|
45
|
+
model and this provider's translator implements the full tool loop, so the
|
|
46
|
+
builder now publishes `tools: :supported` with `:provider_implementation`
|
|
47
|
+
source. An explicit `enable_tools: false` (model level, else instance level)
|
|
48
|
+
remains an operator opt-out expressed as `:unknown` with the matching
|
|
49
|
+
override source — override sources may never carry `:supported` under the
|
|
50
|
+
SSOT v3 tri-state evidence contract.
|
|
51
|
+
- **`thinking` capability evidence semantics made explicit** — support is a
|
|
52
|
+
per-model chat-template fact the vLLM catalog does not expose, and a config
|
|
53
|
+
permission is not evidence, so it stays `:unknown` in every configuration
|
|
54
|
+
(override source when `enable_thinking` is set at model/instance level,
|
|
55
|
+
`:default_false` otherwise).
|
|
56
|
+
|
|
57
|
+
## [0.4.2] - 2026-08-13
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
- **Removed ALL remaining `rubocop:disable` directives** — zero directives across `lib/` and `spec/`. Every suppressed metric resolved by real refactoring: `translator.rb` split into 9 focused modules (`TranslatorMessageHelpers`, `TranslatorToolCallHelpers`, `TranslatorToolHelpers`, `TranslatorParamHelpers`, `TranslatorThinkingHelpers`, `TranslatorToolCallParseHelpers`, `TranslatorResponseHelpers`, `TranslatorChunkBuilderHelpers`, `TranslatorChunkHelpers`, `TranslatorRenderHelpers`), bringing every class/module under the 100-line limit.
|
|
61
|
+
- **Reverted `.rubocop.yml` weakening** — removed `Metrics/ClassLength: Exclude` for `provider.rb` added in prior pass; class genuinely reduced by module extraction.
|
|
62
|
+
- **§9 default substitution removed** — `translator.rb` `extract_wire_model` now raises `ArgumentError` when no model is present rather than substituting `'default'`.
|
|
63
|
+
- **§1 settings guards removed** — `global_thinking_enabled?` in `provider.rb` no longer uses `defined?(Legion::Settings)` or `Legion::Settings.dig`; replaced with `settings[:enable_thinking]` bracket access.
|
|
64
|
+
- **§1 swallowed rescue fixed** — `extract_host_port` in `discovery_refresh.rb` now calls `handle_exception` instead of silently swallowing `URI::InvalidURIError`.
|
|
65
|
+
- **Settings-authoritative embedding removed** — `embedding_supported?` in `discovery_refresh.rb` uses only server evidence (`model_data[:type]` or `model_data[:capabilities]`); `instance_cfg:` parameter eliminated.
|
|
66
|
+
- **`api_base` correctly navigates instance settings** — reads `settings.dig(:instances, :default, :endpoint)` (the registered default) instead of the non-existent top-level `settings[:endpoint]` key.
|
|
67
|
+
- **Ruby constant lexical scope fixed** — `SUPPORTED_PARAMS`, `PARAM_WIRE_KEYS`, and `FALLBACK_STOP_REASON` moved into the modules that reference them (`TranslatorParamHelpers` and `TranslatorChunkHelpers`) so constant lookup works correctly without the including class.
|
|
68
|
+
- **`RSpec/SpecFilePathFormat` fixed** — `fleet_worker_spec.rb` moved from `spec/.../vllm/actors/` (plural) to `spec/.../vllm/actor/` (singular) to match the `Actor::FleetWorker` module path.
|
|
69
|
+
- **Conformance fixtures updated** — all canonical request fixtures in `lex-llm` now include `"metadata": {"model": "test-fixture-model"}`, required for §9-compliant translators that raise on absent model.
|
|
70
|
+
|
|
71
|
+
## [0.4.1] - 2026-08-13
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
- **§8 health firewall enforced in harness and callable.** `instance_unavailable_error` now returns an explicit vLLM offline HTTP 503 response (body contains "server is going offline"); `classify_server_error_ext` detects this specific body text to return `:instance_unavailable`. Connection failures, generic 503s, and timeouts are never promoted to `:instance_unavailable`. Adds a firewall proof test.
|
|
75
|
+
- **Removed all `rubocop:disable` directives** from `provider.rb` and the conformance spec. All metrics (AbcSize, ParameterLists, CyclomaticComplexity, PerceivedComplexity, ModuleLength) resolved by extraction instead of suppression.
|
|
76
|
+
- **`provider.rb` ParameterLists compliance.** `build_canonical_request` and `render_payload` now use `**opts` passthrough, reducing explicit parameter lists to ≤5.
|
|
77
|
+
- **`discovery_refresh.rb` ParameterLists compliance.** `store_instance_state` uses `**opts` for the trailing group of mutable-state params.
|
|
78
|
+
- **`DiscoveryRefreshEvidenceBuilders` ModuleLength compliance.** Value-evidence methods (`build_context_evidence`, `build_max_output_evidence`, `build_embedding_dimensions_evidence`, `build_model_revision_evidence`, `build_tokenizer_evidence`, and helpers) extracted to new `DiscoveryRefreshValueEvidenceHelpers` module.
|
|
79
|
+
- **Conformance spec `MultipleMemoizedHelpers` compliance.** Removed the file-level `rubocop:disable/enable` wrapper; all four over-limit describe blocks reduced to ≤3 lets per group by converting extras to `def` methods or consolidating into a `setup` hash let.
|
|
80
|
+
- **`offering_attrs` uses `provider_instance_id`**, not `config.instance_id` (which does not exist on `Legion::Extensions::Llm::Configuration`).
|
|
81
|
+
|
|
82
|
+
## [0.4.0] - 2026-08-13
|
|
83
|
+
|
|
84
|
+
### Changed
|
|
85
|
+
- **SSOT v3 provider migration.** Rewrite `DiscoveryRefresh` actor to publish exact vLLM instances through the lex-llm 0.7.0 `Inventory::Publisher` contract. Each configured vLLM server now claims an independent exact `InstanceKey`, builds complete `OfferingDraft` snapshots with honest per-operation evidence, gates selector visibility behind immediate `/health` readiness, and supports probe-cleared exact-instance availability.
|
|
86
|
+
- Raise `lex-llm` gemspec floor to `>= 0.7.0`.
|
|
87
|
+
- Remove all `Legion::LLM::Call::Registry` reverse references; discovery no longer scans loaded providers through the coordinator.
|
|
88
|
+
- Remove `ScopedRefresher` mixin and `compose_id` delimiter lane IDs; use canonical `lane:v1:` SHA-256 framed identity.
|
|
89
|
+
- Derive stable InstanceKey per independently addressable vLLM server: normalized `host:port` plus non-secret auth fingerprint.
|
|
90
|
+
- Normalize dispatch errors via `ProviderOutcome`; only an explicit flat service-unavailable (never raw 503/timeout/connection error) may return `instance_unavailable`.
|
|
91
|
+
- Advertise `exact_offering_v1` fleet execution contract with exact offering/operation/model/instance verification.
|
|
92
|
+
- Register `discovery_interval: 300` as a documented extension default; read directly without `.dig`/`||` fallback guards.
|
|
93
|
+
- Add comprehensive SSOT v3 conformance specs including the shared `'an SSOT v3 provider adapter'` examples.
|
|
94
|
+
|
|
3
95
|
## [0.3.17] - 2026-08-04
|
|
4
96
|
|
|
5
97
|
### Fixed
|
data/Gemfile
CHANGED
data/lex-llm-vllm.gemspec
CHANGED
|
@@ -25,7 +25,10 @@ Gem::Specification.new do |spec|
|
|
|
25
25
|
|
|
26
26
|
spec.add_dependency 'legion-json', '>= 1.2.1'
|
|
27
27
|
spec.add_dependency 'legion-logging', '>= 1.3.2'
|
|
28
|
-
spec.add_dependency 'legion-settings', '>= 1.
|
|
28
|
+
spec.add_dependency 'legion-settings', '>= 1.4.2'
|
|
29
29
|
spec.add_dependency 'legion-transport', '>= 1.4.14'
|
|
30
|
-
|
|
30
|
+
# 0.7.1 adds InstanceKey#physical_id (secondary physical identity) — the
|
|
31
|
+
# discovery runner claims with physical_id:, so the floor must not resolve
|
|
32
|
+
# to a lex-llm without the field.
|
|
33
|
+
spec.add_dependency 'lex-llm', '>= 0.7.1'
|
|
31
34
|
end
|
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'digest'
|
|
4
|
-
|
|
5
3
|
begin
|
|
6
4
|
require 'legion/extensions/actors/every'
|
|
7
5
|
rescue LoadError => e
|
|
8
6
|
warn(e.message) if $VERBOSE
|
|
9
7
|
end
|
|
10
8
|
|
|
11
|
-
begin
|
|
12
|
-
require 'legion/extensions/llm/inventory/scoped_refresher'
|
|
13
|
-
rescue LoadError => e
|
|
14
|
-
warn(e.message) if $VERBOSE
|
|
15
|
-
end
|
|
16
|
-
|
|
17
9
|
return unless defined?(Legion::Extensions::Actors::Every)
|
|
18
10
|
|
|
19
11
|
module Legion
|
|
@@ -21,135 +13,30 @@ module Legion
|
|
|
21
13
|
module Llm
|
|
22
14
|
module Vllm
|
|
23
15
|
module Actor
|
|
24
|
-
# Periodic
|
|
16
|
+
# Periodic trigger for vLLM discovery. Stateless: it fires on the
|
|
17
|
+
# configured discovery interval and dispatches to
|
|
18
|
+
# Runners::DiscoveryRefresh, which owns the work and holds its
|
|
19
|
+
# process-local instance state.
|
|
25
20
|
class DiscoveryRefresh < Legion::Extensions::Actors::Every
|
|
26
|
-
include Legion::
|
|
27
|
-
|
|
28
|
-
if defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
|
|
29
|
-
include Legion::Extensions::Llm::Inventory::ScopedRefresher
|
|
30
|
-
end
|
|
21
|
+
include Legion::Extensions::Helpers::Lex
|
|
31
22
|
|
|
32
|
-
def self.every_seconds = 300
|
|
33
|
-
|
|
34
|
-
def runner_class = self.class
|
|
35
|
-
def runner_function = 'manual'
|
|
36
23
|
def run_now? = true
|
|
37
24
|
def use_runner? = false
|
|
38
|
-
def
|
|
39
|
-
def
|
|
25
|
+
def runner_class = 'Legion::Extensions::Llm::Vllm::Runners::DiscoveryRefresh'
|
|
26
|
+
def runner_function = 'refresh'
|
|
40
27
|
|
|
28
|
+
# Honor the registered discovery interval. A nil TimerTask interval
|
|
29
|
+
# fires once and then stops, so resolve to the registered default
|
|
30
|
+
# (300s) whenever the setting is missing or non-positive.
|
|
41
31
|
def time
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
Legion::Settings.dig(:extensions, :llm, :vllm, :discovery_interval) || self.class.every_seconds
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def scope_key = { provider: :vllm }
|
|
48
|
-
def offering_type(raw_type) = %i[embed embedding].include?(raw_type) ? :embedding : :inference
|
|
49
|
-
|
|
50
|
-
def vllm_cfg
|
|
51
|
-
return unless defined?(Legion::Settings)
|
|
52
|
-
|
|
53
|
-
Legion::Settings.dig(:extensions, :llm,
|
|
54
|
-
:vllm)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def compute_lanes_for_scope(**)
|
|
58
|
-
return [] unless defined?(Legion::LLM::Call::Registry)
|
|
59
|
-
|
|
60
|
-
vllm_instances.flat_map { |entry| lanes_from_instance(entry) }
|
|
61
|
-
rescue StandardError => e
|
|
62
|
-
handle_exception(e, level: :warn, handled: true, operation: 'vllm.actor.compute_lanes_for_scope')
|
|
63
|
-
[]
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def credential_hash(**)
|
|
67
|
-
cfg = vllm_cfg
|
|
68
|
-
Digest::SHA256.hexdigest(cfg&.dig(:api_key).to_s + cfg&.dig(:instances).to_s)[0, 16]
|
|
69
|
-
rescue StandardError
|
|
70
|
-
'unknown'
|
|
32
|
+
interval = settings.dig(:discovery, :interval_seconds)&.to_i
|
|
33
|
+
interval&.positive? ? interval : 300
|
|
71
34
|
end
|
|
72
35
|
|
|
73
|
-
def
|
|
74
|
-
|
|
36
|
+
def shutdown
|
|
37
|
+
Legion::Extensions::Llm::Vllm::Runners::DiscoveryRefresh.remove_all_instances
|
|
75
38
|
rescue StandardError => e
|
|
76
|
-
handle_exception(e, level: :warn,
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
private
|
|
80
|
-
|
|
81
|
-
def run_scoped_tick
|
|
82
|
-
return unless defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
|
|
83
|
-
return unless self.class.ancestors.include?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
|
|
84
|
-
|
|
85
|
-
tick
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def vllm_instances
|
|
89
|
-
Legion::LLM::Call::Registry.all_instances.select { |e| (e[:provider] || '').to_sym == :vllm }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def lanes_from_instance(instance_entry)
|
|
93
|
-
adapter = instance_entry[:adapter]
|
|
94
|
-
return [] unless adapter.respond_to?(:discover_offerings)
|
|
95
|
-
|
|
96
|
-
Array(adapter.discover_offerings(live: true)).flat_map do |offering|
|
|
97
|
-
raw = offering_to_hash(offering)
|
|
98
|
-
lane = build_lane(raw, instance_entry)
|
|
99
|
-
fleet = maybe_fleet_lane(lane)
|
|
100
|
-
fleet ? [lane, fleet] : [lane]
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# ModelOffering objects do not implement `[]`; normalize to a Hash so the
|
|
105
|
-
# rest of the writer stays Hash-shaped. Hash inputs pass through untouched.
|
|
106
|
-
def offering_to_hash(offering)
|
|
107
|
-
return offering if offering.is_a?(Hash)
|
|
108
|
-
|
|
109
|
-
hash = offering.to_h
|
|
110
|
-
hash[:type] ||= hash[:usage_type]
|
|
111
|
-
hash[:enabled] = offering.respond_to?(:enabled?) ? offering.enabled? : true
|
|
112
|
-
hash
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def build_lane(offering, instance_entry) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
|
|
116
|
-
tier = offering[:tier] || :direct
|
|
117
|
-
type = offering_type(offering[:type])
|
|
118
|
-
instance_id = offering[:instance_id] ||
|
|
119
|
-
instance_entry[:instance] ||
|
|
120
|
-
instance_entry[:instance_id] ||
|
|
121
|
-
instance_entry[:id]
|
|
122
|
-
provider_family = offering[:provider_family] || :vllm
|
|
123
|
-
model = offering[:model]
|
|
124
|
-
lane_id = Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(
|
|
125
|
-
tier: tier, provider_family: provider_family, instance_id: instance_id, type: type, model: model
|
|
126
|
-
)
|
|
127
|
-
{ id: lane_id, tier: tier, provider_family: provider_family, instance_id: instance_id,
|
|
128
|
-
model: model, canonical_model_alias: offering[:canonical_model_alias], type: type,
|
|
129
|
-
capabilities: normalize_caps(offering[:capabilities]),
|
|
130
|
-
limits: offering[:limits] || {}, enabled: offering.fetch(:enabled, true), cost: offering[:cost] || {} }
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def maybe_fleet_lane(lane)
|
|
134
|
-
return unless lane[:type] == :inference && vllm_cfg&.dig(:fleet, :dispatch, :enabled)
|
|
135
|
-
|
|
136
|
-
fleet_id = Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(
|
|
137
|
-
tier: :fleet, provider_family: lane[:provider_family],
|
|
138
|
-
instance_id: lane[:instance_id], type: lane[:type], model: lane[:model]
|
|
139
|
-
)
|
|
140
|
-
lane.merge(id: fleet_id, tier: :fleet)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def normalize_caps(caps)
|
|
144
|
-
# Inventory::Capabilities lives in lex-llm; the previous fallback (`return []
|
|
145
|
-
# unless defined?(...)`) silently swallowed every capability the operator
|
|
146
|
-
# declared via enable_thinking/enable_tools when the constant wasn't loaded.
|
|
147
|
-
# Always normalize through the shared vocabulary so aliases collapse.
|
|
148
|
-
if defined?(Legion::Extensions::Llm::Inventory::Capabilities)
|
|
149
|
-
Legion::Extensions::Llm::Inventory::Capabilities.normalize(caps)
|
|
150
|
-
else
|
|
151
|
-
Array(caps).compact.map(&:to_sym).uniq
|
|
152
|
-
end
|
|
39
|
+
handle_exception(e, level: :warn, operation: 'vllm.actor.discovery_refresh.shutdown')
|
|
153
40
|
end
|
|
154
41
|
end
|
|
155
42
|
end
|
|
@@ -3,18 +3,13 @@
|
|
|
3
3
|
begin
|
|
4
4
|
require 'legion/extensions/actors/subscription'
|
|
5
5
|
rescue LoadError => e
|
|
6
|
-
|
|
7
|
-
unless defined?(Legion::Extensions::Actors::Subscription)
|
|
8
|
-
Legion::Extensions::Llm::Vllm.handle_exception(e, level: :warn, handled: false,
|
|
9
|
-
operation: 'vllm.fleet_worker.load_actor_runtime')
|
|
10
|
-
end
|
|
6
|
+
warn(e.message) if $VERBOSE
|
|
11
7
|
end
|
|
12
8
|
|
|
13
|
-
unless defined?(Legion::Extensions::Actors::Subscription)
|
|
14
|
-
raise LoadError, 'LegionIO actor runtime is required for vLLM fleet worker'
|
|
15
|
-
end
|
|
9
|
+
return unless defined?(Legion::Extensions::Actors::Subscription)
|
|
16
10
|
|
|
17
11
|
require 'legion/extensions/llm/vllm'
|
|
12
|
+
require 'legion/extensions/llm/vllm/runners/fleet_worker'
|
|
18
13
|
require 'legion/extensions/llm/fleet/provider_responder'
|
|
19
14
|
require 'legion/logging'
|
|
20
15
|
|
|
@@ -24,11 +19,18 @@ module Legion
|
|
|
24
19
|
module Vllm
|
|
25
20
|
module Actor
|
|
26
21
|
# Subscription actor for vLLM fleet request consumption.
|
|
22
|
+
#
|
|
23
|
+
# `runner_class` resolves to the concrete runner MODULE (not a
|
|
24
|
+
# String) because the Subscription dispatch path with
|
|
25
|
+
# `use_runner? = false` calls `runner_class.send(fn, **message)`
|
|
26
|
+
# directly — a String cannot be `send`-ed. The runner's
|
|
27
|
+
# `handle_fleet_request(**message)` accepts the delivered envelope as
|
|
28
|
+
# keyword arguments.
|
|
27
29
|
class FleetWorker < Legion::Extensions::Actors::Subscription
|
|
28
|
-
include Legion::
|
|
30
|
+
include Legion::Extensions::Helpers::Lex
|
|
29
31
|
|
|
30
32
|
def runner_class
|
|
31
|
-
|
|
33
|
+
Legion::Extensions::Llm::Vllm::Runners::FleetWorker
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
def runner_function
|
|
@@ -40,9 +42,13 @@ module Legion
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
def enabled?
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
instances = Vllm.discover_instances
|
|
46
|
+
enabled = Legion::Extensions::Llm::Fleet::ProviderResponder.enabled_for?(instances)
|
|
47
|
+
log.debug { "vLLM fleet worker enablement: enabled=#{enabled}, instance_count=#{instances.size}" }
|
|
48
|
+
enabled
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
handle_exception(e, level: :warn, handled: true, operation: 'vllm.fleet_worker.enabled')
|
|
51
|
+
false
|
|
46
52
|
end
|
|
47
53
|
end
|
|
48
54
|
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/llm/routing/provider_outcome'
|
|
4
|
+
require 'legion/extensions/llm/vllm/provider'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Llm
|
|
9
|
+
module Vllm
|
|
10
|
+
# Callable wrapper for a vLLM provider instance. Implements the
|
|
11
|
+
# `disconnect` and `normalize_dispatch_error(error:)` contracts required
|
|
12
|
+
# by Inventory::CallableHandle and Routing::ProviderOutcome, plus the
|
|
13
|
+
# fleet dispatch operations the coordinator invokes (chat, stream_chat,
|
|
14
|
+
# embed, count_tokens). Each dispatch delegates to a per-instance
|
|
15
|
+
# Vllm::Provider built lazily from the instance config; provider and
|
|
16
|
+
# Faraday errors are NOT rescued here so the coordinator's
|
|
17
|
+
# normalize_dispatch_error can classify them.
|
|
18
|
+
class VllmCallable
|
|
19
|
+
# Keys the base Provider exposes as named kwargs for the completion
|
|
20
|
+
# operations. Anything else the fleet passes is folded into the
|
|
21
|
+
# payload `params` hash.
|
|
22
|
+
COMPLETION_NAMED_KEYS = %i[tools temperature schema thinking tool_prefs headers].freeze
|
|
23
|
+
EMBED_NAMED_KEYS = %i[dimensions headers].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(instance_cfg:, logger:)
|
|
26
|
+
@instance_cfg = instance_cfg
|
|
27
|
+
@logger = logger
|
|
28
|
+
@disconnected = false
|
|
29
|
+
@inference_calls = 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call_count
|
|
33
|
+
@inference_calls
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def disconnected?
|
|
37
|
+
@disconnected
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def disconnect
|
|
41
|
+
@disconnected = true
|
|
42
|
+
@provider&.disconnect
|
|
43
|
+
@logger.debug { '[vllm][callable] disconnected' }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# ── Fleet dispatch operations ──────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def chat(messages:, model:, **rest)
|
|
49
|
+
record_inference
|
|
50
|
+
named, params = split_fleet_kwargs(rest, COMPLETION_NAMED_KEYS)
|
|
51
|
+
provider.chat(messages: messages, model: model, params: params, **named)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def stream_chat(messages:, model:, **rest, &)
|
|
55
|
+
record_inference
|
|
56
|
+
named, params = split_fleet_kwargs(rest, COMPLETION_NAMED_KEYS)
|
|
57
|
+
provider.stream_chat(messages: messages, model: model, params: params, **named, &)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def embed(text:, model:, **rest)
|
|
61
|
+
record_inference
|
|
62
|
+
named, params = split_fleet_kwargs(rest, EMBED_NAMED_KEYS)
|
|
63
|
+
provider.embed(text: text, model: model, params: params, **named)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def count_tokens(messages:, model:, **rest)
|
|
67
|
+
record_inference
|
|
68
|
+
_named, params = split_fleet_kwargs(rest, [])
|
|
69
|
+
provider.count_tokens(messages: messages, model: model, params: params)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def normalize_dispatch_error(error:)
|
|
73
|
+
reason = error.message.to_s[0, 512]
|
|
74
|
+
kind = classify_dispatch_error(error: error)
|
|
75
|
+
Legion::Extensions::Llm::Routing::ProviderOutcome.new(
|
|
76
|
+
kind: kind, reason: reason.empty? ? 'unknown dispatch error' : reason
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def record_inference
|
|
83
|
+
@inference_calls += 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def provider
|
|
87
|
+
@provider ||= Legion::Extensions::Llm::Vllm::Provider.new(@instance_cfg)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Split the fleet's **rest into the base Provider's named kwargs and a
|
|
91
|
+
# payload params hash (any passed :params merged with unknown keys).
|
|
92
|
+
def split_fleet_kwargs(rest, named_keys)
|
|
93
|
+
named = rest.slice(*named_keys)
|
|
94
|
+
extra = rest.reject { |key, _| named.key?(key) }
|
|
95
|
+
params = (extra.delete(:params) || {}).to_h.merge(extra)
|
|
96
|
+
[named, params]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# D17: in production the base Connection's ErrorMiddleware raises
|
|
100
|
+
# Legion::Extensions::Llm::*Error (NOT raw Faraday classes), so this
|
|
101
|
+
# maps the full Llm error set (mirroring base Provider#
|
|
102
|
+
# normalize_dispatch_error) and layers the vLLM offline-body detection
|
|
103
|
+
# on top. Raw Faraday classes are also handled for the direct-HTTP
|
|
104
|
+
# paths that bypass the middleware.
|
|
105
|
+
def classify_dispatch_error(error:)
|
|
106
|
+
case error
|
|
107
|
+
when Legion::Extensions::Llm::OverloadedError then :overloaded
|
|
108
|
+
when Legion::Extensions::Llm::RateLimitError then :rate_limited
|
|
109
|
+
when Legion::Extensions::Llm::UnauthorizedError then :authentication
|
|
110
|
+
when Legion::Extensions::Llm::PaymentRequiredError then :billing
|
|
111
|
+
when Legion::Extensions::Llm::ForbiddenError then :authorization
|
|
112
|
+
when Legion::Extensions::Llm::ContextLengthExceededError then :context_rejected
|
|
113
|
+
when Legion::Extensions::Llm::BadRequestError then :invalid_request
|
|
114
|
+
when Legion::Extensions::Llm::ModelNotFoundError then :model_missing
|
|
115
|
+
when Legion::Extensions::Llm::ModelNotAllowedError then :policy
|
|
116
|
+
when Legion::Extensions::Llm::ServiceUnavailableError
|
|
117
|
+
if explicit_vllm_offline?(status: dispatch_error_status(error:),
|
|
118
|
+
body: dispatch_error_body(error:))
|
|
119
|
+
:instance_unavailable
|
|
120
|
+
else
|
|
121
|
+
:provider_error
|
|
122
|
+
end
|
|
123
|
+
when Legion::Extensions::Llm::ServerError then :provider_error
|
|
124
|
+
when Faraday::TimeoutError, Timeout::Error then :timeout
|
|
125
|
+
when Faraday::ConnectionFailed, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError
|
|
126
|
+
:connection_failure
|
|
127
|
+
when Faraday::ClientError then classify_client_error(error:)
|
|
128
|
+
when Faraday::ServerError then classify_server_error(error:)
|
|
129
|
+
else :provider_error
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def classify_client_error(error:)
|
|
134
|
+
case dispatch_error_status(error:)
|
|
135
|
+
when 401 then :authentication
|
|
136
|
+
when 403 then :authorization
|
|
137
|
+
when 404 then :model_missing
|
|
138
|
+
when 429 then :rate_limited
|
|
139
|
+
else :invalid_request
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def classify_server_error(error:)
|
|
144
|
+
status = dispatch_error_status(error:)
|
|
145
|
+
return :instance_unavailable if explicit_vllm_offline?(status: status, body: dispatch_error_body(error:))
|
|
146
|
+
|
|
147
|
+
case status
|
|
148
|
+
when 503, 529 then :overloaded
|
|
149
|
+
else :provider_error
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Read the HTTP status off a dispatch error whether its response is a
|
|
154
|
+
# plain Hash, a Faraday::Response, or a Faraday::Env. Real Faraday
|
|
155
|
+
# errors carry a Faraday::Env (a Struct, NOT a Hash), so a legacy
|
|
156
|
+
# `error.response.is_a?(Hash)` gate would never fire in production.
|
|
157
|
+
def dispatch_error_status(error:)
|
|
158
|
+
return error.response_status if error.respond_to?(:response_status) && !error.response_status.nil?
|
|
159
|
+
|
|
160
|
+
response = error.response if error.respond_to?(:response)
|
|
161
|
+
return response.status if response.respond_to?(:status) && !response.status.nil?
|
|
162
|
+
|
|
163
|
+
response[:status] if response.respond_to?(:[]) && !response[:status].nil?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def dispatch_error_body(error:)
|
|
167
|
+
return error.response_body.to_s if error.respond_to?(:response_body) && !error.response_body.nil?
|
|
168
|
+
|
|
169
|
+
response = error.response if error.respond_to?(:response)
|
|
170
|
+
return response.body.to_s if response.respond_to?(:body) && !response.body.nil?
|
|
171
|
+
|
|
172
|
+
response[:body].to_s if response.respond_to?(:[]) && !response[:body].nil?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# An explicit flat vLLM service-offline body (never just HTTP 503
|
|
176
|
+
# alone). connection_failure, timeout, overload, 429, and generic 5xx
|
|
177
|
+
# are request-local per §8 and must not map to instance_unavailable.
|
|
178
|
+
def explicit_vllm_offline?(status:, body:)
|
|
179
|
+
status == 503 && (
|
|
180
|
+
body.to_s.downcase.include?('instance not available') ||
|
|
181
|
+
body.to_s.downcase.include?('server is going offline') ||
|
|
182
|
+
body.to_s.downcase.include?('service unavailable, server offline')
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|