lex-llm 0.8.0 → 0.8.5

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +34 -0
  4. data/lib/legion/extensions/llm/canonical/chunk.rb +1 -2
  5. data/lib/legion/extensions/llm/canonical/content_block.rb +1 -2
  6. data/lib/legion/extensions/llm/canonical/message.rb +1 -2
  7. data/lib/legion/extensions/llm/canonical/params.rb +3 -5
  8. data/lib/legion/extensions/llm/canonical/request.rb +1 -2
  9. data/lib/legion/extensions/llm/canonical/response.rb +1 -2
  10. data/lib/legion/extensions/llm/canonical/thinking.rb +2 -98
  11. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  12. data/lib/legion/extensions/llm/canonical/tool_call.rb +1 -2
  13. data/lib/legion/extensions/llm/canonical.rb +1 -0
  14. data/lib/legion/extensions/llm/connection.rb +1 -1
  15. data/lib/legion/extensions/llm/credential_sources.rb +1 -5
  16. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -1
  17. data/lib/legion/extensions/llm/responses/tool_arguments.rb +19 -5
  18. data/lib/legion/extensions/llm/version.rb +1 -1
  19. data/lib/legion/extensions/llm.rb +3 -0
  20. data/spec/legion/extensions/llm/canonical/params_spec.rb +0 -2
  21. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  22. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +0 -68
  23. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  24. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  25. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  26. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +2 -4
  27. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +2 -4
  28. data/spec/legion/extensions/llm/connection_tls_spec.rb +103 -0
  29. data/spec/legion/extensions/llm/credential_sources_spec.rb +16 -7
  30. data/spec/legion/extensions/llm/fleet_messages_spec.rb +1 -2
  31. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  32. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +23 -0
  33. data/spec/legion/extensions/llm/provider_spec.rb +1 -2
  34. data/spec/support/tls_endpoint.rb +92 -0
  35. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6482361b65a465a6eb683fb1789cd47a6a1a80c709c344eefb57205321e90c12
4
- data.tar.gz: b2dd262d310f469fd1c89fa0b00eb1199822397bdb762c17c056b05cbad1b1d6
3
+ metadata.gz: e2216f17aaf84164eccc09087fb78e2a52339c24ae67207a43e220de31927fc3
4
+ data.tar.gz: 143caaf03ac594ef2cf9fb298836b7226f67c8b43ee4e0c0e5cdc26aaeb9b4d0
5
5
  SHA512:
