lex-llm-ollama 0.3.5 → 0.3.7

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: c00ff8929f8723127315d78b2141144a64319b2af41526098397717f4833568a
4
- data.tar.gz: 2bc5494b6cdd8ec0f4268218e530719baeadd75126c71f70bdbbc96f4e4fc468
3
+ metadata.gz: a177e3c66491656118281eebec6069dd43e940b3a196988cd3d8584475d25204
4
+ data.tar.gz: 57b9c3122995c06dbfe08a3fffa43e7f77c793d9193b5786999322d8d32a2ee1
5
5
  SHA512:
6
- metadata.gz: b3a2b92564e059f37584612350f1e67a1f260d224b3237feaa8ba3158d4cef7d741765930dbd6a3d34694fcb7d8b1ce50ec43da743874a558134fbe32a9ad454
7
- data.tar.gz: 6f79d61f8fd32e268d1363c21a78209f41a78560d8f22f8a60cfe0d5c35f97a3987e0900fb111b99b5721fe23d94bc9842f4132f86a3fe64c2f828c10c38984f
6
+ metadata.gz: d4bcfbd0097b907bbee4bcf18de5e7942f0c288f79d09c9c4e54e853ca5f03db8ac1b419ae102575adeee5c8f3af90289527a148cdefd7b3c2a747ce90d83252
7
+ data.tar.gz: ed1527ef8e11f45410a8161078826acd7a2a20842a58ccd9e85d9956d947f68c49311ddc41f7f2ba751f53c46976f8540dddbf019ad2d5d650ac2d3e738d5091
data/.rubocop.yml CHANGED
@@ -14,6 +14,8 @@ Metrics/BlockLength:
14
14
  - spec/**/*
15
15
  Metrics/MethodLength:
16
16
  Enabled: false
17
+ Metrics/ModuleLength:
18
+ Max: 350
17
19
  Metrics/ParameterLists:
18
20
  Enabled: false
19
21
  Metrics/AbcSize:
data/CHANGELOG.md CHANGED
@@ -1,8 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.7] - 2025-08-25
4
+
5
+ ### Fixed
6
+ - **Thinking config contract** — reconcile to lex-llm 0.8.x `Canonical::Thinking::Config` (Data struct with `enabled`, `effort`, `budget`, `summary`, `metadata`). Removed the deleted `Canonical::Params#max_thinking_tokens` reference (`log_dropped_thinking_params` method and call site deleted — the member no longer exists). Removed the `Hash` branch in `enable_thinking?` — `request.thinking` is always a `Canonical::Thinking::Config` or nil. `apply_thinking_config` now emits `think: false` for a disabled Config (explicit off) and `think: true` for an enabled Config; omits the key entirely when no Config is present.
7
+
3
8
  ## [Unreleased]
4
9
 
