lex-llm 0.8.0 → 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +34 -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/connection.rb +1 -1
- data/lib/legion/extensions/llm/credential_sources.rb +1 -5
- 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/lib/legion/extensions/llm.rb +3 -0
- 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/connection_tls_spec.rb +103 -0
- data/spec/legion/extensions/llm/credential_sources_spec.rb +16 -7
- 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
- data/spec/support/tls_endpoint.rb +92 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2216f17aaf84164eccc09087fb78e2a52339c24ae67207a43e220de31927fc3
|
|
4
|
+
data.tar.gz: 143caaf03ac594ef2cf9fb298836b7226f67c8b43ee4e0c0e5cdc26aaeb9b4d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 24b19766bdc02f567c50d2a76305cdbbc15252607ab02d46484811f9723164f6bb75dae86e5556901b675b235f750f14a3c41dc25e2b5d357cb52a7d81399c19
|
|
7
|
+
data.tar.gz: 7a65c4c5f65c5b43d123f408a6c399a983a5fbbbedbdc13dcd5e96ab16edf414e186266ae4c4e43f35927466303997d5b74d6cd95391eb6b8a64edf8702a55aa
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.5 - 2026-10-29
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- Make shared provider TLS certificate and hostname verification configurable through
|
|
7
|
+
`Legion::Settings` at `extensions.llm.ssl.verify`, defaulting to `false`. Set it to
|
|
8
|
+
`true` to enable verification with Typhoeus or Net::HTTP; explicitly trusted custom
|
|
9
|
+
certificate authorities remain supported. (#50)
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Correct asynchronous TCP probes that could report refused connections as open
|
|
13
|
+
on Darwin; inspect the completed socket's error status instead of reconnecting.
|
|
14
|
+
|
|
15
|
+
## 0.8.4 - 2026-08-25
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **OpenAI-compatible transposer routes thinking through the canonical bridge.**
|
|
19
|
+
`Provider::OpenAICompatible#openai_reasoning_effort` now returns `nil` (omitting
|
|
20
|
+
`reasoning_effort`) when thinking is absent or `enabled: false`, and uses
|
|
21
|
+
`Thinking::Config#resolved_effort` instead of the raw `effort` — so a budget-only
|
|
22
|
+
request derives an effort instead of being silently dropped. Added spec coverage.
|
|
23
|
+
- **`Responses::ToolArguments.parse!` accepts every legitimate wire form (10 U2).**
|
|
24
|
+
An already-decoded `Hash` passes through unchanged (it is the canonical Hash); a
|
|
25
|
+
`null`/empty/whitespace-only string and a JSON `null` resolve to `{}` (the documented
|
|
26
|
+
no-arguments default, not tolerance). Genuinely corrupt arguments — a non-object JSON
|
|
27
|
+
value or invalid JSON — still raise; they are never fabricated into `{}`.
|
|
28
|
+
|
|
29
|
+
## 0.8.3 - 2026-08-25
|
|
30
|
+
|
|
31
|
+
### Removed
|
|
32
|
+
- **Removed `Canonical::Params#max_thinking_tokens`.** Thinking budget now lives solely
|
|
33
|
+
on `Thinking::Config#budget`. The redundant dual-home on Params is deleted from the
|
|
34
|
+
`Data.define` member list, `.build`, `.from_hash`, and the strict `.new` validation
|
|
35
|
+
block. Any wire payload carrying `max_thinking_tokens` in params will now fold into
|
|
36
|
+
`metadata` as an unknown key (04 L5).
|
|
37
|
+
|
|
38
|
+
## 0.8.2 - 2026-08-25
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- **Expand `Thinking::Config` to the common superset shape.** The canonical struct
|
|
42
|
+
is now `Data.define(:enabled, :effort, :budget, :summary, :metadata)`:
|
|
43
|
+
- `enabled` — explicit Boolean (true/false). Default true when a Config is built;
|
|
44
|
+
nil request.thinking means "client said nothing"; `enabled: false` means explicit
|
|
45
|
+
OFF. Validated strictly (non-boolean raises).
|
|
46
|
+
- `effort` — closed 6-level enum: `none`, `low`, `medium`, `high`, `xhigh`, `max`
|
|
47
|
+
(downcased strings). Unknown values raise at construction.
|
|
48
|
+
- `budget` — positive Integer or nil. Zero and negatives rejected at construction
|
|
49
|
+
(off rides `enabled: false`; dynamic rides `enabled: true` + no axis).
|
|
50
|
+
- `summary` — closed enum `{nil, :auto, :none, :concise, :detailed}` for
|
|
51
|
+
thought-visibility; independent of the effort/budget axes.
|
|
52
|
+
- `metadata` — unchanged passthrough Hash.
|
|
53
|
+
- `EFFORT_BUDGET` extended to the full ladder:
|
|
54
|
+
`{'low'=>1024,'medium'=>8192,'high'=>16384,'xhigh'=>24576,'max'=>32768}`.
|
|
55
|
+
`none` resolves to nil budget. `resolved_budget` and `resolved_effort` only FILL
|
|
56
|
+
an axis the client didn't supply — never overwrite a supplied one.
|
|
57
|
+
- `enabled?` returns the `enabled` member directly.
|
|
58
|
+
|
|
59
|
+
## 0.8.1 - 2026-08-25
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
- **Extract `Thinking::Config` to its own file.** `Canonical::Thinking::Config` (the
|
|
63
|
+
`Data.define` type and its constants) now lives in `canonical/thinking_config.rb`
|
|
64
|
+
instead of being nested inside `canonical/thinking.rb`. Spec coverage split into a
|
|
65
|
+
matching `thinking_config_spec.rb`. Pure refactor — no behavior changes.
|
|
66
|
+
|
|
3
67
|
## 0.8.0 - 2026-08-20
|
|
4
68
|
|
|
5
69
|
**The SSOT v4 contract cut.** This is a complete contract line, not a patch wave:
|
data/README.md
CHANGED
|
@@ -489,10 +489,44 @@ Key settings paths:
|
|
|
489
489
|
- `extensions.llm.fleet.endpoint` -- endpoint-style worker configuration
|
|
490
490
|
- `extensions.llm.fleet.compliance.encrypt_fleet` -- encrypt fleet envelopes (default true)
|
|
491
491
|
- `extensions.llm.fleet.auth.verify_issuer` -- validate JWT issuer (default true)
|
|
492
|
+
- `extensions.llm.ssl.verify` -- verify provider HTTPS certificates and hostnames (default false)
|
|
492
493
|
- `extensions.llm.security.credential_source_probing` -- gate credential probing (default true)
|
|
493
494
|
- `extensions.llm.model_whitelist` / `model_blacklist` -- provider-level model filters
|
|
494
495
|
- `extensions.llm.<family>.instance.<name>.model_whitelist` -- per-instance override
|
|
495
496
|
|
|
497
|
+
### TLS verification and custom certificate authorities
|
|
498
|
+
|
|
499
|
+
Shared provider connections read `extensions.llm.ssl.verify` from `Legion::Settings`
|
|
500
|
+
when constructed. The default is `false` for both Typhoeus and Net::HTTP.
|
|
501
|
+
With verification disabled, untrusted and wrong-host certificates are accepted,
|
|
502
|
+
so credentials can be sent to an unauthenticated peer.
|
|
503
|
+
|
|
504
|
+
Enable verification before creating provider connections:
|
|
505
|
+
|
|
506
|
+
```json
|
|
507
|
+
{
|
|
508
|
+
"extensions": {
|
|
509
|
+
"llm": {
|
|
510
|
+
"ssl": { "verify": true }
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
With verification enabled, an untrusted certificate or hostname mismatch fails
|
|
517
|
+
before an authenticated HTTP request is sent.
|
|
518
|
+
|
|
519
|
+
When constructing a shared connection for a private or corporate CA, configure
|
|
520
|
+
the underlying Faraday SSL options before making the first request:
|
|
521
|
+
|
|
522
|
+
```ruby
|
|
523
|
+
connection = Legion::Extensions::Llm::Connection.new(provider, config)
|
|
524
|
+
connection.connection.ssl.ca_file = '/path/to/trusted-ca-bundle.pem'
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
When verification is enabled, private or self-signed HTTPS servers need explicit
|
|
528
|
+
CA trust. Plain HTTP behavior is unchanged.
|
|
529
|
+
|
|
496
530
|
---
|
|
497
531
|
|
|
498
532
|
## Provider Dependencies
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# rubocop:disable Metrics/ParameterLists -- factory methods have many params
|
|
3
|
+
# rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
|
|
4
4
|
module Legion
|
|
5
5
|
module Extensions
|
|
6
6
|
module Llm
|
|
@@ -207,4 +207,3 @@ module Legion
|
|
|
207
207
|
end
|
|
208
208
|
end
|
|
209
209
|
end
|
|
210
|
-
# rubocop:enable Metrics/ParameterLists
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# rubocop:disable Metrics/ParameterLists -- factory methods have many params
|
|
3
|
+
# rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
|
|
4
4
|
module Legion
|
|
5
5
|
module Extensions
|
|
6
6
|
module Llm
|
|
@@ -149,4 +149,3 @@ module Legion
|
|
|
149
149
|
end
|
|
150
150
|
end
|
|
151
151
|
end
|
|
152
|
-
# rubocop:enable Metrics/ParameterLists
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'securerandom'
|
|
4
4
|
|
|
5
|
-
# rubocop:disable Metrics/ParameterLists -- factory methods have many params
|
|
5
|
+
# rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
|
|
6
6
|
module Legion
|
|
7
7
|
module Extensions
|
|
8
8
|
module Llm
|
|
@@ -138,4 +138,3 @@ module Legion
|
|
|
138
138
|
end
|
|
139
139
|
end
|
|
140
140
|
end
|
|
141
|
-
# rubocop:enable Metrics/ParameterLists
|
|
@@ -11,19 +11,19 @@ module Legion
|
|
|
11
11
|
# Per G18: all standard/useful params are first-class, mapped per provider by translators.
|
|
12
12
|
# Canonical keys only (O03a): provider spellings are translated at the edges.
|
|
13
13
|
Params = ::Data.define(
|
|
14
|
-
:max_tokens, :
|
|
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
|
|
@@ -40,7 +40,7 @@ module Legion
|
|
|
40
40
|
@config = config
|
|
41
41
|
|
|
42
42
|
@connection ||= Faraday.new(provider.api_base) do |faraday|
|
|
43
|
-
faraday.ssl.verify =
|
|
43
|
+
faraday.ssl.verify = Legion::Settings.dig(:extensions, :llm, :ssl, :verify) == true
|
|
44
44
|
setup_timeout(faraday)
|
|
45
45
|
setup_logging(faraday)
|
|
46
46
|
setup_retry(faraday)
|
|
@@ -114,11 +114,7 @@ module Legion
|
|
|
114
114
|
rescue IO::WaitWritable
|
|
115
115
|
return false unless sock.wait_writable(timeout)
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
sock.connect_nonblock(addr)
|
|
119
|
-
rescue Errno::EISCONN
|
|
120
|
-
# already connected — success
|
|
121
|
-
end
|
|
117
|
+
return sock.getsockopt(Socket::SOL_SOCKET, Socket::SO_ERROR).int.zero?
|
|
122
118
|
end
|
|
123
119
|
true
|
|
124
120
|
rescue StandardError => e
|
|
@@ -154,10 +154,16 @@ module Legion
|
|
|
154
154
|
{ type: 'json_schema', json_schema: schema_hash }
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
+
# Transpose canonical thinking -> OpenAI `reasoning_effort` (edge, O03a).
|
|
158
|
+
# Omit the key when there is no thinking or it is explicitly disabled
|
|
159
|
+
# (a reasoning model then uses its own default). resolved_effort fills
|
|
160
|
+
# the effort axis from budget when the client supplied only a budget,
|
|
161
|
+
# so a budget-only request is never silently dropped.
|
|
157
162
|
def openai_reasoning_effort(thinking)
|
|
158
163
|
return nil unless thinking.is_a?(Canonical::Thinking::Config)
|
|
164
|
+
return nil unless thinking.enabled
|
|
159
165
|
|
|
160
|
-
thinking.
|
|
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
|