lex-llm 0.7.6 → 0.8.0

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 (162) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +8 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. metadata +11 -54
  110. data/lib/legion/extensions/llm/agent.rb +0 -366
  111. data/lib/legion/extensions/llm/aliases.json +0 -436
  112. data/lib/legion/extensions/llm/aliases.rb +0 -67
  113. data/lib/legion/extensions/llm/attachment.rb +0 -229
  114. data/lib/legion/extensions/llm/chat.rb +0 -354
  115. data/lib/legion/extensions/llm/chunk.rb +0 -10
  116. data/lib/legion/extensions/llm/content.rb +0 -81
  117. data/lib/legion/extensions/llm/context.rb +0 -33
  118. data/lib/legion/extensions/llm/embedding.rb +0 -33
  119. data/lib/legion/extensions/llm/image.rb +0 -109
  120. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  121. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  122. data/lib/legion/extensions/llm/message.rb +0 -118
  123. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  124. data/lib/legion/extensions/llm/model/info.rb +0 -286
  125. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  126. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  127. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  128. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  129. data/lib/legion/extensions/llm/model.rb +0 -11
  130. data/lib/legion/extensions/llm/models.json +0 -57313
  131. data/lib/legion/extensions/llm/models.rb +0 -530
  132. data/lib/legion/extensions/llm/models_schema.json +0 -168
  133. data/lib/legion/extensions/llm/moderation.rb +0 -60
  134. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  135. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  136. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  137. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  138. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  139. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  140. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  141. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  142. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  143. data/lib/legion/extensions/llm/thinking.rb +0 -53
  144. data/lib/legion/extensions/llm/tokens.rb +0 -51
  145. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  146. data/lib/legion/extensions/llm/transcription.rb +0 -39
  147. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  148. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  149. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  150. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  151. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  152. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  153. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  154. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  155. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  156. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  157. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  158. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  159. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  160. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  161. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  162. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module Llm
