lex-llm-gemini 0.3.13 → 0.4.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.
@@ -2,8 +2,8 @@
2
2
 
3
3
  begin
4
4
  require 'legion/extensions/actors/subscription'
5
- rescue LoadError => e
6
- warn(e.message) if $VERBOSE
5
+ rescue LoadError
6
+ nil
7
7
  end
8
8
 
9
9
  unless defined?(Legion::Extensions::Actors::Subscription)
@@ -11,6 +11,7 @@ unless defined?(Legion::Extensions::Actors::Subscription)
11
11
  end
12
12
 
13
13
  require 'legion/extensions/llm/gemini'
14
+ require 'legion/extensions/llm/gemini/runners/fleet_worker'
14
15
  require 'legion/extensions/llm/fleet/provider_responder'
15
16
 
16
17
  module Legion
@@ -20,8 +21,11 @@ module Legion
20
21
  module Actor
21
22
  # Subscription actor for Gemini fleet request consumption.
22
23
  class FleetWorker < Legion::Extensions::Actors::Subscription
24
+ # The Subscription dispatch path (use_runner? == false) calls
25
+ # runner_class.send(fn, **message) — a String cannot be send-ed,
26
+ # so the runner must be the resolved module constant.
23
27
  def runner_class
24
- 'Legion::Extensions::Llm::Gemini::Runners::FleetWorker'
28
+ Legion::Extensions::Llm::Gemini::Runners::FleetWorker
25
29
  end
26
30
 
27
31
  def runner_function
@@ -6,124 +6,26 @@ module Legion
6
6
  module Extensions
7
7
  module Llm
8
8
  module Gemini
9
- # Gemini provider implementation for the Legion::Extensions::Llm base provider contract.
10
- class Provider < Legion::Extensions::Llm::Provider # rubocop:disable Metrics/ClassLength
11
- include Legion::Logging::Helper
12
-
13
- class << self
14
- attr_writer :registry_publisher
15
-
16
- def slug = 'gemini'
17
- def configuration_options = %i[gemini_api_key gemini_api_base]
18
- def configuration_requirements = %i[gemini_api_key]
19
- def capabilities = Capabilities
20
-
21
- def registry_publisher
22
- @registry_publisher ||= Legion::Extensions::Llm::RegistryPublisher.new(provider_family: :gemini)
23
- end
24
- end
25
-
26
- # Capability predicates for Gemini API models.
27
- module Capabilities
28
- module_function
29
-
30
- def chat?(model) = supported?(model, 'generateContent')
31
- def streaming?(model) = supported?(model, 'streamGenerateContent')
32
- def embeddings?(model) = supported?(model, 'embedContent')
33
- def vision?(model) = chat?(model) && model_id(model).match?(/gemini|flash|pro/)
34
- def functions?(model) = chat?(model)
35
-
36
- def critical_capabilities_for(model)
37
- [
38
- ('streaming' if streaming?(model)),
39
- ('embeddings' if embeddings?(model)),
40
- ('function_calling' if functions?(model)),
41
- ('vision' if vision?(model))
42
- ].compact
43
- end
44
-
45
- def supported?(model, action)
46
- methods = generation_methods(model)
47
- return model_id(model).include?('embedding') if action == 'embedContent' && methods.empty?
48
- return true if methods.empty? && action != 'embedContent'
49
-
50
- methods.include?(action)
51
- end
52
-
53
- def generation_methods(model)
54
- metadata = metadata_for(model)
55
- Array(metadata[:supported_generation_methods] || metadata['supported_generation_methods'] ||
56
- metadata['supportedGenerationMethods'])
57
- end
58
-
59
- def model_id(model)
60
- return model.fetch('name', '').delete_prefix('models/') if model.is_a?(Hash)
61
-
62
- model.respond_to?(:id) ? model.id.to_s : model.to_s
63
- end
64
-
65
- def metadata_for(model)
66
- return model if model.is_a?(Hash)
67
- return model.metadata if model.respond_to?(:metadata)
68
-
69
- {}
70
- end
71
- end
72
-
73
- def settings
74
- Gemini.default_settings
75
- end
76
-
77
- def api_base
78
- config.gemini_api_base || settings[:endpoint] || 'https://generativelanguage.googleapis.com/v1beta'
79
- end
80
-
81
- def headers
82
- identity_headers.merge('x-goog-api-key' => config.gemini_api_key)
83
- end
84
-
85
- def completion_url = generate_content_url(model: @model)
86
- def stream_url = stream_generate_content_url(model: @model)
87
- def models_url = 'models'
88
- def embedding_url(model:) = embed_content_url(model:)
89
-
90
- def generate_content_url(model:)
91
- "#{model_path(model)}:generateContent"
92
- end
93
-
94
- def stream_generate_content_url(model:)
95
- "#{model_path(model)}:streamGenerateContent?alt=sse"
96
- end
97
-
98
- def embed_content_url(model:)
99
- "#{model_path(model)}:embedContent"
100
- end
101
-
102
- def list_models(**)
103
- log.info { 'listing available Gemini models' }
104
- super.tap do |models|
105
- log.info { "discovered #{models.size} Gemini model(s); publishing to registry" }
106
- self.class.registry_publisher.publish_models_async(models, readiness: readiness(live: false))
107
- end
108
- end
109
-
9
+ # Message formatting helpers mixed into Provider.
10
+ module MessageFormatter
110
11
  private
