Skip to content

Commit 33203f4

Browse files
committed
[TASK] Add live model discovery for dynamic bridges
1 parent b414b46 commit 33203f4

5 files changed

Lines changed: 167 additions & 13 deletions

File tree

Classes/Controller/ProviderController.php

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use B13\Aim\Domain\Repository\ProviderConfigurationRepository;
2121
use B13\Aim\Domain\Repository\RequestLogRepository;
2222
use B13\Aim\Pagination\DemandedArrayPaginator;
23+
use B13\Aim\Provider\LiveModelDiscovery;
2324
use B13\Aim\Registry\AiProviderRegistry;
2425
use B13\Aim\Registry\DisabledModelRegistry;
2526
use B13\Aim\Request\ConversationRequest;
@@ -56,6 +57,7 @@ public function __construct(
5657
private readonly DisabledModelRegistry $disabledModelRegistry,
5758
private readonly RequestLogRepository $requestLogRepository,
5859
private readonly Registry $registry,
60+
private readonly LiveModelDiscovery $liveModelDiscovery,
5961
) {}
6062

6163
public function overviewAction(ServerRequestInterface $request): ResponseInterface
@@ -172,7 +174,7 @@ public function availableProvidersAction(ServerRequestInterface $request): Respo
172174
'id' => $modelId,
173175
'disabled' => $this->disabledModelRegistry->isDisabled($manifest->identifier, $modelId),
174176
],
175-
array_keys($manifest->supportedModels),
177+
$this->collectModelIds($manifest),
176178
),
177179
'capabilities' => $this->resolveCapabilityLabels($manifest->capabilities),
178180
],
@@ -207,6 +209,40 @@ public function toggleModelAction(ServerRequestInterface $request): ResponseInte
207209
]);
208210
}
209211

212+
/**
213+
* Collect the model IDs to surface for a provider in the Available
214+
* Providers modal.
215+
*
216+
* For static catalogs (OpenAI, Anthropic, …) this is just the manifest's
217+
* supportedModels. For dynamic catalogs (Ollama, LM Studio, …) the
218+
* manifest is empty at compile time; instead we walk every saved
219+
* configuration record for the provider, live-fetch the model list from
220+
* each unique endpoint, and merge the results so the admin can disable
221+
* specific models even on dynamic backends.
222+
*
223+
* @return list<string>
224+
*/
225+
private function collectModelIds(AiProviderManifest $manifest): array
226+
{
227+
if ($manifest->supportedModels !== []) {
228+
return array_keys($manifest->supportedModels);
229+
}
230+
231+
$models = [];
232+
$seenEndpoints = [];
233+
foreach ($this->configurationRepository->findByProviderIdentifier($manifest->identifier) as $configuration) {
234+
$endpoint = $configuration->apiKey;
235+
if ($endpoint === '' || isset($seenEndpoints[$endpoint]) || !$this->liveModelDiscovery->isHttpEndpoint($endpoint)) {
236+
continue;
237+
}
238+
$seenEndpoints[$endpoint] = true;
239+
foreach ($this->liveModelDiscovery->fetchModelNames($endpoint) as $modelId) {
240+
$models[$modelId] = true;
241+
}
242+
}
243+
return array_keys($models);
244+
}
245+
210246
/**
211247
* Map capability FQCNs to human-readable labels via locallang.
212248
*

Classes/DependencyInjection/SymfonyAiCompilerPass.php

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -210,28 +210,43 @@ private function buildBridgeDefinition(array $package): ?array
210210
// Detect factory auth parameter via reflection
211211
$factoryParam = $this->detectFactoryParam($factoryClass);
212212

213-
// Read models + capabilities from ModelCatalog
213+
// Read models + capabilities from ModelCatalog.
214+
//
215+
// Some bridges (Ollama, LM Studio, …) ship a ModelCatalog that requires
216+
// runtime context (an HTTP client pointing at the user's endpoint) and
217+
// queries the live server for the model list. We can't do that at
218+
// container-compile time as the endpoint URL lives in a TCA record we
219+
// don't have access to here. For those bridges we register the provider
220+
// with an empty model list.
214221
$catalogClass = $namespace . '\\ModelCatalog';
215222
$models = [];
216223
$modelCapabilities = [];
217224
$features = ['supportsStreaming' => true];
225+
$catalogIsDynamic = false;
218226

219227
if (class_exists($catalogClass)) {
220-
try {
221-
$catalog = new $catalogClass();
222-
if (method_exists($catalog, 'getModels')) {
223-
[$models, $modelCapabilities, $features] = $this->extractModelsFromCatalog(
224-
$catalog->getModels(),
225-
$features,
226-
);
228+
$constructor = (new \ReflectionClass($catalogClass))->getConstructor();
229+
if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) {
230+
try {
231+
$catalog = new $catalogClass();
232+
if (method_exists($catalog, 'getModels')) {
233+
[$models, $modelCapabilities, $features] = $this->extractModelsFromCatalog(
234+
$catalog->getModels(),
235+
$features,
236+
);
237+
}
238+
} catch (\Throwable) {
239+
// Catalog instantiation failed for an unexpected reason —
240+
// fall through with empty models.
227241
}
228-
} catch (\Throwable) {
229-
// Catalog instantiation failed — continue with empty models
242+
} else {
243+
$catalogIsDynamic = true;
230244
}
231245
}
232246

233-
// Skip bridges with no discoverable models (e.g. shared/internal packages)
234-
if ($models === []) {
247+
// Skip bridges that have neither a static catalog nor a dynamic one
248+
// (the package matches the naming pattern but isn't a real bridge).
249+
if ($models === [] && !$catalogIsDynamic) {
235250
return null;
236251
}
237252

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of TYPO3 CMS-based extension "aim" by b13.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*/
12+
13+
namespace B13\Aim\Provider;
14+
15+
use TYPO3\CMS\Core\Http\RequestFactory;
16+
17+
/**
18+
* Discovers models live from an OpenAI-compatible HTTP endpoint.
19+
*
20+
* Bridges with dynamic catalogs (Ollama, LM Studio, etc.) can't be enumerated
21+
* at container-compile time. Models live on the running server and only the
22+
* admin's configuration record knows the endpoint URL. This service hits the
23+
* OpenAI-compatible /v1/models endpoint, which is exposed by both Ollama
24+
* (compat layer, on by default) and LM Studio (its only API).
25+
*
26+
* Failure modes (unreachable, non-JSON, unexpected shape, non-200 status)
27+
* return an empty array instead of throwing; the caller decides whether to
28+
* surface the gap to the admin.
29+
*/
30+
final class LiveModelDiscovery
31+
{
32+
public function __construct(
33+
private readonly RequestFactory $requestFactory,
34+
) {}
35+
36+
/**
37+
* Return the model names available on the given endpoint, or [] on any failure.
38+
*
39+
* @return list<string>
40+
*/
41+
public function fetchModelNames(string $endpoint): array
42+
{
43+
if (!$this->isHttpEndpoint($endpoint)) {
44+
return [];
45+
}
46+
47+
try {
48+
$response = $this->requestFactory->request(
49+
rtrim($endpoint, '/') . '/v1/models',
50+
'GET',
51+
['timeout' => 3, 'connect_timeout' => 2, 'http_errors' => false],
52+
);
53+
if ($response->getStatusCode() !== 200) {
54+
return [];
55+
}
56+
$payload = json_decode((string)$response->getBody(), true, 512, JSON_THROW_ON_ERROR);
57+
} catch (\Throwable) {
58+
return [];
59+
}
60+
61+
$names = [];
62+
foreach ($payload['data'] ?? [] as $entry) {
63+
$name = (string)($entry['id'] ?? '');
64+
if ($name !== '') {
65+
$names[] = $name;
66+
}
67+
}
68+
return $names;
69+
}
70+
71+
public function isHttpEndpoint(string $value): bool
72+
{
73+
return str_starts_with($value, 'http://') || str_starts_with($value, 'https://');
74+
}
75+
}