6
- metadata.gz: 64cb921e6ccf8361704df0fe4846fb80b3d074ed09b6940f4cff2c2440c8260e36258acda18ed786415d8052a111e33dcd17ad0f1cef1240dd634f14c30f0172
7
- data.tar.gz: e2bb88cc4d672f4a11a64cdc91cc922964e67eab8baa85ad3108c40cc269363dc8009684162c70d1f98dbf8d0a0757d0ca8790838eb066ddab59a1edf37cb28d
6
+ metadata.gz: 24b19766bdc02f567c50d2a76305cdbbc15252607ab02d46484811f9723164f6bb75dae86e5556901b675b235f750f14a3c41dc25e2b5d357cb52a7d81399c19
7
+ data.tar.gz: 7a65c4c5f65c5b43d123f408a6c399a983a5fbbbedbdc13dcd5e96ab16edf414e186266ae4c4e43f35927466303997d5b74d6cd95391eb6b8a64edf8702a55aa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.5 - 2026-10-29
4
+
5
+ ### Changed
6
+ - Make shared provider TLS certificate and hostname verification configurable through
7
+ `Legion::Settings` at `extensions.llm.ssl.verify`, defaulting to `false`. Set it to
8
+ `true` to enable verification with Typhoeus or Net::HTTP; explicitly trusted custom
9
+ certificate authorities remain supported. (#50)
10
+
11
+ ### Fixed
12
+ - Correct asynchronous TCP probes that could report refused connections as open
13
+ on Darwin; inspect the completed socket's error status instead of reconnecting.
14
+
15
+ ## 0.8.4 - 2026-08-25
16
+
17
+ ### Changed
18
+ - **OpenAI-compatible transposer routes thinking through the canonical bridge.**
19
+ `Provider::OpenAICompatible#openai_reasoning_effort` now returns `nil` (omitting
20
+ `reasoning_effort`) when thinking is absent or `enabled: false`, and uses
21
+ `Thinking::Config#resolved_effort` instead of the raw `effort` — so a budget-only
22
+ request derives an effort instead of being silently dropped. Added spec coverage.
23
+ - **`Responses::ToolArguments.parse!` accepts every legitimate wire form (10 U2).**
24
+ An already-decoded `Hash` passes through unchanged (it is the canonical Hash); a
25
+ `null`/empty/whitespace-only string and a JSON `null` resolve to `{}` (the documented
26
+ no-arguments default, not tolerance). Genuinely corrupt arguments — a non-object JSON
27
+ value or invalid JSON — still raise; they are never fabricated into `{}`.
28
+
29
+ ## 0.8.3 - 2026-08-25
30
+
31
+ ### Removed
32
+ - **Removed `Canonical::Params#max_thinking_tokens`.** Thinking budget now lives solely
33
+ on `Thinking::Config#budget`. The redundant dual-home on Params is deleted from the
34
+ `Data.define` member list, `.build`, `.from_hash`, and the strict `.new` validation
35
+ block. Any wire payload carrying `max_thinking_tokens` in params will now fold into
36
+ `metadata` as an unknown key (04 L5).
37
+
38
+ ## 0.8.2 - 2026-08-25
39
+
40
+ ### Changed
41
+ - **Expand `Thinking::Config` to the common superset shape.** The canonical struct
42
+ is now `Data.define(:enabled, :effort, :budget, :summary, :metadata)`:
43
+ - `enabled` — explicit Boolean (true/false). Default true when a Config is built;
44
+ nil request.thinking means "client said nothing"; `enabled: false` means explicit
45
+ OFF. Validated strictly (non-boolean raises).
46
+ - `effort` — closed 6-level enum: `none`, `low`, `medium`, `high`, `xhigh`, `max`
47
+ (downcased strings). Unknown values raise at construction.
48
+ - `budget` — positive Integer or nil. Zero and negatives rejected at construction
49
+ (off rides `enabled: false`; dynamic rides `enabled: true` + no axis).
50
+ - `summary` — closed enum `{nil, :auto, :none, :concise, :detailed}` for
51
+ thought-visibility; independent of the effort/budget axes.
52
+ - `metadata` — unchanged passthrough Hash.
53
+ - `EFFORT_BUDGET` extended to the full ladder:
54
+ `{'low'=>1024,'medium'=>8192,'high'=>16384,'xhigh'=>24576,'max'=>32768}`.
55
+ `none` resolves to nil budget. `resolved_budget` and `resolved_effort` only FILL
56
+ an axis the client didn't supply — never overwrite a supplied one.
57
+ - `enabled?` returns the `enabled` member directly.
58
+
59
+ ## 0.8.1 - 2026-08-25
60
+
61
+ ### Changed
62
+ - **Extract `Thinking::Config` to its own file.** `Canonical::Thinking::Config` (the
63
+ `Data.define` type and its constants) now lives in `canonical/thinking_config.rb`
64
+ instead of being nested inside `canonical/thinking.rb`. Spec coverage split into a
65
+ matching `thinking_config_spec.rb`. Pure refactor — no behavior changes.
66
+
3
67
  ## 0.8.0 - 2026-08-20
4
68
 
5
69
  **The SSOT v4 contract cut.** This is a complete contract line, not a patch wave:
data/README.md CHANGED
@@ -489,10 +489,44 @@ Key settings paths:
489
489
  - `extensions.llm.fleet.endpoint` -- endpoint-style worker configuration
490
490
  - `extensions.llm.fleet.compliance.encrypt_fleet` -- encrypt fleet envelopes (default true)
491
491
  - `extensions.llm.fleet.auth.verify_issuer` -- validate JWT issuer (default true)
492
+ - `extensions.llm.ssl.verify` -- verify provider HTTPS certificates and hostnames (default false)
492
493
  - `extensions.llm.security.credential_source_probing` -- gate credential probing (default true)
493
494
  - `extensions.llm.model_whitelist` / `model_blacklist` -- provider-level model filters
494
495
  - `extensions.llm.<family>.instance.<name>.model_whitelist` -- per-instance override
495
496
 
497
+ ### TLS verification and custom certificate authorities
498
+
499
+ Shared provider connections read `extensions.llm.ssl.verify` from `Legion::Settings`
500
+ when constructed. The default is `false` for both Typhoeus and Net::HTTP.
501
+ With verification disabled, untrusted and wrong-host certificates are accepted,
502
+ so credentials can be sent to an unauthenticated peer.
503
+
504
+ Enable verification before creating provider connections:
505
+
506
+ ```json
507
+ {
508
+ "extensions": {
509
+ "llm": {
510
+ "ssl": { "verify": true }
511
+ }
512
+ }
513
+ }
514
+ ```
515
+
516
+ With verification enabled, an untrusted certificate or hostname mismatch fails
517
+ before an authenticated HTTP request is sent.
518
+
519
+ When constructing a shared connection for a private or corporate CA, configure
520
+ the underlying Faraday SSL options before making the first request:
521
+
522
+ ```ruby
523
+ connection = Legion::Extensions::Llm::Connection.new(provider, config)
524
+ connection.connection.ssl.ca_file = '/path/to/trusted-ca-bundle.pem'
525
+ ```
526
+
527
+ When verification is enabled, private or self-signed HTTPS servers need explicit
528
+ CA trust. Plain HTTP behavior is unchanged.
529
+
496
530
  ---
497
531
 
498
532
  ## Provider Dependencies
@@ -1,6 +1,6 @@
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
@@ -207,4 +207,3 @@ module Legion
207
207
  end
208
208
  end
209
209
  end
210
- # rubocop:enable Metrics/ParameterLists
@@ -1,6 +1,6 @@
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
@@ -149,4 +149,3 @@ module Legion
149
149
  end
150
150
  end
151
151
  end
152
- # rubocop:enable Metrics/ParameterLists
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'securerandom'
4
4
 
5
- # rubocop:disable Metrics/ParameterLists -- factory methods have many params
5
+ # rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
6
6
  module Legion
7
7
  module Extensions
8
8
  module Llm
@@ -138,4 +138,3 @@ module Legion
138
138
  end
139
139
  end
140
140
  end
141
- # rubocop:enable Metrics/ParameterLists
@@ -11,19 +11,19 @@ module Legion
11
11
  # Per G18: all standard/useful params are first-class, mapped per provider by translators.
12
12
  # Canonical keys only (O03a): provider spellings are translated at the edges.
13
13
  Params = ::Data.define(
14
- :max_tokens, :max_thinking_tokens, :temperature, :top_p, :top_k,
14
+ :max_tokens, :temperature, :top_p, :top_k,
15
15
  :stop_sequences, :seed, :frequency_penalty, :presence_penalty,
16
16
  :response_format, :metadata
17
17
  ) do
18
18
  # rubocop:disable Metrics/ParameterLists -- factory methods have many params
19
19
  # Build from keyword args (primary constructor).
20
20
  def self.build(
21
- max_tokens: nil, max_thinking_tokens: nil, temperature: nil, top_p: nil, top_k: nil,
21
+ max_tokens: nil, temperature: nil, top_p: nil, top_k: nil,
22
22
  stop_sequences: nil, seed: nil, frequency_penalty: nil, presence_penalty: nil,
23
23
  response_format: nil, metadata: {}
24
24
  )
25
25
  new(
26
- max_tokens:, max_thinking_tokens:, temperature:, top_p:, top_k:,
26
+ max_tokens:, temperature:, top_p:, top_k:,
27
27
  stop_sequences:, seed:, frequency_penalty:, presence_penalty:,
28
28
  response_format:, metadata: Strict.metadata!(metadata, self::BUILD_SITE)
29
29
  )
@@ -39,7 +39,6 @@ module Legion
39
39
  metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
40
40
  build(
41
41
  max_tokens: hash[:max_tokens],
42
- max_thinking_tokens: hash[:max_thinking_tokens],
43
42
  temperature: hash[:temperature],
44
43
  top_p: hash[:top_p],
45
44
  top_k: hash[:top_k],
@@ -73,7 +72,6 @@ module Legion
73
72
  # wire. The factories fill their defaults and delegate here.
74
73
  Strict.install_strict_new!(self) do |values, site|
75
74
  values[:max_tokens] = Strict.expect_type!(values[:max_tokens], [::Integer], site, :max_tokens)
76
- values[:max_thinking_tokens] = Strict.expect_type!(values[:max_thinking_tokens], [::Integer], site, :max_thinking_tokens)
77
75
  values[:temperature] = Strict.expect_type!(values[:temperature], [::Numeric], site, :temperature)
78
76
  values[:top_p] = Strict.expect_type!(values[:top_p], [::Numeric], site, :top_p)
79
77
  values[:top_k] = Strict.expect_type!(values[:top_k], [::Integer], site, :top_k)
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'securerandom'
4
4
 
5
- # rubocop:disable Metrics/ParameterLists -- factory methods have many params
5
+ # rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
6
6
  module Legion
7
7
  module Extensions
8
8
  module Llm
@@ -136,4 +136,3 @@ module Legion
136
136
  end
137
137
  end
138
138
  end
139
- # rubocop:enable Metrics/ParameterLists
@@ -1,6 +1,6 @@
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
@@ -115,4 +115,3 @@ module Legion
115
115
  end
116
116
  end
117
117
  end
118
- # rubocop:enable Metrics/ParameterLists
@@ -69,105 +69,9 @@ module Legion
69
69
  Thinking::BUILD_SITE = 'Canonical::Thinking.build'
70
70
  Thinking::FROM_HASH_SITE = 'Canonical::Thinking.from_hash'
71
71
  Thinking::NEW_SITE = 'Canonical::Thinking.new'
72
-
73
- # Normalized config for thinking across providers — one name, one shape
74
- # (04 §8): Canonical::Thinking::Config. # -- required for Data.define block scope
75
- Thinking::Config = ::Data.define(:effort, :budget, :metadata) do
76
- def self.build(effort: nil, budget: nil, metadata: {})
77
- new(effort: effort_string!(effort, self::BUILD_SITE), budget:,
78
- metadata: Strict.metadata!(metadata, self::BUILD_SITE))
79
- end
80
-
81
- # Build from a Hash.
82
- def self.from_hash(source)
83
- Strict.require_hash!(source, self::FROM_HASH_SITE)
84
- hash = Strict.symbolize_keys(source)
85
- metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
86
- build(effort: hash[:effort], budget: hash[:budget], metadata:)
87
- end
88
-
89
- # M4: effort is a closed enum (the EFFORT_BUDGET keys), not an
90
- # unbounded string — an unrecognized effort is a contract error at
91
- # construction, never a silently-derived budget.
92
- def self.effort_string!(effort, site)
93
- return nil if effort.nil?
94
-
95
- value = effort.is_a?(::Symbol) ? effort.to_s : Strict.expect_type!(effort, [::String], site, :effort)
96
- normalized = value.downcase
97
- allowed = self::EFFORT_BUDGET.keys
98
- raise ArgumentError, "#{site}: Invalid effort: #{value.inspect}. Must be one of: #{allowed.join(', ')}" unless allowed.include?(normalized)
99
-
100
- normalized
101
- end
102
-
103
- # Serialize to a Hash for AMQP/fleet/wire transport. Faithful to what was
104
- # SET — never fabricates the missing axis (use resolved_* for that).
105
- def to_h
106
- super.compact
107
- end
108
-
109
- def as_json(*)
110
- to_h
111
- end
112
-
113
- def to_json(*)
114
- to_h.to_json(*)
115
- end
116
-
117
- # Whether thinking is configured.
118
- def enabled?
119
- !effort.nil? || !budget.nil?
120
- end
121
-
122
- # Budget for a provider that needs a token budget (e.g. Anthropic),
123
- # derived from effort when budget was not explicitly set. nil only
124
- # when neither axis is configured. M4: effort is enum-validated at
125
- # construction, so the lookup cannot miss — the silent medium
126
- # fallback is deleted.
127
- def resolved_budget
128
- return budget unless budget.nil?
129
- return nil if effort.nil?
130
-
131
- self.class::EFFORT_BUDGET[effort]
132
- end
133
-
134
- # Effort for a provider that needs an effort level (e.g. OpenAI),
135
- # derived from budget when effort was not explicitly set. nil only
136
- # when neither axis is configured.
137
- def resolved_effort
138
- return effort unless effort.nil?
139
- return nil if budget.nil?
140
-
141
- bands = self.class::EFFORT_BUDGET
142
- if budget < bands['medium'] then 'low'
143
- elsif budget < bands['high'] then 'medium'
144
- else 'high'
145
- end
146
- end
147
-
148
- # H1/M4: the single strict constructor — .new runs the same member
149
- # contract as the factories (effort enum, Integer budget); the
150
- # factories fill their defaults and delegate here.
151
- Strict.install_strict_new!(self) do |values, site|
152
- values[:effort] = effort_string!(values[:effort], site)
153
- values[:budget] = Strict.expect_type!(values[:budget], [::Integer], site, :budget)
154
- values[:metadata] = Strict.metadata!(values[:metadata], site)
155
- values
156
- end
157
- end
158
-
159
- # SSOT for the effort<->budget conversion. A client dialect supplies only
160
- # ONE axis (Anthropic = budget_tokens only; OpenAI = effort only), but a
161
- # provider translator may need the OTHER. This single map lets every
162
- # provider ask for whichever axis it needs and always get a usable value,
163
- # so thinking survives any client x provider pair (best-effort, never
164
- # silently dropped). effort -> budget is exact; budget -> effort uses the
165
- # band boundaries above.
166
- Thinking::Config::EFFORT_BUDGET = { 'low' => 1024, 'medium' => 8192, 'high' => 16_384 }.freeze
167
- Thinking::Config::BUILD_SITE = 'Canonical::Thinking::Config.build'
168
- Thinking::Config::FROM_HASH_SITE = 'Canonical::Thinking::Config.from_hash'
169
- Thinking::Config::NEW_SITE = 'Canonical::Thinking::Config.new'
170
72
  end
171
73
  end
172
74
  end
173
75
  end
76
+
77
+ require_relative 'thinking_config'
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ # -- extracted from thinking.rb; depends on Thinking being defined first
4
+ module Legion
5
+ module Extensions
6
+ # -- module doc is in canonical.rb entry point
7
+ module Llm
8
+ # -- required for Data.define block scope
9
+ module Canonical
10
+ # Normalized config for thinking across providers — one name, one shape
11
+ # (04 §8): Canonical::Thinking::Config.
12
+ # Members: enabled, effort, budget, summary, metadata
13
+ Thinking::Config = ::Data.define(:enabled, :effort, :budget, :summary, :metadata) do
14
+ def self.build(enabled: true, effort: nil, budget: nil, summary: nil, metadata: {})
15
+ new(enabled: enabled, effort: effort_string!(effort, self::BUILD_SITE), budget: budget,
16
+ summary: summary, metadata: Strict.metadata!(metadata, self::BUILD_SITE))
17
+ end
18
+
19
+ # Build from a Hash.
20
+ def self.from_hash(source)
21
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
22
+ hash = Strict.symbolize_keys(source)
23
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
24
+ build(enabled: hash.key?(:enabled) ? hash[:enabled] : true,
25
+ effort: hash[:effort], budget: hash[:budget],
26
+ summary: hash[:summary], metadata: metadata)
27
+ end
28
+
29
+ # M4: effort is a closed enum (the EFFORT_BUDGET keys), not an
30
+ # unbounded string — an unrecognized effort is a contract error at
31
+ # construction, never a silently-derived budget.
32
+ def self.effort_string!(effort, site)
33
+ return nil if effort.nil?
34
+
35
+ value = effort.is_a?(::Symbol) ? effort.to_s : Strict.expect_type!(effort, [::String], site, :effort)
36
+ normalized = value.downcase
37
+ allowed = self::EFFORT_LEVELS
38
+ raise ArgumentError, "#{site}: Invalid effort: #{value.inspect}. Must be one of: #{allowed.join(', ')}" unless allowed.include?(normalized)
39
+
40
+ normalized
41
+ end
42
+
43
+ # Validate summary is a closed enum.
44
+ def self.summary_enum!(value, site)
45
+ return nil if value.nil?
46
+
47
+ sym = value.is_a?(::String) ? value.to_sym : value
48
+ Strict.expect_type!(sym, [::Symbol], site, :summary)
49
+ allowed = self::SUMMARY_LEVELS
50
+ unless allowed.include?(sym)
51
+ raise ArgumentError,
52
+ "#{site}: Invalid summary: #{value.inspect}. Must be one of: #{allowed.map(&:inspect).join(', ')}"
53
+ end
54
+
55
+ sym
56
+ end
57
+
58
+ # Serialize to a Hash for AMQP/fleet/wire transport. Faithful to what was
59
+ # SET — never fabricates the missing axis (use resolved_* for that).
60
+ def to_h
61
+ super.compact
62
+ end
63
+
64
+ def as_json(*)
65
+ to_h
66
+ end
67
+
68
+ def to_json(*)
69
+ to_h.to_json(*)
70
+ end
71
+
72
+ # Whether thinking is enabled (the enabled member).
73
+ def enabled?
74
+ enabled
75
+ end
76
+
77
+ # Budget for a provider that needs a token budget (e.g. Anthropic),
78
+ # derived from effort when budget was not explicitly set. nil when
79
+ # effort is 'none' or neither axis is configured. Only FILLS — never
80
+ # overwrites a supplied budget. Both axes may be carried together.
81
+ def resolved_budget
82
+ return budget unless budget.nil?
83
+ return nil if effort.nil?
84
+ return nil if effort == 'none'
85
+
86
+ self.class::EFFORT_BUDGET[effort]
87
+ end
88
+
89
+ # Effort for a provider that needs an effort level (e.g. OpenAI),
90
+ # derived from budget when effort was not explicitly set. nil only
91
+ # when neither axis is configured. Only FILLS — never overwrites a
92
+ # supplied effort.
93
+ def resolved_effort
94
+ return effort unless effort.nil?
95
+ return nil if budget.nil?
96
+
97
+ bands = self.class::EFFORT_BUDGET
98
+ if budget <= bands['low'] then 'low'
99
+ elsif budget <= bands['medium'] then 'medium'
100
+ elsif budget <= bands['high'] then 'high'
101
+ elsif budget <= bands['xhigh'] then 'xhigh'
102
+ else 'max'
103
+ end
104
+ end
105
+
106
+ # H1/M4: the single strict constructor — .new runs the same member
107
+ # contract as the factories (enabled bool, effort enum, Integer budget,
108
+ # summary enum); the factories fill their defaults and delegate here.
109
+ Strict.install_strict_new!(self) do |values, site|
110
+ # enabled: must be true or false
111
+ raise ArgumentError, "#{site}: enabled must be true or false, got #{values[:enabled].inspect}" unless [true, false].include?(values[:enabled])
112
+
113
+ values[:effort] = effort_string!(values[:effort], site)
114
+
115
+ # budget: must be a positive Integer or nil
116
+ values[:budget] = Strict.expect_type!(values[:budget], [::Integer], site, :budget)
117
+ raise ArgumentError, "#{site}: budget must be positive, got #{values[:budget]}" if values[:budget] && !values[:budget].positive?
118
+
119
+ values[:summary] = summary_enum!(values[:summary], site)
120
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
121
+ values
122
+ end
123
+ end
124
+
125
+ # Closed set of valid effort levels.
126
+ Thinking::Config::EFFORT_LEVELS = %w[none low medium high xhigh max].freeze
127
+
128
+ # SSOT for the effort<->budget conversion. A client dialect supplies only
129
+ # ONE axis (Anthropic = budget_tokens only; OpenAI = effort only), but a
130
+ # provider translator may need the OTHER. This single map lets every
131
+ # provider ask for whichever axis it needs and always get a usable value,
132
+ # so thinking survives any client x provider pair (best-effort, never
133
+ # silently dropped). effort -> budget is exact; budget -> effort uses the
134
+ # band boundaries above. 'none' has no budget — resolves to nil.
135
+ Thinking::Config::EFFORT_BUDGET = {
136
+ 'low' => 1024, 'medium' => 8192, 'high' => 16_384,
137
+ 'xhigh' => 24_576, 'max' => 32_768
138
+ }.freeze
139
+
140
+ # Closed set of valid summary levels.
141
+ Thinking::Config::SUMMARY_LEVELS = %i[auto none concise detailed].freeze
142
+
143
+ Thinking::Config::BUILD_SITE = 'Canonical::Thinking::Config.build'
144
+ Thinking::Config::FROM_HASH_SITE = 'Canonical::Thinking::Config.from_hash'
145
+ Thinking::Config::NEW_SITE = 'Canonical::Thinking::Config.new'
146
+ end
147
+ end
148
+ end
149
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'securerandom'
4
4
 
5
- # rubocop:disable Metrics/ParameterLists -- factory methods have many params
5
+ # rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
6
6
  module Legion
7
7
  module Extensions
8
8
  module Llm
@@ -132,4 +132,3 @@ module Legion
132
132
  end
133
133
  end
134
134
  end
135
- # rubocop:enable Metrics/ParameterLists
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'canonical/strict'
4
4
  require_relative 'canonical/thinking'
5
+ require_relative 'canonical/thinking_config'
5
6
  require_relative 'canonical/usage'
6
7
  require_relative 'canonical/params'
7
8
  require_relative 'canonical/content_block'
@@ -40,7 +40,7 @@ module Legion
40
40
  @config = config
41
41
 
42
42
  @connection ||= Faraday.new(provider.api_base) do |faraday|
43
- faraday.ssl.verify = false
43
+ faraday.ssl.verify = Legion::Settings.dig(:extensions, :llm, :ssl, :verify) == true
44
44
  setup_timeout(faraday)
45
45
  setup_logging(faraday)
46
46
  setup_retry(faraday)
@@ -114,11 +114,7 @@ module Legion
114
114
  rescue IO::WaitWritable
115
115
  return false unless sock.wait_writable(timeout)
116
116
 
117
- begin
118
- sock.connect_nonblock(addr)
119
- rescue Errno::EISCONN
120
- # already connected — success
121
- end
117
+ return sock.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int.zero?
122
118
  end
123
119
  true
124
120
  rescue StandardError => e
@@ -154,10 +154,16 @@ module Legion
154
154
  { type: 'json_schema', json_schema: schema_hash }
155
155
  end
156
156
 
157
+ # Transpose canonical thinking -> OpenAI `reasoning_effort` (edge, O03a).
158
+ # Omit the key when there is no thinking or it is explicitly disabled
159
+ # (a reasoning model then uses its own default). resolved_effort fills
160
+ # the effort axis from budget when the client supplied only a budget,
161
+ # so a budget-only request is never silently dropped.
157
162
  def openai_reasoning_effort(thinking)
158
163
  return nil unless thinking.is_a?(Canonical::Thinking::Config)
164
+ return nil unless thinking.enabled
159
165
 
160
- thinking.effort
166
+ thinking.resolved_effort
161
167
  end
162
168
 
163
169
  # One response-parse boundary (08 R2): returns Canonical::Response.
@@ -12,15 +12,29 @@ module Legion
12
12
  module ToolArguments
13
13
  module_function
14
14
 
15
- # Parse assembled tool-call arguments JSON into a Hash.
16
- # nil / empty string means "no arguments" and returns {} (documented
17
- # default, not tolerance).
15
+ # Parse tool-call arguments into the canonical Hash (10 U2).
16
+ #
17
+ # The provider wire legitimately encodes the arguments in a few
18
+ # forms; this ONE parser maps every legitimate form to the canonical
19
+ # Hash so every lex-llm-* provider parses arguments identically:
20
+ # - nil, empty/whitespace String, or JSON null -> {} (no arguments;
21
+ # documented default, not tolerance)
22
+ # - an already-decoded wire object (Hash) -> passed through
23
+ # (it IS the canonical Hash)
24
+ # - a JSON-object String -> parsed to Hash
25
+ #
26
+ # Genuinely corrupted arguments still raise — a non-object JSON value
27
+ # (array/number/string) or invalid JSON is a contract error, never a
28
+ # fabricated {} (04 L1/L7; the old rescue-to-{} policies are deleted).
18
29
  def parse!(raw)
19
30
  return {} if raw.nil?
20
- return {} if raw.is_a?(::String) && raw.strip.empty?
21
- raise ArgumentError, "tool call arguments expected JSON String, got #{raw.class}" unless raw.is_a?(::String)
31
+ return raw if raw.is_a?(::Hash)
32
+ return {} if raw.is_a?(::String) && (raw.strip.empty? || raw.strip.casecmp?('null'))
33
+
34
+ raise ArgumentError, "tool call arguments expected JSON String or Hash, got #{raw.class}" unless raw.is_a?(::String)
22
35
 
23
36
  parsed = ::Legion::JSON.parse(raw, symbolize_names: false)
37
+ return {} if parsed.nil?
24
38
  raise ArgumentError, "tool call arguments must be a JSON object, got #{parsed.class}" unless parsed.is_a?(::Hash)
25
39
 
26
40
  parsed
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.8.0'
6
+ VERSION = '0.8.5'
7
7
  end
8
8
  end
9
9
  end
@@ -156,6 +156,9 @@ module Legion
156
156
 
157
157
  def self.default_settings
158
158
  {
159
+ ssl: {
160
+ verify: false
161
+ },
159
162
  fleet: {
160
163
  consumer: {
161
164
  enabled: false,
@@ -9,7 +9,6 @@ RSpec.describe Legion::Extensions::Llm::Canonical::Params do
9
9
  let(:type_source) do
10
10
  {
11
11
  max_tokens: 1024,
12
- max_thinking_tokens: 512,
13
12
  temperature: 0.7,
14
13
  top_p: 0.9,
15
14
  top_k: 40,
@@ -28,7 +27,6 @@ RSpec.describe Legion::Extensions::Llm::Canonical::Params do
28
27
  it 'does not translate provider spellings (they fold into metadata)' do
29
28
  params = described_class.from_hash(max_output_tokens: 999, num_predict: 888, stop: ['x'])
30
29
  expect(params.max_tokens).to be_nil
31
- expect(params.max_thinking_tokens).to be_nil
32
30
  expect(params.stop_sequences).to be_nil
33
31
  expect(params.metadata).to eq(max_output_tokens: 999, num_predict: 888, stop: ['x'])
34
32
  end