111
12
 
112
- def model_path(model)
113
- value = model.respond_to?(:id) ? model.id : model.to_s
114
- value.start_with?('models/') ? value : "models/#{value}"
13
+ def render_payload(messages, **opts)
14
+ model = opts.fetch(:model)
15
+ tools = opts.fetch(:tools)
16
+ temperature = opts.fetch(:temperature)
17
+ @model = model.id
18
+ build_request_payload(messages: messages, tools: tools, temperature: temperature,
19
+ schema: opts[:schema], tool_prefs: opts[:tool_prefs])
115
20
  end
116
21
 
117
- # rubocop:disable Metrics/ParameterLists,Lint/UnusedMethodArgument
118
- def render_payload(messages, tools:, temperature:, model:, stream:, schema:, thinking:, tool_prefs:)
119
- @model = model.id
22
+ def build_request_payload(messages:, tools:, temperature:, schema:, tool_prefs:)
120
23
  payload = { contents: format_messages(messages), generationConfig: generation_config(temperature, schema) }
121
24
  payload[:systemInstruction] = system_instruction(messages)
122
25
  payload[:tools] = format_tools(tools) unless tools.empty?
123
26
  payload[:toolConfig] = tool_config(tool_prefs) if tool_prefs
124
27
  payload.compact
125
28
  end
126
- # rubocop:enable Metrics/ParameterLists,Lint/UnusedMethodArgument
127
29
 
128
30
  def generation_config(temperature, schema)
