lex-llm 0.8.0 → 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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/lib/legion/extensions/llm/canonical/chunk.rb +1 -2
  4. data/lib/legion/extensions/llm/canonical/content_block.rb +1 -2
  5. data/lib/legion/extensions/llm/canonical/message.rb +1 -2
  6. data/lib/legion/extensions/llm/canonical/params.rb +3 -5
  7. data/lib/legion/extensions/llm/canonical/request.rb +1 -2
  8. data/lib/legion/extensions/llm/canonical/response.rb +1 -2
  9. data/lib/legion/extensions/llm/canonical/thinking.rb +2 -98
  10. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  11. data/lib/legion/extensions/llm/canonical/tool_call.rb +1 -2
  12. data/lib/legion/extensions/llm/canonical.rb +1 -0
  13. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -1
  14. data/lib/legion/extensions/llm/responses/tool_arguments.rb +19 -5
  15. data/lib/legion/extensions/llm/version.rb +1 -1
  16. data/spec/legion/extensions/llm/canonical/params_spec.rb +0 -2
  17. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  18. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +0 -68
  19. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  20. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  21. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  22. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +2 -4
  23. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +2 -4
  24. data/spec/legion/extensions/llm/fleet_messages_spec.rb +1 -2
  25. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  26. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +23 -0
  27. data/spec/legion/extensions/llm/provider_spec.rb +1 -2
  28. metadata +3 -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: 2798bd117208f95e197b523f01bbd4937d7128f98fd8661ecfc609db33cb0a43
4
+ data.tar.gz: 0ad1e3cbc9654be5959d8bbd66fe13f1b6f9711150a62d06aa1436f1e0029974
5
5
  SHA512:
6
- metadata.gz: 64cb921e6ccf8361704df0fe4846fb80b3d074ed09b6940f4cff2c2440c8260e36258acda18ed786415d8052a111e33dcd17ad0f1cef1240dd634f14c30f0172
7
- data.tar.gz: e2bb88cc4d672f4a11a64cdc91cc922964e67eab8baa85ad3108c40cc269363dc8009684162c70d1f98dbf8d0a0757d0ca8790838eb066ddab59a1edf37cb28d
6
+ metadata.gz: 6ebaf6abe1388508ee2be7f365a129b3c83c5846a3cae522758323d000bb97e965a64c7dfb5ed4d4efc093e8f6b8245cc860861a725238da862faee01820f1fd
7
+ data.tar.gz: 119413c94340f70c038782af0724835c00becfef3d1794f7de5b43a9c3c5e633663cafb73e5c67d68c22a3fdd0bd403e2cdfb86f4bc25f2edadd729709f2968b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
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
+
3
55
  ## 0.8.0 - 2026-08-20
4
56
 
5
57
  **The SSOT v4 contract cut.** This is a complete contract line, not a patch wave:
@@ -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'
@@ -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.4'
7
7
  end
8
8
  end
9
9
  end
