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.
Files changed (24) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/lex-llm.gemspec +1 -1
  4. data/lib/legion/extensions/llm/canonical/message.rb +5 -1
  5. data/lib/legion/extensions/llm/inventory/identity.rb +25 -3
  6. data/lib/legion/extensions/llm/inventory/publisher.rb +22 -16
  7. data/lib/legion/extensions/llm/inventory/records.rb +9 -2
  8. data/lib/legion/extensions/llm/provider.rb +12 -29
  9. data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
  10. data/lib/legion/extensions/llm/version.rb +1 -1
  11. data/lib/legion/extensions/llm.rb +3 -0
  12. data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
  13. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
  14. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
  15. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
  16. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
  17. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
  18. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
  19. data/spec/legion/extensions/llm/inventory/evidence_spec.rb +13 -0
  20. data/spec/legion/extensions/llm/inventory/identity_spec.rb +55 -2
  21. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +40 -0
  22. data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +14 -0
  23. data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
  24. metadata +5 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6ab83cce686d4d63038e65c45039320fd61abf117acbde7ea05e4bca8ce1878
4
- data.tar.gz: 768282bf56c16956f91a2be653e460e7d961775b8d782ad3177d9a637fb3a032
3
+ metadata.gz: e91ed0737e9946e5fce06f1a594f3139ea3f5dfd55e7e4a757fb66b77a099d13
4
+ data.tar.gz: 9284cbd89a01863b1d2226296b23d33250785b7988094d1b9e0223fb31cbbcb9
5
5
  SHA512:
6
- metadata.gz: b9026c5963387e0c286bcdc80d7dceb61610fc5be4c67acf026b5e3ff59f95475edfdca79fb61f5910e5b143a02b946f36601679acc827fed55f1c998fe4148c
7
- data.tar.gz: 2b896edf5d53f9d90c8ac6bdc410b84129d23f50640bd5a981e145d900f097294b3eeb7ffc60d696575a16974a47987eb5d30f8441b55f28041495d80bcc891a
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.3.14'
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
- build(**h)
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
- InstanceKey = ::Data.define(:provider_family, :instance_id) do
105
- def initialize(provider_family:, instance_id:)
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
- super(provider_family: family.to_sym, instance_id: instance)
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
- def claim_instance(instance_id:, callable:, probe_request_handle:)
28
- key = instance_key(instance_id)
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(instance_key: instance_key(instance_id), publisher_token: publisher_token)
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
- trimmed = value.strip
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 instance > provider > global precedence.
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
- # Instance-level
586
- val = cfg[key] || cfg[key.to_s]
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
- # Provider-level
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
- provider_models = provider_capability_models
807
- instance_models = extract_models_config(config)
808
- provider_override = provider_models[model_id.to_s] || provider_models[model_id.to_sym] || {}
809
- instance_override = instance_models[model_id.to_s] || instance_models[model_id.to_sym] || {}
810
- provider_override.to_h.merge(instance_override.to_h)
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
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.7.0'
6
+ VERSION = '0.7.2'
7
7
  end
8
8
  end
9
9
  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
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_params_mapping_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "messages": [
4
5
  {
5
6
  "role": "user",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_simple_text_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "messages": [
4
5
  {
5
6
  "role": "user",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_system_prompt_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "system": "You are a helpful assistant that always responds in haiku form.",
4
5
  "messages": [
5
6
  {
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_thinking_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "messages": [
4
5
  {
5
6
  "role": "user",
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "description": "Full multi-turn history: mixed client/server tool calls + thinking block per G4",
3
3
  "id": "req_tool_continuation_001",
4
+ "metadata": { "model": "test-fixture-model" },
4
5
  "system": "You are a helpful assistant.",
5
6
  "messages": [
6
7
  {
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_tools_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "messages": [
4
5
  {
5
6
  "role": "user",
@@ -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 two fields' do
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.0
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.3.14
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.3.14
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