lex-llm 0.7.6 → 0.8.4

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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -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 +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b11032eff9bab4fc8c4ad457cd78fdce95f6ec245a5938dbd6ea987729db4238
4
- data.tar.gz: f799901fe6f05201a25237aeafb7779538a995e63e5117e18550935b9e77e12e
3
+ metadata.gz: 2798bd117208f95e197b523f01bbd4937d7128f98fd8661ecfc609db33cb0a43
4
+ data.tar.gz: 0ad1e3cbc9654be5959d8bbd66fe13f1b6f9711150a62d06aa1436f1e0029974
5
5
  SHA512:
6
- metadata.gz: d8f610a4c4bb9fdf202c633360c689082f974033e27bdb9bb5c15afa8b609fee313cb36e32109c829b2f6a3613ae64e6859fc962536c804ec3d30f9e47bbc959
7
- data.tar.gz: 82b0d2428591452f708a4fbccd9be04c40752cd3b2a5989e0e0df220406a9e6cfe103da82ad9c5d7123d11875bec97f01ad8c81b83e7ca21deac37b4fde03c98
6
+ metadata.gz: 6ebaf6abe1388508ee2be7f365a129b3c83c5846a3cae522758323d000bb97e965a64c7dfb5ed4d4efc093e8f6b8245cc860861a725238da862faee01820f1fd
7
+ data.tar.gz: 119413c94340f70c038782af0724835c00becfef3d1794f7de5b43a9c3c5e633663cafb73e5c67d68c22a3fdd0bd403e2cdfb86f4bc25f2edadd729709f2968b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,144 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.4 - 2026-08-25
4
+
5
+ ### Changed
6
+ - **OpenAI-compatible transposer routes thinking through the canonical bridge.**
7
+ `Provider::OpenAICompatible#openai_reasoning_effort` now returns `nil` (omitting
8
+ `reasoning_effort`) when thinking is absent or `enabled: false`, and uses
9
+ `Thinking::Config#resolved_effort` instead of the raw `effort` — so a budget-only
10
+ request derives an effort instead of being silently dropped. Added spec coverage.
11
+ - **`Responses::ToolArguments.parse!` accepts every legitimate wire form (10 U2).**
12
+ An already-decoded `Hash` passes through unchanged (it is the canonical Hash); a
13
+ `null`/empty/whitespace-only string and a JSON `null` resolve to `{}` (the documented
14
+ no-arguments default, not tolerance). Genuinely corrupt arguments — a non-object JSON
15
+ value or invalid JSON — still raise; they are never fabricated into `{}`.
16
+
17
+ ## 0.8.3 - 2026-08-25
18
+
19
+ ### Removed
20
+ - **Removed `Canonical::Params#max_thinking_tokens`.** Thinking budget now lives solely
21
+ on `Thinking::Config#budget`. The redundant dual-home on Params is deleted from the
22
+ `Data.define` member list, `.build`, `.from_hash`, and the strict `.new` validation
23
+ block. Any wire payload carrying `max_thinking_tokens` in params will now fold into
24
+ `metadata` as an unknown key (04 L5).
25
+
26
+ ## 0.8.2 - 2026-08-25
27
+
28
+ ### Changed
29
+ - **Expand `Thinking::Config` to the common superset shape.** The canonical struct
30
+ is now `Data.define(:enabled, :effort, :budget, :summary, :metadata)`:
31
+ - `enabled` — explicit Boolean (true/false). Default true when a Config is built;
32
+ nil request.thinking means "client said nothing"; `enabled: false` means explicit
33
+ OFF. Validated strictly (non-boolean raises).
34
+ - `effort` — closed 6-level enum: `none`, `low`, `medium`, `high`, `xhigh`, `max`
35
+ (downcased strings). Unknown values raise at construction.
36
+ - `budget` — positive Integer or nil. Zero and negatives rejected at construction
37
+ (off rides `enabled: false`; dynamic rides `enabled: true` + no axis).
38
+ - `summary` — closed enum `{nil, :auto, :none, :concise, :detailed}` for
39
+ thought-visibility; independent of the effort/budget axes.
40
+ - `metadata` — unchanged passthrough Hash.
41
+ - `EFFORT_BUDGET` extended to the full ladder:
42
+ `{'low'=>1024,'medium'=>8192,'high'=>16384,'xhigh'=>24576,'max'=>32768}`.
43
+ `none` resolves to nil budget. `resolved_budget` and `resolved_effort` only FILL
44
+ an axis the client didn't supply — never overwrite a supplied one.
45
+ - `enabled?` returns the `enabled` member directly.
46
+
47
+ ## 0.8.1 - 2026-08-25
48
+
49
+ ### Changed
50
+ - **Extract `Thinking::Config` to its own file.** `Canonical::Thinking::Config` (the
51
+ `Data.define` type and its constants) now lives in `canonical/thinking_config.rb`
52
+ instead of being nested inside `canonical/thinking.rb`. Spec coverage split into a
53
+ matching `thinking_config_spec.rb`. Pure refactor — no behavior changes.
54
+
55
+ ## 0.8.0 - 2026-08-20
56
+
57
+ **The SSOT v4 contract cut.** This is a complete contract line, not a patch wave:
58
+ Canonical is the only internal language, every authoritative value is created once
59
+ and executed exactly, and one path / one class / one method exists per function.
60
+ The architecture law for the line ships in `RULES.md` at the repository root.
61
+
62
+ ### Added
63
+ - **`RULES.md`** — the 17 architecture laws of the 0.8.x contract line, byte-for-byte
64
+ at the repo root (G5; mirrored to sibling repos at their next releases).
65
+ - **Fleet protocol v3.** `Fleet::Protocol::VERSION = 3`; exact execution only
66
+ (marker required — absence is rejected); explicit `protocol_version` (no default
67
+ fill); one `Fleet::Protocol::REQUIRED_FIELDS` list consumed by both edges;
68
+ `Fleet::ContractError` for envelope/param/message-shape violations; retryability
69
+ derived from the `ProviderOutcome` kind table (contract/policy/auth kinds never
70
+ retry); the response envelope carries the serialized `Canonical::Response` with
71
+ the G5 thinking exclusion performed exactly once at the responder builder.
72
+ - **Conformance kit.** `spec/legion/extensions/llm/conformance/` now carries the
73
+ full 09 oracle: T1-T7 shared examples for all 12 canonical types plus the B1-B9
74
+ (boundary), F1-F7 (fleet), and R1-R5 (registry) shared examples with a kit
75
+ self-test host.
76
+ - **`Canonical::Thinking::Config`** — one name, a `Data` type (the top-level
77
+ `ThinkingConfig` constant and its alias are gone).
78
+ - **`Canonical::Message#metadata`** — unknown keys fold into metadata on every
79
+ canonical type (04 L5); `cache_control` survival through the full JSON wire is
80
+ conformance-pinned (E01).
81
+ - **Strict argument parser** (`Responses::ToolArguments`) — one strict tool-argument
82
+ parse for sync and streaming paths; invalid JSON is a contract error, never a
83
+ fabricated `{}`.
84
+
85
+ ### Changed
86
+ - **Canonical strictification (04 L1-L10).** No nil-returning factories (typed
87
+ `ArgumentError` naming type, member, and offending class); no rescue-and-repair;
88
+ no silent drops; canonical keys only (provider-dialect aliases moved to the
89
+ translator edges, O03a); declared enums validated in both factories; `build` and
90
+ `from_hash` share one normalization path per member; uniform `to_h`/`as_json`/
91
+ `to_json` serialization; G20d (chunk produce-strict / consume-unknown pass-through)
92
+ is stated law.
93
+ - **Provider funnel (05/08).** `complete` enforces canonical messages centrally
94
+ (F2); the `temperature:` kwarg is deleted (temperature lives only in
95
+ `Canonical::Params`, O4); positional `messages` on the callable contract; the
96
+ provider-object fleet dispatch topology is deleted (exact registry dispatch only);
97
+ the legacy offering production path is replaced by the `Registry.snapshot` read
98
+ path (07 C5); one `ensure_configured!` (the Connection duplicate is deleted); one
99
+ health classifier; `normalize_dispatch_error` reads the shared kind table on
100
+ `Routing::ProviderOutcome`; silent rescues now log.
101
+ - **Streaming (02 FL13 / 10 U1-U3).** The streaming path produces
102
+ `Canonical::Chunk` / `Canonical::Response` through the shared `ThinkingExtractor`
103
+ segment core and the strict tool-argument parser, preserving the wire
104
+ index-first / recency-fallback tool-call correlation law.
105
+ - **`FleetLane` queue defaults** come from the one settings home
106
+ (`Llm.default_settings`, `consumer_ack_timeout_ms: 90_000`); the downgraded
107
+ `CredentialSources` paths fail closed (O11).
108
+ - **`Routing::Selection` weight validation** is structural only — a zero component
109
+ (operator disable) is accepted, matching `RecordSupport` (U4).
110
+
111
+ ### Removed
112
+ - **The legacy type set:** `Llm::Message`, `Llm::Chunk`, `Llm::Tokens`,
113
+ `Llm::Thinking` (+ `Thinking::Config`), `Llm::ToolCall`, `Llm::Content`
114
+ (+ `Content::Raw`), `Llm::Attachment`, the `Responses::ChatResponse` /
115
+ `StreamChunk` / `EmbeddingResponse` wrapper family, and `to_internal_h`.
116
+ - **The provider-native facade** (O12): `Llm.chat/context/embed/moderate/paint/
117
+ transcribe/models/providers` entry points, `Chat`, `Agent`, `Context` — zero
118
+ callers in any repo; capability is preserved through the provider base +
119
+ canonical pipeline + fleet.
120
+ - **Legacy offering/lane surface:** `Routing::ModelOffering`, `Routing::OfferingRegistry`,
121
+ `Routing::LaneKey`, `Inventory::ScopedRefresher` (+ `LegacyCoordinatorAdapter` —
122
+ the lex-llm -> legion-llm reverse dependency is gone), the `Llm::Types` alias
123
+ module, `AutoRegistration#discover_instances`/`#provider_aliases`, the
124
+ `UnsupportedCapabilityError` compat alias, `Llm::Aliases`,
125
+ `Taxonomies::OPERATION_ALIASES`/`OPERATION_TO_LANE_TYPE`/`CIRCUIT_STATES`/
126
+ `HEALTH_KEYS`, `Message.wrap`, `Message#to_provider_hash`, `AmbiguousLegacyOfferingError`,
127
+ and the class-level `model_policy`/`resolve_policy_value` policy cascade.
128
+
129
+ ## 0.7.7 - 2026-08-19
130
+
131
+
132
+ ### Added
133
+ - **`Canonical::Message` carries `cache_control`.** Prompt-cache breakpoints are a first-class canonical member (build/from_hash/to_h and the fleet JSON round-trip preserve it) instead of being dropped by member projection.
134
+
135
+ ### Changed
136
+ - **Fleet worker rehydrates wire messages to canonical objects.** `Fleet::WorkerExecution` rebuilds `params[:messages]` into `Canonical::Message` at the wire boundary for chat, stream, and count_tokens dispatch (exact and local-provider paths), so callables receive canonical input only. Non-Hash wire entries raise `ExactOfferingMismatchError`.
137
+
138
+ ### Fixed
139
+ - **Dispatch-boundary contract helper.** `Provider#enforce_canonical_messages!` is the shared loud-reject contract for provider callables: non-canonical message shapes raise `ArgumentError` instead of being coerced or tolerated. `Provider#count_tokens` enforces it and reads canonical content, removing the hash fallback.
140
+ - **Fleet specs assert the canonical boundary.** Worker/responder spec doubles read `.content` off `Canonical::Message` (wire rehydration delivers objects, not hashes).
141
+
3
142
  ## 0.7.6 - 2026-08-19
