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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -1
- data/lib/labimotion/apis/generic_dataset_api.rb +93 -3
- data/lib/labimotion/apis/generic_element_api.rb +198 -8
- data/lib/labimotion/apis/generic_klass_api.rb +74 -8
- data/lib/labimotion/apis/klass_share_api.rb +648 -0
- data/lib/labimotion/apis/labimotion_ai_api.rb +252 -0
- data/lib/labimotion/apis/labimotion_api.rb +4 -0
- data/lib/labimotion/apis/labimotion_doi_api.rb +24 -10
- data/lib/labimotion/apis/labimotion_template_browse_api.rb +13 -1
- data/lib/labimotion/apis/ontology_root_api.rb +74 -0
- data/lib/labimotion/apis/segment_api.rb +77 -10
- data/lib/labimotion/apis/user_klass_settings_api.rb +93 -0
- data/lib/labimotion/conf.rb +5 -0
- data/lib/labimotion/constants.rb +64 -0
- data/lib/labimotion/entities/application_entity.rb +8 -0
- data/lib/labimotion/entities/eln_element_entity.rb +6 -0
- data/lib/labimotion/entities/generic_klass_entity.rb +126 -0
- data/lib/labimotion/entities/klass_share_entity.rb +48 -0
- data/lib/labimotion/entities/properties_entity.rb +78 -2
- data/lib/labimotion/entities/segment_entity.rb +8 -0
- data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
- data/lib/labimotion/helpers/cover_image_helpers.rb +181 -0
- data/lib/labimotion/helpers/dataset_helpers.rb +187 -1
- data/lib/labimotion/helpers/element_helpers.rb +282 -6
- data/lib/labimotion/helpers/exporter_helpers.rb +17 -2
- data/lib/labimotion/helpers/generic_helpers.rb +293 -4
- data/lib/labimotion/helpers/param_helpers.rb +105 -0
- data/lib/labimotion/helpers/sample_association_helpers.rb +7 -0
- data/lib/labimotion/helpers/segment_helpers.rb +102 -4
- data/lib/labimotion/libs/ai_egress_guard.rb +99 -0
- data/lib/labimotion/libs/ai_klass_queue.rb +88 -0
- data/lib/labimotion/libs/ai_klass_validator.rb +74 -0
- data/lib/labimotion/libs/ai_models.rb +201 -0
- data/lib/labimotion/libs/ai_template.rb +2285 -0
- data/lib/labimotion/libs/converter.rb +5 -43
- data/lib/labimotion/libs/data/datacite/labimotion_template.html.erb +67 -0
- data/lib/labimotion/libs/export_element.rb +128 -13
- data/lib/labimotion/libs/file_extractor.rb +210 -0
- data/lib/labimotion/libs/linked_element.rb +313 -0
- data/lib/labimotion/libs/ontology_store.rb +226 -0
- data/lib/labimotion/libs/ontology_terms.rb +227 -0
- data/lib/labimotion/libs/owner_resolver.rb +50 -0
- data/lib/labimotion/libs/ownership_audit.rb +73 -0
- data/lib/labimotion/libs/sample_association.rb +52 -1
- data/lib/labimotion/libs/share_notifier.rb +114 -0
- data/lib/labimotion/libs/share_resolver.rb +373 -0
- data/lib/labimotion/libs/user_ai_settings.rb +129 -0
- data/lib/labimotion/models/cellline.rb +47 -0
- data/lib/labimotion/models/concerns/datasetable.rb +3 -0
- data/lib/labimotion/models/concerns/matrice_labimotion.rb +124 -0
- data/lib/labimotion/models/concerns/segmentable.rb +2 -0
- data/lib/labimotion/models/concerns/template_doi.rb +133 -0
- data/lib/labimotion/models/dataset_klass.rb +1 -1
- data/lib/labimotion/models/element_klass.rb +1 -1
- data/lib/labimotion/models/klass_share.rb +129 -0
- data/lib/labimotion/models/segment_klass.rb +1 -1
- data/lib/labimotion/models/user_klass_setting.rb +58 -0
- data/lib/labimotion/models/user_setting.rb +60 -0
- data/lib/labimotion/usecases/build_template_doi_xml.rb +69 -23
- data/lib/labimotion/usecases/release_template_doi.rb +42 -17
- data/lib/labimotion/usecases/template_doi_helpers.rb +28 -6
- data/lib/labimotion/usecases/update_template_publication_metadata.rb +71 -2
- data/lib/labimotion/utils/export_utils.rb +1 -0
- data/lib/labimotion/utils/import_utils.rb +20 -3
- data/lib/labimotion/utils/serializer.rb +27 -0
- data/lib/labimotion/utils/units.rb +32 -67
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +27 -0
- 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
|