@@ -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
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../../conformance/conformance'
5
+
6
+ RSpec.describe Legion::Extensions::Llm::Canonical::Thinking::Config do
7
+ subject(:config_class) { described_class }
8
+
9
+ let(:type_class) { described_class }
10
+ let(:auto_generated_members) { [] }
11
+ let(:type_source) do
12
+ { enabled: true, effort: 'high', budget: 4096, summary: :concise, metadata: { source: 'client' } }
13
+ end
14
+
15
+ it_behaves_like 'a canonical type'
16
+
17
+ it 'converts from a plain class to a Data with one name (04 §8)' do
18
+ expect(config_class).to be_a(Class)
19
+ expect(config_class.new(enabled: true, effort: 'low', budget: nil, summary: nil, metadata: {})).to be_a(config_class)
20
+ end
21
+
22
+ it 'normalizes symbol effort to String' do
23
+ config = config_class.build(effort: :low)
24
+ expect(config.effort).to eq('low')
25
+ end
26
+
27
+ it 'raises on a wrong-class effort' do
28
+ expect { config_class.build(effort: 3) }
29
+ .to raise_error(ArgumentError, /effort expected String, got Integer/)
30
+ end
31
+
32
+ describe 'enabled member' do
33
+ it 'defaults to true in .build' do
34
+ config = config_class.build
35
+ expect(config.enabled).to be(true)
36
+ end
37
+
38
+ it 'defaults to true in .from_hash when key absent' do
39
+ config = config_class.from_hash({})
40
+ expect(config.enabled).to be(true)
41
+ end
42
+
43
+ it 'accepts explicit true' do
44
+ config = config_class.build(enabled: true)
45
+ expect(config.enabled).to be(true)
46
+ end
47
+
48
+ it 'accepts explicit false' do
49
+ config = config_class.build(enabled: false)
50
+ expect(config.enabled).to be(false)
51
+ end
52
+
53
+ it 'rejects non-boolean in .new' do
54
+ expect { config_class.new(enabled: 'yes', effort: nil, budget: nil, summary: nil, metadata: {}) }
55
+ .to raise_error(ArgumentError, /enabled must be true or false/)
56
+ end
57
+
58
+ it 'rejects nil in .new (must be an explicit boolean)' do
59
+ expect { config_class.new(enabled: nil, effort: nil, budget: nil, summary: nil, metadata: {}) }
60
+ .to raise_error(ArgumentError, /enabled must be true or false/)
61
+ end
62
+
63
+ it 'enabled? returns the enabled member' do
64
+ expect(config_class.build(enabled: true).enabled?).to be(true)
65
+ expect(config_class.build(enabled: false).enabled?).to be(false)
66
+ end
67
+ end
68
+
69
+ describe 'H1/M4 — effort is a closed enum, validated in every constructor' do
70
+ it 'rejects an unrecognized effort in build, from_hash, and .new' do
71
+ expect { config_class.build(effort: 'banana') }
72
+ .to raise_error(ArgumentError, /Invalid effort: "banana"/)
73
+ expect { config_class.from_hash(effort: 'banana') }
74
+ .to raise_error(ArgumentError, /Invalid effort: "banana"/)
75
+ expect { config_class.new(enabled: true, effort: 'banana', budget: nil, summary: nil, metadata: {}) }
76
+ .to raise_error(ArgumentError, /Invalid effort: "banana"/)
77
+ end
78
+
79
+ it 'accepts the enum case-insensitively (symbol or string)' do
80
+ expect(config_class.build(effort: :HIGH).effort).to eq('high')
81
+ expect(config_class.new(enabled: true, effort: 'Medium', budget: nil, summary: nil, metadata: {}).effort).to eq('medium')
82
+ end
83
+
84
+ it 'accepts all six effort levels' do
85
+ %w[none low medium high xhigh max].each do |level|
86
+ expect(config_class.build(effort: level).effort).to eq(level)
87
+ end
88
+ end
89
+
90
+ it 'resolved_budget derives from the validated effort (no silent medium fallback)' do
91
+ expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
92
+ expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
93
+ expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
94
+ expect(config_class.build(effort: 'xhigh').resolved_budget).to eq(24_576)
95
+ expect(config_class.build(effort: 'max').resolved_budget).to eq(32_768)
96
+ expect(config_class.build(effort: 'none').resolved_budget).to be_nil
97
+ expect(config_class.build(budget: 123).resolved_budget).to eq(123)
98
+ expect(config_class.build(enabled: false).resolved_budget).to be_nil
99
+ end
100
+ end
101
+
102
+ describe 'budget validation' do
103
+ it 'accepts a positive integer' do
104
+ expect(config_class.build(budget: 1).budget).to eq(1)
105
+ expect(config_class.build(budget: 50_000).budget).to eq(50_000)
106
+ end
107
+
108
+ it 'accepts nil' do
109
+ expect(config_class.build(budget: nil).budget).to be_nil
110
+ end
111
+
112
+ it 'rejects zero' do
113
+ expect { config_class.build(budget: 0) }
114
+ .to raise_error(ArgumentError, /budget must be positive/)
115
+ end
116
+
117
+ it 'rejects negative integers' do
118
+ expect { config_class.build(budget: -1) }
119
+ .to raise_error(ArgumentError, /budget must be positive/)
120
+ expect { config_class.build(budget: -100) }
121
+ .to raise_error(ArgumentError, /budget must be positive/)
122
+ end
123
+
124
+ it 'rejects non-Integer types' do
125
+ expect { config_class.build(budget: 1.5) }
126
+ .to raise_error(ArgumentError, /budget expected Integer/)
127
+ expect { config_class.build(budget: '1024') }
128
+ .to raise_error(ArgumentError, /budget expected Integer/)
129
+ end
130
+ end
131
+
132
+ describe 'summary enum' do
133
+ it 'accepts nil (default)' do
134
+ expect(config_class.build(summary: nil).summary).to be_nil
135
+ end
136
+
137
+ it 'accepts :auto, :none, :concise, :detailed' do
138
+ %i[auto none concise detailed].each do |level|
139
+ expect(config_class.build(summary: level).summary).to eq(level)
140
+ end
141
+ end
142
+
143
+ it 'coerces string to symbol' do
144
+ expect(config_class.from_hash(summary: 'concise').summary).to eq(:concise)
145
+ end
146
+
147
+ it 'rejects invalid summary values' do
148
+ expect { config_class.build(summary: :verbose) }
149
+ .to raise_error(ArgumentError, /Invalid summary/)
150
+ expect { config_class.build(summary: :full) }
151
+ .to raise_error(ArgumentError, /Invalid summary/)
152
+ end
153
+
154
+ it 'rejects non-symbol/string types' do
155
+ expect { config_class.build(summary: 42) }
156
+ .to raise_error(ArgumentError, /summary expected Symbol/)
157
+ end
158
+ end
159
+
160
+ it 'keeps the effort<->budget SSOT conversions' do
161
+ expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
162
+ expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
163
+ expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
164
+ expect(config_class.build(effort: 'xhigh').resolved_budget).to eq(24_576)
165
+ expect(config_class.build(effort: 'max').resolved_budget).to eq(32_768)
166
+ expect(config_class.build(budget: 512).resolved_effort).to eq('low')
167
+ expect(config_class.build(budget: 1024).resolved_effort).to eq('low')
168
+ expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
169
+ expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
170
+ expect(config_class.build(budget: 24_576).resolved_effort).to eq('xhigh')
171
+ expect(config_class.build(budget: 32_768).resolved_effort).to eq('max')
172
+ expect(config_class.build(budget: 50_000).resolved_effort).to eq('max')
173
+ end
174
+
175
+ describe 'resolved_effort band boundaries' do
176
+ it 'maps budget at boundary to the correct effort level' do
177
+ # At the low boundary
178
+ expect(config_class.build(budget: 1024).resolved_effort).to eq('low')
179
+ # Just above low
180
+ expect(config_class.build(budget: 1025).resolved_effort).to eq('medium')
181
+ # At medium boundary
182
+ expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
183
+ # Just above medium
184
+ expect(config_class.build(budget: 8193).resolved_effort).to eq('high')
185
+ # At high boundary
186
+ expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
187
+ # Just above high
188
+ expect(config_class.build(budget: 16_385).resolved_effort).to eq('xhigh')
189
+ # At xhigh boundary
190
+ expect(config_class.build(budget: 24_576).resolved_effort).to eq('xhigh')
191
+ # Just above xhigh
192
+ expect(config_class.build(budget: 24_577).resolved_effort).to eq('max')
193
+ end
194
+
195
+ it 'returns nil when budget is nil (no fabrication)' do
196
+ expect(config_class.build.resolved_effort).to be_nil
197
+ end
198
+
199
+ it 'returns the supplied effort when both are set (no overwrite)' do
200
+ config = config_class.build(effort: 'low', budget: 30_000)
201
+ expect(config.resolved_effort).to eq('low')
202
+ expect(config.resolved_budget).to eq(30_000)
203
+ end
204
+ end
205
+
206
+ it 'to_h is faithful to what was set (no fabricated axis)' do
207
+ expect(config_class.build(effort: 'low').to_h).to eq(enabled: true, effort: 'low', metadata: {})
208
+ # enabled:false is explicit OFF signal — it IS set, so it appears in to_h
209
+ expect(config_class.build(enabled: false).to_h).to eq(enabled: false, metadata: {})
210
+ end
211
+
212
+ it 'to_h omits nil members (compact)' do
213
+ config = config_class.build(enabled: true, effort: 'high', budget: 4096, summary: :detailed)
214
+ expect(config.to_h).to eq(enabled: true, effort: 'high', budget: 4096, summary: :detailed, metadata: {})
215
+ end
216
+
217
+ it 'from_hash round-trips through to_h' do
218
+ original = config_class.build(enabled: true, effort: 'xhigh', budget: 24_576, summary: :auto)
219
+ rebuilt = config_class.from_hash(original.to_h)
220
+ expect(rebuilt).to eq(original)
221
+ end
222
+ end
@@ -38,72 +38,4 @@ RSpec.describe Legion::Extensions::Llm::Canonical::Thinking do
38
38
  expect(round_tripped.signature).to eq('sig-1')
