lex-llm-gemini 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3524657be64163617964f55cfeb0f375536587ebb79f1d118d8b7b53032b59d
4
- data.tar.gz: f80938bffd0e0f8794a0de0cc7857064cf1db3c19c948042acdfb423da63d9ec
3
+ metadata.gz: 20a06f104a1d3428d0bde0a55989136dfc492468eaf348ce943f3d7d33f1d868
4
+ data.tar.gz: f673546dad910895150994e33e4e9ccec4c268fd88720d9ce7788be6c7baadbd
5
5
  SHA512:
6
- metadata.gz: 42f5f91e84ca2b380f983dbe04d4f4ff1171cf06c4aa14cf4b884e219700869c19e85b57c39f90e57bbca81f9b9b490a9e11dab2bff5641d55b0e03bfc02ea3b
7
- data.tar.gz: a70c14d20367d1e7b2dfe57647cfa1a56f235396087707a84759732e408b6b37aa366cde5e2ee5957fdcce7e252ac9f3ec45d8c255d95de821a53c8447a99ba2
6
+ metadata.gz: 23aee8f67392983feb3676cc3075da064dc4a05c5cedbba071ba886e8ba1e7517a5c4d2f531c20efe7b28d5efceeeefa696709f9b3e65daa7f6d7cb4a90c82f4
7
+ data.tar.gz: 68d4afe2632f44ddc092733825ba26200ee207c7655ae2819efe1384896acb04abaa64f7ebcc06571a3b533da27a2c8eec5882cfbf13fe447b9e442f4e4c5bae
data/.rubocop.yml CHANGED
@@ -14,6 +14,11 @@ Metrics/BlockLength:
14
14
  - spec/**/*
15
15
  Metrics/MethodLength:
16
16
  Enabled: false
17
+ # Aligned with the sibling lex-llm-* providers (vllm/anthropic): the rubocop
18
+ # default ModuleLength (100) is too strict for a discovery runner carrying the
19
+ # full evidence-building surface. Length is still bounded (Max 350).
20
+ Metrics/ModuleLength:
21
+ Max: 350
17
22
  RSpec/ExampleLength:
18
23
  Enabled: false
19
24
  RSpec/MultipleExpectations:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.6] - 2026-08-20
