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

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

Returns:

  • (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