39
39
  end
40
40
  end
41
-
42
- describe Legion::Extensions::Llm::Canonical::Thinking::Config do
43
- subject(:config_class) { described_class }
44
-
45
- let(:type_class) { described_class }
46
- let(:auto_generated_members) { [] }
47
- let(:type_source) do
48
- { effort: 'high', budget: 4096, metadata: { source: 'client' } }
49
- end
50
-
51
- it_behaves_like 'a canonical type'
52
-
53
- it 'converts from a plain class to a Data with one name (04 §8)' do
54
- expect(config_class).to be_a(Class)
55
- expect(config_class.new(effort: 'low', budget: nil, metadata: {})).to be_a(config_class)
56
- end
57
-
58
- it 'normalizes symbol effort to String' do
59
- config = config_class.build(effort: :low)
60
- expect(config.effort).to eq('low')
61
- end
62
-
63
- it 'raises on a wrong-class effort' do
64
- expect { config_class.build(effort: 3) }
65
- .to raise_error(ArgumentError, /effort expected String, got Integer/)
66
- end
67
-
68
- describe 'H1/M4 — effort is a closed enum, validated in every constructor' do
69
- it 'rejects an unrecognized effort in build, from_hash, and .new' do
70
- expect { config_class.build(effort: 'banana') }
71
- .to raise_error(ArgumentError, /Invalid effort: "banana"/)
72
- expect { config_class.from_hash(effort: 'banana') }
73
- .to raise_error(ArgumentError, /Invalid effort: "banana"/)
74
- expect { config_class.new(effort: 'banana') }
75
- .to raise_error(ArgumentError, /Invalid effort: "banana"/)
76
- end
77
-
78
- it 'accepts the enum case-insensitively (symbol or string)' do
79
- expect(config_class.build(effort: :HIGH).effort).to eq('high')
80
- expect(config_class.new(effort: 'Medium').effort).to eq('medium')
81
- end
82
-
83
- it 'resolved_budget derives from the validated effort (no silent medium fallback)' do
84
- expect(config_class.new(effort: 'low').resolved_budget).to eq(1024)
85
- expect(config_class.build(budget: 123).resolved_budget).to eq(123)
86
- expect(config_class.build.resolved_budget).to be_nil
87
- end
88
- end
89
-
90
- it 'keeps the effort<->budget SSOT conversions' do
91
- expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
92
- expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
93
- expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
94
- expect(config_class.build(budget: 512).resolved_effort).to eq('low')
95
- expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
96
- expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
97
- end
98
-
99
- it 'is enabled only when an axis is set' do
100
- expect(config_class.build(effort: 'low').enabled?).to be(true)
101
- expect(config_class.build(budget: 1).enabled?).to be(true)
102
- expect(config_class.build.enabled?).to be(false)
103
- end
104
-
105
- it 'to_h is faithful to what was set (no fabricated axis)' do
106
- expect(config_class.build(effort: 'low').to_h).to eq(effort: 'low', metadata: {})
107
- end
108
- end
109
41
  end
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # Usage:
12
12
  # it_behaves_like 'a canonical client translator', MyClientTranslatorClass