10
+ ### Changed
11
+ - **lex-llm 0.8.0 conformance** — legacy-to-canonical migration and the 0.8.0 contract cut across the gem:
12
+ - **Legacy type rip** — `provider.rb` no longer constructs or consumes the deleted `Llm::Message`/`Chunk`/`Thinking`/`ToolCall`/`Content::Raw`/`Embedding` types. `parse_completion_response` is the one response-parse boundary returning `Canonical::Response` (08 R2); `build_chunk` is the one chunk-parse boundary returning `Canonical::Chunk`; the `to_legacy_*` re-canonicalization bridges are deleted. Streaming runs the shared `StreamAccumulator` (10 U1) and honors the one streaming contract: yields `Canonical::Chunk`, ends in exactly one `done` chunk (or an `error` chunk before the raise), returns the accumulated `Canonical::Response`; a non-200 NDJSON body now raises the typed error through the shared streaming failure path instead of being silently skipped into an empty "successful" stream.
13
+ - **Renderer law (08 R1–R4)** — `render_payload` renders FROM canonical values with the 0.8.0 funnel signature (`params:` is `Canonical::Params | nil`, the `temperature:` kwarg is gone); the provider-spelled options keys (G18: `num_predict`, ...) are the translator's `options_for` edge mapping — the one params table in the gem. Tool arguments parse through the shared strict `Responses::ToolArguments.parse!` (10 U2): invalid JSON raises, the rescue-to-`{}` tolerance is deleted. Streaming tool-call deltas carry the accumulator fragment shape (`{ id:, name:, arguments: String, index: }`); Ollama's per-chunk complete-state re-send is handled by the accumulator's per-id replace semantics.
14
+ - **Discovery read path (07 C5)** — the provider's legacy `discover_offerings` live-discovery override and its `Routing::ModelOffering` production (`offering_from_model` and helpers) are deleted; the base read path serves the activated inventory offerings from `Registry.snapshot`. The SSOT discovery actor remains the sole publication path (`OfferingDraft` + `WeightReconciler` under the writer mutex). The embedding operation returns the 05 §3 documented artifact (`{ text:, model:, embedding:, usage: }`) in place of the deleted `Llm::Embedding` object.
15
+ - **Coordinator wiring rip** — the `Inventory::ScopedRefresher::LegacyCoordinatorAdapter` require and `Publisher` argument are removed from the discovery actor (the file is gone from lex-llm 0.8.0; the mixed-version projection window is over). The fleet worker runner passes `registry:` explicitly to `ProviderResponder.call` (fleet protocol v3: exact execution resolves the callable from the SSOT registry; the legacy `provider_class:`/`provider_instances:` arguments are gone).
16
+ - **Callable contract** — `OllamaCallable#chat`/`#stream_chat` take `messages` positionally, matching the 0.8.0 base Provider funnel and the fleet worker dispatch shape; `count_tokens`/`embed` keep their keyword `messages:`/`text:`. The callable-side `enforce_canonical_messages!` calls (the one shared helper at the exact-execution boundary) are kept.
17
+ - **Floor bump** — gemspec now requires `lex-llm >= 0.8.0`. `RULES.md` (the 0.8.0 architecture law) ships at the repo root, byte-for-byte.
18
+ - **Specs** — the provider spec rewrites the streaming/parser coverage against canonical types; the SSOT v3 conformance suite runs the kit's B1 (central enforcement) and B2 (canonical outputs) groups against the real callable boundary; the dispatch-boundary regression block now drives rejection through the funnel/callable (the base enforces centrally — the per-provider render-seam check is deleted with the legacy Chat-facade entry it existed for).
19
+
5
20
  ### Fixed
21
+ - **Canonical dispatch boundary (2026-08-19 incident)** — provider input is now canonical-only at both dispatch seams: `OllamaCallable`'s fleet dispatch ops (`chat`, `stream_chat`, `count_tokens`) call the lex-llm 0.7.7 base `Provider#enforce_canonical_messages!` before delegating, and the provider's render seam (`render_payload` → `enforce_render_messages!`) accepts only `Canonical::Message` (pipeline dispatch) or provider-native `Legion::Extensions::Llm::Message` (Chat facade), raising a loud `ArgumentError` for anything else. The lenient provider-side re-canonicalization — the path that masked the 2026-08-19 hash bypass (25/25 failed openai dispatches) — is removed: zero Hash tolerance, no bridges, no adapters. The Ollama wire payload and client request formats are unchanged.
22
+ - **lex-llm 0.7.7 floor** — the gemspec now requires `lex-llm >= 0.7.7` for `Provider#enforce_canonical_messages!` and the first-class canonical `cache_control` member; the bare registry `gem 'lex-llm'` line is replaced by a test-group path dependency on the adjacent `../lex-llm` checkout so the boundary work resolves against 0.7.7 during development.
23
+ - **Dispatch-boundary specs** — the SSOT v3 conformance suite gains a dispatch-boundary regression block: canonical input (including the `cache_control` member) renders through the production render seam without leaking the transport-only key onto the wire, plain-Hash input raises at both the fleet callable and the render seam, and provider-native `Message` input continues to render through the seam; the `RecordingOllamaProvider` stand-in delegates `enforce_canonical_messages!` to the real base implementation.
6
24
  - **Complete discovery change detection** — ordinary discovery now compares the complete stable `OfferingDraft#to_h` contract as an order-independent, duplicate-count-preserving multiset. Only evidence `observed_at` telemetry is excluded; quota domains, metadata, publication source, scalar evidence authority, and every other draft field trigger one atomic replacement when changed.
7
25
  - **Startup claim atomicity** — initial model discovery and write-time weight validation now complete before Ollama constructs a callable, coordinator, or Registry claim, so malformed settings cannot leave an orphan `:initializing` publication status or token; the next corrected discovery pass claims and activates the instance exactly once without a restart or operator repair.
