lex-llm 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5613f655c5743be588ec5030535de96dc998cb8f03ffb8f8b2be21c95dc55b84
4
- data.tar.gz: cba342c0f0662d7612b2d8647791149ddb716878e79e487e84a640dbcd3582cc
3
+ metadata.gz: e441fc6b1e568e1e7cde529aecb14655cfc6df845accdfbf706cade536faa25e
4
+ data.tar.gz: fa14725026a56fe266e5c7b4be232fc1b750bf35293830514a6ad103b1da3abc
5
5
  SHA512:
6
- metadata.gz: b2eb4840e8f1f39d65690dc9226387d6fecbcc9352845cc796d2421b56b660fddae35e2373abdbb88e5c3b127a5b5a6ba621c33682762bbf2eeb271809ec2beb
7
- data.tar.gz: 7cce383baa60095d03479240c16076624706d56d605200efee5943d3de290ba96c5c18c58e7ada252572966657d846107a3f55945d9f95aa278f553c31ac82a2
6
+ metadata.gz: f14ac394687d2059d90562529518085a4cced1732e9baa85c5847ad2fffd4ea89efeb735167ebae6533d4b335f9ddd7fcc8982c0bdac137add3d35a48010b19e
7
+ data.tar.gz: ed81c83224bdcbbf18b0b4fc6f8b9e2bf0886ed44d80a2dde363d4920a3a4957d4f56f34429b6ea7b7fd5ba96fd4a5b5e60fb9bc0e59cca556b1ed11e7314080
data/.rubocop.yml CHANGED
@@ -51,3 +51,6 @@ RSpec/LeakyLocalVariable:
51
51
  RSpec/SpecFilePathFormat:
52
52
  Exclude:
53
53
  - 'spec/legion/extensions/llm/conformance/**/*'
54
+
55
+ Style/ArrayIntersect:
56
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.10 - 2026-07-09
4
+
5
+ ### Added
6
+ - `ThinkingConfig#resolved_budget` and `#resolved_effort` — cross-axis derivation so provider translators get whichever thinking axis they need (Anthropic `budget_tokens` vs OpenAI `effort`) even when the client supplied only the other. A client dialect supplies only one axis (Anthropic = budget only; OpenAI = effort only), so a single `EFFORT_BUDGET` map (SSOT) drives the conversion in both directions: effort -> budget is exact (`low` = 1024, `medium` = 8192, `high` = 16384), budget -> effort maps by band boundary. Explicitly-set values always win over derivation, and both accessors return `nil` when neither axis is configured. `to_h` stays faithful to what was actually set (no fabrication). This is the foundation for fixing thinking being silently dropped on cross-provider routes (e.g. a Claude budget request routed to OpenAI effort).
7
+
8
+ ## 0.6.9 - 2026-07-05
9
+
10
+ ### Added
11
+ - `StopReasonMapping` mixin (`legion/extensions/llm/stop_reason_mapping`) — a shared stop_reason vocabulary included by provider translators (and available to legion-llm, which depends on lex-llm). Keys are incoming provider wire strings, values are the canonical `stop_reason` symbol. Provider wire formats spell the same six canonical end-states differently (OpenAI/vLLM emit `tool_calls`, Anthropic `tool_use`; `stop`/`end_turn`/`eos` all mean the same thing), so the common vocabulary now lives in one place instead of being copy-pasted (and drifting) across provider gems.
12
+ - `#stop_reason_map` — the common vocabulary, inherited by all.
13
+ - `#stop_reason_map_additions` — returns `{}` in the base; a provider overrides it to ADD provider-specific strings (merged on top, additions win on collision). No guards needed.
14
+ - `#stop_reason_lookup(key)` — merges additions over the map, coerces the key via `to_s`, returns the canonical symbol or `nil` (caller decides the default). To REPLACE the whole vocabulary, a provider overrides `#stop_reason_map`.
15
+
3
16
  ## 0.6.8 - 2026-07-03
4
17
 
5
18
  ### Changed
@@ -50,6 +50,16 @@ module Legion
50
50
  # Mirrors lex-llm Thinking::Config.
51
51
  class ThinkingConfig
52
52
  INCLUDES = Thinking