4
+
5
+ ### Changed
6
+ - **lex-llm 0.8.0 conformance (SSOT v4 contract cut):** the Gemini provider's parse/build paths migrate from the deleted legacy types to Canonical. `parse_completion_response` returns `Canonical::Response`, `build_chunk` yields `Canonical::Chunk` (text_delta / thinking_delta / tool_call_delta / usage), tool calls parse to `Array<Canonical::ToolCall>`, usage translates from `usageMetadata` to `Canonical::Usage`, and `finishReason` maps through the shared `StopReasonMapping` with the Gemini vocabulary additions (STOP, MAX_TOKENS, SAFETY, RECITATION, PROHIBITED_CONTENT, SPII, IMAGE_SAFETY). Wire-dialect tolerance (thought parts, thoughtSignature, JSON-object vs JSON-string tool arguments, Gemini finishReason spelling) lives only in the Gemini renderer/parser, per the 08 R1-R4 law.
7
+ - The `embed` operation returns the documented artifact shape `{ text:, model:, embedding:, usage: Canonical::Usage }` (05 section 3 / O07) in place of the deleted `Llm::Embedding` type.
8
+ - Rip the legacy `offering_from_model` -> `Routing::ModelOffering` production path from the provider: offering construction is the discovery writer's job (`OfferingDraft` + Registry publication, v3.1), and the base read path serves inventory offerings from the registry snapshot (07 C5).
9
+ - Enforce the canonical dispatch boundary end to end (N x N law): the base funnel's central `enforce_canonical_messages!` covers chat/stream_chat, the fleet callable invokes the same shared helper at every operation entry, and the provider no longer re-implements the check (08 F2 / O05). Plain Hash/String/nil messages raise a typed `ArgumentError` at both entry forms. The callable's chat/stream_chat accept the canonical messages positionally, as the 0.8.0 funnel and fleet `WorkerExecution` hand them, and temperature travels only in `Canonical::Params` (05 O4).
10
+ - Rip the `ScopedRefresher::LegacyCoordinatorAdapter` wiring from the discovery actor: the `Publisher` is built without a compatibility adapter and the deleted `scoped_refresher` require goes with it.
11
+ - Raise the `lex-llm` floor from `>= 0.7.6` to `>= 0.8.0`.
12
+ - Keep the local-tree `lex-llm` path dependency in the test group so the adjacent checkout resolves against the 0.8.x contract cut during development.
13
+
14
+ ### Added
15
+ - Run the conformance kit B1 (central canonical enforcement) and B2 (canonical outputs, asserted by type) shared examples against the REAL callable boundary - the production `GeminiCallable` over the production `Gemini::Provider`, with only the HTTP transport stubbed.
16
+ - Cover the canonical pipeline in the provider spec: `Canonical::Response` / `Canonical::Chunk` / `Canonical::ToolCall` / `Canonical::Thinking` / `Canonical::Usage` outputs asserted by type, a full streaming exchange driven through the real funnel ending in exactly one done chunk, and the Gemini wire format unchanged.
17
+
18
+ ### Removed
19
+ - `provider_capability_policy_spec.rb` - it tested the deleted legacy offering production path; in 0.8.0 the SSOT data plane expresses capabilities as the writer's `capability_evidence`, and the capability-flag cascade has no provider home.
20
+
21
+ ## [0.4.5] - 2026-08-19
22
+
23
+ ### Changed
24
+ - Publish the validated four-axis lane weight pair on every Gemini offering and reconcile weight-only changes atomically on the existing discovery cadence.
25
+ - Track initializing instances before readiness, rebuild weights from current settings at activation, and serialize activation, replacement, removal, and cache mutation behind one writer mutex.
26
+
27
+ ### Fixed
28
+ - Report configured-but-unpublished Gemini weight keys once per dormant cycle without adding a settings callback or operator workflow.
29
+ - Render canonical system messages through the actual Gemini callable path as native `systemInstruction` payloads.
30
+ - Validate weighted offering drafts before constructing or claiming a callable scope, so malformed weights leave no orphaned Registry publication and a later corrected ordinary pass activates without restart or operator cleanup.
31
+ - Compare rebuilt catalogs as duplicate-preserving multisets of complete offering contracts, ignoring only evidence observation timestamps so catalog reordering cannot churn snapshots and authoritative metadata, quota, publication-source, or evidence-source changes cannot be missed.
32
+
33
+ ### Dependencies
34
+ - Raise the `lex-llm` floor from `>= 0.7.1` to `>= 0.7.6`; the `legion-settings` floor remains unchanged.
35
+
3
36
  ## [0.4.4] - 2026-08-18
4
37
 
5
38
  ### Fixed
data/Gemfile CHANGED
@@ -2,10 +2,15 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- gem 'lex-llm'
6
-
7
5
  gemspec
8
6
 
7
+ # Local-tree resolution: use the adjacent lex-llm checkout when present so the
8
+ # 0.8.0 conformance work resolves against the 0.8.x contract cut.
9
+ group :test do
10
+ lex_llm_path = File.expand_path('../lex-llm', __dir__)
11
+ gem 'lex-llm', path: lex_llm_path if Dir.exist?(lex_llm_path)
12
+ end
13
+
9
14
  group :development do
10
15
  gem 'bundler', '>= 2.0'
11
16
  gem 'rake', '>= 13.0'
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.
@@ -27,5 +27,5 @@ Gem::Specification.new do |spec|
27
27
  spec.add_dependency 'legion-logging', '>= 1.3.2'
28
28
  spec.add_dependency 'legion-settings', '>= 1.4.2'
29
29
  spec.add_dependency 'legion-transport', '>= 1.4.14'
