lex-llm 0.7.0 → 0.7.2
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 +15 -0
- data/lex-llm.gemspec +1 -1
- data/lib/legion/extensions/llm/canonical/message.rb +5 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +25 -3
- data/lib/legion/extensions/llm/inventory/publisher.rb +22 -16
- data/lib/legion/extensions/llm/inventory/records.rb +9 -2
- data/lib/legion/extensions/llm/provider.rb +12 -29
- data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +3 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
- data/spec/legion/extensions/llm/inventory/evidence_spec.rb +13 -0
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +55 -2
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +40 -0
- data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +14 -0
- data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e91ed0737e9946e5fce06f1a594f3139ea3f5dfd55e7e4a757fb66b77a099d13
|
|
4
|
+
data.tar.gz: 9284cbd89a01863b1d2226296b23d33250785b7988094d1b9e0223fb31cbbcb9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e8d7a88f398636313e23b74c8749b709e8400f5d67f86fa55f5d94cbfa2a9c1b4f47910d86020d0641ddfa2ae615de8759a9f6a233fdf53473c4dd48f0e7dd81
|
|
7
|
+
data.tar.gz: 443f471cf5875ddfa9995376cb3bbcce9a73cd16c3bcfffd3d1b101bb6e3fd0d1471c3c69052626b39b720909842f5e5a68961991a78bfee84a3d99488a971b6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.2 - 2026-08-17
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **`Canonical::Message.from_hash` projects onto known member keys.** The factory now builds via `build(**h.slice(*members))` (mirroring `Request.from_hash`) instead of passing the raw hash to a fixed kwarg list, so transport-only keys such as `:cache_control` (injected by the prompt-cache step on multi-message requests) are dropped instead of raising `ArgumentError: unknown keyword: :cache_control` before the HTTP request was ever sent.
|
|
7
|
+
- **`sanitized_reason` coerces non-UTF-8 reasons instead of raising.** Non-UTF-8 reasons (e.g. an ASCII-8BIT `ArgumentError` message) are now `force_encoding`'d to UTF-8 and `scrub`bed (`'?'`) rather than raising `ValidationError: reason is not valid UTF-8`, which masked the original dispatch error end-to-end (never logged) and produced a retriable 500. Non-String, empty, and oversized reasons still raise.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Regression specs for the dispatch-boundary fixes.** `Message.from_hash` with a transport-only `cache_control` key, and `ProviderOutcome` with a non-UTF-8 reason; both proven to fail pre-fix.
|
|
11
|
+
- **legion-settings floor raised to `>= 1.4.2`.** Its segments-based nested resolution fixes two-segment extensions resolving to a flat key (e.g. `:llm_vllm`) instead of `[:extensions][:llm][:vllm]`, which left `settings[:instances]` nil so SSOT discovery actors saw zero configured instances.
|
|
12
|
+
|
|
13
|
+
## 0.7.1 - 2026-08-14
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Shared conformance fixtures now carry `metadata.model`.** SSOT v3 §9-compliant provider translators require an exact selected model to be present in the canonical request before `render_request` is called (they read `request.metadata[:model]`). The six request fixtures used by the shared `'a canonical provider translator'` examples previously carried no model, causing those conformance examples to fail for §9-compliant providers. Each fixture now includes `"metadata": { "model": "test-fixture-model" }`. No production behavior change; existing providers that fall back to a default model are unaffected.
|
|
17
|
+
|
|
3
18
|
## 0.7.0 - 2026-08-13
|
|
4
19
|
|
|
5
20
|
### Added
|
data/lex-llm.gemspec
CHANGED
|
@@ -40,7 +40,7 @@ Gem::Specification.new do |spec|
|
|
|
40
40
|
spec.add_dependency 'legion-crypt', '>= 1.5.1'
|
|
41
41
|
spec.add_dependency 'legion-json', '>= 1.2.1'
|
|
42
42
|
spec.add_dependency 'legion-logging', '>= 1.3.2'
|
|
43
|
-
spec.add_dependency 'legion-settings', '>= 1.
|
|
43
|
+
spec.add_dependency 'legion-settings', '>= 1.4.2'
|
|
44
44
|
spec.add_dependency 'legion-transport', '>= 1.4.14'
|
|
45
45
|
spec.add_dependency 'marcel', '~> 1'
|
|
46
46
|
spec.add_dependency 'ruby_llm-schema', '~> 0'
|
|
@@ -93,7 +93,11 @@ module Legion
|
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
# Tolerate transport-only keys (e.g. the prompt-cache step's :cache_control,
|
|
97
|
+
# injected onto every >=2-message request) by projecting onto the known
|
|
98
|
+
# member set — mirrors Canonical::Request.from_hash, which folds unknown keys
|
|
99
|
+
# instead of raising. Message has no metadata field, so unknown keys are dropped.
|
|
100
|
+
build(**h.slice(*members))
|
|
97
101
|
end
|
|
98
102
|
|
|
99
103
|
# Wrap input: pass through if already a Message, parse if Hash.
|
|
@@ -101,15 +101,37 @@ module Legion
|
|
|
101
101
|
# A mandatory, immutable provider_family + instance_id pair. Provider
|
|
102
102
|
# family alone is never executable; two instances of the same provider
|
|
103
103
|
# are independent targets. See section 8.1.
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
#
|
|
105
|
+
# instance_id is the operator's CONFIG NAME — the identity the router
|
|
106
|
+
# keys settings lookups (instances.<name>) by. physical_id is an
|
|
107
|
+
# optional SECONDARY field carrying the physical/derived id
|
|
108
|
+
# (e.g. "host:port" or "host:port/ak:<digest>") preserved for dedup
|
|
109
|
+
# and diagnostics. It is NOT identity: it never participates in
|
|
110
|
+
# equality, hashing, or registry-scope identity, so two config names
|
|
111
|
+
# pointing at the same endpoint stay distinct instances.
|
|
112
|
+
InstanceKey = ::Data.define(:provider_family, :instance_id, :physical_id) do
|
|
113
|
+
def initialize(provider_family:, instance_id:, physical_id: nil)
|
|
106
114
|
family = Identity.normalize_text(value: provider_family, field: :provider_family).downcase
|
|
107
115
|
raise Errors::ValidationError, 'provider_family must match /\A[a-z][a-z0-9_]*\z/' unless family.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
108
116
|
|
|
109
117
|
instance = Identity.normalize_text(value: instance_id, field: :instance_id)
|
|
110
118
|
raise Errors::ValidationError, 'instance_id must not be the reserved value "default"' if instance == 'default'
|
|
111
119
|
|
|
112
|
-
|
|
120
|
+
physical = physical_id.nil? ? nil : Identity.normalize_text(value: physical_id, field: :physical_id)
|
|
121
|
+
|
|
122
|
+
super(provider_family: family.to_sym, instance_id: instance, physical_id: physical)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Identity is exactly (provider_family, instance_id). The
|
|
126
|
+
# secondary physical_id never affects equality, hashing, or
|
|
127
|
+
# registry-scope identity.
|
|
128
|
+
def ==(other)
|
|
129
|
+
other.is_a?(self.class) && other.provider_family == provider_family && other.instance_id == instance_id
|
|
130
|
+
end
|
|
131
|
+
alias_method :eql?, :==
|
|
132
|
+
|
|
133
|
+
def hash
|
|
134
|
+
[self.class, provider_family, instance_id].hash
|
|
113
135
|
end
|
|
114
136
|
end
|
|
115
137
|
end
|
|
@@ -24,19 +24,25 @@ module Legion
|
|
|
24
24
|
@compatibility_adapter = compatibility_adapter
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
# Every instance operation takes `instance_id:` — the operator's
|
|
28
|
+
# CONFIG NAME (the identity the router keys by) — and an optional
|
|
29
|
+
# `physical_id:` carrying the physical/derived id (host:port/ak) for
|
|
30
|
+
# dedup and diagnostics only. See Identity::InstanceKey.
|
|
31
|
+
def claim_instance(instance_id:, callable:, probe_request_handle:, physical_id: nil)
|
|
32
|
+
key = instance_key(instance_id: instance_id, physical_id: physical_id)
|
|
29
33
|
token = @registry.claim_instance(instance_key: key, callable: callable, probe_request_handle: probe_request_handle)
|
|
30
34
|
sync_compatibility(key, :claimed)
|
|
31
35
|
token
|
|
32
36
|
end
|
|
33
37
|
|
|
34
|
-
def readiness_probe_started(instance_id:, publisher_token:)
|
|
35
|
-
@registry.readiness_probe_started(
|
|
38
|
+
def readiness_probe_started(instance_id:, publisher_token:, physical_id: nil)
|
|
39
|
+
@registry.readiness_probe_started(
|
|
40
|
+
instance_key: instance_key(instance_id: instance_id, physical_id: physical_id), publisher_token: publisher_token
|
|
41
|
+
)
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
def activate_instance_snapshot(instance_id:, publisher_token:, offerings:, sequence:, probe_token:)
|
|
39
|
-
key = instance_key(instance_id)
|
|
44
|
+
def activate_instance_snapshot(instance_id:, publisher_token:, offerings:, sequence:, probe_token:, physical_id: nil) # rubocop:disable Metrics/ParameterLists
|
|
45
|
+
key = instance_key(instance_id: instance_id, physical_id: physical_id)
|
|
40
46
|
result = @registry.activate_instance_snapshot(
|
|
41
47
|
publisher_token: publisher_token, instance_key: key, offerings: offerings,
|
|
42
48
|
sequence: sequence, probe_token: probe_token
|
|
@@ -44,26 +50,26 @@ module Legion
|
|
|
44
50
|
sync_applied(key, result)
|
|
45
51
|
end
|
|
46
52
|
|
|
47
|
-
def replace_instance_snapshot(instance_id:, publisher_token:, offerings:, sequence:)
|
|
48
|
-
key = instance_key(instance_id)
|
|
53
|
+
def replace_instance_snapshot(instance_id:, publisher_token:, offerings:, sequence:, physical_id: nil)
|
|
54
|
+
key = instance_key(instance_id: instance_id, physical_id: physical_id)
|
|
49
55
|
result = @registry.replace_instance_snapshot(
|
|
50
56
|
publisher_token: publisher_token, instance_key: key, offerings: offerings, sequence: sequence
|
|
51
57
|
)
|
|
52
58
|
sync_applied(key, result)
|
|
53
59
|
end
|
|
54
60
|
|
|
55
|
-
def readiness_succeeded(instance_id:, probe_token:)
|
|
56
|
-
key = instance_key(instance_id)
|
|
61
|
+
def readiness_succeeded(instance_id:, probe_token:, physical_id: nil)
|
|
62
|
+
key = instance_key(instance_id: instance_id, physical_id: physical_id)
|
|
57
63
|
sync_applied(key, @registry.readiness_succeeded(instance_key: key, probe_token: probe_token))
|
|
58
64
|
end
|
|
59
65
|
|
|
60
|
-
def readiness_failed(instance_id:, probe_token:, reason:)
|
|
61
|
-
key = instance_key(instance_id)
|
|
66
|
+
def readiness_failed(instance_id:, probe_token:, reason:, physical_id: nil)
|
|
67
|
+
key = instance_key(instance_id: instance_id, physical_id: physical_id)
|
|
62
68
|
sync_applied(key, @registry.readiness_failed(instance_key: key, probe_token: probe_token, reason: reason))
|
|
63
69
|
end
|
|
64
70
|
|
|
65
|
-
def remove_instance(instance_id:, publisher_token:)
|
|
66
|
-
key = instance_key(instance_id)
|
|
71
|
+
def remove_instance(instance_id:, publisher_token:, physical_id: nil)
|
|
72
|
+
key = instance_key(instance_id: instance_id, physical_id: physical_id)
|
|
67
73
|
sync_applied(key, @registry.remove_instance(instance_key: key, publisher_token: publisher_token))
|
|
68
74
|
end
|
|
69
75
|
|
|
@@ -73,8 +79,8 @@ module Legion
|
|
|
73
79
|
|
|
74
80
|
private
|
|
75
81
|
|
|
76
|
-
def instance_key(instance_id)
|
|
77
|
-
Identity::InstanceKey.new(provider_family: @provider_family, instance_id: instance_id)
|
|
82
|
+
def instance_key(instance_id:, physical_id: nil)
|
|
83
|
+
Identity::InstanceKey.new(provider_family: @provider_family, instance_id: instance_id, physical_id: physical_id)
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
def sync_applied(instance_key, result)
|
|
@@ -42,9 +42,16 @@ module Legion
|
|
|
42
42
|
|
|
43
43
|
def sanitized_reason(value:, field:, max_bytes: 1024)
|
|
44
44
|
raise Errors::ValidationError, "#{field} must be a String" unless value.is_a?(::String)
|
|
45
|
-
raise Errors::ValidationError, "#{field} is not valid UTF-8" unless valid_utf8?(value)
|
|
46
45
|
|
|
47
|
-
|
|
46
|
+
# Coerce any source encoding to valid UTF-8 rather than raising: provider
|
|
47
|
+
# error messages can be ASCII-8BIT/binary (raw response bodies, Ruby kernel
|
|
48
|
+
# error messages). A reason is diagnostic text, not operator input to reject —
|
|
49
|
+
# raising here would mask the real dispatch error as an unclassifiable 500 and
|
|
50
|
+
# defeat fail-forward. Undecodable bytes are replaced.
|
|
51
|
+
coerced = value.dup.force_encoding(::Encoding::UTF_8)
|
|
52
|
+
coerced = coerced.scrub('?') unless coerced.valid_encoding?
|
|
53
|
+
|
|
54
|
+
trimmed = coerced.strip
|
|
48
55
|
raise Errors::ValidationError, "#{field} must not be empty" if trimmed.empty?
|
|
49
56
|
raise Errors::ValidationError, "#{field} exceeds #{max_bytes} UTF-8 bytes" if trimmed.bytesize > max_bytes
|
|
50
57
|
|
|
@@ -580,17 +580,15 @@ module Legion
|
|
|
580
580
|
}
|
|
581
581
|
end
|
|
582
582
|
|
|
583
|
-
# Resolve a single policy value with
|
|
583
|
+
# Resolve a single policy value with the shared cascade
|
|
584
|
+
# (SettingsCascade: instance > provider, model leg skipped — no model
|
|
585
|
+
# exists at policy-build time) plus the legacy global
|
|
586
|
+
# extensions.llm.<key> leg. Empty values fall through.
|
|
584
587
|
def self.resolve_policy_value(cfg, provider_conf, global_conf, key)
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
return val if val && !val.to_s.empty? && (val.is_a?(Array) ? val.any? : true)
|
|
588
|
+
value = SettingsCascade.resolve_value(provider_conf: provider_conf, instance_cfg: cfg, key: key)
|
|
589
|
+
return value unless value.nil?
|
|
588
590
|
|
|
589
|
-
|
|
590
|
-
val = provider_conf[key] || provider_conf[key.to_s]
|
|
591
|
-
return val if val && !val.to_s.empty? && (val.is_a?(Array) ? val.any? : true)
|
|
592
|
-
|
|
593
|
-
# Global
|
|
591
|
+
global_conf = {} unless global_conf.is_a?(::Hash)
|
|
594
592
|
global_conf[key] || global_conf[key.to_s]
|
|
595
593
|
end
|
|
596
594
|
|
|
@@ -803,11 +801,11 @@ module Legion
|
|
|
803
801
|
end
|
|
804
802
|
|
|
805
803
|
def model_capability_config(model_id)
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
804
|
+
SettingsCascade.merge_model_scopes(
|
|
805
|
+
provider_conf: provider_capability_config,
|
|
806
|
+
instance_cfg: config.respond_to?(:to_h) ? config.to_h : {},
|
|
807
|
+
model: model_id
|
|
808
|
+
)
|
|
811
809
|
rescue StandardError => e
|
|
812
810
|
handle_exception(e, level: :warn, handled: true, operation: "#{slug}.model_capability_config")
|
|
813
811
|
{}
|
|
@@ -864,21 +862,6 @@ module Legion
|
|
|
864
862
|
end
|
|
865
863
|
end
|
|
866
864
|
|
|
867
|
-
def extract_models_config(source)
|
|
868
|
-
return {} unless source.respond_to?(:models)
|
|
869
|
-
|
|
870
|
-
models = source.models
|
|
871
|
-
models.respond_to?(:to_h) ? models.to_h : {}
|
|
872
|
-
rescue StandardError
|
|
873
|
-
{}
|
|
874
|
-
end
|
|
875
|
-
|
|
876
|
-
def provider_capability_models
|
|
877
|
-
config = provider_capability_config
|
|
878
|
-
models = config[:models] || config['models']
|
|
879
|
-
models.respond_to?(:to_h) ? models.to_h : {}
|
|
880
|
-
end
|
|
881
|
-
|
|
882
865
|
def offering_from_model(model, health: {})
|
|
883
866
|
capability_sources = Array(model.capabilities).to_h do |cap|
|
|
884
867
|
[cap.to_sym, { value: true, source: :model_metadata }]
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# The shared 3-level settings cascade: provider -> instance -> model,
|
|
7
|
+
# most-specific-first. Single home for resolving operator config
|
|
8
|
+
# (enable_* overrides, weight, preferred_min/max_context_tokens,
|
|
9
|
+
# model_whitelist/blacklist, ...) for a
|
|
10
|
+
# (provider_family, instance, model, key) request.
|
|
11
|
+
#
|
|
12
|
+
# `instance` is the operator's CONFIG NAME — the same identity
|
|
13
|
+
# Identity::InstanceKey#instance_id and the router key settings lookups
|
|
14
|
+
# by — never a derived host:port/credential value.
|
|
15
|
+
#
|
|
16
|
+
# Lookup order (first meaningful value wins):
|
|
17
|
+
# 1. model: extensions.llm.<provider>.instances.<instance>.models.<model>.<key>
|
|
18
|
+
# then extensions.llm.<provider>.models.<model>.<key>
|
|
19
|
+
# 2. instance: extensions.llm.<provider>.instances.<instance>.<key>
|
|
20
|
+
# 3. provider: extensions.llm.<provider>.<key>
|
|
21
|
+
#
|
|
22
|
+
# Empty values (nil, blank String, empty Array) never resolve; the
|
|
23
|
+
# cascade falls through to the next scope and returns nil when no scope
|
|
24
|
+
# carries a meaningful value.
|
|
25
|
+
#
|
|
26
|
+
# The cascade READS config only. It never publishes evidence: config and
|
|
27
|
+
# override sources remain unknown-only (Taxonomies::
|
|
28
|
+
# UNKNOWN_ONLY_EVIDENCE_SOURCES), and enable_* overrides are consumed
|
|
29
|
+
# router-side, not published as capability evidence.
|
|
30
|
+
module SettingsCascade
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Resolve `key` for (provider_family, instance, model) from the live
|
|
34
|
+
# Legion::Settings[:extensions][:llm][<provider>] path.
|
|
35
|
+
def resolve(provider_family:, instance:, key:, model: nil)
|
|
36
|
+
text_name!(provider_family, :provider_family)
|
|
37
|
+
text_name!(instance, :instance)
|
|
38
|
+
text_name!(key, :key)
|
|
39
|
+
text_name!(model, :model) if model
|
|
40
|
+
|
|
41
|
+
llm_conf = Legion::Settings.dig(:extensions, :llm)
|
|
42
|
+
resolve_from(llm_conf: llm_conf, provider_family: provider_family, instance: instance, key: key, model: model)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Resolve against a pre-fetched extensions.llm subtree (plain Hash),
|
|
46
|
+
# e.g. a router settings snapshot. Same cascade as .resolve.
|
|
47
|
+
def resolve_from(llm_conf:, provider_family:, instance:, key:, model: nil)
|
|
48
|
+
text_name!(provider_family, :provider_family)
|
|
49
|
+
text_name!(instance, :instance)
|
|
50
|
+
text_name!(key, :key)
|
|
51
|
+
|
|
52
|
+
provider_conf = lookup(llm_conf, provider_family)
|
|
53
|
+
instances = lookup(provider_conf, :instances)
|
|
54
|
+
instance_cfg = lookup(instances, instance)
|
|
55
|
+
resolve_value(provider_conf: provider_conf, instance_cfg: instance_cfg, key: key, model: model)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The pure 3-level cascade over pre-fetched scope hashes:
|
|
59
|
+
# model scopes (instance-scoped first, then provider-scoped) > instance
|
|
60
|
+
# > provider. `model: nil` skips the model leg. Returns the first
|
|
61
|
+
# meaningful value or nil.
|
|
62
|
+
def resolve_value(provider_conf:, instance_cfg:, key:, model: nil)
|
|
63
|
+
text_name!(key, :key)
|
|
64
|
+
text_name!(model, :model) if model
|
|
65
|
+
|
|
66
|
+
scopes = []
|
|
67
|
+
scopes << model_scope(instance_cfg, model) if model
|
|
68
|
+
scopes << model_scope(provider_conf, model) if model
|
|
69
|
+
scopes << instance_cfg
|
|
70
|
+
scopes << provider_conf
|
|
71
|
+
|
|
72
|
+
scopes.each do |scope|
|
|
73
|
+
value = lookup(scope, key)
|
|
74
|
+
return value if meaningful?(value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The merged model-scope config hash for one model: the provider's
|
|
81
|
+
# models.<model> entry with the instance's models.<model> entry
|
|
82
|
+
# overriding it (the merge the capability feeders use).
|
|
83
|
+
def merge_model_scopes(provider_conf:, instance_cfg:, model:)
|
|
84
|
+
text_name!(model, :model)
|
|
85
|
+
|
|
86
|
+
model_scope(provider_conf, model).merge(model_scope(instance_cfg, model))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def model_scope(scope_conf, model)
|
|
90
|
+
models = lookup(scope_conf, :models)
|
|
91
|
+
return {} unless models.is_a?(::Hash)
|
|
92
|
+
|
|
93
|
+
entry = lookup(models, model)
|
|
94
|
+
entry.is_a?(::Hash) ? entry : {}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def meaningful?(value)
|
|
98
|
+
!value.nil? && !value.to_s.empty? && (!value.is_a?(::Array) || value.any?)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Tries the name as-given, as a Symbol, then as a String, preserving
|
|
102
|
+
# meaningful falsy values (false, 0) when the key is present.
|
|
103
|
+
def lookup(scope, name)
|
|
104
|
+
return nil unless scope.is_a?(::Hash)
|
|
105
|
+
|
|
106
|
+
return scope[name] if scope.key?(name)
|
|
107
|
+
return scope[name.to_sym] if scope.key?(name.to_sym)
|
|
108
|
+
|
|
109
|
+
scope[name.to_s]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def text_name!(name, field)
|
|
113
|
+
return if name.is_a?(::String) || name.is_a?(::Symbol)
|
|
114
|
+
|
|
115
|
+
raise ::ArgumentError, "#{field} must be a String or Symbol"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -45,6 +45,9 @@ module Legion
|
|
|
45
45
|
# --- Capability resolution policy (no internal deps) ---
|
|
46
46
|
require_relative 'llm/capability_policy'
|
|
47
47
|
|
|
48
|
+
# --- Shared 3-level settings cascade (provider -> instance -> model) ---
|
|
49
|
+
require_relative 'llm/settings_cascade'
|
|
50
|
+
|
|
48
51
|
# --- SSOT v3 runtime contract (process-local; no legion-llm/LegionIO/transport/db/timer) ---
|
|
49
52
|
require_relative 'llm/inventory/errors'
|
|
50
53
|
require_relative 'llm/inventory/immutable_value'
|
|
@@ -106,6 +106,30 @@ RSpec.describe Legion::Extensions::Llm::Canonical::Message do
|
|
|
106
106
|
expect(msg.content).to eq('hello')
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
+
it 'drops the transport-only :cache_control key instead of raising ArgumentError' do
|
|
110
|
+
# Regression guard (root cause B): the prompt-cache step injects :cache_control
|
|
111
|
+
# onto every >=2-message request. from_hash previously did build(**h) with the
|
|
112
|
+
# whole hash, raising ArgumentError 'unknown keyword: :cache_control' before HTTP.
|
|
113
|
+
# It now projects onto the known member keys, so the transport-only key is dropped.
|
|
114
|
+
expect do
|
|
115
|
+
described_class.from_hash(
|
|
116
|
+
role: 'user',
|
|
117
|
+
content: 'hi',
|
|
118
|
+
cache_control: { type: 'ephemeral' }
|
|
119
|
+
)
|
|
120
|
+
end.not_to raise_error
|
|
121
|
+
|
|
122
|
+
msg = described_class.from_hash(
|
|
123
|
+
role: 'user',
|
|
124
|
+
content: 'hi',
|
|
125
|
+
cache_control: { type: 'ephemeral' }
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expect(msg).to be_a(described_class)
|
|
129
|
+
expect(msg.role).to eq(:user)
|
|
130
|
+
expect(msg.text).to eq('hi')
|
|
131
|
+
end
|
|
132
|
+
|
|
109
133
|
it 'returns nil for nil source' do
|
|
110
134
|
expect(described_class.from_hash(nil)).to be_nil
|
|
111
135
|
end
|
|
@@ -92,6 +92,19 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Evidence do
|
|
|
92
92
|
.not_to raise_error
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
|
+
|
|
96
|
+
it 'never lets config/override sources carry :supported (enable_* is consumed router-side, not published)' do
|
|
97
|
+
%i[model_override instance_override provider_override].each do |source|
|
|
98
|
+
expect { capability_evidence.new(capability: :tools, status: :supported, source: source) }
|
|
99
|
+
.to raise_error(errors::ValidationError)
|
|
100
|
+
expect { capability_evidence.new(capability: :thinking, status: :supported, source: source) }
|
|
101
|
+
.to raise_error(errors::ValidationError)
|
|
102
|
+
expect { operation_evidence.new(operation: :chat, status: :supported, source: source) }
|
|
103
|
+
.to raise_error(errors::ValidationError)
|
|
104
|
+
expect { capability_evidence.new(capability: :tools, status: :unknown, source: source) }
|
|
105
|
+
.not_to raise_error
|
|
106
|
+
end
|
|
107
|
+
end
|
|
95
108
|
end
|
|
96
109
|
|
|
97
110
|
describe 'ValueEvidence' do
|
|
@@ -183,9 +183,9 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Identity do
|
|
|
183
183
|
expect(key.instance_id).to eq('Prod:EastUS')
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
-
it 'exposes to_h with the
|
|
186
|
+
it 'exposes to_h with the three fields (physical_id nil by default)' do
|
|
187
187
|
key = instance_key_class.new(provider_family: :vllm, instance_id: 'h200')
|
|
188
|
-
expect(key.to_h).to eq(provider_family: :vllm, instance_id: 'h200')
|
|
188
|
+
expect(key.to_h).to eq(provider_family: :vllm, instance_id: 'h200', physical_id: nil)
|
|
189
189
|
end
|
|
190
190
|
|
|
191
191
|
it 'is value-equal for equal normalized fields' do
|
|
@@ -213,6 +213,59 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Identity do
|
|
|
213
213
|
expect { instance_key_class.new(provider_family: 'vllm', instance_id: nil) }
|
|
214
214
|
.to raise_error(errors::ValidationError)
|
|
215
215
|
end
|
|
216
|
+
|
|
217
|
+
it 'keeps the two-argument constructor working (physical_id defaults to nil)' do
|
|
218
|
+
key = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo')
|
|
219
|
+
expect(key.physical_id).to be_nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
describe 'physical_id (secondary, not identity)' do
|
|
223
|
+
it 'normalizes like identity text: trims, NFC, frozen String' do
|
|
224
|
+
key = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo', physical_id: ' 10.0.0.1:8000/ak:abc123 ')
|
|
225
|
+
expect(key.physical_id).to eq('10.0.0.1:8000/ak:abc123')
|
|
226
|
+
expect(key.physical_id).to be_frozen
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it 'preserves the physical id for diagnostics while identity stays the config name' do
|
|
230
|
+
key = instance_key_class.new(
|
|
231
|
+
provider_family: :vllm, instance_id: 'apollo', physical_id: '10.0.0.1:8000/ak:abc123'
|
|
232
|
+
)
|
|
233
|
+
expect(key.instance_id).to eq('apollo')
|
|
234
|
+
expect(key.physical_id).to eq('10.0.0.1:8000/ak:abc123')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'excludes physical_id from equality and hashing' do
|
|
238
|
+
bare = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo')
|
|
239
|
+
with_physical = instance_key_class.new(
|
|
240
|
+
provider_family: :vllm, instance_id: 'apollo', physical_id: '10.0.0.1:8000/ak:abc123'
|
|
241
|
+
)
|
|
242
|
+
other_physical = instance_key_class.new(
|
|
243
|
+
provider_family: :vllm, instance_id: 'apollo', physical_id: '10.0.0.9:9000'
|
|
244
|
+
)
|
|
245
|
+
expect(bare).to eq(with_physical)
|
|
246
|
+
expect(bare.hash).to eq(with_physical.hash)
|
|
247
|
+
expect(with_physical).to eq(other_physical)
|
|
248
|
+
expect({ bare => :found }[with_physical]).to eq(:found)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it 'does not reserve "default" for physical_id (only instance_id is reserved)' do
|
|
252
|
+
key = instance_key_class.new(provider_family: :vllm, instance_id: 'apollo', physical_id: 'default')
|
|
253
|
+
expect(key.physical_id).to eq('default')
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
it 'rejects a blank or non-text physical_id' do
|
|
257
|
+
expect { instance_key_class.new(provider_family: 'vllm', instance_id: 'x', physical_id: ' ') }
|
|
258
|
+
.to raise_error(errors::ValidationError)
|
|
259
|
+
expect { instance_key_class.new(provider_family: 'vllm', instance_id: 'x', physical_id: 5) }
|
|
260
|
+
.to raise_error(errors::ValidationError)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it 'still distinguishes distinct config names on the same physical endpoint' do
|
|
264
|
+
apollo = instance_key_class.new(provider_family: :ollama, instance_id: 'apollo', physical_id: 'localhost:11434')
|
|
265
|
+
apollo_embed = instance_key_class.new(provider_family: :ollama, instance_id: 'apollo-embed', physical_id: 'localhost:11434')
|
|
266
|
+
expect(apollo).not_to eq(apollo_embed)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
216
269
|
end
|
|
217
270
|
|
|
218
271
|
describe 'public surface' do
|
|
@@ -101,4 +101,44 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Publisher do
|
|
|
101
101
|
expect(result).to be_a(inventory::PublisherToken)
|
|
102
102
|
end
|
|
103
103
|
end
|
|
104
|
+
|
|
105
|
+
describe 'secondary physical_id' do
|
|
106
|
+
it 'carries the physical id on the committed key while identity stays the config name' do
|
|
107
|
+
token = plain_publisher.claim_instance(
|
|
108
|
+
instance_id: 'h200', callable: fake_callable,
|
|
109
|
+
probe_request_handle: probe_coordinator(instance_key), physical_id: '10.0.0.1:8000/ak:abc123'
|
|
110
|
+
)
|
|
111
|
+
probe = plain_publisher.readiness_probe_started(instance_id: 'h200', publisher_token: token)
|
|
112
|
+
plain_publisher.activate_instance_snapshot(
|
|
113
|
+
instance_id: 'h200', publisher_token: token, offerings: drafts, sequence: 0, probe_token: probe
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
keyed_with_physical = inventory::Identity::InstanceKey.new(
|
|
117
|
+
provider_family: 'vllm', instance_id: 'h200', physical_id: '10.0.0.1:8000/ak:abc123'
|
|
118
|
+
)
|
|
119
|
+
record = plain_publisher.snapshot.instance(instance_key: keyed_with_physical)
|
|
120
|
+
expect(record).not_to be_nil
|
|
121
|
+
expect(record.instance_key.physical_id).to eq('10.0.0.1:8000/ak:abc123')
|
|
122
|
+
|
|
123
|
+
# Identity lookup without the physical id finds the same committed instance.
|
|
124
|
+
expect(plain_publisher.snapshot.instance(instance_key: instance_key)).to eq(record)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'replaces the same scope when the physical id changes for the same config name' do
|
|
128
|
+
old_token = plain_publisher.claim_instance(
|
|
129
|
+
instance_id: 'h200', callable: fake_callable,
|
|
130
|
+
probe_request_handle: probe_coordinator(instance_key), physical_id: '10.0.0.1:8000/ak:abc123'
|
|
131
|
+
)
|
|
132
|
+
new_token = plain_publisher.claim_instance(
|
|
133
|
+
instance_id: 'h200', callable: fake_callable,
|
|
134
|
+
probe_request_handle: probe_coordinator(instance_key), physical_id: '10.0.0.9:9000'
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# The old publisher is superseded on the SAME scope (no parallel scope).
|
|
138
|
+
expect { plain_publisher.readiness_probe_started(instance_id: 'h200', publisher_token: old_token) }
|
|
139
|
+
.to raise_error(inventory::Errors::FencedPublisherError)
|
|
140
|
+
expect(plain_publisher.readiness_probe_started(instance_id: 'h200', publisher_token: new_token))
|
|
141
|
+
.to be_a(inventory::ProbeToken)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
104
144
|
end
|
|
@@ -56,4 +56,18 @@ RSpec.describe Legion::Extensions::Llm::Routing::ProviderOutcome do
|
|
|
56
56
|
expect { described_class.new(kind: :success, reason: 'ok', metadata: { api_key: 'x' }) }
|
|
57
57
|
.to raise_error(errors::ValidationError)
|
|
58
58
|
end
|
|
59
|
+
|
|
60
|
+
it 'coerces a non-UTF-8 (binary) provider error reason to valid UTF-8 instead of raising' do
|
|
61
|
+
# Regression guard (root cause A): RecordSupport.sanitized_reason previously raised
|
|
62
|
+
# ValidationError 'is not valid UTF-8' on an ASCII-8BIT/BINARY reason (a raw provider
|
|
63
|
+
# error body or Ruby kernel error message), masking the real dispatch error as an
|
|
64
|
+
# unclassifiable retriable 500. It now coerces to valid UTF-8 (undecodable bytes
|
|
65
|
+
# replaced), so a provider error can no longer mask itself.
|
|
66
|
+
raw = "provider 500 \xFF\x80 body".dup.force_encoding(Encoding::BINARY)
|
|
67
|
+
expect { described_class.new(kind: :provider_error, reason: raw) }.not_to raise_error
|
|
68
|
+
|
|
69
|
+
outcome = described_class.new(kind: :provider_error, reason: raw)
|
|
70
|
+
expect(outcome.kind).to eq(:provider_error)
|
|
71
|
+
expect(outcome.reason.valid_encoding?).to be(true)
|
|
72
|
+
end
|
|
59
73
|
end
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::SettingsCascade do
|
|
6
|
+
let(:llm_conf) do
|
|
7
|
+
{
|
|
8
|
+
vllm: {
|
|
9
|
+
weight: 5,
|
|
10
|
+
model_whitelist: %w[gpt],
|
|
11
|
+
models: {
|
|
12
|
+
'alpha' => { weight: 11, enable_tools: true }
|
|
13
|
+
},
|
|
14
|
+
instances: {
|
|
15
|
+
apollo: {
|
|
16
|
+
weight: 7,
|
|
17
|
+
enable_tools: false,
|
|
18
|
+
models: {
|
|
19
|
+
'alpha' => { weight: 13 },
|
|
20
|
+
'beta' => { enable_tools: true, preferred_min_context_tokens: 100 }
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
'string-keyed' => { weight: 9 }
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
ollama: {
|
|
27
|
+
instances: { local: {} }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '.resolve_value (pure 3-level cascade, most-specific-first)' do
|
|
33
|
+
let(:provider_conf) { llm_conf[:vllm] }
|
|
34
|
+
let(:instance_conf) { llm_conf[:vllm][:instances][:apollo] }
|
|
35
|
+
|
|
36
|
+
it 'resolves the provider leg when no more specific scope carries the key' do
|
|
37
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: {}, key: :weight)).to eq(5)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'resolves the instance leg over the provider leg' do
|
|
41
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: instance_conf, key: :weight)).to eq(7)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'resolves the instance-scoped model leg over the provider-scoped model leg' do
|
|
45
|
+
result = described_class.resolve_value(
|
|
46
|
+
provider_conf: provider_conf, instance_cfg: instance_conf, key: :weight, model: 'alpha'
|
|
47
|
+
)
|
|
48
|
+
expect(result).to eq(13) # instance models.alpha (13) beats provider models.alpha (11) and instance weight (7)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'resolves the provider-scoped model leg when the instance model map has no entry' do
|
|
52
|
+
result = described_class.resolve_value(
|
|
53
|
+
provider_conf: provider_conf, instance_cfg: {}, key: :weight, model: 'alpha'
|
|
54
|
+
)
|
|
55
|
+
expect(result).to eq(11)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'resolves the model leg before the instance leg (most-specific-first)' do
|
|
59
|
+
result = described_class.resolve_value(
|
|
60
|
+
provider_conf: provider_conf, instance_cfg: instance_conf, key: :enable_tools, model: 'alpha'
|
|
61
|
+
)
|
|
62
|
+
# provider models.alpha.enable_tools (true) wins over instance enable_tools (false):
|
|
63
|
+
# the model leg is consulted before the instance leg
|
|
64
|
+
expect(result).to be(true)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'skips a model with no model-map entries and falls through to instance/provider' do
|
|
68
|
+
result = described_class.resolve_value(
|
|
69
|
+
provider_conf: provider_conf, instance_cfg: instance_conf, key: :weight, model: 'gamma'
|
|
70
|
+
)
|
|
71
|
+
expect(result).to eq(7)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'returns nil when no scope carries the key' do
|
|
75
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: instance_conf, key: :missing)).to be_nil
|
|
76
|
+
expect(described_class.resolve_value(provider_conf: {}, instance_cfg: {}, key: :weight)).to be_nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
context 'with empty values in scopes' do
|
|
80
|
+
it 'skips nil, blank String, and empty Array values, falling through to the next scope' do
|
|
81
|
+
conf = {
|
|
82
|
+
weight: '',
|
|
83
|
+
model_whitelist: [],
|
|
84
|
+
instances: { apollo: { weight: nil, model_whitelist: %w[claude], model_blacklist: [] } }
|
|
85
|
+
}
|
|
86
|
+
# instance nil + provider '' both skip -> nil
|
|
87
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: conf[:instances][:apollo], key: :weight)).to be_nil
|
|
88
|
+
# instance '' / provider [] : a meaningful instance value resolves, an empty one falls to provider (empty -> nil)
|
|
89
|
+
expect(described_class.resolve_value(provider_conf: {}, instance_cfg: conf[:instances][:apollo], key: :model_whitelist)).to eq(%w[claude])
|
|
90
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: {}, key: :model_whitelist)).to be_nil
|
|
91
|
+
# instance [] + provider absent -> nil
|
|
92
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: conf[:instances][:apollo], key: :model_blacklist)).to be_nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'does NOT skip meaningful non-String values (false, 0)' do
|
|
96
|
+
conf = { enable_thinking: false, weight: 0 }
|
|
97
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: {}, key: :enable_thinking)).to be(false)
|
|
98
|
+
expect(described_class.resolve_value(provider_conf: conf, instance_cfg: {}, key: :weight)).to eq(0)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'skips empty values in model scopes too' do
|
|
102
|
+
conf = {
|
|
103
|
+
models: { 'alpha' => { weight: '', enable_tools: true } },
|
|
104
|
+
instances: { apollo: { models: { 'alpha' => { enable_tools: false, weight: [] } } } }
|
|
105
|
+
}
|
|
106
|
+
# instance model weight [] skipped, provider model weight '' skipped, no instance/provider weight -> nil
|
|
107
|
+
expect(
|
|
108
|
+
described_class.resolve_value(provider_conf: conf, instance_cfg: conf[:instances][:apollo], key: :weight, model: 'alpha')
|
|
109
|
+
).to be_nil
|
|
110
|
+
# instance model enable_tools false is meaningful and wins
|
|
111
|
+
expect(
|
|
112
|
+
described_class.resolve_value(provider_conf: conf, instance_cfg: conf[:instances][:apollo], key: :enable_tools, model: 'alpha')
|
|
113
|
+
).to be(false)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
context 'with String and Symbol key/name variants' do
|
|
118
|
+
it 'accepts String or Symbol keys and instance/model names' do
|
|
119
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: instance_conf, key: 'weight')).to eq(7)
|
|
120
|
+
expect(described_class.resolve_value(provider_conf: provider_conf, instance_cfg: {}, key: :weight, model: :alpha)).to eq(11)
|
|
121
|
+
expect(
|
|
122
|
+
described_class.resolve_from(llm_conf: llm_conf, provider_family: 'vllm', instance: :apollo, key: :weight)
|
|
123
|
+
).to eq(7)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'accepts string-keyed settings hashes (YAML/JSON variants)' do
|
|
127
|
+
conf = { 'vllm' => { 'weight' => 3, 'instances' => { 'apollo' => { 'weight' => 4 } } } }
|
|
128
|
+
expect(described_class.resolve_from(llm_conf: conf, provider_family: :vllm, instance: 'apollo', key: :weight)).to eq(4)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it 'tolerates non-Hash scopes' do
|
|
132
|
+
expect(described_class.resolve_value(provider_conf: nil, instance_cfg: nil, key: :weight)).to be_nil
|
|
133
|
+
expect(described_class.resolve_from(llm_conf: nil, provider_family: :vllm, instance: 'apollo', key: :weight)).to be_nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
context 'with invalid argument types' do
|
|
138
|
+
it 'rejects non-text keys, instances, models, and provider families' do
|
|
139
|
+
expect { described_class.resolve_value(provider_conf: provider_conf, instance_cfg: {}, key: 5) }
|
|
140
|
+
.to raise_error(ArgumentError, /key must be a String or Symbol/)
|
|
141
|
+
expect { described_class.resolve_value(provider_conf: provider_conf, instance_cfg: {}, key: :weight, model: 5) }
|
|
142
|
+
.to raise_error(ArgumentError, /model must be a String or Symbol/)
|
|
143
|
+
expect { described_class.resolve_from(llm_conf: llm_conf, provider_family: 5, instance: 'a', key: :k) }
|
|
144
|
+
.to raise_error(ArgumentError, /provider_family must be a String or Symbol/)
|
|
145
|
+
expect { described_class.resolve_from(llm_conf: llm_conf, provider_family: :vllm, instance: 5, key: :k) }
|
|
146
|
+
.to raise_error(ArgumentError, /instance must be a String or Symbol/)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe '.merge_model_scopes (capability-feeder merge)' do
|
|
152
|
+
it 'merges the provider models.<model> entry with the instance models.<model> entry overriding it' do
|
|
153
|
+
merged = described_class.merge_model_scopes(
|
|
154
|
+
provider_conf: llm_conf[:vllm],
|
|
155
|
+
instance_cfg: llm_conf[:vllm][:instances][:apollo],
|
|
156
|
+
model: 'alpha'
|
|
157
|
+
)
|
|
158
|
+
expect(merged).to eq(weight: 13, enable_tools: true)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'returns {} when no scope carries a models map' do
|
|
162
|
+
expect(described_class.merge_model_scopes(provider_conf: {}, instance_cfg: {}, model: 'alpha')).to eq({})
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'ignores non-Hash model entries' do
|
|
166
|
+
conf = { models: { 'alpha' => 'not-a-hash' } }
|
|
167
|
+
expect(described_class.merge_model_scopes(provider_conf: conf, instance_cfg: {}, model: 'alpha')).to eq({})
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe '.resolve (live Legion::Settings path)' do
|
|
172
|
+
around do |example|
|
|
173
|
+
saved = Legion::Settings.loader.settings[:extensions]
|
|
174
|
+
example.run
|
|
175
|
+
ensure
|
|
176
|
+
if saved.nil?
|
|
177
|
+
Legion::Settings.loader.settings.delete(:extensions)
|
|
178
|
+
else
|
|
179
|
+
Legion::Settings.loader.settings[:extensions] = saved
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'reads the real nested extensions.llm.<provider> path, keyed by the config name' do
|
|
184
|
+
Legion::Settings.loader.settings[:extensions] = { llm: llm_conf }
|
|
185
|
+
|
|
186
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'apollo', key: :weight)).to eq(7)
|
|
187
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'apollo', key: :weight, model: 'alpha')).to eq(13)
|
|
188
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'nobody', key: :model_whitelist)).to eq(%w[gpt])
|
|
189
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'nobody', key: :missing)).to be_nil
|
|
190
|
+
expect(described_class.resolve(provider_family: :nope, instance: 'apollo', key: :weight)).to be_nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'returns nil when no extensions.llm subtree exists at all' do
|
|
194
|
+
Legion::Settings.loader.settings[:extensions] = {}
|
|
195
|
+
expect(described_class.resolve(provider_family: :vllm, instance: 'apollo', key: :weight)).to be_nil
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
describe '.resolve_from (frozen subtree, e.g. a router settings snapshot)' do
|
|
200
|
+
it 'resolves the same cascade against a pre-fetched extensions.llm hash' do
|
|
201
|
+
frozen_subtree = llm_conf
|
|
202
|
+
expect(described_class.resolve_from(llm_conf: frozen_subtree, provider_family: :vllm, instance: 'apollo', key: :weight)).to eq(7)
|
|
203
|
+
expect(
|
|
204
|
+
described_class.resolve_from(llm_conf: frozen_subtree, provider_family: :vllm, instance: 'string-keyed', key: :weight)
|
|
205
|
+
).to eq(9)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.7.
|
|
4
|
+
version: 0.7.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- LegionIO
|
|
@@ -184,14 +184,14 @@ dependencies:
|
|
|
184
184
|
requirements:
|
|
185
185
|
- - ">="
|
|
186
186
|
- !ruby/object:Gem::Version
|
|
187
|
-
version: 1.
|
|
187
|
+
version: 1.4.2
|
|
188
188
|
type: :runtime
|
|
189
189
|
prerelease: false
|
|
190
190
|
version_requirements: !ruby/object:Gem::Requirement
|
|
191
191
|
requirements:
|
|
192
192
|
- - ">="
|
|
193
193
|
- !ruby/object:Gem::Version
|
|
194
|
-
version: 1.
|
|
194
|
+
version: 1.4.2
|
|
195
195
|
- !ruby/object:Gem::Dependency
|
|
196
196
|
name: legion-transport
|
|
197
197
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -337,6 +337,7 @@ files:
|
|
|
337
337
|
- lib/legion/extensions/llm/routing/provider_outcome.rb
|
|
338
338
|
- lib/legion/extensions/llm/routing/records.rb
|
|
339
339
|
- lib/legion/extensions/llm/routing/registry_event.rb
|
|
340
|
+
- lib/legion/extensions/llm/settings_cascade.rb
|
|
340
341
|
- lib/legion/extensions/llm/stop_reason_mapping.rb
|
|
341
342
|
- lib/legion/extensions/llm/stream_accumulator.rb
|
|
342
343
|
- lib/legion/extensions/llm/streaming.rb
|
|
@@ -457,6 +458,7 @@ files:
|
|
|
457
458
|
- spec/legion/extensions/llm/routing/provider_outcome_spec.rb
|
|
458
459
|
- spec/legion/extensions/llm/routing/records_spec.rb
|
|
459
460
|
- spec/legion/extensions/llm/routing/registry_event_spec.rb
|
|
461
|
+
- spec/legion/extensions/llm/settings_cascade_spec.rb
|
|
460
462
|
- spec/legion/extensions/llm/stop_reason_mapping_spec.rb
|
|
461
463
|
- spec/legion/extensions/llm/stream_accumulator_spec.rb
|
|
462
464
|
- spec/legion/extensions/llm/streaming_spec.rb
|