4
143
 
5
144
  ### Added
data/RULES.md ADDED
@@ -0,0 +1,97 @@
1
+ RULES.md — Legion LLM Architecture Law
2
+ These rules apply to every task, file, repository, agent, model, session, test, refactor, migration, incident, and release.
3
+ The requested task defines what may change. These rules define how the system ALWAYS works.
4
+ Every rule remains active 100% of the time. If requested work conflicts with these rules, stop and surface the conflict before changing code.
5
+ These are architecture laws. Scope, compatibility, urgency, convenience, tests, existing behavior, and model judgment do not change them.
6
+ 1. Canonical is the only internal language.
7
+ Every client translates client wire -> Canonical before shared execution.
8
+ Shared execution carries Canonical through context, tools, routing, direct dispatch, fleet dispatch, and response handling.
9
+ Every provider translates Canonical -> provider wire at the provider boundary, then provider wire -> Canonical before returning to shared execution.
10
+ Every internal boundary validates the Canonical type it is defined to receive and raises immediately when that contract is violated.
11
+ Client Wire -> Client Translator -> Canonical -> Shared Execution -> Canonical -> Provider Translator -> Provider Wire.
12
+ 2. Serialization preserves Canonical.
13
+ Transport may serialize Canonical state. The receiving transport boundary ALWAYS rehydrates the exact Canonical type before execution continues.
14
+ Fleet follows Canonical -> serialize -> wire -> deserialize -> rehydrate Canonical -> Canonical.
15
+ Serialization changes encoding only. Ownership, identity, model, operation, capability, selection, and meaning remain exactly the same.
16
+ After rehydration, shared execution continues only with Canonical objects.
17
+ 3. Every authoritative fact has exactly one owner.
18
+ The owner creates the fact once. Every downstream layer carries, projects, serializes, rehydrates, verifies, or executes that exact fact.
19
+ A downstream layer receiving missing or contradictory authoritative state raises and returns the defect to the owning layer.
20
+ Authority ALWAYS moves forward by preservation.
21
+ Authority is created once and is never recreated downstream.
22
+ 4. Requirements describe the request. Inventory describes reality. Router chooses. Dispatch executes.
23
+ Canonical request construction owns request semantics. RequestRequirements expresses operation, capabilities, modality, context, output, tools, and explicit pins.
24
+ Providers publish exact executable facts into Inventory. Inventory owns canonical instance, offering, lane, capability, context, quota, health, and published weight state.
25
+ Router.next_lane consumes Requirements plus one immutable Inventory snapshot and produces one authoritative Selection.
26
+ Dispatch executes that Selection exactly. Once Selection exists, routing is finished.
27
+ 5. Inventory facts are immutable executable facts.
28
+ Providers publish exact instances and complete offering snapshots through the Inventory publication contract.
29
+ Identity, capability evidence, context evidence, quota domains, availability, and write-time weights are consumed from published Inventory state.
30
+ A changed fact becomes authoritative only through the owning publication or reconciliation path and a new Inventory snapshot.
31
+ Routing reads Inventory. Dispatch verifies and executes Inventory-backed Selection.
32
+ 6. Identity, capabilities, weights, and context policy retain exact ownership.
33
+ Inventory::Identity owns instance, offering, and lane identity; canonical instance identity is provider family plus the operator/configured instance name; physical endpoint data remains secondary.
34
+ Providers publish capability evidence. Requirements state required capabilities. Candidate evaluation compares the two and determines capability eligibility.
35
+ The weight owner computes lane weight at publication time; Inventory stores it; ranking consumes that stored weight; a stored zero disables the lane.
36
+ Preferred-context binning orders eligible candidates into preference bands and preserves eligibility. Capability, health, binning, and weight ALWAYS retain distinct meanings.
37
+ 7. Routing chooses exactly once.
38
+ Router.next_lane is the sole routing authority.
39
+ Candidate evaluation determines eligibility from Requirements and Inventory. Ranking orders eligible candidates from published routing facts.
40
+ Selection freezes the exact provider, instance, offering, lane, model, operation, and routing identity required for execution.
41
+ Every downstream component consumes the Selection it receives.
42
+ Selection is preserved, not reconstructed.
43
+ 8. Exact execution stays exact through every boundary.
44
+ Direct dispatch executes the exact Selection-derived binding it receives.
45
+ Fleet dispatch serializes and signs that exact binding; fleet validation verifies it; fleet rehydration restores it; worker resolution verifies it against authoritative Inventory.
46
+ The selected provider, instance, offering, lane, model, and operation remain identical through projection, signing, transport, validation, rehydration, resolution, and callable invocation.
47
+ A mismatch raises before provider execution.
48
+ An exact execution request ALWAYS remains exact execution.
49
+ 9. Health and errors preserve one authoritative meaning.
50
+ Inventory owns exact-instance availability. An authoritative instance-unavailable result removes that exact instance; readiness probing owns recovery; successful readiness republish re-admits it.
51
+ Overload, timeout, rate limit, model-not-ready, and transient provider failures remain request-local according to ProviderOutcome semantics.
52
+ The first layer that can authoritatively classify an error performs that classification once. Every downstream layer preserves it.
53
+ Programming errors remain programming errors. Contract violations remain contract violations. Routing exhaustion remains the defined typed Rejection.
54
+ 10. Compatibility exists only at explicit edges.
55
+ Supported legacy clients and protocols are translated into the current Canonical and SSOT architecture at explicit compatibility boundaries.
56
+ Shared execution remains Canonical. Routing remains SSOT-driven. Exact execution remains exact.
57
+ Compatibility code adapts an external contract to the current internal architecture.
58
+ The current internal architecture ALWAYS has one representation, one routing authority, one identity system, and one execution truth.
59
+ 11. Fix every defect at its owner.
60
+ Trace the incorrect value to the layer that owns it, then fix that owner.
61
+ Fix client wire in the client translator; Canonical shape in Canonical construction; Requirements in Requirements construction; provider facts in publication; identity in Inventory identity; weights in publication/reconciliation.
62
+ Fix eligibility in candidate evaluation; ordering in ranking; choice in Router.next_lane; execution preservation in dispatch; provider wire in the provider translator.
63
+ The layer where a defect becomes visible is evidence. The owning layer is where the correction belongs.
64
+ 12. A discovered issue remains in its owning domain.
65
+ Complete the requested task inside its stated scope.
66
+ When investigation exposes a separate defect owned by another architectural domain, record and surface it as separate work unless the requested task is explicitly expanded.
67
+ Routing work consumes existing Canonical Requirements and Inventory facts. Canonical work changes Canonical contracts. Provider work changes publication or translation. Transport work changes transport.
68
+ Nearby code never changes ownership. “While we are here” never changes architecture.
69
+ 13. N x N ALWAYS converges through Canonical.
70
+ Equivalent client semantics produce equivalent Canonical state before shared execution. Every provider consumes the same Canonical semantics for the same request.
71
+ When two paths disagree, capture the state at every involved boundary and locate the FIRST point where Canonical meaning diverges.
72
+ Fix that first divergent boundary, then run the exact failing path again.
73
+ Client behavior is proven at client-wire <-> Canonical. Provider behavior is proven at Canonical <-> provider-wire. Shared execution is proven with Canonical throughout.
74
+ 14. Debug from captured authoritative state.
75
+ Capture the actual input at the failing boundary before reasoning from symptoms.
76
+ For translation or transport defects, capture Canonical immediately before and after every involved boundary.
77
+ For routing or dispatch defects, capture Requirements, relevant Inventory facts, Selection, execution binding, and ProviderOutcome.
78
+ Compare each captured value to the contract owned by that layer. Find the first divergence. Fix its owner. Re-run the exact path.
79
+ Then inspect sibling implementations for the same defect class.
80
+ 15. Tests prove the real boundary and the invariant.
81
+ A boundary test exercises the real boundary it claims to protect.
82
+ Fleet tests exercise real serialization, deserialization, Canonical rehydration, signing, validation, exact resolution, and callable dispatch.
83
+ Provider tests exercise the real callable boundary and provider translator. Routing tests exercise real Requirements, Inventory records, candidate evaluation, ranking, and Selection.
84
+ Regression tests prove the violated invariant, not only the observed symptom.
85
+ A green suite is release evidence only when the tested path traverses the real architecture.
86
+ 16. Shared contracts are consumed directly.
87
+ Shared Canonical types own execution representation. Shared Inventory types own inventory state. Shared Routing types own routing state.
88
+ Shared taxonomy owns canonical mappings. Shared ProviderOutcome owns provider-neutral outcomes. Shared fleet protocol owns exact execution claims.
89
+ Every repository consumes these shared owners directly.
90
+ A defect in one shared boundary triggers an audit of every sibling implementation of that boundary. Fix the shared owner centrally whenever the defect belongs to a shared contract.
91
+ 17. Architecture is the release gate.
92
+ Every change preserves every rule in this file.
93
+ Tests, compatibility, historical behavior, migration phase, patch urgency, nearby code, task wording, and model judgment are evaluated UNDER these rules.
94
+ A contradiction between existing behavior and these rules is surfaced as an architecture conflict and resolved at the owning boundary before release.
95
+ Limited scope means do less. Limited scope NEVER means fewer rules apply.
96
+ These rules apply 100% of the time.
97
+ These are the law.
@@ -10,20 +10,11 @@ module Legion
10
10
  # Prerequisites on the extending module:
