labimotion 2.3.0 → 2.4.0.rc11

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -1
  3. data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
  4. data/lib/labimotion/apis/generic_element_api.rb +198 -8
  5. data/lib/labimotion/apis/generic_klass_api.rb +74 -8
  6. data/lib/labimotion/apis/klass_share_api.rb +648 -0
  7. data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
  8. data/lib/labimotion/apis/labimotion_api.rb +4 -0
  9. data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
  10. data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
  11. data/lib/labimotion/apis/ontology_root_api.rb +74 -0
  12. data/lib/labimotion/apis/segment_api.rb +77 -10
  13. data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
  14. data/lib/labimotion/conf.rb +5 -0
  15. data/lib/labimotion/constants.rb +64 -0
  16. data/lib/labimotion/entities/application_entity.rb +8 -0
  17. data/lib/labimotion/entities/eln_element_entity.rb +6 -0
  18. data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
  19. data/lib/labimotion/entities/klass_share_entity.rb +48 -0
  20. data/lib/labimotion/entities/properties_entity.rb +78 -2
  21. data/lib/labimotion/entities/segment_entity.rb +8 -0
  22. data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
  23. data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
  24. data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
  25. data/lib/labimotion/helpers/element_helpers.rb +282 -6
  26. data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
  27. data/lib/labimotion/helpers/generic_helpers.rb +293 -4
  28. data/lib/labimotion/helpers/param_helpers.rb +105 -0
  29. data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
  30. data/lib/labimotion/helpers/segment_helpers.rb +102 -4
  31. data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
  32. data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
  33. data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
  34. data/lib/labimotion/libs/ai_models.rb +201 -0
  35. data/lib/labimotion/libs/ai_template.rb +2285 -0
  36. data/lib/labimotion/libs/converter.rb +5 -43
  37. data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
  38. data/lib/labimotion/libs/export_element.rb +128 -13
  39. data/lib/labimotion/libs/file_extractor.rb +210 -0
  40. data/lib/labimotion/libs/linked_element.rb +313 -0
  41. data/lib/labimotion/libs/ontology_store.rb +226 -0
  42. data/lib/labimotion/libs/ontology_terms.rb +227 -0
  43. data/lib/labimotion/libs/owner_resolver.rb +50 -0
  44. data/lib/labimotion/libs/ownership_audit.rb +73 -0
  45. data/lib/labimotion/libs/sample_association.rb +52 -1
  46. data/lib/labimotion/libs/share_notifier.rb +114 -0
  47. data/lib/labimotion/libs/share_resolver.rb +373 -0
  48. data/lib/labimotion/libs/user_ai_settings.rb +129 -0
  49. data/lib/labimotion/models/cellline.rb +47 -0
  50. data/lib/labimotion/models/concerns/datasetable.rb +3 -0
  51. data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
  52. data/lib/labimotion/models/concerns/segmentable.rb +2 -0
  53. data/lib/labimotion/models/concerns/template_doi.rb +133 -0
  54. data/lib/labimotion/models/dataset_klass.rb +1 -1
  55. data/lib/labimotion/models/element_klass.rb +1 -1
  56. data/lib/labimotion/models/klass_share.rb +129 -0
  57. data/lib/labimotion/models/segment_klass.rb +1 -1
  58. data/lib/labimotion/models/user_klass_setting.rb +58 -0
  59. data/lib/labimotion/models/user_setting.rb +60 -0
  60. data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
  61. data/lib/labimotion/usecases/release_template_doi.rb +42 -17
  62. data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
  63. data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
  64. data/lib/labimotion/utils/export_utils.rb +1 -0
  65. data/lib/labimotion/utils/import_utils.rb +20 -3
  66. data/lib/labimotion/utils/serializer.rb +27 -0
  67. data/lib/labimotion/utils/units.rb +32 -67
  68. data/lib/labimotion/version.rb +1 -1
  69. data/lib/labimotion.rb +27 -0
  70. metadata +45 -3
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'resolv'
4
+ require 'ipaddr'
5
+ require 'uri'
6
+
7
+ module Labimotion
8
+ # Validates a USER-SUPPLIED AI provider base URL before the server makes an
9
+ # outbound request to it, to prevent SSRF. A user may point the AI feature at
10
+ # their own OpenAI-compatible endpoint, but only at a PUBLIC https host — never
11
+ # at loopback / private / link-local / cloud-metadata addresses.
12
+ #
13
+ # Known limitation: validation resolves DNS at check time; a hostile resolver
14
+ # could rebind between this check and the actual request (TOCTOU). Redirect
15
+ # following is disabled at the HTTP layer, which removes the most common bypass;
16
+ # pinning the connection to the validated IP would close the residual window.
17
+ class AiEgressGuard
18
+ class BlockedError < StandardError; end
19
+
20
+ # Ranges Ruby's IPAddr#private?/#loopback?/#link_local? don't all cover:
21
+ # unspecified, CGNAT, benchmarking/test-net, IPv4-broadcast, reserved,
22
+ # and the IPv6 unique-local / link-local / multicast blocks.
23
+ EXTRA_BLOCKED = [
24
+ '0.0.0.0/8', '100.64.0.0/10', '192.0.0.0/24', '192.0.2.0/24',
25
+ '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '240.0.0.0/4',
26
+ '255.255.255.255/32', '::/128', '::1/128', 'fc00::/7', 'fe80::/10', 'ff00::/8'
27
+ ].map { |cidr| IPAddr.new(cidr) }.freeze
28
+
29
+ # Raise BlockedError unless url is a public https endpoint (http is also
30
+ # accepted outside production, e.g. against a local dev provider). Returns url.
31
+ def self.validate!(url)
32
+ uri = parse(url)
33
+ allowed_schemes = Rails.env.production? ? %w[https] : %w[https http]
34
+ raise BlockedError, "AI provider URL must use #{allowed_schemes.join(' or ')}" unless
35
+ allowed_schemes.include?(uri.scheme)
36
+
37
+ host = uri.host.to_s
38
+ raise BlockedError, 'AI provider URL has no host' if host.empty?
39
+
40
+ addresses = resolve(host)
41
+ raise BlockedError, "AI provider host could not be resolved: #{host}" if addresses.empty?
42
+
43
+ blocked = addresses.find { |ip| !allowed_ip?(ip) }
44
+ raise BlockedError, "AI provider host resolves to a non-public address (#{blocked})" if blocked
45
+
46
+ url
47
+ end
48
+
49
+ def self.parse(url)
50
+ URI.parse(url.to_s.strip)
51
+ rescue URI::InvalidURIError
52
+ raise BlockedError, 'AI provider URL is not a valid URL'
53
+ end
54
+
55
+ # All A/AAAA addresses for host as IPAddr; a literal IP resolves to itself.
56
+ def self.resolve(host)
57
+ return [IPAddr.new(host)] if literal_ip?(host)
58
+
59
+ Resolv.getaddresses(host).filter_map { |addr| safe_ipaddr(addr) }
60
+ rescue StandardError
61
+ []
62
+ end
63
+
64
+ def self.literal_ip?(str)
65
+ IPAddr.new(str)
66
+ true
67
+ rescue IPAddr::InvalidAddressError
68
+ false
69
+ end
70
+
71
+ def self.safe_ipaddr(addr)
72
+ ip = IPAddr.new(addr.to_s)
73
+ ip.ipv4_mapped? ? ip.native : ip
74
+ rescue StandardError
75
+ nil
76
+ end
77
+
78
+ # Public = not loopback/private/link-local and not in EXTRA_BLOCKED.
79
+ def self.public_ip?(ip)
80
+ return false if ip.loopback? || ip.private? || ip.link_local?
81
+
82
+ EXTRA_BLOCKED.none? { |net| net.include?(ip) }
83
+ rescue StandardError
84
+ false
85
+ end
86
+
87
+ # Outside production, a private-network address (e.g. a self-hosted model
88
+ # on another machine on the LAN) is allowed; loopback/link-local/other
89
+ # EXTRA_BLOCKED ranges (cloud metadata etc.) stay blocked regardless of env.
90
+ def self.allowed_ip?(ip)
91
+ return public_ip?(ip) if Rails.env.production?
92
+ return false if ip.loopback? || ip.link_local?
93
+
94
+ EXTRA_BLOCKED.none? { |net| net.include?(ip) }
95
+ rescue StandardError
96
+ false
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Labimotion
6
+ # Hands AI template generation to the host's background worker.
7
+ #
8
+ # Generating a template is one long provider call — 18s for a small segment,
9
+ # past 90s for a large one — and running it inside the request held the dialog
10
+ # open for all of it, losing the work if the tab was closed or reloaded. The
11
+ # endpoints enqueue instead and answer at once; the result comes back as a
12
+ # notification carrying a link into the designer.
13
+ #
14
+ # The job itself belongs to the host: ActiveJob, the queue and the Message
15
+ # machinery are all its, and a gem that shipped its own would be defining
16
+ # infrastructure it does not own. So the host class is reached by its bare
17
+ # name and only if it exists — the same NameError-guarded pattern used for
18
+ # Matrice and Message elsewhere in this gem. A host without it gets the old
19
+ # inline behaviour rather than an error, which is what `queue` returning false
20
+ # tells the caller.
21
+ module AiKlassQueue
22
+ JOB = 'AiTemplateJob'
23
+
24
+ # @return [Boolean] true when the work was handed off
25
+ def self.queue(kind:, params:, user:, element_klass_id: nil)
26
+ job = job_class
27
+ return false if job.nil?
28
+
29
+ # Grape params are a Hashie::Mash; the queue serialises what it is given,
30
+ # and a Mash carries method_missing behaviour a worker process should not
31
+ # be asked to rebuild. A plain hash is the whole contract.
32
+ args = [kind.to_s, plain(params), user.id, element_klass_id]
33
+ inline? ? run_now(job, args, user) : job.perform_later(*args)
34
+ true
35
+ rescue StandardError => e
36
+ # An enqueue that fails must not fail the request: the caller falls back
37
+ # to generating inline, which is slower but still correct.
38
+ Labimotion.log_exception(e, user)
39
+ false
40
+ end
41
+
42
+ # Queued in every environment, development included: one code path, and the
43
+ # request returns straight away wherever it runs. Development therefore
44
+ # needs a worker like anywhere else — without one the job sits in the table
45
+ # and the notification never arrives.
46
+ #
47
+ # LABIMOTION_AI_INLINE=true runs it in the request instead, for a machine
48
+ # with no worker to spare. Off unless asked for, because a create that
49
+ # blocks for the length of a provider call is not the behaviour anyone
50
+ # should get by accident.
51
+ def self.inline?
52
+ ENV['LABIMOTION_AI_INLINE'] == 'true'
53
+ rescue StandardError
54
+ false
55
+ end
56
+
57
+ # Deliberately swallows: the job reports its own failure as a notification,
58
+ # exactly as it would on a worker, and letting it raise here would send the
59
+ # caller down the inline-generation fallback — spending a second provider
60
+ # call on work that has already been done and paid for.
61
+ def self.run_now(job, args, user)
62
+ job.perform_now(*args)
63
+ rescue StandardError => e
64
+ Labimotion.log_exception(e, user)
65
+ nil
66
+ end
67
+
68
+ def self.job_class
69
+ Object.const_get(JOB)
70
+ rescue NameError
71
+ nil
72
+ end
73
+
74
+ # Only what the helpers read, and stringified: file payloads included, since
75
+ # the reference files are part of the request the worker has to replay.
76
+ #
77
+ # A JSON round trip rather than as_json, because that is precisely what the
78
+ # queue will do to these arguments anyway — so anything that would not
79
+ # survive being written to the jobs table fails here, in the request, rather
80
+ # than in a worker nobody is watching.
81
+ def self.plain(params)
82
+ return {} if params.nil?
83
+
84
+ source = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params
85
+ JSON.parse(JSON.generate(source))
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Labimotion
4
+ # The checks that must happen BEFORE a template is generated.
5
+ #
6
+ # Generation moved to a background job, and the cheap objections — this name
7
+ # is taken, this ontology term already has a template — used to live inside
8
+ # the create helpers, which now run inside that job. So the user spent a
9
+ # provider call and 15-90s of waiting only to be told the label was already in
10
+ # use, which was knowable before a single token was spent. Worse, the answer
11
+ # arrived as a notification long after the dialog that could have fixed it had
12
+ # closed.
13
+ #
14
+ # These are only the objections that can be settled by a lookup. Anything the
15
+ # model decides at create! stays there: this is a courtesy gate, not a second
16
+ # source of truth, and the create still validates for real.
17
+ module AiKlassValidator
18
+ # @return [String, nil] the objection, or nil when there is none
19
+ def self.error_for(kind:, params:, element_klass: nil)
20
+ files = file_error(params)
21
+ return files if files
22
+
23
+ case kind.to_s
24
+ when 'element' then element_error(params)
25
+ when 'segment' then segment_error(params, element_klass)
26
+ when 'dataset' then dataset_error(params)
27
+ end
28
+ end
29
+
30
+ def self.file_error(params)
31
+ count = Array(params[:files]).size
32
+ return nil if count <= Labimotion::AiTemplate::MAX_FILES
33
+
34
+ "Too many files (max #{Labimotion::AiTemplate::MAX_FILES})."
35
+ end
36
+
37
+ # Mirrors ElementKlass's `validates :name, uniqueness:` — scoped to rows that
38
+ # are not soft-deleted, so a deleted template does not block the name it no
39
+ # longer holds.
40
+ def self.element_error(params)
41
+ name = params[:name].to_s.strip
42
+ return 'A name is required.' if name.blank?
43
+ return 'A label is required.' if params[:label].to_s.strip.blank?
44
+
45
+ return unless Labimotion::ElementKlass.where(deleted_at: nil).exists?(name: name)
46
+
47
+ "An element named [#{name}] already exists."
48
+ end
49
+
50
+ # Mirrors SegmentKlass's `validates :label, uniqueness: { scope: :element_klass_id }`.
51
+ # The label only has to be unique WITHIN its parent element, which is why the
52
+ # parent has to be known before this can be answered at all.
53
+ def self.segment_error(params, element_klass)
54
+ label = params[:label].to_s.strip
55
+ return 'A label is required.' if label.blank?
56
+ return 'A parent element is required.' if element_klass.nil?
57
+
58
+ taken = Labimotion::SegmentKlass.where(deleted_at: nil)
59
+ .exists?(label: label, element_klass_id: element_klass.id)
60
+ return unless taken
61
+
62
+ "A segment template labelled [#{label}] already exists for this element."
63
+ end
64
+
65
+ def self.dataset_error(params)
66
+ term = params[:ols_term_id].to_s.split('|').first.to_s.strip
67
+ return 'An ontology term (CHMO) is required.' if term.blank?
68
+
69
+ return unless Labimotion::DatasetKlass.where(deleted_at: nil).exists?(ols_term_id: term)
70
+
71
+ "A dataset template already exists for #{term}. Edit it in the designer instead."
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Labimotion
6
+ # Fetches the LIVE model catalogue from an OpenAI-compatible AI provider (the KIT
7
+ # KI-Toolbox, https://ki-toolbox.scc.kit.edu, by default) using the user's OWN API
8
+ # key. "My LabIMotion" offers the result in its model dropdown, so a user does not
9
+ # depend on the static pick-list in config/labimotion_ai.yml (:models), which goes
10
+ # stale whenever the provider changes its line-up.
11
+ #
12
+ # Only a PERSONAL key is ever used here: the shared server key must never be sent
13
+ # to a user-supplied provider URL, and a keyless user's choice is restricted to the
14
+ # admin allowlist anyway (Chemotion::ProfileAPI#labimotion_ai_admin_model_ids), so
15
+ # there would be nothing for them to pick out of a live list.
16
+ #
17
+ # Endpoint resolution mirrors Labimotion::AiTemplate: a personal :base_url (and
18
+ # :api_path) is honored only alongside a personal key and is SSRF-validated by
19
+ # Labimotion::AiEgressGuard before any request; otherwise the server-wide config
20
+ # applies.
21
+ #
22
+ # @example
23
+ # Labimotion::AiModels.new(api_key: key).call
24
+ # # => { models: [{ id: 'azure.gpt-4.1-mini', label: 'GPT-4.1 mini' }, ...],
25
+ # # endpoint: 'https://ki-toolbox.scc.kit.edu/api/v1/models' }
26
+ class AiModels
27
+ # Raised with a message meant for the user (the API forwards it verbatim).
28
+ class Error < StandardError; end
29
+
30
+ DEFAULT_BASE_URL = 'https://ki-toolbox.scc.kit.edu'
31
+ DEFAULT_API_PATH = '/api/v1/chat/completions'
32
+ # List endpoints tried after the one derived from the configured chat-completions
33
+ # path: Open WebUI (what KI-Toolbox runs) serves /api/models, the OpenAI standard
34
+ # is /v1/models. The first that answers with a usable list wins.
35
+ FALLBACK_PATHS = ['/api/models', '/v1/models', '/api/v1/models'].freeze
36
+ REQUEST_TIMEOUT = 20
37
+
38
+ # Model ids/names that are not chat models. KI-Toolbox lists embedding, reranking,
39
+ # speech and image models next to the chat ones; offering those in the dropdown
40
+ # would only produce puzzling failures when a template is generated.
41
+ NON_CHAT_PATTERN = /
42
+ embed | rerank | whisper | \btts\b | \bstt\b | flux | stable-?diffusion |
43
+ dall-?e | \bbge\b | moderation
44
+ /xi
45
+
46
+ # A provider answering these has a working endpoint but not the caller's key.
47
+ UNAUTHORIZED_CODES = [401, 403].freeze
48
+
49
+ MISSING_KEY_ERROR = 'Add your personal API key first — the model list is fetched with your own key.'
50
+
51
+ def initialize(api_key:, base_url: nil, api_path: nil)
52
+ @api_key = api_key.to_s.strip
53
+ @base_url_override = base_url.to_s.strip.presence
54
+ @api_path_override = api_path.to_s.strip.presence
55
+ end
56
+
57
+ # @return [Hash] { models: [{ id:, label: }], endpoint: String }
58
+ # @raise [Error] with a user-facing message when no list could be read
59
+ def call
60
+ raise Error, MISSING_KEY_ERROR if @api_key.blank?
61
+
62
+ statuses = {}
63
+ candidate_paths.each do |path|
64
+ url = "#{base_url}#{path}"
65
+ response = get(url)
66
+ statuses[path] = response&.code
67
+ models = parse_models(response)
68
+ return { models: models, endpoint: url } if models.present?
69
+ end
70
+ raise Error, failure_message(statuses)
71
+ end
72
+
73
+ private
74
+
75
+ def get(url)
76
+ HTTParty.get(
77
+ url,
78
+ headers: { 'Authorization' => "Bearer #{@api_key}", 'Accept' => 'application/json' },
79
+ timeout: REQUEST_TIMEOUT,
80
+ # Never follow redirects: a public URL that 302s to an internal address is the
81
+ # classic SSRF bypass of the egress guard.
82
+ follow_redirects: false
83
+ )
84
+ rescue StandardError => e
85
+ @network_error = e.message
86
+ nil
87
+ end
88
+
89
+ # The provider's models, or nil when this response is not a usable list.
90
+ def parse_models(response)
91
+ return nil unless response&.code == 200
92
+
93
+ body = json_body(response)
94
+ entries =
95
+ case body
96
+ when Array then body
97
+ when Hash then body['data'] || body['models']
98
+ end
99
+ # A provider may answer 200 with something else entirely (an HTML login page,
100
+ # an object where a list was expected): treat that as "no list here".
101
+ entries.is_a?(Array) ? normalize(entries) : nil
102
+ end
103
+
104
+ def json_body(response)
105
+ body = response.parsed_response
106
+ body.is_a?(String) ? JSON.parse(body) : body
107
+ rescue StandardError
108
+ nil
109
+ end
110
+
111
+ def normalize(entries)
112
+ entries
113
+ .filter_map { |entry| normalize_entry(entry) }
114
+ .select { |model| chat_model?(model) }
115
+ .uniq { |m| m[:id] }
116
+ .sort_by { |m| m[:label].downcase }
117
+ end
118
+
119
+ # An entry is either an id String or a Hash carrying at least an id.
120
+ def normalize_entry(entry)
121
+ id, label = entry.is_a?(Hash) ? hash_entry(entry) : [entry, nil]
122
+ id = id.to_s.strip
123
+ return nil if id.blank?
124
+
125
+ { id: id, label: label.to_s.strip.presence || id }
126
+ end
127
+
128
+ # [id, label] out of an OpenAI (id only) or Open WebUI (id + display name) entry.
129
+ def hash_entry(entry)
130
+ [entry['id'] || entry['name'] || entry['model'],
131
+ entry['name'] || entry['label'] || entry.dig('info', 'name')]
132
+ end
133
+
134
+ def chat_model?(model)
135
+ !(model[:id].match?(NON_CHAT_PATTERN) || model[:label].match?(NON_CHAT_PATTERN))
136
+ end
137
+
138
+ # True when the caller brought a provider URL on top of the personal key (which
139
+ # #call already requires): only then is the user endpoint honored, so the server
140
+ # key can never reach a user URL — same rule as Labimotion::AiTemplate.
141
+ def custom_endpoint?
142
+ @base_url_override.present?
143
+ end
144
+
145
+ def base_url
146
+ @base_url ||=
147
+ if custom_endpoint?
148
+ Labimotion::AiEgressGuard.validate!(@base_url_override)
149
+ else
150
+ setting(:base_url, 'KI_TOOLBOX_BASE_URL', DEFAULT_BASE_URL)
151
+ end.to_s.chomp('/')
152
+ end
153
+
154
+ # The list path derived from the chat-completions path first
155
+ # (/api/v1/chat/completions -> /api/v1/models), then the well-known fallbacks.
156
+ def candidate_paths
157
+ derived = chat_path.sub(%r{/chat/completions/?\z}, '/models')
158
+ ([derived] + FALLBACK_PATHS).filter_map { |path| normalize_path(path) }.uniq
159
+ end
160
+
161
+ def normalize_path(path)
162
+ path = path.to_s.strip
163
+ return nil if path.blank?
164
+
165
+ path.start_with?('/') ? path : "/#{path}"
166
+ end
167
+
168
+ def chat_path
169
+ return @api_path_override if custom_endpoint? && @api_path_override.present?
170
+
171
+ setting(:api_path, 'KI_TOOLBOX_API_PATH', DEFAULT_API_PATH)
172
+ end
173
+
174
+ def setting(key, env_key, default)
175
+ cfg[key].presence || ENV[env_key].presence || default
176
+ end
177
+
178
+ def cfg
179
+ @cfg ||= (Rails.configuration.labimotion_ai if Rails.configuration.respond_to?(:labimotion_ai)) || {}
180
+ end
181
+
182
+ def failure_message(statuses)
183
+ codes = statuses.map { |path, code| "#{path} → #{code || 'no response'}" }.join(', ')
184
+ # rubocop:disable Style/ArrayIntersect -- Array#intersect? is Ruby 3.1+;
185
+ # this gem still runs on 2.7.
186
+ if statuses.values.any? { |code| UNAUTHORIZED_CODES.include?(code) }
187
+ # rubocop:enable Style/ArrayIntersect
188
+ "#{base_url} rejected your API key (#{codes}). Create a new token in the provider UI and save it here."
189
+ elsif statuses.values.all?(&:nil?)
190
+ "#{base_url} did not respond#{network_error_suffix}."
191
+ else
192
+ "Could not read a model list from #{base_url} (tried #{codes}). " \
193
+ 'The provider may not expose an OpenAI-compatible models endpoint.'
194
+ end
195
+ end
196
+
197
+ def network_error_suffix
198
+ @network_error.present? ? ": #{@network_error}" : ''
199
+ end
200
+ end
201
+ end