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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/lib/legion/extensions/llm/canonical/chunk.rb +1 -2
- data/lib/legion/extensions/llm/canonical/content_block.rb +1 -2
- data/lib/legion/extensions/llm/canonical/message.rb +1 -2
- data/lib/legion/extensions/llm/canonical/params.rb +3 -5
- data/lib/legion/extensions/llm/canonical/request.rb +1 -2
- data/lib/legion/extensions/llm/canonical/response.rb +1 -2
- data/lib/legion/extensions/llm/canonical/thinking.rb +2 -98
- data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +1 -2
- data/lib/legion/extensions/llm/canonical.rb +1 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +19 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/spec/legion/extensions/llm/canonical/params_spec.rb +0 -2
- data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +0 -68
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +2 -4
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +2 -4
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +1 -2
- data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +23 -0
- data/spec/legion/extensions/llm/provider_spec.rb +1 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2798bd117208f95e197b523f01bbd4937d7128f98fd8661ecfc609db33cb0a43
|
|
4
|
+
data.tar.gz: 0ad1e3cbc9654be5959d8bbd66fe13f1b6f9711150a62d06aa1436f1e0029974
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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, :
|
|
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,
|
|
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:,
|
|
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
|
|
@@ -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.
|
|
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
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
|
21
|
-
|
|
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
|
|
@@ -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" },
|
|
@@ -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.
|
|
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
|