11
11
  # - `PROVIDER_FAMILY` constant (Symbol, e.g. :ollama)
12
12
  # - `provider_class` singleton method returning the Provider subclass
13
- # DEPRECATED (Phase 4 removal): discover_instances/provider_aliases are kept
14
- # for old consumers; no new registration side effect is added here. Migrated
15
- # provider actors use Inventory::Publisher directly.
13
+ #
14
+ # The legacy discover_instances/provider_aliases defaults are deleted
15
+ # (Phase 4): providers define their own instance discovery, and
16
+ # publication goes through Inventory::Publisher directly.
16
17
  module AutoRegistration
17
- # Override in each provider. Returns { instance_id => config_hash }.
18
- def discover_instances
19
- {}
20
- end
21
-
22
- # Optional provider-family aliases that legion-llm should register
23
- # against the same discovered provider instances.
24
- def provider_aliases
25
- []
26
- end
27
18
  end
28
19
  end
29
20
  end
@@ -1,30 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/ParameterLists -- factory methods have many params
3
+ # rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
4
4
  module Legion
5
5
  module Extensions
6
6
  module Llm
7
7
  module Canonical
8
8
  # Canonical streaming chunk with full lifecycle support.
9
9
  # Per R4: block_index/item_id/signature lifecycle, multi-tool-call deltas.