53
+
54
+ # SSOT for the effort<->budget conversion. A client dialect supplies only
55
+ # ONE axis (Anthropic = budget_tokens only; OpenAI = effort only), but a
56
+ # provider translator may need the OTHER. This single map lets every
57
+ # provider ask for whichever axis it needs and always get a usable value,
58
+ # so thinking survives any client x provider pair (best-effort, never
59
+ # silently dropped). effort -> budget is exact; budget -> effort uses the
60
+ # band boundaries below.
61
+ EFFORT_BUDGET = { 'low' => 1024, 'medium' => 8192, 'high' => 16_384 }.freeze
62
+
53
63
  attr_reader :effort, :budget
54
64
 
55
65
  def initialize(effort: nil, budget: nil)
@@ -70,7 +80,8 @@ module Legion
70
80
  build(effort: h[:effort], budget: h[:budget])
71
81
  end
72
82
 
73
- # Serialize to a Hash for AMQP/fleet/wire transport.
83
+ # Serialize to a Hash for AMQP/fleet/wire transport. Faithful to what was
84
+ # SET — never fabricates the missing axis (use resolved_* for that).
74
85
  def to_h
75
86
  { effort: effort, budget: budget }.compact
76
87
  end
@@ -79,6 +90,30 @@ module Legion
79
90
  def enabled?
80
91
  !effort.nil? || !budget.nil?
81
92
  end
93
+
94
+ # Budget for a provider that needs a token budget (e.g. Anthropic),
95
+ # derived from effort when budget was not explicitly set. nil only when
96
+ # neither axis is configured.
97
+ def resolved_budget
98
+ return budget unless budget.nil?
99
+ return nil if effort.nil?
100
+
101
+ EFFORT_BUDGET[effort.to_s.downcase] || EFFORT_BUDGET['medium']
102
+ end
103
+
104
+ # Effort for a provider that needs an effort level (e.g. OpenAI),
105
+ # derived from budget when effort was not explicitly set. nil only when
106
+ # neither axis is configured.
107
+ def resolved_effort
108
+ return effort unless effort.nil?
109
+ return nil if budget.nil?
110
+
111
+ b = budget.to_i
112
+ if b < EFFORT_BUDGET['medium'] then 'low'
113
+ elsif b < EFFORT_BUDGET['high'] then 'medium'
114
+ else 'high'
115
+ end
116
+ end
82
117
  end
83
118
 
84
119
  # Alias for convenience: Canonical::Thinking::Config
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ # Shared stop_reason vocabulary, included by every provider translator (and
7
+ # available to legion-llm, which depends on lex-llm). Provider wire formats
8
+ # spell the same canonical end-states differently — OpenAI/vLLM emit
9
+ # "tool_calls", Anthropic "tool_use", and "stop"/"end_turn"/"eos" all mean
10
+ # the same thing. This puts the common vocabulary in one place so it is not
11
+ # copy-pasted (and drifting) across five provider gems.
12
+ #
13
+ # Keys are the incoming provider strings; values are the canonical
14
+ # stop_reason (see Canonical::Response::STOP_REASONS).
15
+ #
16
+ # Provider overrides, all by plain method definition — no guards:
17
+ # * ADD provider-specific strings → override #stop_reason_map_additions
18
+ # * REPLACE the whole vocabulary → override #stop_reason_map
19
+ module StopReasonMapping
20
+ # The common vocabulary every provider inherits for free.
21
+ def stop_reason_map
22
+ {
23
+ 'stop' => :end_turn,
24
+ 'end_turn' => :end_turn,
25
+ 'eos' => :end_turn,
26
+ 'complete' => :end_turn,
27
+ 'tool_calls' => :tool_use,
28
+ 'tool_call' => :tool_use,
29
+ 'tool_use' => :tool_use,
30
+ 'function_call' => :tool_use,
31
+ 'length' => :max_tokens,
32
+ 'max_tokens' => :max_tokens,
33
+ 'stop_sequence' => :stop_sequence,
34
+ 'stop_sequences' => :stop_sequence,
35
+ 'content_filter' => :content_filter,
36
+ 'error' => :error
37
+ }
38
+ end
39
+
40
+ # Provider-specific additions, merged on top of the common map. Base
41
+ # returns {} so callers never need an "if defined?" guard. Additions win
42
+ # over the common map on key collision.
43
+ def stop_reason_map_additions
44
+ {}
45
+ end
46
+
47
+ # Resolve a provider wire stop_reason string to its canonical symbol, or
48
+ # nil when unmapped (the caller decides the default — usually :end_turn).
49
+ def stop_reason_lookup(key)
50
+ stop_reason_map.merge(stop_reason_map_additions).fetch(key.to_s, nil)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.6.8'
6
+ VERSION = '0.6.10'
7
7
  end