8
26
  - **SSOT v3 write-time weights** — Ollama drafts now publish the validated four-axis weight pair from live settings, and the existing discovery cadence atomically reconciles weight-only changes, initialization/activation races, sequence/cache updates, removals, and dormant configured-weight logging through lex-llm 0.7.6. No Settings callback, reload, reset, migration, restart, or operator action is introduced.
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 unreleased 0.8.0 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,6 +27,9 @@ 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
- # 0.7.6 adds write-time WeightSchema records and the atomic writer reconciler.
31
- spec.add_dependency 'lex-llm', '>= 0.7.6'
30
+ # 0.8.0 is the contract cut this gem conforms to: canonical-only provider
31
+ # funnel (central enforce_canonical_messages!), Canonical::Response/Chunk
32
+ # output, Registry-snapshot discovery read path, fleet protocol v3, and the
33
+ # legacy Llm::* type rip.
34
+ spec.add_dependency 'lex-llm', '>= 0.8.0'
32
35
  end
@@ -0,0 +1,23 @@
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 Ollama
13
+ module Actor
14
+ # Ollama 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 Ollama-specific work lives in
17
+ # Ollama::Runners::Discovery, resolved by the base from this namespace.
18
+ class Discovery < Legion::Extensions::Llm::Discovery::Actor; end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require 'legion/extensions/llm/routing/provider_outcome'
6
+ require 'legion/extensions/llm/ollama/provider'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Llm
11
+ module Ollama
12
+ module Helpers
13
+ # Callable wrapper for an Ollama provider instance. Implements the
14
+ # fleet dispatch ops (chat/stream_chat/embed/count_tokens) by delegating
15
+ # to a per-instance Ollama::Provider, plus the disconnect and
16
+ # normalize_dispatch_error contracts required by Inventory::CallableHandle
17
+ # and Routing::ProviderOutcome. Dispatch errors propagate untouched so
18
+ # normalize_dispatch_error can classify them.
19
+ class Callable
20
+ # Keys the base Provider exposes as named kwargs for the completion
21
+ # operations. Anything else the fleet passes (sampling scalars,
22
+ # `temperature` — a Canonical::Params member, 05 O4) is folded into
23
+ # Canonical::Params at the dispatch boundary.
24
+ COMPLETION_NAMED_KEYS = %i[tools schema thinking tool_prefs headers].freeze
25
+ EMBED_NAMED_KEYS = %i[dimensions headers].freeze
26
+
27
+ def initialize(instance_cfg:, logger:, provider: nil)
28
+ @instance_cfg = instance_cfg
29
+ @logger = logger
30
+ @provider = provider
31
+ @disconnected = false
32
+ end
33
+
34
+ def disconnected? = @disconnected
35
+
36
+ def disconnect
37
+ @disconnected = true
38
+ @provider&.disconnect
39
+ @logger.debug { '[ollama][callable] disconnected' }
40
+ end
41
+
42
+ # Fleet and SelectionDispatch pass model as a RAW STRING (the
43
+ # offering's model id). Ollama's render path is string-tolerant
44
+ # (model.respond_to?(:id) ? model.id : model) for chat and embed,
45
+ # embed places the model verbatim in the 05 §3 embedding artifact,
46
+ # and count_tokens ignores it — so the model passes through
47
+ # UNWRAPPED on every op.
48
+ # 0.8.0 callable contract: chat/stream_chat take the rehydrated
49
+ # message array positionally (WorkerExecution dispatch shape);
50
+ # count_tokens takes the messages: kwarg (worker_execution.rb);
51
+ # the Selection-derived model is a bare String.
52
+ def chat(messages, model:, **rest)
53
+ # Canonical boundary (N x N law): pipeline dispatch delivers
54
+ # Canonical::Message objects only. Hash shapes are the bypass
55
+ # class — reject loudly, never coerce.
56
+ provider.enforce_canonical_messages!(messages)
57
+ named, params = split_fleet_kwargs(rest, COMPLETION_NAMED_KEYS)
58
+ provider.chat(messages, model: model, params: canonical_params(params), **named)
59
+ end
60
+
61
+ def stream_chat(messages, model:, **rest, &)
62
+ provider.enforce_canonical_messages!(messages)
63
+ named, params = split_fleet_kwargs(rest, COMPLETION_NAMED_KEYS)
64
+ provider.stream_chat(messages, model: model, params: canonical_params(params), **named, &)
65
+ end
66
+
67
+ def embed(text:, model:, **rest)
68
+ named, params = split_fleet_kwargs(rest, EMBED_NAMED_KEYS)
69
+ provider.embed(text: text, model: model, params: params, **named)
70
+ end
71
+
72
+ def count_tokens(messages:, model:, **rest)
73
+ provider.enforce_canonical_messages!(messages)
74
+ _named, params = split_fleet_kwargs(rest, [])
75
+ provider.count_tokens(messages: messages, model: model, params: params)
76
+ end
77
+
78
+ def normalize_dispatch_error(error:)
79
+ Legion::Extensions::Llm::Routing::ProviderOutcome.new(
80
+ kind: classify_dispatch_error(error: error),
81
+ reason: dispatch_reason(error)
82
+ )
83
+ end
84
+
85
+ private
86
+
87
+ def canonical_params(params)
88
+ Legion::Extensions::Llm::Canonical::Params.from_hash(params)
89
+ end
90
+
91
+ def split_fleet_kwargs(rest, named_keys)
92
+ named = rest.slice(*named_keys)
93
+ extra = rest.reject { |key, _| named.key?(key) }
94
+ params = (extra.delete(:params) || {}).to_h.merge(extra)
95
+ [named, params]
96
+ end
97
+
98
+ def classify_dispatch_error(error:)
99
+ return :model_not_ready if model_not_ready?(error: error)
100
+
101
+ base_kind = base_classifier.normalize_dispatch_error(error: error).kind
102
+ return base_kind unless base_kind == :provider_error && faraday_status_error?(error)
103
+
104
+ status_kind(dispatch_status(error))
105
+ end
106
+
107
+ def base_classifier
108
+ @base_classifier ||= Legion::Extensions::Llm::Ollama::Provider.new({})
109
+ end
110
+
111
+ def faraday_status_error?(error)
112
+ error.is_a?(Faraday::ClientError) || error.is_a?(Faraday::ServerError)
113
+ end
114
+
115
+ # §8 health firewall: Ollama emits no explicit flat
116
+ # instance-unavailable dispatch signal (a dead server simply drops
117
+ # the connection — that is the :connection_failure down-signal, which
118
+ # the readiness probe then turns into an availability transition).
119
+ # Status code alone (503/5xx) never maps to :instance_unavailable.
120
+ def status_kind(status)
121
+ case status
122
+ when 401 then :authentication
123
+ when 403 then :authorization
124
+ when 404 then :model_missing
125
+ when 429 then :rate_limited
126
+ when 503, 529 then :overloaded
127
+ when 400...500 then :invalid_request
128
+ else :provider_error
129
+ end
130
+ end
131
+
132
+ def model_not_ready?(error:)
133
+ status = dispatch_status(error)
134
+ return false unless status.is_a?(::Integer) && status >= 500
135
+
136
+ response_body_string(error).to_s.match?(/model.{0,10}(not\s+(loaded|ready)|loading)/i)
137
+ end
138
+
139
+ def response_body_string(error)
140
+ response = error.respond_to?(:response) ? error.response : nil
141
+ return nil if response.nil?
142
+
143
+ body = response.respond_to?(:body) ? response.body : (response[:body] if response.respond_to?(:[]))
144
+ return body if body.is_a?(String)
145
+
146
+ body && ::JSON.generate(body)
147
+ end
148
+
149
+ def dispatch_status(error)
150
+ return error.response_status if error.respond_to?(:response_status) && error.response_status
151
+
152
+ response = error.respond_to?(:response) ? error.response : nil
153
+ return response.status if response.respond_to?(:status) && response.status
154
+ return response[:status] if response.respond_to?(:[]) && response[:status]
155
+
156
+ nil
157
+ end
158
+
159
+ def dispatch_reason(error)
160
+ reason = error.message.to_s[0, 512]
161
+ reason.empty? ? 'unknown dispatch error' : reason
162
+ end
163
+
164
+ def provider
165
+ @provider ||= build_provider
166
+ end
167
+
168
+ def build_provider
169
+ Legion::Extensions::Llm::Ollama::Provider.new(provider_config)
170
+ end
171
+
172
+ def provider_config
173
+ cfg = @instance_cfg.to_h.transform_keys(&:to_sym)
174
+ base_url = cfg[:base_url] || cfg[:endpoint]
175
+ return {} unless base_url.is_a?(String) && !base_url.strip.empty?
176
+
177
+ { base_url: base_url }
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end