13
- # rubocop:disable Lint/NonLocalExitFromIterator -- return guard is idiomatic in shared_example blocks
13
+ # rubocop:disable-next Lint/NonLocalExitFromIterator -- return guard is idiomatic in shared_example blocks
14
14
  RSpec.shared_examples 'a canonical client translator' do |translator_class|
15
15
  let(:translator) { translator_class.new }
16
16
  let(:canonical) { Legion::Extensions::Llm::Canonical }
@@ -429,4 +429,3 @@ RSpec.shared_examples 'a canonical client translator' do |translator_class|
429
429
  end
430
430
  end
431
431
  end
432
- # rubocop:enable Lint/NonLocalExitFromIterator
@@ -21,7 +21,7 @@
21
21
  }
22
22
  },
23
23
  "params": { "max_tokens": 1024, "temperature": 0.5 },
24
- "thinking": { "effort": "low", "budget": 1024 },
24
+ "thinking": { "enabled": true, "effort": "low", "budget": 1024 },
25
25
  "stream": false,
26
26
  "conversation_id": "conv_fleet_rt_001",
27
27
  "caller": { "type": "anthropic_messages", "client_id": "test_client" },
@@ -8,12 +8,12 @@
8
8
  }
9
9
  ],
10
10
  "thinking": {
11
+ "enabled": true,
11
12
  "effort": "high",
12
13
  "budget": 4096
13
14
  },