30
- spec.add_dependency 'lex-llm', '>= 0.7.1'
30
+ spec.add_dependency 'lex-llm', '>= 0.8.0'
31
31
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/llm/discovery/actor'
4
+
5
+ # The base discovery actor only exists inside the daemon (it inherits the
6
+ # LegionIO time-based Every actor). In a standalone load, define nothing.
7
+ return unless defined?(Legion::Extensions::Llm::Discovery::Actor)
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Llm
12
+ module Gemini
13
+ module Actor
14
+ # Gemini discovery actor: an EMPTY subclass of the shared base. The
15
+ # timer, dispatch, and runner-resolution convention are inherited —
16
+ # this class redefines nothing. The Gemini-specific work lives in
17
+ # Gemini::Runners::Discovery, resolved by the base from this
18
+ # namespace.
19
+ class Discovery < Legion::Extensions::Llm::Discovery::Actor; end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'faraday'
5
+
6
+ require 'legion/extensions/llm/error'
7
+ require 'legion/extensions/llm/routing/provider_outcome'
8
+ require 'legion/extensions/llm/canonical'
9
+ require 'legion/extensions/llm/gemini/provider'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module Llm
14
+ module Gemini
15
+ module Helpers
16
+ # Dispatch-error classification for the Gemini callable (mixed into
17
+ # Callable). Pure functions of the error — no instance state.
18
+ # §8 health firewall: only the explicit Gemini UNAVAILABLE body maps
19
+ # to :instance_unavailable; status codes alone (503/529) are
20
+ # request-local overload conditions.
21
+ module DispatchErrorClassification
22
+ private
23
+
24
+ def classify_dispatch_error(error:)
25
+ return :connection_failure if error.is_a?(Faraday::ConnectionFailed)
26
+ return :timeout if error.is_a?(Faraday::TimeoutError)
27
+ return :overloaded if error.is_a?(Legion::Extensions::Llm::OverloadedError)
28
+ return classify_by_status(error: error) if http_status_error?(error)
29
+
30
+ :provider_error
31
+ end
32
+
33
+ def http_status_error?(error)
34
+ error.is_a?(Faraday::ClientError) || error.is_a?(Faraday::ServerError) ||
35
+ error.is_a?(Legion::Extensions::Llm::Error)
36
+ end
37
+
38
+ def classify_by_status(error:)
39
+ return :instance_unavailable if explicit_service_unavailable?(error: error)
40
+
41
+ status = dispatch_status(error)
42
+ return :model_not_ready if status.is_a?(::Integer) && status >= 500 &&
43
+ model_not_ready_signal?(error: error)
44
+
45
+ status_kind(status)
46
+ end
47
+
48
+ def status_kind(status)
49
+ case status
50
+ when 401 then :authentication
51
+ when 403 then :authorization
52
+ when 404 then :model_missing
53
+ when 429 then :rate_limited
54
+ when 503, 529 then :overloaded
55
+ when 400...500 then :invalid_request
56
+ else :provider_error
57
+ end
58
+ end
59
+
60
+ # Returns true only when the Gemini API response body explicitly
61
+ # carries "status":"UNAVAILABLE" — the flat service-level
62
+ # unavailability signal distinct from throttling
63
+ # (RESOURCE_EXHAUSTED) or model loading.
64
+ def explicit_service_unavailable?(error:)
65
+ body = response_body_string(error)
66
+ return false if body.nil?
67
+
68
+ (body.include?('"status":"UNAVAILABLE"') || body.include?('"status": "UNAVAILABLE"')) &&
69
+ !body.include?('RESOURCE_EXHAUSTED')
70
+ end
71
+
72
+ def model_not_ready_signal?(error:)
73
+ body = response_body_string(error)&.downcase
74
+ body.to_s.include?('model not ready') || body.to_s.include?('model is still loading')
75
+ end
76
+
77
+ # Reads the body from every real Faraday error shape: Faraday::Env
78
+ # (Faraday 2.x — a Struct, NOT a Hash, which is why an
79
+ # is_a?(Hash) gate here is dead in production), Faraday::Response
80
+ # (lex-llm ErrorMiddleware), or the plain response Hash (Faraday
81
+ # RaiseError middleware / Faraday 1.x).
82
+ def response_body_string(error)
83
+ response = error.respond_to?(:response) ? error.response : nil
84
+ return nil unless response
85
+
86
+ body = response.respond_to?(:body) ? response.body : (response[:body] if response.respond_to?(:[]))
87
+ return body if body.is_a?(String)
88
+
89
+ body && ::JSON.generate(body)
90
+ end
91
+
92
+ def dispatch_status(error)
93
+ return error.response_status if error.respond_to?(:response_status) && error.response_status
94
+
95
+ response = error.respond_to?(:response) ? error.response : nil
96
+ response.respond_to?(:status) ? response.status : nil
97
+ end
98
+ end
99
+
100
+ # Callable wrapper for a Gemini provider instance. Implements the
101
+ # fleet dispatch ops (chat/stream_chat/embed/count_tokens) by
102
+ # delegating to a per-instance Gemini::Provider, plus the
103
+ # disconnect and normalize_dispatch_error contracts required by
104
+ # Inventory::CallableHandle and Routing::ProviderOutcome. Dispatch
105
+ # errors propagate untouched so normalize_dispatch_error can
106
+ # classify them.
107
+ class Callable
108
+ include DispatchErrorClassification
109
+
110
+ def initialize(instance_cfg:, logger:, provider: nil)
111
+ @instance_cfg = instance_cfg
112
+ @logger = logger
113
+ @provider = provider
114
+ @disconnected = false
115
+ end
116
+
117
+ def disconnected? = @disconnected
118
+
119
+ def disconnect
120
+ @disconnected = true
121
+ @provider&.disconnect
122
+ @logger.debug { '[gemini][callable] disconnected' }
123
+ end
124
+
125
+ # Named completion kwargs the base Provider funnel accepts
126
+ # directly (08 F3); everything else in the fleet's **rest is
127
+ # folded wire params that become a Canonical::Params at the
128
+ # boundary (05 O4 — temperature is a params member, never a kwarg).
129
+ COMPLETION_NAMED_KEYS = %i[tools schema thinking tool_prefs headers].freeze
130
+ EMBED_NAMED_KEYS = %i[dimensions headers].freeze
131
+
132
+ # Fleet and SelectionDispatch pass model as a RAW STRING (the
133
+ # offering's model id). It passes through untranslated to the
134
+ # provider funnel — the base model_identity and the Gemini
135
+ # renderer both accept the bare string (0.8.0 contract).
136
+ # messages is positional — the 0.8.0 funnel and fleet WorkerExecution
137
+ # both hand the canonical Array<Canonical::Message> positionally.
138
+ def chat(messages, model:, **rest)
139
+ # Canonical boundary (N x N law): pipeline dispatch delivers
140
+ # Canonical::Message objects only. Hash/legacy shapes are the
141
+ # bypass class — reject loudly, never coerce.
142
+ provider.enforce_canonical_messages!(messages)
143
+ named, params = split_fleet_kwargs(rest, COMPLETION_NAMED_KEYS)
144
+ provider.chat(messages, model: model, params: canonical_params(params), **named)
145
+ end
146
+
147
+ def stream_chat(messages, model:, **rest, &)
148
+ provider.enforce_canonical_messages!(messages)
149
+ named, params = split_fleet_kwargs(rest, COMPLETION_NAMED_KEYS)
150
+ provider.stream_chat(messages, model: model, params: canonical_params(params), **named, &)
151
+ end
152
+
153
+ def embed(text:, model:, **rest)
154
+ named, params = split_fleet_kwargs(rest, EMBED_NAMED_KEYS)
155
+ provider.embed(text: text, model: model, params: params, **named)
156
+ end
157
+
158
+ def count_tokens(messages:, model:, **rest)
159
+ provider.enforce_canonical_messages!(messages)
160
+ _named, params = split_fleet_kwargs(rest, [])
161
+ provider.count_tokens(messages: messages, model: model, params: params)
162
+ end
163
+
164
+ def normalize_dispatch_error(error:)
165
+ reason = error.message.to_s[0, 512]
166
+ Legion::Extensions::Llm::Routing::ProviderOutcome.new(
167
+ kind: classify_dispatch_error(error: error), reason: reason.empty? ? 'unknown dispatch error' : reason
168
+ )
169
+ end
170
+
171
+ private
172
+
173
+ # The 0.8.0 completion funnel receives canonical values only
174
+ # (08 F3): the folded wire params become a Canonical::Params at
175
+ # the dispatch boundary — the renderer reads params.temperature /
176
+ # params.max_tokens, a raw Hash would NoMethodError.
177
+ def canonical_params(params)
178
+ Legion::Extensions::Llm::Canonical::Params.from_hash(params)
179
+ end
180
+
181
+ # Split the fleet's **rest into the base Provider's named kwargs
182
+ # and a payload params hash (any passed :params merged with the
183
+ # remaining unknown keys). Mirrors the shared callable boundary.
184
+ def split_fleet_kwargs(rest, named_keys)
185
+ named = rest.slice(*named_keys)
186
+ extra = rest.reject { |key, _| named.key?(key) }
187
+ params = (extra.delete(:params) || {}).to_h.merge(extra)
188
+ [named, params]
189
+ end
190
+
191
+ def provider = @provider ||= build_provider
192
+
193
+ def build_provider
194
+ Legion::Extensions::Llm::Gemini::Provider.new(
195
+ {
196
+ gemini_api_key: @instance_cfg[:gemini_api_key] || @instance_cfg[:api_key] ||
197
+ @instance_cfg.dig(:credentials, :api_key),
198
+ gemini_api_base: @instance_cfg[:gemini_api_base] || @instance_cfg[:endpoint]
199
+ }.compact
200
+ )
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end