6
- # Core embedding interface.
7
- class Embedding
8
- attr_reader :vectors, :model, :input_tokens
9
-
10
- def initialize(vectors:, model:, input_tokens: 0)
11
- @vectors = vectors
12
- @model = model
13
- @input_tokens = input_tokens
14
- end
15
-
16
- def self.embed(text, # rubocop:disable Metrics/ParameterLists
17
- model: nil,
18
- provider: nil,
19
- assume_model_exists: false,
20
- context: nil,
21
- dimensions: nil)
22
- config = context&.config || Legion::Extensions::Llm.config
23
- model ||= config.default_embedding_model
24
- model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
25
- config: config)
26
- model_id = model.id
27
-
28
- provider_instance.embed(text:, model: model_id, dimensions:)
29
- end
30
- end
31
- end
32
- end
33
- end
@@ -1,109 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module Llm
6
- # Represents a generated image from an AI model.
7
- class Image
8
- attr_reader :url, :data, :mime_type, :revised_prompt, :model_id, :usage
9
-
10
- def initialize(url: nil, data: nil, mime_type: nil, revised_prompt: nil, model_id: nil, usage: {}) # rubocop:disable Metrics/ParameterLists
11
- @url = url
12
- @data = data
13
- @mime_type = mime_type
14
- @revised_prompt = revised_prompt
15
- @model_id = model_id
16
- @usage = usage
17
- end
18
-
19
- def base64?
20
- !@data.nil?
21
- end
22
-
23
- def to_blob
24
- if base64?
25
- Base64.decode64 @data
26
- else
27
- response = Connection.basic.get @url
28
- response.body
29
- end
30
- end
31
-
32
- def save(path)
33
- File.binwrite(File.expand_path(path), to_blob)
34
- path
35
- end
36
-
37
- def self.paint(prompt, # rubocop:disable Metrics/ParameterLists
38
- model: nil,
39
- provider: nil,
40
- assume_model_exists: false,
41
- size: '1024x1024',
42
- context: nil,
43
- with: nil,
44
- mask: nil,
45
- params: {})
46
- config = context&.config || Legion::Extensions::Llm.config
47
- model ||= config.default_image_model
48
- model, provider_instance = Models.resolve(model, provider: provider, assume_exists: assume_model_exists,
49
- config: config)
50
- model_id = model.id
51
-
52
- provider_instance.paint(prompt, model: model_id, size:, with:, mask:, params:)
53
- end
54
-
55
- def total_cost
56
- input_cost + output_cost
57
- end
58
-
59
- def input_cost
60
- return flat_input_cost unless detailed_input_usage?
61
-
62
- text_input_cost + image_input_cost
63
- end
64
-
65
- def output_cost
66
- tokens_for('output_tokens') * output_token_price.to_f / 1_000_000
67
- end
68
-
69
- def model_info
70
- @model_info ||= Legion::Extensions::Llm.models.find(model_id)
71
- end
72
-
73
- private
74
-
75
- def flat_input_cost
76
- tokens_for('input_tokens') * model_info.input_price_per_million.to_f / 1_000_000
77
- end
78
-
79
- def text_input_cost
80
- input_detail('text_tokens') * model_info.input_price_per_million.to_f / 1_000_000
81
- end
82
-
83
- def image_input_cost
84
- input_detail('image_tokens') * image_input_token_price.to_f / 1_000_000
85
- end
86
-
87
- def output_token_price
88
- model_info.pricing.images.output || model_info.output_price_per_million
89
- end
90
-
91
- def image_input_token_price
92
- model_info.pricing.images.input || model_info.input_price_per_million
93
- end
94
-
95
- def detailed_input_usage?
96
- usage['input_tokens_details'].is_a?(Hash)
97
- end
98
-
99
- def input_detail(key)
100
- usage.dig('input_tokens_details', key).to_i
101
- end
102
-
103
- def tokens_for(key)
104
- usage[key].to_i
105
- end
106
- end
107
- end
108
- end
109
- end
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../capabilities'
4
-
5
- module Legion
6
- module Extensions
7
- module Llm
8
- module Inventory
9
- # Inventory-side capability normalization. Every per-gem DiscoveryRefresh actor
10
- # calls this from `lanes_from_instance` to coerce offering capabilities into a
11
- # canonical symbol list before writing the lane to Inventory. Without this
12
- # constant, every actor's guard `return [] unless defined?(...)` fires and the
13
- # lane is written with `capabilities: []` — even when the operator declared
14
- # `enable_tools: true` / `enable_thinking: true` on the instance and the
15
- # provider's CapabilityPolicy correctly resolved them in the offering.
16
- #
17
- # Delegates to Legion::Extensions::Llm::Capabilities so the alias table
18
- # (function_calling/tool_use/tool_calls/tool/functions → tools, etc.) stays
19
- # in one place.
20
- module Capabilities
21
- ALIASES = Legion::Extensions::Llm::Capabilities::ALIASES
22
-
23
- module_function
24
-
25
- def normalize(caps, **)
26
- Legion::Extensions::Llm::Capabilities.normalize(caps, **)
27
- end
28
-
29
- def merge(*sets, **)
30
- Legion::Extensions::Llm::Capabilities.merge(*sets, **)
31
- end
32
-
33
- def include_all?(available, required, **)
34
- Legion::Extensions::Llm::Capabilities.include_all?(available, required, **)
35
- end
36
- end
37
- end
38
- end
39
- end
40
- end
@@ -1,310 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'legion/logging'
4
- require 'legion/extensions/llm/inventory/errors'
5
- require 'legion/extensions/llm/inventory/identity'
6
- require 'legion/extensions/llm/inventory/snapshot'
7
- require 'legion/extensions/llm/taxonomies'
8
-
9
- module Legion
10
- module Extensions
11
- module Llm
12
- module Inventory
13
- # DEPRECATED (Phase 4 removal): the only Phase 1 file permitted to hold a
14
- # direct `Legion::LLM::Inventory` reverse reference. Old released provider
15
- # actors keep using ScopedRefresher#tick until their Phase 2 migration; a
16
- # migrated actor instead injects ScopedRefresher::LegacyCoordinatorAdapter
17
- # into Inventory::Publisher and never calls #tick. Neither old lane hashes
18
- # nor old health feed the new registry. Phase 4 deletes this file once
19
- # every provider and coordinator floor has migrated.
20
- #
21
- # Mix into a Legion::Extensions::Llm::*::Actors::DiscoveryRefresh class.
22
- # The host class must include Legion::Extensions::Helpers::Lex (auto-injects
23
- # log / settings / handle_exception / cache_*) and define:
24
- # - #scope_key — Hash like { provider: :vllm, instance: instance_id }
25
- # - #compute_lanes_for_scope — Array<Hash> lane fact-sheets (no health, no
26
- # lane_weight — added by Inventory.write_lane).
27
- # Each lane MUST set :id via compose_id.
28
- # - #credential_hash — String identifying the auth credential for this scope
29
- # (used by the auth-failure cooldown circuit).
30
- module ScopedRefresher
31
- # Auth-failure cooldown TTL (5 minutes). Operator can fix the credential
32
- # and lanes auto-recover on the next tick after expiry.
33
- AUTH_COOLDOWN_TTL = 300
34
-
35
- # G22: 5-part lane id composed here and ONLY here. All gem writers MUST call
36
- # this helper; Inventory.write_lane rejects any lane with a missing or malformed :id.
37
- # Accepts a Hash (or keyword splat) with keys: tier, provider_family, instance_id, type, model.
38
- def self.compose_id(lane_fields)
39
- t = lane_fields[:tier]
40
- pf = lane_fields[:provider_family]
41
- ii = lane_fields[:instance_id]
42
- ty = lane_fields[:type]
43
- mo = lane_fields[:model]
44
- "#{t}:#{pf}:#{ii}:#{ty}:#{mo}"
45
- end
46
-
47
- # G7 write-then-delete-orphans: write new lanes FIRST (eliminates zero-results
48
- # race window), then delete orphans from the previous scope snapshot.
49
- def tick(**)
50
- return if auth_cooldown_active?
51
-
52
- new_lanes = safe_compute
53
- log.info("[llm][scoped_refresher] action=tick provider=#{scope_key[:provider]} lanes_computed=#{new_lanes ? new_lanes.size : 0}")
54
- return unless new_lanes&.any?
55
-
56
- written = 0
57
- new_lanes.each do |lane_fact|
58
- written += 1 if Legion::LLM::Inventory.write_lane(lane: lane_fact)
59
- end
60
- log.info("[llm][scoped_refresher] action=tick_complete provider=#{scope_key[:provider]} lanes_computed=#{new_lanes.size} lanes_written=#{written}")
61
-
62
- orphans = (@prev_scope_keys || []) - new_lanes.map { it[:id] }
63
- orphans.each { |id| Legion::LLM::Inventory.delete_lane(id: id) }
64
-
65
- @prev_scope_keys = new_lanes.map { it[:id] }
66
- end
67
-
68
- private
69
-
70
- # Wraps compute_lanes_for_scope with auth-failure cooldown logic.
71
- # If a cooldown key is present from a previous auth failure, skips the
72
- # compute entirely (no real call burned). On a new auth failure, writes the
73
- # cooldown key with AUTH_COOLDOWN_TTL so subsequent ticks also skip.
74
- def safe_compute
75
- if auth_cooldown_active?
76
- log.warn("[llm][scoped_refresher] action=skip reason=auth_cooldown scope=#{scope_key}")
77
- return nil
78
- end
79
- compute_lanes_for_scope
80
- rescue NotImplementedError
81
- raise
82
- rescue StandardError => e
83
- if auth_failure?(error: e)
84
- Legion::Cache::Local.set(auth_cooldown_key, 1, ttl: AUTH_COOLDOWN_TTL)
85
- handle_exception(e, level: :warn, handled: true,
86
- operation: 'inventory.scoped_refresher.auth_failure',
87
- scope: scope_key)
88
- else
89
- handle_exception(e, level: :warn, handled: true,
90
- operation: 'inventory.scoped_refresher.compute',
91
- scope: scope_key)
92
- end
93
- nil
94
- end
95
-
96
- def auth_cooldown_active?
97
- !Legion::Cache::Local.get(auth_cooldown_key).nil?
98
- rescue StandardError
99
- false
100
- end
101
-
102
- def auth_cooldown_key
103
- "llm_auth_failed:#{credential_hash}"
104
- end
105
-
106
- # Default auth-failure predicate. Matches HTTP 401/403 status codes and
107
- # common auth-error message patterns. Provider gems may override this if
108
- # their error shapes differ (e.g. Bedrock's AccessDeniedException).
109
- def auth_failure?(error:, **)
110
- return true if error.respond_to?(:status_code) && [401, 403].include?(error.status_code)
111
- return true if error.respond_to?(:http_status) && [401, 403].include?(error.http_status)
112
-
113
- error.message&.match?(/unauthorized|invalid[_ ]api[_ ]key|invalid[_ ]credentials|forbidden/i)
114
- end
115
-
116
- # One-way, post-commit projection of an already-committed new Snapshot
117
- # into the old Legion::LLM::Inventory coordinator, for the mixed-version
118
- # window only. It never rediscovers, selects, authorizes new state, or
119
- # feeds an old fact back into the new Registry. See section 13.3.
120
- class LegacyCoordinatorAdapter
121
- include Legion::Logging::Helper
122
-
123
- HEALTHY_COMPAT = { circuit_state: :closed, denied: false, available: true, adjustment: 0 }.freeze
124
- LEGACY_CAPABILITIES = {
125
- chat: :completion, stream_chat: :streaming, embed: :embedding, image: :image,
126
- transcribe: :audio_transcription, translate: :audio_transcription, speak: :audio_speech, moderate: :moderation
127
- }.freeze
128
-
129
- def initialize(provider_family:, legacy_instance_ids_by_instance: {})
130
- @provider_family = canonical_family(provider_family)
131
- @legacy_instance_ids = deep_freeze_legacy_ids(legacy_instance_ids_by_instance)
132
- @published_ids_by_instance = {}.freeze
133
- @mutex = Mutex.new
134
- end
135
-
136
- def sync_snapshot(snapshot:, instance_key:, mutation_reason:)
137
- validate_inputs!(snapshot, instance_key, mutation_reason)
138
- return :not_loaded unless legacy_inventory_available?
139
-
140
- project(snapshot, instance_key)
141
- end
142
-
143
- private
144
-
145
- def project(snapshot, instance_key)
146
- @mutex.synchronize { project_locked(snapshot, instance_key) }
147
- :applied
148
- rescue StandardError => e
149
- handle_exception(
150
- e, handled: true, level: :warn, operation: 'inventory.scoped_refresher.legacy_projection',
151
- provider_family: @provider_family, instance_id: instance_key.instance_id,
152
- source_generation: snapshot.generation
153
- )
154
- :failed
155
- end
156
-
157
- def project_locked(snapshot, instance_key)
158
- desired = desired_lanes(snapshot, instance_key)
159
- written = write_desired(desired)
160
- reconcile_deletions(instance_key, written)
161
- @published_ids_by_instance = @published_ids_by_instance.merge(
162
- instance_key.instance_id => written.to_a.freeze
163
- ).freeze
164
- end
165
-
166
- def desired_lanes(snapshot, instance_key)
167
- record = snapshot.instance(instance_key: instance_key)
168
- return {} if record.nil? || record.availability.state != :available
169
-
170
- build_desired(group_lanes(snapshot, instance_key), snapshot.generation)
171
- end
172
-
173
- def group_lanes(snapshot, instance_key)
174
- groups = {}
175
- snapshot.lanes_for(instance_key: instance_key).each do |lane|
176
- type = Taxonomies.lane_type_for(operation: lane.operation)
177
- key = [lane.tier, instance_key.provider_family, instance_key.instance_id, type, lane.model]
178
- group = (groups[key] ||= new_group(instance_key, lane, type))
179
- accumulate_group(group, lane)
180
- end
181
- groups
182
- end
183
-
184
- def new_group(instance_key, lane, type)
185
- {
186
- tier: lane.tier, provider_family: instance_key.provider_family, instance_id: instance_key.instance_id,
187
- type: type, model: lane.model, offering_ids: Set.new, capabilities: Set.new,
188
- context_values: Set.new, max_output_values: Set.new
189
- }
190
- end
191
-
192
- def accumulate_group(group, lane)
193
- group[:offering_ids] << lane.offering_id
194
- mapped = LEGACY_CAPABILITIES[lane.operation]
195
- group[:capabilities] << mapped if mapped
196
- lane.capability_evidence.each { |capability, evidence| group[:capabilities] << capability if evidence.supported? }
197
- group[:context_values] << lane.context_evidence.value if lane.context_evidence.known?
198
- group[:max_output_values] << lane.max_output_evidence.value if lane.max_output_evidence.known?
199
- end
200
-
201
- def build_desired(groups, generation)
202
- by_id = {}
203
- groups.each_value do |group|
204
- legacy_id = ScopedRefresher.compose_id(identity_fields(group))
205
- (by_id[legacy_id] ||= []) << group
206
- end
207
-
208
- by_id.each_with_object({}) do |(legacy_id, colliding), desired|
209
- if colliding.size > 1 || colliding.first[:offering_ids].size != 1
210
- log.warn("[llm][inventory][legacy_adapter] action=fail_closed reason=ambiguous_group legacy_id=#{legacy_id}")
211
- next
212
- end
213
- desired[legacy_id] = build_lane_hash(legacy_id, colliding.first, generation)
214
- end
215
- end
216
-
217
- def identity_fields(group)
218
- {
219
- tier: group[:tier], provider_family: group[:provider_family],
220
- instance_id: group[:instance_id], type: group[:type], model: group[:model]
221
- }.freeze
222
- end
223
-
224
- def build_lane_hash(legacy_id, group, generation)
225
- {
226
- id: legacy_id, tier: group[:tier], provider_family: group[:provider_family],
227
- provider_instance: group[:instance_id], instance_id: group[:instance_id], type: group[:type],
228
- model: group[:model], offering_id: group[:offering_ids].first,
229
- capabilities: group[:capabilities].to_a.sort, limits: build_limits(group),
230
- metadata: { ssot_v3_compatibility_projection: true, source_generation: generation }, enabled: true
231
- }
232
- end
233
-
234
- def build_limits(group)
235
- limits = {}
236
- limits[:context_window] = group[:context_values].first if group[:context_values].size == 1
237
- limits[:max_output_tokens] = group[:max_output_values].first if group[:max_output_values].size == 1
238
- limits
239
- end
240
-
241
- def write_desired(desired)
242
- written = Set.new
243
- desired.each do |legacy_id, lane_hash|
244
- result = ::Legion::LLM::Inventory.write_lane(lane: lane_hash, health: HEALTHY_COMPAT)
245
- written << legacy_id unless result.nil?
246
- end
247
- written
248
- end
249
-
250
- def reconcile_deletions(instance_key, written)
251
- tracked = @published_ids_by_instance.fetch(instance_key.instance_id, [])
252
- candidates = (tracked.to_a + read_current_old_ids(instance_key).to_a).uniq
253
- candidates.each do |old_id|
254
- next if written.include?(old_id)
255
-
256
- ::Legion::LLM::Inventory.delete_lane(id: old_id)
257
- end
258
- end
259
-
260
- def read_current_old_ids(instance_key)
261
- instance_ids_to_read(instance_key).each_with_object(Set.new) do |instance_id, ids|
262
- lanes = ::Legion::LLM::Inventory.lanes_for(provider: @provider_family, instance: instance_id)
263
- Array(lanes).each { |lane| ids << (lane.is_a?(::Hash) ? lane[:id] : lane) }
264
- end
265
- end
266
-
267
- def instance_ids_to_read(instance_key)
268
- ([instance_key.instance_id] + @legacy_instance_ids.fetch(instance_key.instance_id, [])).uniq
269
- end
270
-
271
- def validate_inputs!(snapshot, instance_key, mutation_reason)
272
- raise Errors::ValidationError, 'snapshot must be an Inventory::Snapshot' unless snapshot.is_a?(Snapshot)
273
- unless instance_key.is_a?(Identity::InstanceKey) && instance_key.provider_family == @provider_family
274
- raise Errors::ValidationError, 'instance_key must belong to the adapter provider family'
275
- end
276
- return if Taxonomies::MUTATION_REASONS.include?(mutation_reason)
277
-
278
- raise Errors::ValidationError, 'mutation_reason must be a Taxonomies::MUTATION_REASONS value'
279
- end
280
-
281
- def legacy_inventory_available?
282
- defined?(::Legion::LLM::Inventory) &&
283
- ::Legion::LLM::Inventory.respond_to?(:lanes_for) &&
284
- ::Legion::LLM::Inventory.respond_to?(:write_lane) &&
285
- ::Legion::LLM::Inventory.respond_to?(:delete_lane)
286
- end
287
-
288
- def canonical_family(value)
289
- text = Identity.normalize_text(value: value, field: :provider_family).downcase
290
- raise Errors::ValidationError, 'provider_family must be snake-case ASCII' unless text.match?(/\A[a-z][a-z0-9_]*\z/)
291
-
292
- text.to_sym
293
- end
294
-
295
- def deep_freeze_legacy_ids(mapping)
296
- raise Errors::ValidationError, 'legacy_instance_ids_by_instance must be a Hash' unless mapping.is_a?(::Hash)
297
-
298
- mapping.each_with_object({}) do |(key, values), frozen|
299
- raise Errors::ValidationError, 'legacy instance id keys must be Strings' unless key.is_a?(::String)
300
- raise Errors::ValidationError, 'legacy superseded ids must be a nonempty Array of Strings' unless values.is_a?(::Array) && !values.empty? && values.all?(::String)
301
-
302
- frozen[key.dup.freeze] = values.map { |value| value.dup.freeze }.freeze
303
- end.freeze
304
- end
305
- end
306
- end
307
- end
308
- end
309
- end
310
- end
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module Llm
6
- # A single message in a chat conversation.
7
- class Message
8
- ROLES = %i[system user assistant tool].freeze
9
-
10
- attr_reader :role, :model_id, :tool_calls, :tool_call_id, :raw, :thinking, :tokens, :stop_reason
11
- attr_writer :content
12
-
13
- def initialize(options = {})
14
- @role = options.fetch(:role).to_sym
15
- @tool_calls = options[:tool_calls]
16
- @content = normalize_content(options.fetch(:content), role: @role, tool_calls: @tool_calls)
17
- @model_id = options[:model_id]
18
- @tool_call_id = options[:tool_call_id]
19
- @tokens = options[:tokens] || Tokens.build(
20
- input: options[:input_tokens],
21
- output: options[:output_tokens],
22
- cached: options[:cached_tokens],
23
- cache_creation: options[:cache_creation_tokens],
24
- thinking: options[:thinking_tokens],
25
- reasoning: options[:reasoning_tokens]
26
- )
27
- @raw = options[:raw]
28
- @thinking = options[:thinking]
29
- @stop_reason = options[:stop_reason]
30
-
31
- ensure_valid_role
32
- end
33
-
34
- def content
35
- if @content.is_a?(Content) && @content.text && @content.attachments.empty?
36
- @content.text
37
- else
38
- @content
39
- end
40
- end
41
-
42
- def tool_call?
43
- !tool_calls.nil? && !tool_calls.empty?
44
- end
45
-
46
- def tool_result?
47
- !tool_call_id.nil? && !tool_call_id.empty?
48
- end
49
-
50
- def tool_results
51
- content if tool_result?
52
- end
53
-
54
- def input_tokens
55
- tokens&.input
56
- end
57
-
58
- def output_tokens
59
- tokens&.output
60
- end
61
-
62
- def cached_tokens
63
- tokens&.cached
64
- end
65
-
66
- def cache_creation_tokens
67
- tokens&.cache_creation
68
- end
69
-
70
- def thinking_tokens
71
- tokens&.thinking
72
- end
73
-
74
- def reasoning_tokens
75
- tokens&.thinking
76
- end
77
-
78
- def to_h
79
- {
80
- role: role,
81
- content: content,
82
- model_id: model_id,
83
- tool_calls: tool_calls,
84
- tool_call_id: tool_call_id
85
- }.merge(tokens ? tokens.to_h : {}).compact
86
- end
87
-
88
- def to_internal_h
89
- to_h.merge(
90
- thinking: thinking&.text,
91
- thinking_signature: thinking&.signature,
92
- raw: raw
93
- ).compact
94
- end
95
-
96
- def instance_variables
97
- super - [:@raw]
98
- end
99
-
100
- private
101
-
102
- def normalize_content(content, role:, tool_calls:)
103
- return '' if role == :assistant && content.nil? && tool_calls && !tool_calls.empty?
104
-
105
- case content
106
- when String then Content.new(content)
107
- when Hash then Content.new(content[:text], content)
108
- else content
109
- end
110
- end
111
-
112
- def ensure_valid_role
113
- raise InvalidRoleError, "Expected role to be one of: #{ROLES.join(', ')}" unless ROLES.include?(role)
114
- end
115
- end
116
- end
117
- end
118
- end
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'marcel'
4
-
5
- module Legion
6
- module Extensions
7
- module Llm
8
- # MimeTypes module provides methods to handle MIME types using Marcel gem
9
- module MimeType
10
- module_function
11
-
12
- def for(...)
13
- Marcel::MimeType.for(...)
14
- end
15
-
16
- def image?(type)
17
- type.start_with?('image/')
18
- end
19
-
20
- def video?(type)
21
- type.start_with?('video/')
22
- end
23
-
24
- def audio?(type)
25
- type.start_with?('audio/')
26
- end
27
-
28
- def pdf?(type)
29
- type == 'application/pdf'
30
- end
31
-
32
- def text?(type)
33
- type.start_with?('text/') ||
34
- TEXT_SUFFIXES.any? { |suffix| type.end_with?(suffix) } ||
35
- NON_TEXT_PREFIX_TEXT_MIME_TYPES.include?(type)
36
- end
37
-
38
- # MIME types that have a text/ prefix but need to be handled differently
39
- TEXT_SUFFIXES = ['+json', '+xml', '+html', '+yaml', '+csv', '+plain', '+javascript', '+svg'].freeze
40
-
41
- # MIME types that don't have a text/ prefix but should be treated as text
42
- NON_TEXT_PREFIX_TEXT_MIME_TYPES = [
43
- 'application/json', # Base type, even if specific ones end with +json
44
- 'application/xml', # Base type, even if specific ones end with +xml
45
- 'application/javascript',
46
- 'application/ecmascript',
47
- 'application/rtf',
48
- 'application/sql',
49
- 'application/x-sh',
50
- 'application/x-csh',
51
- 'application/x-httpd-php',
52
- 'application/sdp',
53
- 'application/sparql-query',
54
- 'application/graphql',
55
- 'application/yang', # Data modeling language, often serialized as XML/JSON but the type itself is distinct
56
- 'application/mbox', # Mailbox format
57
- 'application/x-tex',
58
- 'application/x-latex',
59
- 'application/x-perl',
60
- 'application/x-python',
61
- 'application/x-tcl',
62
- 'application/pgp-signature', # Often ASCII armored
63
- 'application/pgp-keys', # Often ASCII armored
64
- 'application/vnd.coffeescript',
65
- 'application/vnd.dart',
66
- 'application/vnd.oai.openapi', # Base for OpenAPI, often with +json or +yaml suffix
67
- 'application/vnd.zul', # ZK User Interface Language (can be XML-like)
68
- 'application/x-yaml', # Common non-standard for YAML
69
- 'application/yaml', # Standard for YAML
70
- 'application/toml' # TOML configuration files
71
- ].freeze
72
- end
73
- end
74
- end
75
- end