8
8
  end
9
9
  end
@@ -82,6 +82,7 @@ module Legion
82
82
 
83
83
  # --- Provider base & allied modules ---
84
84
  require_relative 'llm/provider_contract'
85
+ require_relative 'llm/stop_reason_mapping'
85
86
  require_relative 'llm/provider_settings'
86
87
  require_relative 'llm/provider'
87
88
 
@@ -147,5 +147,43 @@ RSpec.describe Legion::Extensions::Llm::Canonical::Thinking do
147
147
  expect(config.to_h).to eq(effort: 'low')
148
148
  end
149
149
  end
150
+
151
+ # SSOT / one-oracle for cross-provider thinking (NxN best-effort): a client
152
+ # dialect supplies only ONE axis (Anthropic = budget_tokens only, OpenAI =
153
+ # effort only). Every provider translator must still get a usable value for
154
+ # whichever axis IT needs, so thinking survives ANY client×provider pair
155
+ # instead of being silently dropped. The conversion lives HERE (the canonical
156
+ # config), not scattered/one-directional in each translator. Derived accessors
157
+ # do NOT mutate stored state or #to_h — they only fill the gap on read.
158
+ describe 'derived cross-axis accessors' do
159
+ it 'derives budget from effort when only effort was set' do
160
+ expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
161
+ expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
162
+ expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
163
+ end
164
+
165
+ it 'derives effort from budget by band (< medium=low, < high=medium, else high)' do
166
+ expect(config_class.build(budget: 500).resolved_effort).to eq('low') # < 8192
167
+ expect(config_class.build(budget: 10_000).resolved_effort).to eq('medium') # 8192..16383
168
+ expect(config_class.build(budget: 20_000).resolved_effort).to eq('high') # >= 16384
169
+ end
170
+
171
+ it 'prefers the explicitly-set value over derivation' do
172
+ c = config_class.build(effort: 'high', budget: 2048)
173
+ expect(c.resolved_budget).to eq(2048)
174
+ expect(c.resolved_effort).to eq('high')
175
+ end
176
+
177
+ it 'returns nil derivations when neither axis is set' do
178
+ c = config_class.build
179
+ expect(c.resolved_budget).to be_nil
180
+ expect(c.resolved_effort).to be_nil
181
+ end
182
+
183
+ it 'does not fabricate the missing axis in to_h (stored state stays faithful)' do
184
+ expect(config_class.build(effort: 'high').to_h).to eq(effort: 'high')
185
+ expect(config_class.build(budget: 2048).to_h).to eq(budget: 2048)
186
+ end
187
+ end
150
188
  end