129
31
  {
@@ -187,7 +89,6 @@ module Legion
187
89
  end
188
90
 
189
91
  def tool_call_parts(message)
190
- # Array is canonical (name-keyed hashes dropped parallel same-name calls)
191
92
  calls = message.tool_calls.is_a?(Hash) ? message.tool_calls.values : Array(message.tool_calls)
192
93
  calls.map do |tool_call|
193
94
  { functionCall: { name: tool_call.name, args: tool_call.arguments } }
@@ -223,6 +124,16 @@ module Legion
223
124
  { functionCallingConfig: { mode: choice.to_s } }
224
125
  end
225
126
 
127
+ def model_path(model)
128
+ value = model.respond_to?(:id) ? model.id : model.to_s
129
+ value.start_with?('models/') ? value : "models/#{value}"
130
+ end
131
+ end
132
+
133
+ # Response parsing helpers mixed into Provider.
134
+ module ResponseParser
135
+ private
136
+
226
137
  def parse_completion_response(response)
227
138
  body = response.body
228
139
  parts = response_parts(body)
@@ -312,7 +223,27 @@ module Legion
312
223
  end
313
224
  end
314
225
 
315
- # -- policy resolution requires many sources
226
+ def render_embedding_payload(text, model:, dimensions:)
227
+ {
228
+ model: model_path(model),
229
+ content: { parts: [{ text: text.to_s }] },
230
+ outputDimensionality: dimensions
231
+ }.compact
232
+ end
233
+
234
+ def parse_embedding_response(response, model:, **)
235
+ Legion::Extensions::Llm::Embedding.new(
236
+ vectors: response.body.dig('embedding', 'values'),
237
+ model: model,
238
+ input_tokens: response.body.dig('usageMetadata', 'promptTokenCount').to_i
239
+ )
240
+ end
241
+ end
242
+
243
+ # Offering and capability building helpers mixed into Provider.
244
+ module OfferingBuilder
245
+ private
246
+
316
247
  def offering_from_model(model, health: {})
317
248
  policy = Legion::Extensions::Llm::CapabilityPolicy.resolve(
318
249
  real: real_capabilities_from(model),
@@ -350,54 +281,39 @@ module Legion
350
281
  methods = Array(meta[:supported_generation_methods] || meta['supported_generation_methods'])
351
282
  {
352
283
  streaming: methods.include?('streamGenerateContent'),
353
- embeddings: methods.include?('embedContent'),
354
- vision: Capabilities.vision?(meta.merge('name' => "models/#{model.id}"))
284
+ embedding: methods.include?('embedContent'),
285
+ vision: Legion::Extensions::Llm::Gemini::Provider::Capabilities.vision?(
286
+ meta.merge('name' => "models/#{model.id}")
287
+ )
355
288
  }.compact
356
289
  end
357
290
 
358
291
  def provider_capability_config
359
- return {} unless defined?(Legion::Extensions::Llm::CredentialSources)
360
-
361
292
  conf = Legion::Extensions::Llm::CredentialSources.setting(:extensions, :llm, :gemini)
362
293
  conf.is_a?(Hash) ? conf.to_h.except(:instances, 'instances') : {}
363
294
  rescue StandardError => e
364
- handle_exception(e, level: :debug, handled: true, operation: 'gemini.provider_capability_config')
295
+ handle_exception(e, level: :warn, handled: true, operation: 'gemini.provider_capability_config')
365
296
  {}
366
297
  end
367
298
 
368
299
  def instance_capability_config
369
- cfg = config
370
- result = {}
371
- %i[capabilities enable_streaming enable_tools enable_thinking enable_vision enable_embeddings
372
- thinking_flag tools_flag streaming_flag vision_flag embedding_flag embeddings_flag
373
- tool_flag images_flag image_flag].each do |key|
374
- next unless cfg.respond_to?(key)
375
-
376
- val = cfg.send(key)
377
- result[key] = val unless val.nil?
378
- rescue StandardError
379
- next
380
- end
381
- result
300
+ config.to_h.slice(*Legion::Extensions::Llm::Provider::CAPABILITY_CONFIG_KEYS)
382
301
  end
383
302
 
384
303
  def model_capability_config(model_id)
385
- models_conf = resolve_models_config
386
- return {} unless models_conf.respond_to?(:to_h)
304
+ hash = resolve_models_config
305
+ return {} unless hash.is_a?(Hash)
387
306
 
388
- hash = models_conf.to_h
389
307
  hash[model_id.to_s] || hash[model_id.to_sym] || {}
390
308
  rescue StandardError => e
391
- handle_exception(e, level: :debug, handled: true, operation: 'gemini.model_capability_config')
309
+ handle_exception(e, level: :warn, handled: true, operation: 'gemini.model_capability_config')
392
310
  {}
393
311
  end
394
312
 
395
313
  def resolve_models_config
396
- return config.models if config.respond_to?(:models)
397
- return config[:models] if config.respond_to?(:[])
398
-
399
- nil
400
- rescue StandardError
314
+ config.to_h[:models]
315
+ rescue StandardError => e
316
+ handle_exception(e, level: :warn, handled: true, operation: 'gemini.resolve_models_config')
401
317
  nil
402
318
  end
403
319
 
@@ -406,21 +322,105 @@ module Legion
406
322
 
407
323
  [%w[text image audio video], %w[text]]
408
324
  end
325
+ end
409
326
 
410
- def render_embedding_payload(text, model:, dimensions:)
411
- {
412
- model: model_path(model),
413
- content: { parts: [{ text: text.to_s }] },
414
- outputDimensionality: dimensions
415
- }.compact
327
+ # Gemini provider implementation for the Legion::Extensions::Llm base provider contract.
328
+ class Provider < Legion::Extensions::Llm::Provider
329
+ include Legion::Logging::Helper
330
+ include MessageFormatter
331
+ include ResponseParser
332
+ include OfferingBuilder
333
+
334
+ class << self
335
+ def slug = 'gemini'
336
+ def configuration_options = %i[gemini_api_key gemini_api_base]
337
+ def configuration_requirements = %i[gemini_api_key]
338
+ def capabilities = Capabilities
416
339
  end
417
340
 
418
- def parse_embedding_response(response, model:, **)
419
- Legion::Extensions::Llm::Embedding.new(
420
- vectors: response.body.dig('embedding', 'values'),
421
- model: model,
422
- input_tokens: response.body.dig('usageMetadata', 'promptTokenCount').to_i
423
- )
341
+ # Capability predicates for Gemini API models.
342
+ module Capabilities
343
+ module_function
344
+
345
+ def chat?(model) = supported?(model, 'generateContent')
346
+ def streaming?(model) = supported?(model, 'streamGenerateContent')
347
+ def embeddings?(model) = supported?(model, 'embedContent')
348
+ def vision?(model) = chat?(model) && model_id(model).match?(/gemini|flash|pro/)
349
+ def functions?(model) = chat?(model)
350
+
351
+ def critical_capabilities_for(model)
352
+ [
353
+ ('streaming' if streaming?(model)),
354
+ ('embedding' if embeddings?(model)),
355
+ ('tools' if functions?(model)),
356
+ ('vision' if vision?(model))
357
+ ].compact
358
+ end
359
+
360
+ # When generation methods are absent, treat all capabilities as unknown
361
+ # (unknown = unsupported for selection). Only fall back to name heuristic
362
+ # for embedContent to preserve legacy embedding detection.
363
+ def supported?(model, action)
364
+ methods = generation_methods(model)
365
+ return model_id(model).include?('embedding') if methods.empty?
366
+
367
+ methods.include?(action)
368
+ end
369
+
370
+ def generation_methods(model)
371
+ metadata = metadata_for(model)
372
+ Array(metadata[:supported_generation_methods] || metadata['supported_generation_methods'] ||
373
+ metadata['supportedGenerationMethods'])
374
+ end
375
+
376
+ def model_id(model)
377
+ return model.fetch('name', '').delete_prefix('models/') if model.is_a?(Hash)
378
+
379
+ model.respond_to?(:id) ? model.id.to_s : model.to_s
380
+ end
381
+
382
+ def metadata_for(model)
383
+ return model if model.is_a?(Hash)
384
+ return model.metadata if model.respond_to?(:metadata)
385
+
386
+ {}
387
+ end
388
+ end
389
+
390
+ def settings
391
+ Gemini.default_settings
392
+ end
393
+
394
+ def api_base
395
+ config.gemini_api_base || settings[:instances][:default][:endpoint]
396
+ end
397
+
398
+ def headers
399
+ identity_headers.merge('x-goog-api-key' => config.gemini_api_key)
400
+ end
401
+
402
+ def completion_url = generate_content_url(model: @model)
403
+ def stream_url = stream_generate_content_url(model: @model)
404
+ def models_url = 'models'
405
+ def embedding_url(model:) = embed_content_url(model:)
406
+
407
+ def generate_content_url(model:)
408
+ "#{model_path(model)}:generateContent"
409
+ end
410
+
411
+ def stream_generate_content_url(model:)
412
+ "#{model_path(model)}:streamGenerateContent?alt=sse"
413
+ end
414
+
415
+ def embed_content_url(model:)
416
+ "#{model_path(model)}:embedContent"
417
+ end
418
+
419
+ def list_models(**)
420
+ log.info { 'listing available Gemini models' }
421
+ super.tap do |models|
422
+ log.info { "discovered #{models.size} Gemini model(s)" }
423
+ end
424
424
  end
425
425
  end
426
426
  end
@@ -9,17 +9,23 @@ module Legion
9
9
  module Gemini
10
10
  module Runners
11
11
  # Runner entrypoint for Gemini fleet request execution.
12
+ #
13
+ # The Subscription dispatch path invokes this as
14
+ # runner_class.send(fn, **message) where message is the fleet
15
+ # request envelope merged with transport metadata (routing_key,
16
+ # message_id, headers, ...). The responder parses the envelope out
17
+ # of that hash; unknown keys are inert.
12
18
  module FleetWorker
13
19
  module_function
14
20
 
15
- def handle_fleet_request(payload, delivery: nil, properties: nil)
21
+ def handle_fleet_request(**opts)
16
22
  Legion::Extensions::Llm::Fleet::ProviderResponder.call(
17
- payload: payload,
23
+ payload: opts,
18
24
  provider_family: Gemini::PROVIDER_FAMILY,
19
25
  provider_class: Gemini::Provider,
20
26
  provider_instances: -> { Gemini.discover_instances },
21
- delivery: delivery,
22
- properties: properties
27
+ delivery: opts[:delivery],
28
+ properties: opts[:properties]
23
29
  )
24
30
  end
25
31
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Gemini
7
- VERSION = '0.3.13'
7
+ VERSION = '0.4.4'
8
8
  end
9
9
  end
10
10
  end
@@ -3,7 +3,7 @@
3
3
  require 'legion/extensions/llm'
4
4
  require 'legion/extensions/llm/gemini/provider'
5
5
  require 'legion/extensions/llm/gemini/version'
6
- require_relative 'gemini/actors/discovery_refresh'
6
+ require 'legion/extensions/llm/gemini/actors/discovery_refresh'
7
7
 
8
8
  module Legion
9
9
  module Extensions
@@ -11,7 +11,6 @@ module Legion
11
11
  module Llm
12
12
  # Gemini provider extension namespace.
13
13
  module Gemini
14
- extend ::Legion::Extensions::Core if ::Legion::Extensions.const_defined?(:Core, false)
15
14
  extend Legion::Logging::Helper
16
15
  extend Legion::Extensions::Llm::AutoRegistration
17
16
 
@@ -22,7 +21,7 @@ module Legion
22
21
  family: PROVIDER_FAMILY,
23
22
  instance: {
24
23
  endpoint: 'https://generativelanguage.googleapis.com/v1beta',
25
- default_model: 'gemini-2.0-flash',
24
+ discovery_interval: 3600,
26
25
  tier: :frontier,
27
26
  transport: :http,
28
27
  credentials: { api_key: 'env://GEMINI_API_KEY' },
@@ -69,7 +68,7 @@ module Legion
69
68
 
70
69
  candidates[:settings] = normalize_instance_config(cfg).merge(api_key: api_key,
71
70
  gemini_api_key: api_key,
72
- tier: :cloud)
71
+ tier: cfg[:tier] || cfg['tier'] || :cloud)
73
72
  end
74
73
 
75
74
  def self.add_settings_instances(candidates, cfg)
@@ -83,17 +82,30 @@ module Legion
83
82
  next unless normalized[:gemini_api_key]
84
83
 
85
84
  normalized[:api_key] = normalized[:gemini_api_key]
86
- candidates[name.to_sym] = normalized.merge(tier: :cloud)
85
+ normalized[:tier] ||= :cloud
86
+ candidates[name.to_sym] = normalized
87
87
  end
88
88
  end
89
89
 
90
- def self.normalize_instance_config(config) # rubocop:disable Metrics/AbcSize
91
- normalized = config.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
90
+ def self.normalize_instance_config(config)
91
+ normalized = symbolize_config_keys(config)
92
+ promote_api_key(normalized)
93
+ promote_api_base(normalized)
94
+ normalized.compact.except(:instances)
95
+ end
96
+
97
+ def self.symbolize_config_keys(config)
98
+ config.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
99
+ end
100
+
101
+ def self.promote_api_key(normalized)
92
102
  normalized[:gemini_api_key] ||= normalized.delete(:api_key)
103
+ end
104
+
105
+ def self.promote_api_base(normalized)
93
106
  normalized[:gemini_api_base] ||= normalized.delete(:base_url)
94
107
  normalized[:gemini_api_base] ||= normalized.delete(:api_base)
95
108
  normalized[:gemini_api_base] ||= normalized.delete(:endpoint)
96
- normalized.compact.except(:instances)
97
109
  end
98
110
 
99
111
  def self.sanitize_instance_config(config)
@@ -102,6 +114,7 @@ module Legion
102
114
 
103
115
  private_class_method :discover_from_env, :discover_from_settings,
104
116
  :add_settings_api_key, :add_settings_instances, :normalize_instance_config,
117
+ :symbolize_config_keys, :promote_api_key, :promote_api_base,
105
118
  :sanitize_instance_config
106
119
 
107
120
  Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-gemini
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.13
4
+ version: 0.4.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: 1.3.14
46
+ version: 1.4.2
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: 1.3.14
53
+ version: 1.4.2
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: legion-transport
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - ">="
73
73
  - !ruby/object:Gem::Version
74
- version: 0.5.0
74
+ version: 0.7.1
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
- version: 0.5.0
81
+ version: 0.7.1
82
82
  description: Gemini provider integration for the LegionIO LLM routing framework.
83
83
  email:
84
84
  - matthewdiverson@gmail.com