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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 142f55fe9f62c87ef3fe5fe545ba652ff73426af265aa9a0aaafa2bbb703ec35
4
- data.tar.gz: ce24e483f5dea06abb6be25d26bedeaf531782b344500f73ed4fe1161e69c372
3
+ metadata.gz: bb587332ecdb4d2a8e4535d1b03250b3a63e7a8ce5171dc5198c11ad40549bef
4
+ data.tar.gz: ec906ffff9f3d1a0496a1d0043d25902c08031d6cd613a239847ca97474e4def
5
5
  SHA512:
6
- metadata.gz: be6ebfd3ae8e78a9b0a64078cb7e947d08fb2f58cce8c61b5ea37a0181bcda83775d928b1f638e1518e9ca6640da196305203a169beeb746e6b13335b29f70a8
7
- data.tar.gz: e74e87adb11b6d67071dfb053f72e3ef882fcf5f50b39e889b3d72071af3f41fe97fc524e89a6c20fe7606aea3e1db016d33459b965ea934aeeeb441c0b0dec1
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
- Exclude:
17
- - lib/legion/extensions/llm/vllm/provider.rb
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
- Max: 8
36
+ Enabled: false
22
37
  RSpec/MultipleExpectations:
23
38
  Enabled: false
24
- RSpec/ExampleLength:
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
@@ -2,6 +2,8 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
+ gem 'lex-llm'
6
+
5
7
  gemspec
6
8
 
7
9
  group :development do
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.3.14'
28
+ spec.add_dependency 'legion-settings', '>= 1.4.2'
29
29
  spec.add_dependency 'legion-transport', '>= 1.4.14'
30
- spec.add_dependency 'lex-llm', '>= 0.6.16'
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 actor that refreshes the vLLM discovered model list.
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::Logging::Helper
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 check_subtask? = false
39
- def generate_task? = false
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
- return self.class.every_seconds unless defined?(Legion::Settings)
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 manual
74
- run_scoped_tick
36
+ def shutdown
37
+ Legion::Extensions::Llm::Vllm::Runners::DiscoveryRefresh.remove_all_instances
75
38
  rescue StandardError => e
76
- handle_exception(e, level: :warn, handled: true, operation: 'vllm.actor.discovery_refresh')
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
- require 'legion/extensions/llm/vllm'
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::Logging::Helper
30
+ include Legion::Extensions::Helpers::Lex
29
31
 
30
32
  def runner_class
31
- 'Legion::Extensions::Llm::Vllm::Runners::FleetWorker'
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
- Legion::Extensions::Llm::Fleet::ProviderResponder.enabled_for?(Vllm.discover_instances).tap do |enabled|
44
- log.debug { "vLLM fleet worker enabled=#{enabled}" }
45
- end
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