151
189
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Shared stop_reason vocabulary. Provider wire formats spell the same six
6
+ # canonical end-states differently (OpenAI/vLLM: "tool_calls"; Anthropic:
7
+ # "tool_use"; "stop" vs "end_turn" vs "eos"). Before this module, every
8
+ # provider translator carried its own copy-pasted *_STOP_REASON_MAP, which
9
+ # drifted — vllm/ollama mapped only "tool_use" and silently fell back to
10
+ # :end_turn on the "tool_calls" that OpenAI-compatible backends actually send.
11
+ #
12
+ # StopReasonMapping puts the common vocabulary in one place (inherited by all)
13
+ # with a provider-overridable additions hook and a full-replace hook.
14
+ RSpec.describe Legion::Extensions::Llm::StopReasonMapping do
15
+ let(:host_class) do
16
+ Class.new { include Legion::Extensions::Llm::StopReasonMapping }
17
+ end
18
+ let(:host) { host_class.new }
19
+
20
+ describe 'the common vocabulary (#stop_reason_lookup)' do
21
+ it 'maps OpenAI/vLLM "tool_calls" to :tool_use' do
22
+ expect(host.stop_reason_lookup('tool_calls')).to eq(:tool_use)
23
+ end
24
+
25
+ it 'maps Anthropic "tool_use" to :tool_use' do
26
+ expect(host.stop_reason_lookup('tool_use')).to eq(:tool_use)
27
+ end
28
+
29
+ it 'maps "stop"/"end_turn"/"eos" to :end_turn' do
30
+ expect(host.stop_reason_lookup('stop')).to eq(:end_turn)
31
+ expect(host.stop_reason_lookup('end_turn')).to eq(:end_turn)
32
+ expect(host.stop_reason_lookup('eos')).to eq(:end_turn)
33
+ end
34
+
35
+ it 'maps "length"/"max_tokens" to :max_tokens' do
36
+ expect(host.stop_reason_lookup('length')).to eq(:max_tokens)
37
+ expect(host.stop_reason_lookup('max_tokens')).to eq(:max_tokens)
38
+ end
39
+
40
+ it 'coerces non-string keys via to_s' do
41
+ expect(host.stop_reason_lookup(:tool_calls)).to eq(:tool_use)
42
+ end
43
+
44
+ it 'returns nil for an unmapped key (caller decides the default)' do
45
+ expect(host.stop_reason_lookup('totally_unknown')).to be_nil
46
+ end
47
+ end
48
+
49
+ describe 'provider additions (#stop_reason_map_additions)' do
50
+ it 'base returns {} so no guard is ever needed' do
51
+ expect(host.stop_reason_map_additions).to eq({})
52
+ end
53
+
54
+ it 'a provider adds provider-specific strings on top of the common map' do
55
+ bedrock_like = Class.new do
56
+ include Legion::Extensions::Llm::StopReasonMapping
57
+
58
+ def stop_reason_map_additions
59
+ { 'guardrail_intervened' => :content_filter }
60
+ end
61
+ end.new
62
+
63
+ # addition resolves
64
+ expect(bedrock_like.stop_reason_lookup('guardrail_intervened')).to eq(:content_filter)
65
+ # common vocabulary still inherited
66
+ expect(bedrock_like.stop_reason_lookup('tool_calls')).to eq(:tool_use)
67
+ end
68
+
69
+ it 'additions win over the common map on key collision' do
70
+ override = Class.new do
71
+ include Legion::Extensions::Llm::StopReasonMapping
72
+
73
+ def stop_reason_map_additions
74
+ { 'stop' => :stop_sequence }
75
+ end
76
+ end.new
77
+
78
+ expect(override.stop_reason_lookup('stop')).to eq(:stop_sequence)
79
+ end
80
+ end
81
+
82
+ describe 'full replacement (#stop_reason_map)' do
83
+ it 'a provider can replace the whole map, dropping the defaults' do
84
+ replaced = Class.new do
85
+ include Legion::Extensions::Llm::StopReasonMapping
86
+
87
+ def stop_reason_map
88
+ { 'weird_only' => :end_turn }
89
+ end
90
+ end.new
91
+
92
+ expect(replaced.stop_reason_lookup('weird_only')).to eq(:end_turn)
93
+ expect(replaced.stop_reason_lookup('tool_calls')).to be_nil
94
+ end
95
+ end
96
+ end
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.6.8
4
+ version: 0.6.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -324,6 +324,7 @@ files:
324
324
  - lib/legion/extensions/llm/routing/model_offering.rb
325
325
  - lib/legion/extensions/llm/routing/offering_registry.rb
326
326
  - lib/legion/extensions/llm/routing/registry_event.rb
327
+ - lib/legion/extensions/llm/stop_reason_mapping.rb
327
328
  - lib/legion/extensions/llm/stream_accumulator.rb
328
329
  - lib/legion/extensions/llm/streaming.rb
329
330
  - lib/legion/extensions/llm/taxonomies.rb
@@ -422,6 +423,7 @@ files:
422
423
  - spec/legion/extensions/llm/routing/model_offering_spec.rb
423
424
  - spec/legion/extensions/llm/routing/offering_registry_spec.rb
424
425
  - spec/legion/extensions/llm/routing/registry_event_spec.rb
426
+ - spec/legion/extensions/llm/stop_reason_mapping_spec.rb
425
427
  - spec/legion/extensions/llm/stream_accumulator_spec.rb
426
428
  - spec/legion/extensions/llm/streaming_spec.rb
427
429
  - spec/legion/extensions/llm/taxonomies_spec.rb