Module: Legion::LLM::Call::Dispatch

Extended by:
Dispatch, Legion::Logging::Helper
Included in:
Dispatch
Defined in:
lib/legion/llm/call/dispatch.rb

Constant Summary collapse

CAPABILITY_METHODS =

Mapping of supported capability names to extension method names.

{
  chat:         :chat,
  stream:       :stream,
  responses:    :responses,
  embed:        :embed,
  image:        :image,
  count_tokens: :count_tokens
}.freeze

Instance Method Summary collapse

Instance Method Details

#available?(provider) ⇒ Boolean

Returns true when the provider is registered in Registry.

Parameters:

  • provider (Symbol, String)

Returns:

  • (Boolean)


181
182
183
# File 'lib/legion/llm/call/dispatch.rb', line 181

def available?(provider)
  Registry.registered?(provider)
end

#call(provider:, capability:, instance: nil, model: nil) ⇒ Canonical::Response

Generic dispatch entry point. Routes to the appropriate extension method based on the capability name.

Parameters:

  • provider (Symbol, String)

    provider name

  • capability (Symbol, String)

    one of :chat, :stream, :embed, :count_tokens

  • instance (Symbol, String, nil) (defaults to: nil)

    provider instance (nil = default)

  • model (String, nil) (defaults to: nil)

    model identifier forwarded to the extension

  • block (Proc, nil)

    block forwarded to the extension (e.g. for streaming)

Returns:

  • (Canonical::Response)

    canonical provider response

Raises:



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/legion/llm/call/dispatch.rb', line 115

def call(provider:, capability:, instance: nil, model: nil, **, &)
  cap_sym = capability.to_sym
  method_name = CAPABILITY_METHODS[cap_sym]
  raise Legion::LLM::ProviderError, "unsupported capability: #{capability}" unless method_name

  ext = fetch_extension!(provider, instance: instance)
  raise Legion::LLM::ProviderError, "unsupported capability #{capability} for provider #{provider}" if ext.respond_to?(:supports?) && !ext.supports?(cap_sym)

  enforce_model_policy!(ext, provider: provider, model: model)

  log.info("[llm][dispatch] capability=#{cap_sym} provider=#{provider} " \
           "instance=#{instance || 'default'} model=#{model}")

  raw = ext.public_send(method_name, model: model, **, &)
  normalize_response(raw)
rescue Legion::LLM::LLMError
  raise
rescue StandardError => e
  raise unless defined?(Legion::Extensions::Llm)

  map_lex_llm_error(e, provider: provider, model: model)
end

#dispatch_chat(provider:, model:, messages:) ⇒ Object

Deprecated.

Use #call with capability: :chat instead.



142
143
144
145
146
147
148
# File 'lib/legion/llm/call/dispatch.rb', line 142

def dispatch_chat(provider:, model:, messages:, **)
  unless @chat_deprecation_warned
    log.warn('[llm][dispatch] DEPRECATED: dispatch_chat — use Dispatch.call(capability: :chat)')
    @chat_deprecation_warned = true
  end
  call(provider: provider, capability: :chat, model: model, messages: messages, **)
end

#dispatch_count_tokens(provider:, model:, messages:) ⇒ Object

Deprecated.

Use #call with capability: :count_tokens instead.



169
170
171
172
173
174
175
# File 'lib/legion/llm/call/dispatch.rb', line 169

def dispatch_count_tokens(provider:, model:, messages:, **)
  unless @count_tokens_deprecation_warned
    log.warn('[llm][dispatch] DEPRECATED: dispatch_count_tokens — use Dispatch.call(capability: :count_tokens)')
    @count_tokens_deprecation_warned = true
  end
  call(provider: provider, capability: :count_tokens, model: model, messages: messages, **)
end

#dispatch_embed(provider:, model:, text:) ⇒ Object

Deprecated.

Use #call with capability: :embed instead.



151
152
153
154
155
156
157
# File 'lib/legion/llm/call/dispatch.rb', line 151

def dispatch_embed(provider:, model:, text:, **)
  unless @embed_deprecation_warned
    log.warn('[llm][dispatch] DEPRECATED: dispatch_embed — use Dispatch.call(capability: :embed)')
    @embed_deprecation_warned = true
  end
  call(provider: provider, capability: :embed, model: model, text: text, **)
end

#dispatch_stream(provider:, model:, messages:) ⇒ Object

Deprecated.

Use #call with capability: :stream instead.



160
161
162
163
164
165
166
# File 'lib/legion/llm/call/dispatch.rb', line 160

def dispatch_stream(provider:, model:, messages:, **, &)
  unless @stream_deprecation_warned
    log.warn('[llm][dispatch] DEPRECATED: dispatch_stream — use Dispatch.call(capability: :stream)')
    @stream_deprecation_warned = true
  end
  call(provider: provider, capability: :stream, model: model, messages: messages, **, &)
end

#normalize_response(raw) ⇒ Canonical::Response

Normalize a raw extension response into a Canonical::Response.

Dispatch-boundary response contract: Call::Dispatch.call has always returned this shape, and the SSOT v3 executor dispatch (Inference::RouteAttempts#ssot_v3_direct_dispatch) normalizes the raw SelectionDispatch value through this before the executor consumes it — the native tool loop and response translation are written against Canonical::Response, never the raw provider Message.

Expected extension return shapes (any subset is acceptable):

{ content:, usage: { input_tokens:, output_tokens: }, model: }
{ result:, usage: ... }
lex-llm Message (the sync/stream provider return shape)

Returns:

  • (Canonical::Response)


200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/legion/llm/call/dispatch.rb', line 200

def normalize_response(raw)
  # Already a canonical response — no conversion needed
  return raw if defined?(Canonical::Response) && raw.is_a?(Canonical::Response)

  # Non-hash objects (object with .content) or plain values → hash first
  raw = coerce_to_hash(raw) unless raw.is_a?(Hash)

  text = extract_response_text(raw)
  raw_usage = raw[:usage] || {}

  # Preserve non-string payload (e.g., image URLs, embedding vectors as metadata)
  # so the compat adapter can return [:result] / [:content] for non-chat results.
   = raw[:metadata] || raw[:offering_metadata] || {}
  raw_result = [raw[:result], raw[:content], raw[:response]].find { |v| !v.nil? && !v.is_a?(String) }
  [:raw_result] = raw_result if raw_result

  usage = coerce_usage(raw_usage)

  log.debug("[llm][native] normalized_response usage_class=#{usage.class}")
  text, thinking = extract_thinking_payload(text, raw, )

  tool_calls = to_canonical_tool_calls(
    raw[:tool_calls] || raw['tool_calls'] || raw[:tools] || raw['tools'] || text
  )
  stop_reason = to_canonical_stop_reason(raw[:stop_reason] || raw['stop_reason'], tool_calls)

  Canonical::Response.new(
    text:        text.to_s,
    thinking:    thinking,
    tool_calls:  tool_calls,
    usage:       usage,
    stop_reason: stop_reason,
    model:       raw[:model] || raw['model'],
    routing:     {},
    metadata:    
  )
end