10
- # Per G20d: strict on produce, ignore-unknown on consume.
10
+ # Per G20d (04 §11, stated law): strict on produce the named factories
11
+ # and the generic build validate type against CHUNK_TYPES; lenient on
12
+ # consume — from_hash accepts any type symbol and passes it through. # -- required for Data.define block scope
11
13
  Chunk = ::Data.define(
12
14
  :request_id, :conversation_id, :exchange_id,
13
15
  :index, :type, :block_index,
14
16
  :item_id, :delta, :tool_call, :signature,
15
17
  :usage, :stop_reason, :metadata, :timestamp
16
18
  ) do
19
+ # Generic produce path — the only way to construct an arbitrary-type
20
+ # chunk; type is validated against CHUNK_TYPES (G20d).
21
+ def self.build(
22
+ type:, request_id: nil, conversation_id: nil, exchange_id: nil,
23
+ index: nil, block_index: nil, item_id: nil,
24
+ delta: nil, tool_call: nil, signature: nil,
25
+ usage: nil, stop_reason: nil, metadata: {}, timestamp: nil
26
+ )
27
+ type_sym = type.is_a?(::String) ? type.to_sym : type
28
+ Strict.enum!(type_sym, self::CHUNK_TYPES, self::BUILD_SITE, :type)
29
+ new(
30
+ request_id:, conversation_id:, exchange_id:,
31
+ index:, type: type_sym, block_index:,
32
+ item_id:, delta:,
33
+ tool_call: normalize_tool_call!(tool_call, self::BUILD_SITE),
34
+ signature:,
35
+ usage: normalize_usage!(usage, self::BUILD_SITE),
36
+ stop_reason: stop_reason&.to_sym,
37
+ metadata: Strict.metadata!(metadata, self::BUILD_SITE),
38
+ timestamp: timestamp || ::Time.now
39
+ )
40
+ end
41
+
17
42
  # Build a text delta chunk.