14
15
  "params": {
15
- "max_tokens": 2048,
16
- "max_thinking_tokens": 4096
16
+ "max_tokens": 2048
17
17
  },
18
18
  "stream": false
19
19
  }
@@ -9,7 +9,7 @@ require_relative 'ssot_contract_examples'
9
9
  # Kit self-test: the B/F/R shared examples run against a real Provider-derived
10
10
  # callable, the real Registry, and the real fleet responder/worker — proving
11
11
  # the examples provider gems will load are executable and meaningful.
12
- # rubocop:disable RSpec/DescribeClass, RSpec/MultipleMemoizedHelpers -- kit host
12
+ # rubocop:disable-next RSpec/DescribeClass, RSpec/MultipleMemoizedHelpers -- kit host
13
13
  RSpec.describe 'SSOT v4 contract conformance kit' do
14
14
  include SsotRegistryHelpers
15
15
 
@@ -34,7 +34,7 @@ RSpec.describe 'SSOT v4 contract conformance kit' do
34
34
  Legion::Extensions::Llm::Canonical::Response.build(text: 'ok', model: model, stop_reason: :end_turn)
35
35
  end
36
36
 
37
- # rubocop:disable Lint/UnusedMethodArgument -- model: mirrors the 0.8.0 callable contract
37
+ # rubocop:disable-next Lint/UnusedMethodArgument -- model: mirrors the 0.8.0 callable contract
38
38
  def stream_chat(messages, model: nil, tools: nil, **_contract, &block)
39
39
  enforce_canonical_messages!(messages)
40
40
  enforce_canonical_tools!(tools)
@@ -43,7 +43,6 @@ RSpec.describe 'SSOT v4 contract conformance kit' do
43
43
  usage: Legion::Extensions::Llm::Canonical::Usage.build(input_tokens: 1)))
44
44
  nil
45
45
  end
46
- # rubocop:enable Lint/UnusedMethodArgument
47
46
 
48
47
  def count_tokens(messages:, **_contract)
49
48
  enforce_canonical_messages!(messages)
@@ -127,4 +126,3 @@ RSpec.describe 'SSOT v4 contract conformance kit' do
127
126
  it_behaves_like 'R4 — weight law (07 §4)'
128
127
  it_behaves_like 'R5 — callable lifecycle (07 §6)'
129
128
  end
130
- # rubocop:enable RSpec/DescribeClass, RSpec/MultipleMemoizedHelpers
@@ -44,10 +44,9 @@ RSpec.shared_examples 'B1 — central canonical enforcement (08 F2)' do
44
44
  end
45
45
 
46
46
  it "stream_chat rejects #{label} with a typed ArgumentError" do
47
- # rubocop:disable Lint/EmptyBlock -- the block never runs: enforcement raises first
47
+ # rubocop:disable-next Lint/EmptyBlock -- the block never runs: enforcement raises first
48
48
  expect { callable.stream_chat(bad_messages, model: 'm') { |_c| } }
49
49
  .to raise_error(ArgumentError, /Canonical::Message/)
50
- # rubocop:enable Lint/EmptyBlock
51
50
  end
52
51
 
53
52
  it "count_tokens rejects #{label} with a typed ArgumentError" do
@@ -121,9 +120,8 @@ RSpec.shared_examples 'B3 — operation preservation (PR #189 defect class)' do
121
120
  block&.call(CAN::Chunk.done(request_id: nil))
122
121
  end
123
122
 
124
- # rubocop:disable Lint/EmptyBlock -- the block is the assertion target
123
+ # rubocop:disable-next Lint/EmptyBlock -- the block is the assertion target
125
124
  callable.stream_chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm') { |_c| }
126
- # rubocop:enable Lint/EmptyBlock
127
125
 
128
126
  expect(stream_invocations).to eq(1)
129
127
  expect(chat_invocations).to eq(0)
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
- # rubocop:disable RSpec/DescribeClass
5
+ # rubocop:disable-next RSpec/DescribeClass
6
6
  RSpec.describe 'LLM fleet message envelopes' do
7
7
  let(:protocol) { Legion::Extensions::Llm::Fleet::Protocol }
