Module: Legion::LLM::API::ClientTranslators::SharedExtractors
- Included in:
- AnthropicMessages, OpenAIChat, OpenAIResponses, StreamAssembler
- Defined in:
- lib/legion/llm/api/client_translators/shared_extractors.rb
Overview
Token-count and thinking-text extraction shared by all client translators. Single source of truth — replaces the 3-way duplicate that lived in AnthropicMessages, OpenAIChat, and OpenAIResponses (P6 dedup).
Constant Summary collapse
- PARAM_SPELLINGS =
Client-wire params spellings per canonical member (03 O03a). Canonical::Params accepts canonical keys and types ONLY; the client-dialect spellings are translated at this edge, never in the shared owner.
{ max_tokens: %i[max_tokens max_output_tokens num_predict max_completion_tokens], max_thinking_tokens: %i[max_thinking_tokens budget_tokens thinking_budget], stop_sequences: %i[stop_sequences stop] }.freeze
Instance Method Summary collapse
- #apply_canonical_params_to_inference(request_kwargs, canonical_params) ⇒ Object
-
#args_as_json_string(args) ⇒ Object
Coerce raw tool arguments into the OpenAI wire shape: a JSON string.
-
#args_as_object(args) ⇒ Object
Coerce raw tool arguments into the Anthropic wire shape: a Hash.
-
#canonical_content_block(part) ⇒ Object
One client content part → one canonical content block.
- #canonical_image_block(part) ⇒ Object
-
#canonicalize_content_parts(parts) ⇒ Object
Map client content parts to canonical content blocks (03 O03a).
- #extract_content_text(value) ⇒ Object
-
#extract_thinking_text(value) ⇒ Object
G3: the envelope carries the provider's Canonical::Thinking — the thinking text is the .content member (the former String/Hash/ duck-typed branches were the split-world seam).
- #legion_routing_explicit_from_env(env) ⇒ Object
- #legion_routing_from_env(env) ⇒ Object
- #normalize_canonical_params(value) ⇒ Object
- #normalize_response_format(value) ⇒ Object
- #normalize_stop_sequences(value) ⇒ Object
-
#param_spelling(body, member) ⇒ Object
First non-nil client-wire spelling for a canonical member.
- #text_content_type?(type) ⇒ Boolean
-
#token_value(tokens, *keys) ⇒ Object
The pipeline envelope's tokens member is a plain Hash (or {} when no usage was observed) — one shape, no dual reader (G3).
-
#tool_fragment_field(fragment, field) ⇒ Object
A canonical tool_call_delta fragment (R4) arrives as a Hash (string- or symbol-keyed — wire/kit fixtures) or a Canonical::ToolCall.
Instance Method Details
#apply_canonical_params_to_inference(request_kwargs, canonical_params) ⇒ Object
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 227 def apply_canonical_params_to_inference(request_kwargs, canonical_params) params = normalize_canonical_params(canonical_params) return request_kwargs unless params max_tokens = params[:max_tokens] request_kwargs[:tokens] = { max: max_tokens } if max_tokens generation = params.slice( :temperature, :top_p, :top_k, :frequency_penalty, :presence_penalty, :seed ).compact request_kwargs[:generation] = generation unless generation.empty? stop_sequences = normalize_stop_sequences(params[:stop_sequences] || params[:stop]) request_kwargs[:stop] = { sequences: stop_sequences } unless stop_sequences.empty? response_format = normalize_response_format(params[:response_format]) request_kwargs[:response_format] = response_format if response_format request_kwargs end |
#args_as_json_string(args) ⇒ Object
Coerce raw tool arguments into the OpenAI wire shape: a JSON string. Non-string inputs are JSON-dumped; nil becomes "{}"; malformed values are stringified as a last resort so the wire never carries a Ruby object.
197 198 199 200 201 202 203 204 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 197 def args_as_json_string(args) return args if args.is_a?(String) Legion::JSON.dump(args || {}) rescue StandardError => e handle_exception(e, level: :warn, operation: 'llm.client_translator.args_as_json_string') args.to_s end |
#args_as_object(args) ⇒ Object
Coerce raw tool arguments into the Anthropic wire shape: a Hash.
Strings are parsed as JSON when possible; non-Hash literals
(numbers, arrays from degraded model output) collapse to {}
rather than violating the contract.
210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 210 def args_as_object(args) return args if args.is_a?(Hash) return {} if args.nil? if args.is_a?(String) return {} if args.empty? parsed = Legion::JSON.load(args) return parsed if parsed.is_a?(Hash) end {} rescue StandardError => e handle_exception(e, level: :warn, operation: 'llm.client_translator.args_as_object') {} end |
#canonical_content_block(part) ⇒ Object
One client content part → one canonical content block. Type aliases (input_text/output_text → text) and the OpenAI image_url envelope (→ image with data/media_type/source_type) are the two dialect shapes; anything else passes through and the canonical validation raises loudly on a wrong type.
67 68 69 70 71 72 73 74 75 76 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 67 def canonical_content_block(part) case part[:type].to_s when 'text', 'input_text', 'output_text' { type: 'text', text: part[:text].to_s } when 'image_url' canonical_image_block(part) else part end end |
#canonical_image_block(part) ⇒ Object
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 78 def canonical_image_block(part) image_url = part[:image_url] image_url = image_url.is_a?(Hash) ? (image_url[:url] || image_url['url']) : image_url url = image_url.to_s if url.start_with?('data:') media_type, data = url.sub('data:', '').split(',', 2) { type: 'image', data: data, # Strip any ;parameters (e.g. ";base64") from the media type. Uses a # plain string split rather than /;.*$/ — the latter is a polynomial # ReDoS (rb/polynomial-redos) on attacker-controlled data URLs. media_type: (media_type || '').split(';', 2).first.to_s, source_type: :base64 } else { type: 'image', data: url, source_type: 'url' } end end |
#canonicalize_content_parts(parts) ⇒ Object
Map client content parts to canonical content blocks (03 O03a). Canonical::ContentBlock accepts canonical types only; the dialect spellings are translated at the client edge. String parts (e.g. corrupted ContentBlock#inspect output replayed in history) are wrapped as text blocks; other non-Hash parts pass through untouched — canonical validation raises a typed error.
48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 48 def canonicalize_content_parts(parts) return parts unless parts.is_a?(Array) parts.map do |part| if part.is_a?(String) canonical_content_block({ type: 'text', text: part }) elsif part.is_a?(Hash) canonical_content_block(part.transform_keys(&:to_sym)) else part end end end |
#extract_content_text(value) ⇒ Object
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 119 def extract_content_text(value) return '' if value.nil? return value if value.is_a?(String) if value.is_a?(Array) return value.filter_map do |part| text = extract_content_text(part) text.empty? ? nil : text end.join end if value.is_a?(Hash) normalized = value.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key } content = normalized[:content] return extract_content_text(content) unless content.nil? return '' unless text_content_type?(normalized[:type]) text = normalized[:text] || normalized[:output_text] || normalized[:value] return extract_content_text(text) unless text.nil? return '' end if value.respond_to?(:text) type = value.respond_to?(:type) ? value.type : nil return '' unless text_content_type?(type) return value.text.to_s end return extract_content_text(value.content) if value.respond_to?(:content) value.to_s end |
#extract_thinking_text(value) ⇒ Object
G3: the envelope carries the provider's Canonical::Thinking — the thinking text is the .content member (the former String/Hash/ duck-typed branches were the split-world seam).
113 114 115 116 117 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 113 def extract_thinking_text(value) return '' if value.nil? value.content.to_s end |
#legion_routing_explicit_from_env(env) ⇒ Object
168 169 170 171 172 173 174 175 176 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 168 def legion_routing_explicit_from_env(env) flags = { model: env.key?('HTTP_X_LEGION_MODEL'), provider: env.key?('HTTP_X_LEGION_PROVIDER'), instance: env.key?('HTTP_X_LEGION_INSTANCE'), tier: env.key?('HTTP_X_LEGION_TIER') }.select { |_, value| value } flags.empty? ? nil : flags end |
#legion_routing_from_env(env) ⇒ Object
160 161 162 163 164 165 166 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 160 def legion_routing_from_env(env) { model: env['HTTP_X_LEGION_MODEL'], provider: env['HTTP_X_LEGION_PROVIDER'], instance: env['HTTP_X_LEGION_INSTANCE'] }.compact end |
#normalize_canonical_params(value) ⇒ Object
253 254 255 256 257 258 259 260 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 253 def normalize_canonical_params(value) hash = value.respond_to?(:to_h) ? value.to_h : value return nil unless hash.is_a?(Hash) hash.each_with_object({}) do |(key, param_value), normalized| normalized[key.respond_to?(:to_sym) ? key.to_sym : key] = param_value end end |
#normalize_response_format(value) ⇒ Object
269 270 271 272 273 274 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 269 def normalize_response_format(value) return nil if value.nil? return { type: value.to_sym } if value.is_a?(String) || value.is_a?(Symbol) value end |
#normalize_stop_sequences(value) ⇒ Object
262 263 264 265 266 267 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 262 def normalize_stop_sequences(value) return [] if value.nil? return [value.to_s] if value.is_a?(String) || value.is_a?(Symbol) Array(value).compact.map(&:to_s).reject(&:empty?) end |
#param_spelling(body, member) ⇒ Object
First non-nil client-wire spelling for a canonical member.
34 35 36 37 38 39 40 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 34 def param_spelling(body, member) PARAM_SPELLINGS.fetch(member).each do |key| value = body[key] || body[key.to_s] return value unless value.nil? end nil end |
#text_content_type?(type) ⇒ Boolean
155 156 157 158 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 155 def text_content_type?(type) type_string = type.to_s type_string.empty? || %w[text output_text input_text].include?(type_string) end |
#token_value(tokens, *keys) ⇒ Object
The pipeline envelope's tokens member is a plain Hash (or {} when no usage was observed) — one shape, no dual reader (G3).
100 101 102 103 104 105 106 107 108 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 100 def token_value(tokens, *keys) return 0 if tokens.nil? keys.each do |key| value = tokens.is_a?(Hash) ? (tokens[key] || tokens[key.to_s]) : nil return value.to_i unless value.nil? end 0 end |
#tool_fragment_field(fragment, field) ⇒ Object
A canonical tool_call_delta fragment (R4) arrives as a Hash (string- or symbol-keyed — wire/kit fixtures) or a Canonical::ToolCall. Read one field shape-agnostically.
24 25 26 27 28 29 30 31 |
# File 'lib/legion/llm/api/client_translators/shared_extractors.rb', line 24 def tool_fragment_field(fragment, field) return fragment.public_send(field) if fragment.is_a?(Hash) == false && fragment.respond_to?(field) return fragment[field] if fragment.is_a?(Hash) && fragment.key?(field) return fragment[field.to_s] if fragment.is_a?(Hash) nil end |