18
43
  def self.text_delta(delta:, request_id:, conversation_id: nil, exchange_id: nil,
19
44
  index: 0, block_index: nil, item_id: nil,
20
45
  stop_reason: nil, usage: nil)
21
- new(
22
- type: :text_delta, delta: delta, index: index,
23
- request_id: request_id, conversation_id: conversation_id,
24
- exchange_id: exchange_id, block_index: block_index,
25
- item_id: item_id, tool_call: nil, signature: nil,
26
- usage: usage, stop_reason: stop_reason, metadata: {},
27
- timestamp: ::Time.now
46
+ build(
47
+ type: :text_delta, delta:, request_id:, conversation_id:, exchange_id:,
48
+ index:, block_index:, item_id:, stop_reason:, usage:
28
49
  )
29
50
  end
30
51
 
@@ -32,117 +53,93 @@ module Legion
32
53
  def self.thinking_delta(delta:, request_id:, conversation_id: nil, exchange_id: nil,
33
54
  index: 0, block_index: nil, item_id: nil, signature: nil,
34
55
  stop_reason: nil, usage: nil)
35
- new(
36
- type: :thinking_delta, delta: delta, index: index,
37
- request_id: request_id, conversation_id: conversation_id,
38
- exchange_id: exchange_id, block_index: block_index,
39
- item_id: item_id, tool_call: nil, signature: signature,
40
- usage: usage, stop_reason: stop_reason, metadata: {},
41
- timestamp: ::Time.now
56
+ build(
57
+ type: :thinking_delta, delta:, request_id:, conversation_id:, exchange_id:,
58
+ index:, block_index:, item_id:, signature:, stop_reason:, usage:
42
59
  )
43
60
  end
44
61
 
45
- # Build a tool_call_delta chunk (supports multiple in-flight tool calls via tool_call.id).
62
+ # Build a tool_call_delta chunk (supports multiple in-flight tool calls
63
+ # via the fragment's id/index). tool_call is the delta fragment:
64
+ # { id:, name:, arguments: <String fragment>, index:, signature: }.
46
65
  def self.tool_call_delta(tool_call:, request_id:, conversation_id: nil, exchange_id: nil,
47
66
  index: 0, block_index: nil, item_id: nil,
48
67
  stop_reason: nil, usage: nil)
49
- new(
50
- type: :tool_call_delta, index: index,
51
- request_id: request_id, conversation_id: conversation_id,
52
- exchange_id: exchange_id, block_index: block_index,
53
- item_id: item_id, delta: nil, tool_call: tool_call, signature: nil,
54
- usage: usage, stop_reason: stop_reason, metadata: {},
55
- timestamp: ::Time.now
68
+ build(
69
+ type: :tool_call_delta, tool_call:, request_id:, conversation_id:, exchange_id:,
70
+ index:, block_index:, item_id:, stop_reason:, usage:
56
71
  )
57
72
  end
58
73
 
59
74
  # Build a usage chunk.
60
75
  def self.usage_chunk(usage:, request_id:, conversation_id: nil, exchange_id: nil)
61
- new(
62
- type: :usage, request_id: request_id,
63
- conversation_id: conversation_id, exchange_id: exchange_id,
64
- index: nil, block_index: nil, item_id: nil,
65
- delta: nil, tool_call: nil, signature: nil,
66
- usage: usage, stop_reason: nil, metadata: {},
67
- timestamp: ::Time.now
68
- )
76
+ build(type: :usage, request_id:, conversation_id:, exchange_id:, usage:)
69
77
  end
70
78
 
71
79
  # Build a done chunk.
72
80
  def self.done(request_id:, usage: nil, stop_reason: nil, conversation_id: nil, exchange_id: nil)
73
- new(
74
- type: :done, request_id: request_id,
75
- conversation_id: conversation_id, exchange_id: exchange_id,
76
- index: nil, block_index: nil, item_id: nil,
77
- delta: nil, tool_call: nil, signature: nil,
78
- usage: usage, stop_reason: stop_reason, metadata: {},
79
- timestamp: ::Time.now
80
- )
81
+ build(type: :done, request_id:, usage:, stop_reason:, conversation_id:, exchange_id:)
81
82
  end
82
83
 
83
84
  # Build an error chunk.
84
- def self.error_chunk(error:, request_id:, conversation_id: nil, exchange_id: nil, metadata: nil)
85
- new(
86
- type: :error, request_id: request_id,
87
- conversation_id: conversation_id, exchange_id: exchange_id,
88
- index: nil, block_index: nil, item_id: nil,
89
- delta: nil, tool_call: nil, signature: nil,
90
- usage: nil, stop_reason: :error,
91
- metadata: (metadata || {}).merge(error: error),
92
- timestamp: ::Time.now
85
+ def self.error_chunk(error:, request_id:, conversation_id: nil, exchange_id: nil, metadata: {})
86
+ build(
87
+ type: :error, request_id:, conversation_id:, exchange_id:,
88
+ stop_reason: :error, metadata: metadata.merge(error:)
93
89
  )
94
90
  end
95
91
 
96
- # Build from a Hash (raw provider response or deserialized wire payload).
97
- # Per G20d: ignore-unknown on consume — unknown chunk types are passed through.
98
- def self.from_hash(source)
99
- return nil if source.nil?
100
-
101
- h = source.transform_keys(&:to_sym)
92
+ def self.shape_symbol!(value, site, member)
93
+ return nil if value.nil?
94
+ return value.to_sym if value.is_a?(::String) || value.is_a?(::Symbol)
102
95
 
103
- # Normalize type
104
- type_raw = h.delete(:type)
105
- type_sym = type_raw&.to_sym if type_raw
96
+ raise ArgumentError, "#{site}: #{member} expected String or Symbol, got #{value.class}"
97
+ end
106
98
 
107
- # Normalize nested objects
108
- tool_call_raw = h.delete(:tool_call)
109
- h[:tool_call] = if tool_call_raw.is_a?(ToolCall)
110
- tool_call_raw
111
- elsif tool_call_raw.is_a?(Hash)
112
- ToolCall.from_hash(tool_call_raw)
113
- end
99
+ # Build from a Hash (raw provider response or deserialized wire payload).
100
+ # Per G20d: ignore-unknown on consume — unknown chunk types pass through.
101
+ def self.from_hash(source)
102
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
103
+ hash = Strict.symbolize_keys(source)
104
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
105
+ type_raw = hash.delete(:type)
106
+ tool_call = normalize_tool_call!(hash.delete(:tool_call), self::FROM_HASH_SITE)
107
+ usage = normalize_usage!(hash.delete(:usage), self::FROM_HASH_SITE)
108
+ stop_reason_raw = hash.delete(:stop_reason)
109
+ timestamp = hash.delete(:timestamp)
110
+ # Remaining keys are all members; pass through with consume defaults.
111
+ new(
112
+ request_id: hash[:request_id],
113
+ conversation_id: hash[:conversation_id],
114
+ exchange_id: hash[:exchange_id],
115
+ index: hash[:index],
116
+ type: type_raw&.to_sym,
117
+ block_index: hash[:block_index],
118
+ item_id: hash[:item_id],
119
+ delta: hash[:delta],
120
+ tool_call:,
121
+ signature: hash[:signature],
122
+ usage:,
123
+ stop_reason: stop_reason_raw&.to_sym,
124
+ metadata:,
125
+ timestamp: timestamp || ::Time.now
126
+ )
127
+ end
114
128
 
115
- usage_raw = h.delete(:usage)
116
- h[:usage] = if usage_raw.is_a?(Usage)
117
- usage_raw
118
- elsif usage_raw.is_a?(Hash)
119
- Usage.from_hash(usage_raw)
120
- end
129
+ # tool_call member: the delta fragment (Hash) or a full ToolCall; nil allowed.
130
+ def self.normalize_tool_call!(tool_call, site)
131
+ return nil if tool_call.nil?
132
+ return tool_call if tool_call.is_a?(::Hash) || tool_call.is_a?(ToolCall)
121
133
 
122
- # Normalize stop_reason
123
- stop_reason_raw = h.delete(:stop_reason) || h.delete(:finish_reason)
124
- h[:stop_reason] = stop_reason_raw&.to_sym if stop_reason_raw
134
+ Strict.expect_type!(tool_call, [::Hash, ToolCall], site, :tool_call)
135
+ end
125
136
 
126
- # Ensure metadata is a Hash
127
- h[:metadata] = h[:metadata] || {}
137
+ def self.normalize_usage!(usage, site)
138
+ return nil if usage.nil?
139
+ return usage if usage.is_a?(Usage)
128
140
 
129
- # Provide defaults for missing fields
130
- new(
131
- request_id: h[:request_id],
132
- conversation_id: h[:conversation_id],
133
- exchange_id: h[:exchange_id],
134
- index: h[:index],
135
- type: type_sym,
136
- block_index: h[:block_index],
137
- item_id: h[:item_id],
138
- delta: h[:delta],
139
- tool_call: h[:tool_call],
140
- signature: h[:signature],
141
- usage: h[:usage],
142
- stop_reason: h[:stop_reason],
143
- metadata: h[:metadata],
144
- timestamp: h[:timestamp] || ::Time.now
145
- )
141
+ Strict.expect_type!(usage, [::Hash], site, :usage)
142
+ Usage.from_hash(usage)
146
143
  end
147
144
 
148
145
  # Serialize to a Hash for AMQP/fleet/wire transport.
@@ -186,11 +183,27 @@ module Legion
186
183
  def content?
187
184
  %i[text_delta thinking_delta].include?(type)
188
185
  end
186
+
187
+ # H1: the single strict constructor. Member shapes are validated
188
+ # (type/stop_reason as String|Symbol, tool_call/usage through the
189
+ # normalizers, metadata as a Hash). Per G20d the TYPE is only
190
+ # shape-checked here — the produce-side enum pin stays in build,
191
+ # and from_hash (consume) passes unknown types through.
192
+ Strict.install_strict_new!(self) do |values, site|
193
+ values[:type] = shape_symbol!(values[:type], site, :type)
194
+ values[:stop_reason] = shape_symbol!(values[:stop_reason], site, :stop_reason)
195
+ values[:tool_call] = normalize_tool_call!(values[:tool_call], site)
196
+ values[:usage] = normalize_usage!(values[:usage], site)
197
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
198
+ values
199
+ end
189
200
  end
190
201
 
191
202
  Chunk::CHUNK_TYPES = %i[text_delta thinking_delta tool_call_delta usage done error].freeze
203
+ Chunk::BUILD_SITE = 'Canonical::Chunk.build'
204
+ Chunk::FROM_HASH_SITE = 'Canonical::Chunk.from_hash'
205
+ Chunk::NEW_SITE = 'Canonical::Chunk.new'
192
206
  end
193
207
  end
194
208
  end
195
209
  end
196
- # rubocop:enable Metrics/ParameterLists