lex-llm 0.7.6 → 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 +139 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
- data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
- data/lib/legion/extensions/llm/canonical/message.rb +53 -69
- data/lib/legion/extensions/llm/canonical/params.rb +58 -34
- data/lib/legion/extensions/llm/canonical/request.rb +68 -56
- data/lib/legion/extensions/llm/canonical/response.rb +62 -78
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
- data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +7 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +36 -100
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +14 -26
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +8 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -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/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
- data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +97 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- metadata +13 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'legion/extensions/llm/settings_cascade'
|
|
4
4
|
require 'legion/extensions/llm/inventory/identity'
|
|
5
|
+
require 'legion/extensions/llm/taxonomies'
|
|
5
6
|
|
|
6
7
|
module Legion
|
|
7
8
|
module Extensions
|
|
@@ -11,15 +12,17 @@ module Legion
|
|
|
11
12
|
# reads the stored scalar; it is computed ONLY here, at write events, from a
|
|
12
13
|
# live settings read. Each multiplicative axis reads ITS OWN scope — never a
|
|
13
14
|
# fall-through cascade (a fall-through would double-count the other axes).
|
|
15
|
+
# The offering-scope settings key is the lane's 5 tuple — operator-readable,
|
|
16
|
+
# composed via Identity.compose_lane_id.
|
|
14
17
|
module WeightSchema
|
|
15
18
|
module_function
|
|
16
19
|
|
|
17
20
|
IDENTITY = 100
|
|
18
21
|
|
|
19
|
-
# Exact 4-key hash.
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
def weight_inputs(settings:, instance_key:,
|
|
22
|
+
# Exact 4-key hash. The offering-scope settings key is the 5 tuple of
|
|
23
|
+
# the lanes the draft's operation evidence supports (one per distinct
|
|
24
|
+
# lane type) — finally operator-usable, no digest.
|
|
25
|
+
def weight_inputs(settings:, instance_key:, model:, tier:, operation_evidence:)
|
|
23
26
|
llm_conf = scope_hash(settings.dig(:extensions, :llm), path: 'extensions.llm')
|
|
24
27
|
provider_conf = scope_hash(
|
|
25
28
|
SettingsCascade.lookup(llm_conf, instance_key.provider_family),
|
|
@@ -35,10 +38,10 @@ module Legion
|
|
|
35
38
|
tier_weights = scope_hash(
|
|
36
39
|
settings.dig(:llm, :routing, :tier_weights), path: 'llm.routing.tier_weights'
|
|
37
40
|
)
|
|
38
|
-
offering_id = Identity.offering_id(instance_key: instance_key, provider_native_key: provider_native_key)
|
|
39
41
|
offerings = scope_hash(SettingsCascade.lookup(provider_conf, :offerings), path: 'provider.offerings')
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
offering_w = offering_scope_weight(
|
|
43
|
+
offerings: offerings, instance_key: instance_key, model: model, tier: tier,
|
|
44
|
+
operation_evidence: operation_evidence
|
|
42
45
|
)
|
|
43
46
|
provider_models = scope_hash(
|
|
44
47
|
SettingsCascade.lookup(provider_conf, :models), path: 'provider.models'
|
|
@@ -59,7 +62,6 @@ module Legion
|
|
|
59
62
|
path: "merged model scope #{model}"
|
|
60
63
|
)
|
|
61
64
|
|
|
62
|
-
offering_w = SettingsCascade.lookup(offering_conf, :weight)
|
|
63
65
|
model_w = SettingsCascade.lookup(model_scopes, :weight)
|
|
64
66
|
{
|
|
65
67
|
tier: component(SettingsCascade.lookup(tier_weights, tier), IDENTITY),
|
|
@@ -72,6 +74,43 @@ module Legion
|
|
|
72
74
|
}
|
|
73
75
|
end
|
|
74
76
|
|
|
77
|
+
# The configured offering-scope weight for the draft: the operator keys
|
|
78
|
+
# `extensions.llm.<provider>.offerings` by the lane's 5 tuple. The lanes
|
|
79
|
+
# a draft supports are one 5 tuple per distinct lane type; the draft
|
|
80
|
+
# carries a SINGLE weight pair, so configured weights across its lanes
|
|
81
|
+
# must agree — a disagreement is an ambiguous operator input and RAISES
|
|
82
|
+
# instead of silently picking one.
|
|
83
|
+
def offering_scope_weight(offerings:, instance_key:, model:, tier:, operation_evidence:)
|
|
84
|
+
lane_ids = supported_lane_ids(
|
|
85
|
+
instance_key: instance_key, model: model, tier: tier, operation_evidence: operation_evidence
|
|
86
|
+
)
|
|
87
|
+
weights = lane_ids.filter_map do |lane_id|
|
|
88
|
+
scope = scope_hash(SettingsCascade.lookup(offerings, lane_id), path: "provider.offerings.#{lane_id}")
|
|
89
|
+
SettingsCascade.lookup(scope, :weight)
|
|
90
|
+
end
|
|
91
|
+
return nil if weights.empty?
|
|
92
|
+
|
|
93
|
+
unless weights.uniq.length == 1
|
|
94
|
+
raise ArgumentError, "offering weight scope is ambiguous: the draft's lanes " \
|
|
95
|
+
"#{lane_ids.join(', ')} carry differing configured weights"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
weights.first
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def supported_lane_ids(instance_key:, model:, tier:, operation_evidence:)
|
|
102
|
+
operation_evidence.each_value.with_object([]) do |evidence, lane_ids|
|
|
103
|
+
next unless evidence.supported?
|
|
104
|
+
|
|
105
|
+
lane_id = Identity.compose_lane_id(
|
|
106
|
+
tier: tier, provider_family: instance_key.provider_family,
|
|
107
|
+
instance_id: instance_key.instance_id,
|
|
108
|
+
type: Taxonomies.lane_type_for(operation: evidence.operation), model: model
|
|
109
|
+
)
|
|
110
|
+
lane_ids << lane_id unless lane_ids.include?(lane_id)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
75
114
|
# nil → default; an explicit 0 passes through (0 = operator disable —
|
|
76
115
|
# 0 is TRUTHY in Ruby and must not be defaulted); any other non-Integer
|
|
77
116
|
# RAISES instead of silently applying a different weight.
|
|
@@ -4,7 +4,12 @@ module Legion
|
|
|
4
4
|
module Extensions
|
|
5
5
|
module Llm
|
|
6
6
|
class Provider
|
|
7
|
-
# Shared OpenAI-compatible HTTP payload and response adapter
|
|
7
|
+
# Shared OpenAI-compatible HTTP payload and response adapter — the
|
|
8
|
+
# reference implementation for the OpenAI wire dialect (08 R3).
|
|
9
|
+
# Renders FROM Canonical (render_payload) and parses TO Canonical
|
|
10
|
+
# (parse_completion_response / build_chunk). Provider-dialect
|
|
11
|
+
# translation (usage spellings, JSON-string arguments, reasoning
|
|
12
|
+
# fields) lives here, at the edge (03 O03a).
|
|
8
13
|
module OpenAICompatible
|
|
9
14
|
def stream_usage_supported? = false
|
|
10
15
|
def completion_url = '/v1/chat/completions'
|
|
@@ -20,68 +25,94 @@ module Legion
|
|
|
20
25
|
|
|
21
26
|
private
|
|
22
27
|
|
|
23
|
-
def render_payload(messages, tools:,
|
|
28
|
+
def render_payload(messages, tools:, model:, stream:, schema:, thinking:, params:, tool_prefs:) # rubocop:disable Metrics/ParameterLists
|
|
24
29
|
payload = {
|
|
25
|
-
model: model
|
|
30
|
+
model: model,
|
|
26
31
|
messages: format_openai_messages(messages),
|
|
27
|
-
temperature:
|
|
32
|
+
temperature: maybe_normalize_temperature(params),
|
|
28
33
|
stream: stream,
|
|
29
34
|
tools: format_openai_tools(tools),
|
|
30
35
|
tool_choice: openai_tool_choice(tool_prefs),
|
|
31
36
|
response_format: openai_response_format(schema),
|
|
32
37
|
reasoning_effort: openai_reasoning_effort(thinking)
|
|
33
38
|
}.compact
|
|
39
|
+
payload.merge!(openai_payload_params(params))
|
|
34
40
|
payload[:stream_options] = { include_usage: true } if stream && stream_usage_supported?
|
|
35
41
|
payload
|
|
36
42
|
end
|
|
37
43
|
|
|
44
|
+
# Canonical Params → OpenAI wire keys (edge translation, O03a).
|
|
45
|
+
def openai_payload_params(params)
|
|
46
|
+
return {} unless params
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
max_tokens: params.max_tokens,
|
|
50
|
+
top_p: params.top_p,
|
|
51
|
+
top_k: params.top_k,
|
|
52
|
+
stop: params.stop_sequences,
|
|
53
|
+
seed: params.seed,
|
|
54
|
+
frequency_penalty: params.frequency_penalty,
|
|
55
|
+
presence_penalty: params.presence_penalty,
|
|
56
|
+
response_format: openai_response_format_value(params.response_format)
|
|
57
|
+
}.compact
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# response_format travels in its provider wire shape (client
|
|
61
|
+
# translator concern): a wire Hash passes through, a mode String is
|
|
62
|
+
# wrapped.
|
|
63
|
+
def openai_response_format_value(response_format)
|
|
64
|
+
return nil if response_format.nil?
|
|
65
|
+
return response_format if response_format.is_a?(::Hash)
|
|
66
|
+
|
|
67
|
+
{ type: response_format.to_s }
|
|
68
|
+
end
|
|
69
|
+
|
|
38
70
|
def format_openai_messages(messages)
|
|
39
71
|
messages.map do |message|
|
|
40
72
|
{
|
|
41
73
|
role: message.role.to_s,
|
|
42
|
-
content: openai_content(message.content
|
|
74
|
+
content: openai_content(message.content),
|
|
43
75
|
tool_call_id: message.tool_call_id,
|
|
44
76
|
tool_calls: format_openai_tool_calls(message.tool_calls)
|
|
45
77
|
}.compact
|
|
46
78
|
end
|
|
47
79
|
end
|
|
48
80
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
81
|
+
# L10: the role-parameterized sanitizer is deleted — text content
|
|
82
|
+
# passes through verbatim in every role (the decision comment
|
|
83
|
+
# below documents why).
|
|
84
|
+
def openai_content(content)
|
|
85
|
+
return content.map { |block| openai_content(block) } if content.is_a?(::Array)
|
|
86
|
+
return content.to_s if content.nil? || content.is_a?(::String)
|
|
53
87
|
|
|
54
|
-
|
|
55
|
-
end
|
|
88
|
+
return content.text.to_s if content.text?
|
|
56
89
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
90
|
+
{
|
|
91
|
+
type: content.type.to_s,
|
|
92
|
+
text: content.text,
|
|
93
|
+
data: content.data,
|
|
94
|
+
media_type: content.media_type,
|
|
95
|
+
source_type: content.source_type,
|
|
96
|
+
name: content.name,
|
|
97
|
+
id: content.id,
|
|
98
|
+
input: content.input,
|
|
99
|
+
tool_use_id: content.tool_use_id,
|
|
100
|
+
is_error: content.is_error
|
|
101
|
+
}.compact
|
|
64
102
|
end
|
|
65
103
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
# for client-facing responses; the OpenAI compat layer passes them through.
|
|
73
|
-
text
|
|
74
|
-
end
|
|
104
|
+
# Thinking-tag pass-through decision (L10: the vestigial sanitizer
|
|
105
|
+
# is deleted, the decision stays): qwen3.6 outputs thinking in
|
|
106
|
+
# tags and expects to see its own reasoning on subsequent rounds.
|
|
107
|
+
# The Anthropic API layer separates thinking into distinct content
|
|
108
|
+
# blocks for client-facing responses; the OpenAI compat layer passes
|
|
109
|
+
# them through untouched.
|
|
75
110
|
|
|
76
111
|
def format_openai_tool_calls(tool_calls)
|
|
77
112
|
return nil unless tool_calls&.any?
|
|
78
113
|
|
|
79
|
-
# Array
|
|
80
|
-
|
|
81
|
-
# this renderer depending on caller.
|
|
82
|
-
calls = tool_calls.is_a?(Hash) ? tool_calls.values : Array(tool_calls)
|
|
83
|
-
|
|
84
|
-
calls.map do |tool_call|
|
|
114
|
+
# Array<Canonical::ToolCall> only (the legacy Hash shape is deleted).
|
|
115
|
+
tool_calls.map do |tool_call|
|
|
85
116
|
{
|
|
86
117
|
id: tool_call.id,
|
|
87
118
|
type: 'function',
|
|
@@ -94,7 +125,7 @@ module Legion
|
|
|
94
125
|
end
|
|
95
126
|
|
|
96
127
|
def format_openai_tools(tools)
|
|
97
|
-
return nil if tools.empty?
|
|
128
|
+
return nil if tools.nil? || tools.empty?
|
|
98
129
|
|
|
99
130
|
tools.values.map do |tool|
|
|
100
131
|
{
|
|
@@ -123,176 +154,128 @@ module Legion
|
|
|
123
154
|
{ type: 'json_schema', json_schema: schema_hash }
|
|
124
155
|
end
|
|
125
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.
|
|
126
162
|
def openai_reasoning_effort(thinking)
|
|
127
|
-
return nil unless thinking.is_a?(
|
|
163
|
+
return nil unless thinking.is_a?(Canonical::Thinking::Config)
|
|
164
|
+
return nil unless thinking.enabled
|
|
128
165
|
|
|
129
|
-
thinking
|
|
166
|
+
thinking.resolved_effort
|
|
130
167
|
end
|
|
131
168
|
|
|
169
|
+
# One response-parse boundary (08 R2): returns Canonical::Response.
|
|
132
170
|
def parse_completion_response(response)
|
|
133
171
|
body = response.body
|
|
134
172
|
choice = Array(body['choices']).first || {}
|
|
135
173
|
message = choice['message'] || {}
|
|
136
|
-
usage = body['usage'] || {}
|
|
137
|
-
content, thinking = extract_thinking_from_completion(message)
|
|
138
|
-
|
|
139
|
-
Legion::Extensions::Llm::Message.new(
|
|
140
|
-
role: :assistant,
|
|
141
|
-
content: content,
|
|
142
|
-
model_id: body['model'],
|
|
143
|
-
tool_calls: parse_tool_calls(message['tool_calls']),
|
|
144
|
-
thinking: thinking,
|
|
145
|
-
input_tokens: usage['prompt_tokens'],
|
|
146
|
-
output_tokens: usage['completion_tokens'],
|
|
147
|
-
reasoning_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens'),
|
|
148
|
-
raw: body
|
|
149
|
-
)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def extract_thinking_from_completion(message)
|
|
153
174
|
extraction = Responses::ThinkingExtractor.extract(
|
|
154
175
|
message['content'],
|
|
155
176
|
metadata: thinking_metadata(message)
|
|
156
177
|
)
|
|
157
178
|
|
|
158
|
-
|
|
159
|
-
extraction.content,
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
)
|
|
164
|
-
|
|
179
|
+
Canonical::Response.build(
|
|
180
|
+
text: extraction.content,
|
|
181
|
+
thinking: thinking_value(extraction),
|
|
182
|
+
tool_calls: parse_tool_calls(message['tool_calls']),
|
|
183
|
+
usage: openai_usage_to_canonical(body['usage']),
|
|
184
|
+
stop_reason: stop_reason_lookup(body.dig('choices', 0, 'finish_reason')),
|
|
185
|
+
model: body['model']
|
|
186
|
+
)
|
|
165
187
|
end
|
|
166
188
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
reasoning_signature: message['reasoning_signature']
|
|
175
|
-
}.compact
|
|
189
|
+
# One thinking-metadata key list (10 U3): both the sync and chunk
|
|
190
|
+
# paths extract through the shared ThinkingExtractor core.
|
|
191
|
+
def thinking_metadata(wire_message)
|
|
192
|
+
Responses::ThinkingExtractor::THINKING_METADATA_KEYS.each_with_object({}) do |key, hash|
|
|
193
|
+
value = wire_message[key.to_s]
|
|
194
|
+
hash[key] = value unless value.nil?
|
|
195
|
+
end
|
|
176
196
|
end
|
|
177
197
|
|
|
178
|
-
def
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
thinking: thinking,
|
|
198
|
+
def thinking_value(extraction)
|
|
199
|
+
return nil if extraction.thinking.nil? && extraction.signature.nil?
|
|
200
|
+
|
|
201
|
+
Canonical::Thinking.build(content: extraction.thinking, signature: extraction.signature)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# OpenAI wire usage → canonical keys (edge translation, O03a).
|
|
205
|
+
def openai_usage_to_canonical(usage)
|
|
206
|
+
return nil if usage.to_h.empty?
|
|
207
|
+
|
|
208
|
+
Canonical::Usage.build(
|
|
190
209
|
input_tokens: usage['prompt_tokens'],
|
|
191
210
|
output_tokens: usage['completion_tokens'],
|
|
192
|
-
|
|
211
|
+
cache_read_tokens: usage.dig('prompt_tokens_details', 'cached_tokens') || usage.dig('input_tokens_details', 'cached_tokens'),
|
|
212
|
+
thinking_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens') || usage.dig('output_tokens_details', 'reasoning_tokens')
|
|
193
213
|
)
|
|
194
214
|
end
|
|
195
215
|
|
|
196
|
-
|
|
197
|
-
|
|
216
|
+
# One chunk-parse boundary (08 R2): returns a Canonical::Chunk or an
|
|
217
|
+
# Array of them. In-band think tags are NOT split here — tags split
|
|
218
|
+
# across chunks are the accumulator's stateful job (U1); per-chunk
|
|
219
|
+
# metadata (e.g. reasoning_content) is separated here, statelessly.
|
|
220
|
+
def build_chunk(data)
|
|
221
|
+
choice = Array(data['choices']).first || {}
|
|
222
|
+
delta = choice['delta'] || {}
|
|
223
|
+
usage = data['usage'] || {}
|
|
198
224
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
225
|
+
chunks = []
|
|
226
|
+
metadata = thinking_metadata(delta)
|
|
227
|
+
if metadata.any?
|
|
228
|
+
extraction = Responses::ThinkingExtractor.extract(nil, metadata:)
|
|
229
|
+
if extraction.thinking
|
|
230
|
+
chunks << Canonical::Chunk.thinking_delta(
|
|
231
|
+
delta: extraction.thinking, request_id: nil, signature: extraction.signature
|
|
232
|
+
)
|
|
233
|
+
end
|
|
208
234
|
end
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
def extract_thinking_from_chunk(delta)
|
|
212
|
-
reasoning = delta['reasoning_content'] || delta['reasoning']
|
|
213
235
|
content = delta['content']
|
|
236
|
+
chunks << Canonical::Chunk.text_delta(delta: content.to_s, request_id: nil) if content
|
|
237
|
+
chunks.concat(parse_streaming_tool_calls(delta['tool_calls']))
|
|
238
|
+
canonical_usage = openai_usage_to_canonical(usage)
|
|
239
|
+
chunks << Canonical::Chunk.usage_chunk(usage: canonical_usage, request_id: nil) if canonical_usage
|
|
214
240
|
|
|
215
|
-
if
|
|
216
|
-
|
|
241
|
+
if chunks.empty?
|
|
242
|
+
nil
|
|
217
243
|
else
|
|
218
|
-
|
|
244
|
+
(chunks.size == 1 ? chunks.first : chunks)
|
|
219
245
|
end
|
|
220
246
|
end
|
|
221
247
|
|
|
222
|
-
def
|
|
223
|
-
return
|
|
248
|
+
def parse_streaming_tool_calls(tool_calls)
|
|
249
|
+
return [] unless tool_calls&.any?
|
|
224
250
|
|
|
225
|
-
tool_calls.
|
|
251
|
+
tool_calls.filter_map do |call|
|
|
226
252
|
function = call.fetch('function', {})
|
|
227
253
|
name = function['name']
|
|
228
|
-
id = call['id']
|
|
229
|
-
|
|
230
|
-
[
|
|
231
|
-
|
|
232
|
-
Legion::Extensions::Llm::ToolCall.new(
|
|
233
|
-
id: id&.to_s,
|
|
234
|
-
name: name,
|
|
235
|
-
arguments: parse_tool_arguments(function['arguments'])
|
|
236
|
-
)
|
|
237
|
-
]
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def parse_tool_arguments(arguments)
|
|
242
|
-
return {} if arguments.nil? || arguments == ''
|
|
243
|
-
return arguments if arguments.is_a?(Hash)
|
|
244
|
-
|
|
245
|
-
Legion::JSON.parse(arguments, symbolize_names: false)
|
|
246
|
-
rescue Legion::JSON::ParseError => e
|
|
247
|
-
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.parse_tool_arguments')
|
|
248
|
-
{}
|
|
249
|
-
end
|
|
254
|
+
id = call['id']
|
|
255
|
+
index = call['index']
|
|
256
|
+
arguments_fragment = function['arguments'].to_s
|
|
257
|
+
next nil if id.nil? && name.nil? && index.nil? && arguments_fragment.empty?
|
|
250
258
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
Legion::Extensions::Llm::Model::Info.from_hash(
|
|
255
|
-
id: model.fetch('id'),
|
|
256
|
-
name: model['id'],
|
|
257
|
-
provider: provider,
|
|
258
|
-
created_at: model_created_at(model['created']),
|
|
259
|
-
capabilities: critical_capabilities,
|
|
260
|
-
modalities: modalities_for_capabilities(critical_capabilities),
|
|
261
|
-
metadata: model
|
|
259
|
+
Canonical::Chunk.tool_call_delta(
|
|
260
|
+
tool_call: { id: id, name: name, arguments: arguments_fragment, index: index },
|
|
261
|
+
request_id: nil
|
|
262
262
|
)
|
|
263
263
|
end
|
|
264
264
|
end
|
|
265
265
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def critical_capabilities_for(capabilities, model)
|
|
271
|
-
return [] unless capabilities
|
|
272
|
-
return capabilities.critical_capabilities_for(model) if capabilities.respond_to?(:critical_capabilities_for)
|
|
273
|
-
|
|
274
|
-
{
|
|
275
|
-
'streaming' => :streaming?,
|
|
276
|
-
'function_calling' => :functions?,
|
|
277
|
-
'vision' => :vision?,
|
|
278
|
-
'embeddings' => :embeddings?,
|
|
279
|
-
'moderation' => :moderation?,
|
|
280
|
-
'image' => :images?,
|
|
281
|
-
'audio_transcription' => :audio_transcription?
|
|
282
|
-
}.filter_map do |capability, predicate|
|
|
283
|
-
capability if capabilities.respond_to?(predicate) && capabilities.public_send(predicate, model)
|
|
284
|
-
end
|
|
285
|
-
end
|
|
266
|
+
# Sync tool calls: the ONE strict arguments parser (10 U2).
|
|
267
|
+
def parse_tool_calls(tool_calls)
|
|
268
|
+
return [] unless tool_calls&.any?
|
|
286
269
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
270
|
+
tool_calls.map do |call|
|
|
271
|
+
function = call.fetch('function', {})
|
|
272
|
+
name = function['name']
|
|
273
|
+
id = call['id'] || name || call['index']
|
|
274
|
+
Canonical::ToolCall.build(
|
|
275
|
+
name: name.to_s,
|
|
276
|
+
id: id&.to_s,
|
|
277
|
+
arguments: Responses::ToolArguments.parse!(function['arguments'])
|
|
278
|
+
)
|
|
296
279
|
end
|
|
297
280
|
end
|
|
298
281
|
|
|
@@ -300,51 +283,64 @@ module Legion
|
|
|
300
283
|
{ model: model.respond_to?(:id) ? model.id : model, input: text, dimensions: dimensions }.compact
|
|
301
284
|
end
|
|
302
285
|
|
|
286
|
+
# 05 §3 documented artifact: { text:, model:, embedding: Array<Float>,
|
|
287
|
+
# usage: Canonical::Usage }.
|
|
303
288
|
def parse_embedding_response(response, model:, text:)
|
|
304
289
|
vectors = response.body.fetch('data', []).map { |item| item['embedding'] }
|
|
305
290
|
vectors = vectors.first unless text.is_a?(Array)
|
|
306
291
|
usage = response.body['usage'] || {}
|
|
307
292
|
|
|
308
|
-
|
|
309
|
-
|
|
293
|
+
{
|
|
294
|
+
text: text,
|
|
295
|
+
model: model.respond_to?(:id) ? model.id : model,
|
|
296
|
+
embedding: vectors,
|
|
297
|
+
usage: openai_usage_to_canonical(usage)
|
|
298
|
+
}.compact
|
|
310
299
|
end
|
|
311
300
|
|
|
312
301
|
def render_moderation_payload(input, model:)
|
|
313
|
-
|
|
302
|
+
input_text = input.is_a?(::Array) ? input.map(&:text).join("\n") : input
|
|
303
|
+
{ model: model, input: input_text }.compact
|
|
314
304
|
end
|
|
315
305
|
|
|
306
|
+
# 05 §3 documented artifact: { model:, result: { flagged:, categories: } }.
|
|
316
307
|
def parse_moderation_response(response, model:)
|
|
317
|
-
|
|
318
|
-
|
|
308
|
+
result = Array(response.body['results']).first || {}
|
|
309
|
+
{
|
|
310
|
+
model: response.body['model'] || model,
|
|
311
|
+
result: {
|
|
312
|
+
flagged: result['flagged'] == true,
|
|
313
|
+
categories: result['categories'] || {}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
319
316
|
end
|
|
320
317
|
|
|
321
318
|
def render_image_payload(prompt, model:, size:, with:, mask:, params:) # rubocop:disable Metrics/ParameterLists
|
|
322
319
|
{ model: model, prompt: prompt, size: size, image: with, mask: mask }.merge(params).compact
|
|
323
320
|
end
|
|
324
321
|
|
|
322
|
+
# 05 §3 documented artifact: { model:, image:, size: }.
|
|
325
323
|
def parse_image_response(response, model:)
|
|
326
324
|
image = response.body.fetch('data', []).first || {}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
usage: response.body['usage'] || {}
|
|
333
|
-
)
|
|
325
|
+
{
|
|
326
|
+
model: model,
|
|
327
|
+
image: image['url'] || image['b64_json'],
|
|
328
|
+
size: image['size']
|
|
329
|
+
}.compact
|
|
334
330
|
end
|
|
335
331
|
|
|
336
332
|
def render_transcription_payload(file_part, model:, language:, **options)
|
|
337
333
|
{ model: model, file: file_part, language: language }.merge(options).compact
|
|
338
334
|
end
|
|
339
335
|
|
|
336
|
+
# 05 §3 documented artifact: { model:, text:, language:, duration: }.
|
|
340
337
|
def parse_transcription_response(response, model:)
|
|
341
|
-
|
|
342
|
-
text: response.body['text'],
|
|
338
|
+
{
|
|
343
339
|
model: model,
|
|
340
|
+
text: response.body['text'],
|
|
344
341
|
language: response.body['language'],
|
|
345
|
-
duration: response.body['duration']
|
|
346
|
-
|
|
347
|
-
)
|
|
342
|
+
duration: response.body['duration']
|
|
343
|
+
}.compact
|
|
348
344
|
end
|
|
349
345
|
end
|
|
350
346
|
end
|