Classes/Tca/ItemsProcFunc/AiProvidersItemsProcFunc.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
namespace B13\Aim\Tca\ItemsProcFunc;
1414

15+
use B13\Aim\Provider\LiveModelDiscovery;
1516
use B13\Aim\Registry\AiProviderRegistry;
1617
use B13\Aim\Registry\DisabledModelRegistry;
1718
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
@@ -25,6 +26,7 @@ public function __construct(
2526
private readonly AiProviderRegistry $aiProviderRegistry,
2627
private readonly DisabledModelRegistry $disabledModelRegistry,
2728
private readonly LanguageServiceFactory $languageServiceFactory,
29+
private readonly LiveModelDiscovery $liveModelDiscovery,
2830
) {}
2931

3032
public function getAiProviders(&$fieldDefinition): void
@@ -69,6 +71,31 @@ public function getAiProviderModels(&$fieldDefinition): void
6971
'value' => $modelId,
7072
];
7173
}
74+
75+
// No static catalog (Ollama, LM Studio, …) — when the record's api_key
76+
// points at an HTTP endpoint, query the live server for its models.
77+
// Currently understands the Ollama-compatible /api/tags shape.
78+
if ($provider->supportedModels === []) {
79+
$this->appendLiveModels($fieldDefinition, $aiProviderIdentifier);
80+
}
81+
}
82+
83+
private function appendLiveModels(array &$fieldDefinition, string $providerIdentifier): void
84+
{
85+
$endpoint = (string)($fieldDefinition['row']['api_key'] ?? '');
86+
foreach ($this->liveModelDiscovery->fetchModelNames($endpoint) as $name) {
87+
if ($this->disabledModelRegistry->isDisabled($providerIdentifier, $name)) {
88+
continue;
89+
}
90+
// Value stays intact (Ollama needs the full "model:tag" string).
91+
// Label is colon-free so it bypasses TYPO3 v14's LanguageService::sL(),
92+
// which treats any colon-bearing string as a "domain:key" reference
93+
// and tries to look up "domain" as a TYPO3 package.
94+
$fieldDefinition['items'][] = [
95+
'label' => str_replace(':', ' / ', $name),
96+
'value' => $name,
97+
];
98+
}
7299
}
73100

74101
protected function getBackendUser(): BackendUserAuthentication

Configuration/TCA/tx_aim_configuration.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
],
9797
'api_key' => [
9898
'label' => 'LLL:EXT:aim/Resources/Private/Language/locallang_tca.xlf:tx_aim_configuration.columns.api_key.label',
99+
'onChange' => 'reload',
99100
'config' => [
100101
'type' => 'input',
101102
'required' => true,

0 commit comments

Comments
 (0)