8
8
  let(:channel_class) do
@@ -408,4 +408,3 @@ RSpec.describe 'LLM fleet message envelopes' do
408
408
  end
409
409
  end
410
410
  end
411
- # rubocop:enable RSpec/DescribeClass
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'spec_helper'
4
4
 
5
- # rubocop:disable RSpec/DescribeClass
5
+ # rubocop:disable-next RSpec/DescribeClass
6
6
  RSpec.describe 'lex-llm.gemspec' do
7
7
  subject(:gemspec) { Gem::Specification.load(File.expand_path('../../../../lex-llm.gemspec', __dir__)) }
8
8
 
@@ -22,4 +22,3 @@ RSpec.describe 'lex-llm.gemspec' do
22
22
  expect(gemspec.name).to eq('lex-llm')
23
23
  end
24
24
  end
25
- # rubocop:enable RSpec/DescribeClass
@@ -49,6 +49,29 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
49
49
  expect(payload[:messages]).to eq([{ role: 'assistant', content: "internal\n</thinking>\n\nHello" }])
50
50
  end
51
51
 
52
+ describe 'reasoning_effort transposition (canonical Thinking::Config -> OpenAI wire)' do
53
+ def payload_for(thinking)
54
+ provider.send(:render_payload, [], tools: {}, model: model_id, stream: false,
55
+ schema: nil, thinking: thinking, tool_prefs: nil, params: nil)
56
+ end
57
+
58
+ it 'passes an explicit effort through' do
59
+ expect(payload_for(canonical::Thinking::Config.build(effort: 'high'))[:reasoning_effort]).to eq('high')
60
+ end
61
+
62
+ it 'derives effort from a budget-only config so it is never dropped' do
63
+ expect(payload_for(canonical::Thinking::Config.build(budget: 8192))[:reasoning_effort]).to eq('medium')
64
+ end
65
+
66
+ it 'omits reasoning_effort when thinking is explicitly disabled' do
67
+ expect(payload_for(canonical::Thinking::Config.build(enabled: false))).not_to have_key(:reasoning_effort)
68
+ end
69
+
70
+ it 'omits reasoning_effort when there is no thinking' do
71
+ expect(payload_for(nil)).not_to have_key(:reasoning_effort)
72
+ end
73
+ end
74
+
52
75
  it 'renders content block arrays in the OpenAI wire shape' do
53
76
  message = canonical::Message.build(
54
77
  role: :user,
@@ -201,9 +201,8 @@ RSpec.describe Legion::Extensions::Llm::Provider do
201
201
  it 'accepts Hash<name, Canonical::ToolDefinition> through chat and stream_chat' do
202
202
  expect(provider.chat([], model: 'test-model', tools: { get_weather: tool })).to eq('ok')
203
203
  expect do
204
- # rubocop:disable Lint/EmptyBlock -- the streamed chunk is consumed by the stub
204
+ # rubocop:disable-next Lint/EmptyBlock -- the streamed chunk is consumed by the stub
205
205
  provider.stream_chat([], model: 'test-model', tools: { get_weather: tool }) { |_c| }
206
- # rubocop:enable Lint/EmptyBlock
207
206
  end.not_to raise_error
208
207
  end
209
208
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -267,6 +267,7 @@ files:
267
267
  - lib/legion/extensions/llm/canonical/response.rb
268
268
  - lib/legion/extensions/llm/canonical/strict.rb
269
269
  - lib/legion/extensions/llm/canonical/thinking.rb
270
+ - lib/legion/extensions/llm/canonical/thinking_config.rb
270
271
  - lib/legion/extensions/llm/canonical/tool_call.rb
271
272
  - lib/legion/extensions/llm/canonical/tool_definition.rb
272
273
  - lib/legion/extensions/llm/canonical/tool_schema.rb
@@ -342,6 +343,7 @@ files:
342
343
  - spec/legion/extensions/llm/canonical/params_spec.rb
343
344
  - spec/legion/extensions/llm/canonical/request_spec.rb
344
345
  - spec/legion/extensions/llm/canonical/response_spec.rb
346
+ - spec/legion/extensions/llm/canonical/thinking/config_spec.rb
345
347
  - spec/legion/extensions/llm/canonical/thinking_spec.rb
346
348
  - spec/legion/extensions/llm/canonical/tool_call_spec.rb
347
349
  - spec/legion/extensions/llm/canonical/tool_definition_spec.rb