2024-10-07 13:26:25 +02:00
using System.Net.Http.Headers ;
2024-07-03 20:31:04 +02:00
using System.Runtime.CompilerServices ;
2026-06-11 15:46:17 +02:00
using System.Text.Json ;
2024-07-03 20:31:04 +02:00
using AIStudio.Chat ;
2026-09-13 14:17:25 +02:00
using AIStudio.Models.Live ;
2024-07-03 20:31:04 +02:00
using AIStudio.Provider.OpenAI ;
2025-01-02 14:50:54 +01:00
using AIStudio.Settings ;
2026-01-09 12:45:21 +01:00
using AIStudio.Tools.PluginSystem ;
2024-07-03 20:31:04 +02:00
namespace AIStudio.Provider.SelfHosted ;
2026-05-31 18:46:54 +02:00
public sealed class ProviderSelfHosted ( Host host , string hostname ) : BaseProvider ( LLMProviders . SELF_HOSTED , new Uri ( $"{hostname}{host.BaseURL()}" ), ExternalHttpTrustPolicy . ALLOW_CUSTOM_ROOTS_WHEN_HOST_WHITELISTED , LOGGER )
2024-07-03 20:31:04 +02:00
{
2025-09-03 21:25:17 +02:00
private static readonly ILogger < ProviderSelfHosted > LOGGER = Program . LOGGER_FACTORY . CreateLogger < ProviderSelfHosted >();
2026-01-09 12:45:21 +01:00
private static string TB ( string fallbackEN ) => I18N . I . T ( fallbackEN , typeof ( ProviderSelfHosted ). Namespace , nameof ( ProviderSelfHosted ));
2025-09-03 21:25:17 +02:00
2024-07-03 20:31:04 +02:00
#region Implementation of IProvider
2026-04-16 11:24:22 +02:00
/// <inheritdoc />
2026-06-20 15:55:09 +02:00
public override string Id => LLMProviders . SELF_HOSTED . ToSecretId ();
2024-07-03 20:31:04 +02:00
2026-04-16 11:24:22 +02:00
/// <inheritdoc />
2024-12-03 15:24:40 +01:00
public override string InstanceName { get ; set ; } = "Self-hosted" ;
2026-04-16 11:24:22 +02:00
/// <inheritdoc />
2026-06-11 15:46:17 +02:00
public override bool HasModelLoadingCapability => host is Host . OLLAMA or Host . LM_STUDIO or Host . VLLM or Host . LLAMA_CPP ;
2024-07-03 20:31:04 +02:00
2024-09-01 20:10:03 +02:00
/// <inheritdoc />
2025-08-31 14:27:35 +02:00
public override async IAsyncEnumerable < ContentStreamChunk > StreamChatCompletion ( Provider . Model chatModel , ChatThread chatThread , SettingsManager settingsManager , [ EnumeratorCancellation ] CancellationToken token = default )
2024-07-03 20:31:04 +02:00
{
2026-06-11 15:46:17 +02:00
var effectiveChatModel = await this . ResolveChatModelForRequest ( chatModel , token );
2026-04-13 13:33:17 +02:00
await foreach ( var content in this . StreamOpenAICompatibleChatCompletion < ChatCompletionAPIRequest , ChatCompletionDeltaStreamLine , ChatCompletionAnnotationStreamLine >(
"self-hosted provider" ,
2026-06-11 15:46:17 +02:00
effectiveChatModel ,
2026-04-13 13:33:17 +02:00
chatThread ,
settingsManager ,
2026-09-04 15:48:07 +02:00
async ( systemPrompt , apiParameters , tools ) =>
2026-04-13 13:33:17 +02:00
{
// Build the list of messages. The image format depends on the host:
// - Ollama uses the direct image URL format: { "type": "image_url", "image_url": "data:..." }
// - LM Studio, vLLM, and llama.cpp use the nested image URL format: { "type": "image_url", "image_url": { "url": "data:..." } }
var messages = host switch
{
2026-09-13 14:17:25 +02:00
Host . OLLAMA => await chatThread . Blocks . BuildMessagesUsingDirectImageUrlAsync ( this . CreateSettingsProvider ( effectiveChatModel )),
_ => await chatThread . Blocks . BuildMessagesUsingNestedImageUrlAsync ( this . CreateSettingsProvider ( effectiveChatModel )),
2026-04-13 13:33:17 +02:00
};
return new ChatCompletionAPIRequest
{
2026-06-11 15:46:17 +02:00
Model = effectiveChatModel . Id ,
2026-04-13 13:33:17 +02:00
// Build the messages:
// - First of all the system prompt
// - Then none-empty user and AI messages
Messages = [ systemPrompt , .. messages ],
// Right now, we only support streaming completions:
Stream = true ,
2026-09-04 15:48:07 +02:00
Tools = tools ,
2026-04-13 13:33:17 +02:00
AdditionalApiParameters = apiParameters
};
},
isTryingSecret : true ,
requestPath : host . ChatURL (),
token : token ))
2025-01-04 14:11:32 +01:00
yield return content ;
2024-07-03 20:31:04 +02:00
}
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
/// <inheritdoc />
2024-12-03 15:24:40 +01:00
public override async IAsyncEnumerable < ImageURL > StreamImageCompletion ( Provider . Model imageModel , string promptPositive , string promptNegative = FilterOperator . String . Empty , ImageURL referenceImageURL = default , [ EnumeratorCancellation ] CancellationToken token = default )
2024-07-03 20:31:04 +02:00
{
yield break ;
}
#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously
2025-05-11 12:51:35 +02:00
2026-01-11 16:02:28 +01:00
/// <inheritdoc />
2026-05-23 11:25:18 +02:00
public override async Task < TranscriptionResult > TranscribeAudioAsync ( Provider . Model transcriptionModel , string audioFilePath , SettingsManager settingsManager , CancellationToken token = default )
2026-01-11 16:02:28 +01:00
{
2026-06-10 21:01:27 +02:00
var requestedSecret = await Program . RUST_SERVICE . GetAPIKey ( this , SecretStoreType . TRANSCRIPTION_PROVIDER , isTrying : true );
2026-01-11 16:02:28 +01:00
return await this . PerformStandardTranscriptionRequest ( requestedSecret , transcriptionModel , audioFilePath , host , token );
}
2026-02-20 15:32:54 +01:00
/// <inhertidoc />
public override async Task < IReadOnlyList < IReadOnlyList < float >>> EmbedTextAsync ( Provider . Model embeddingModel , SettingsManager settingsManager , CancellationToken token = default , params List < string > texts )
{
2026-06-10 21:01:27 +02:00
var requestedSecret = await Program . RUST_SERVICE . GetAPIKey ( this , SecretStoreType . EMBEDDING_PROVIDER , isTrying : true );
2026-02-22 15:09:51 +01:00
return await this . PerformStandardTextEmbeddingRequest ( requestedSecret , embeddingModel , host , token : token , texts : texts );
2026-02-20 15:32:54 +01:00
}
2026-04-14 13:39:11 +02:00
public override async Task < ModelLoadResult > GetTextModels ( string? apiKeyProvisional = null , CancellationToken token = default )
2024-07-03 20:31:04 +02:00
{
2024-07-16 10:28:13 +02:00
try
{
2024-12-03 15:24:40 +01:00
switch ( host )
2024-07-16 10:28:13 +02:00
{
2026-01-09 12:45:21 +01:00
case Host . LLAMA_CPP :
2026-09-04 15:48:07 +02:00
return await this . LoadLlamaCppTextModels ([ "embed" ], [], apiKeyProvisional , token );
2024-07-16 10:28:13 +02:00
case Host . LM_STUDIO :
case Host . OLLAMA :
2025-08-10 16:26:25 +02:00
case Host . VLLM :
2026-09-04 15:48:07 +02:00
return await this . LoadModels ( SecretStoreType . LLM_PROVIDER , [ "embed" ], [], apiKeyProvisional , token );
2024-07-16 10:28:13 +02:00
}
2024-07-03 20:31:04 +02:00
2026-04-14 13:39:11 +02:00
return ModelLoadResult . FromModels ([]);
2024-07-16 10:28:13 +02:00
}
catch ( Exception e )
{
2025-09-03 21:25:17 +02:00
LOGGER . LogError ( $"Failed to load text models from self-hosted provider: {e.Message}" );
2026-04-14 13:39:11 +02:00
return ModelLoadResult . Failure ( ModelLoadFailureReason . UNKNOWN , e . Message );
2024-07-16 10:28:13 +02:00
}
2024-07-03 20:31:04 +02:00
}
/// <inheritdoc />
2026-04-14 13:39:11 +02:00
public override Task < ModelLoadResult > GetImageModels ( string? apiKeyProvisional = null , CancellationToken token = default )
2024-07-03 20:31:04 +02:00
{
2026-04-14 13:39:11 +02:00
return Task . FromResult ( ModelLoadResult . FromModels ([]));
2024-07-03 20:31:04 +02:00
}
2024-12-03 15:24:40 +01:00
2026-04-14 13:39:11 +02:00
public override async Task < ModelLoadResult > GetEmbeddingModels ( string? apiKeyProvisional = null , CancellationToken token = default )
2024-12-03 15:24:40 +01:00
{
try
{
switch ( host )
{
case Host . LM_STUDIO :
case Host . OLLAMA :
2025-08-10 16:26:25 +02:00
case Host . VLLM :
2026-09-04 15:48:07 +02:00
return await this . LoadModels ( SecretStoreType . EMBEDDING_PROVIDER , [], [ "embed" ], apiKeyProvisional , token );
2024-12-03 15:24:40 +01:00
}
2026-04-14 13:39:11 +02:00
return ModelLoadResult . FromModels ([]);
2024-12-03 15:24:40 +01:00
}
catch ( Exception e )
{
2025-09-03 21:25:17 +02:00
LOGGER . LogError ( $"Failed to load text models from self-hosted provider: {e.Message}" );
2026-04-14 13:39:11 +02:00
return ModelLoadResult . Failure ( ModelLoadFailureReason . UNKNOWN , e . Message );
2024-12-03 15:24:40 +01:00
}
}
2025-05-11 12:51:35 +02:00
2026-01-09 12:45:21 +01:00
/// <inheritdoc />
2026-04-14 13:39:11 +02:00
public override async Task < ModelLoadResult > GetTranscriptionModels ( string? apiKeyProvisional = null , CancellationToken token = default )
2026-01-09 12:45:21 +01:00
{
try
{
switch ( host )
{
case Host . WHISPER_CPP :
2026-04-14 13:39:11 +02:00
return ModelLoadResult . FromModels (
[
new Provider.Model("loaded-model", TB("Model as configured by whisper.cpp")),
] );
2026-01-09 12:45:21 +01:00
case Host . OLLAMA :
case Host . VLLM :
2026-09-04 15:48:07 +02:00
return await this . LoadModels ( SecretStoreType . TRANSCRIPTION_PROVIDER , [], [], apiKeyProvisional , token );
2026-01-09 12:45:21 +01:00
default :
2026-04-14 13:39:11 +02:00
return ModelLoadResult . FromModels ([]);
2026-01-09 12:45:21 +01:00
}
}
catch ( Exception e )
{
2026-01-18 17:15:18 +01:00
LOGGER . LogError ( $"Failed to load transcription models from self-hosted provider: {e.Message}" );
2026-04-14 13:39:11 +02:00
return ModelLoadResult . Failure ( ModelLoadFailureReason . UNKNOWN , e . Message );
2026-01-09 12:45:21 +01:00
}
}
2024-07-03 20:31:04 +02:00
#endregion
2024-12-03 15:24:40 +01:00
2026-09-04 15:48:07 +02:00
private async Task < ModelLoadResult > LoadModels ( SecretStoreType storeType , string [] ignorePhrases , string [] filterPhrases , string? apiKeyProvisional , CancellationToken token )
2024-12-03 15:24:40 +01:00
{
2026-09-16 18:53:23 +02:00
var secretKey = await this . GetModelLoadingSecretKey ( storeType , apiKeyProvisional , isTryingSecret : true );
2026-05-21 16:48:34 +02:00
try
{
using var lmStudioRequest = new HttpRequestMessage ( HttpMethod . Get , "models" );
2026-09-16 18:53:23 +02:00
// An empty token is worse than none at all: a proxy which enforces authentication
// rejects an empty bearer with 401, where it would have let a request without any
// authorization header through. The dialogs hand us their key field as it stands, so
// an empty string arrives here whenever the user stored no key:
if (! string . IsNullOrWhiteSpace ( secretKey ))
2026-05-21 16:48:34 +02:00
lmStudioRequest . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , secretKey );
using var lmStudioResponse = await this . HttpClient . SendAsync ( lmStudioRequest , token );
if (! lmStudioResponse . IsSuccessStatusCode )
2026-05-25 17:32:54 +02:00
{
var responseBody = await lmStudioResponse . Content . ReadAsStringAsync ( token );
LOGGER . LogError ( "Model loading request failed with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}')." , lmStudioResponse . StatusCode , lmStudioResponse . ReasonPhrase , responseBody );
return FailedModelLoadResult ( this . GetModelLoadFailureReason ( lmStudioResponse , responseBody ), $"Status={(int)lmStudioResponse.StatusCode} {lmStudioResponse.ReasonPhrase}; Body='{responseBody}'" );
}
2026-05-21 16:48:34 +02:00
2026-09-13 14:17:25 +02:00
//
// Read with the shared options, the way every other model list of this app is read.
// This one route did without them, which quietly cost it every field an engine spells
// in snake case: owned_by has been arriving as nothing all along, and the next field
// somebody adds here would have gone the same way without anything failing.
//
var lmStudioModelResponse = await lmStudioResponse . Content . ReadFromJsonAsync < ModelsResponse >( JSON_SERIALIZER_OPTIONS , token );
2026-06-11 15:46:17 +02:00
var models = lmStudioModelResponse . Data ?? [];
2026-09-13 14:17:25 +02:00
//
// What the engine said about its own models, taken from the whole list rather than
// from what is offered below: a model filtered out here as an embedding model is still
// a model somebody may have configured this instance with, and this list is the only
// place its window is ever stated.
//
ListedModels . Shared . Report ( this . ConfiguredProviderId , ListingsOf ( models ));
2026-06-11 15:46:17 +02:00
return SuccessfulModelLoadResult ( models .
Where ( model => ! string . IsNullOrWhiteSpace ( model . Id ) &&
! ignorePhrases . Any ( ignorePhrase => model . Id . Contains ( ignorePhrase , StringComparison . InvariantCulture )) &&
2026-05-21 16:48:34 +02:00
filterPhrases . All ( filter => model . Id . Contains ( filter , StringComparison . InvariantCulture )))
. Select ( n => new Provider . Model ( n . Id , null )));
}
catch ( Exception e ) when ( this . IsTimeoutException ( e , token ))
{
await this . SendTimeoutError ( "loading the available models" );
LOGGER . LogError ( e , "Timed out while loading models from self-hosted provider '{ProviderInstanceName}'." , this . InstanceName );
return FailedModelLoadResult ( ModelLoadFailureReason . PROVIDER_UNAVAILABLE , e . Message );
}
2024-12-03 15:24:40 +01:00
}
2026-06-11 15:46:17 +02:00
private async Task < Provider . Model > ResolveChatModelForRequest ( Provider . Model chatModel , CancellationToken token )
{
if ( host is not Host . LLAMA_CPP || ! chatModel . IsSystemModel )
return chatModel ;
2026-09-04 15:48:07 +02:00
var modelLoadResult = await this . LoadLlamaCppTextModels ([ "embed" ], [], null , token );
2026-06-11 15:46:17 +02:00
if (! modelLoadResult . Success )
return chatModel ;
var availableModels = modelLoadResult . Models
. Where ( model => ! model . IsSystemModel && ! string . IsNullOrWhiteSpace ( model . Id ))
. ToList ();
if ( modelLoadResult . Models . All ( model => ! model . IsSystemModel ) && availableModels . Count is 0 )
{
LOGGER . LogError ( "The llama.cpp provider '{ProviderInstanceName}' does not offer a usable text model. Please check your provider settings." , this . InstanceName );
throw new ProviderRequestException (
ProviderRequestFailureReason . NONE ,
string . Format (
TB ( "The llama.cpp provider '{0}' does not offer a usable text model. Please check your provider settings." ),
this . InstanceName ));
}
if ( availableModels . Count is 1 )
return availableModels [ 0 ];
if ( availableModels . Count > 1 )
{
LOGGER . LogError (
"The llama.cpp provider '{ProviderInstanceName}' offers {ModelCount} models, but the configured model is the legacy system placeholder. The provider settings must be updated to select a specific model." ,
this . InstanceName ,
availableModels . Count );
throw new ProviderRequestException (
ProviderRequestFailureReason . NONE ,
string . Format (
TB ( "The llama.cpp provider '{0}' offers multiple models. Please open the provider settings and select the model to use." ),
this . InstanceName ));
}
return chatModel ;
}
2026-09-04 15:48:07 +02:00
private async Task < ModelLoadResult > LoadLlamaCppTextModels ( string [] ignorePhrases , string [] filterPhrases , string? apiKeyProvisional , CancellationToken token )
2026-06-11 15:46:17 +02:00
{
var secretKey = await this . GetModelLoadingSecretKey ( SecretStoreType . LLM_PROVIDER , apiKeyProvisional , true );
try
{
using var request = new HttpRequestMessage ( HttpMethod . Get , "models" );
if (! string . IsNullOrWhiteSpace ( secretKey ))
request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , secretKey );
using var response = await this . HttpClient . SendAsync ( request , token );
var responseBody = await response . Content . ReadAsStringAsync ( token );
if (! response . IsSuccessStatusCode )
{
if ( response . StatusCode is System . Net . HttpStatusCode . NotFound )
return LlamaCppLegacyModelResult ();
LOGGER . LogError ( "llama.cpp model loading request failed with status code {ResponseStatusCode} (message = '{ResponseReasonPhrase}', error body = '{ErrorBody}')." , response . StatusCode , response . ReasonPhrase , responseBody );
return FailedModelLoadResult ( this . GetModelLoadFailureReason ( response , responseBody ), $"Status={(int)response.StatusCode} {response.ReasonPhrase}; Body='{responseBody}'" );
}
try
{
var modelResponse = JsonSerializer . Deserialize < ModelsResponse >( responseBody , JSON_SERIALIZER_OPTIONS );
var responseModels = modelResponse . Data ?
. Where ( model => ! string . IsNullOrWhiteSpace ( model . Id ))
. ToList () ?? [];
if ( responseModels . Count is 0 )
return LlamaCppLegacyModelResult ();
var models = responseModels
. Where ( model => IsMatchingLlamaCppTextModel ( model , ignorePhrases , filterPhrases ))
. Select ( model => new Provider . Model ( model . Id , null ))
. ToList ();
return SuccessfulModelLoadResult ( models );
}
catch ( JsonException e )
{
LOGGER . LogWarning ( e , "The llama.cpp model loading response could not be parsed. Falling back to the legacy system-configured model." );
return LlamaCppLegacyModelResult ();
}
}
catch ( Exception e ) when ( this . IsTimeoutException ( e , token ))
{
await this . SendTimeoutError ( "loading the available models" );
LOGGER . LogError ( e , "Timed out while loading models from llama.cpp provider '{ProviderInstanceName}'." , this . InstanceName );
return FailedModelLoadResult ( ModelLoadFailureReason . PROVIDER_UNAVAILABLE , e . Message );
}
catch ( Exception e )
{
LOGGER . LogError ( e , "Failed to load models from llama.cpp provider '{ProviderInstanceName}'." , this . InstanceName );
return FailedModelLoadResult ( ModelLoadFailureReason . UNKNOWN , e . Message );
}
}
2026-09-13 14:17:25 +02:00
/// <summary>
/// What an engine stated about the models it serves.
/// </summary>
/// <param name="models">The models exactly as the engine listed them.</param>
/// <returns>One listing per model, which says nothing for the models the engine was silent about.</returns>
private static IEnumerable < ModelListing > ListingsOf ( IEnumerable < Model > models ) => models . Select ( model => ModelListing . For ( model . Id , model . ContextWindowTokens ));
2026-06-11 15:46:17 +02:00
private static bool IsMatchingLlamaCppTextModel ( Model model , string [] ignorePhrases , string [] filterPhrases )
{
if ( string . IsNullOrWhiteSpace ( model . Id ))
return false ;
if ( ignorePhrases . Any ( ignorePhrase => model . Id . Contains ( ignorePhrase , StringComparison . InvariantCultureIgnoreCase )))
return false ;
if (! filterPhrases . All ( filter => model . Id . Contains ( filter , StringComparison . InvariantCultureIgnoreCase )))
return false ;
var outputModalities = model . Architecture ?. OutputModalities ;
if ( outputModalities is { Length : > 0 } &&
! outputModalities . Any ( modality => string . Equals ( modality , "text" , StringComparison . OrdinalIgnoreCase )))
return false ;
return true ;
}
private static ModelLoadResult LlamaCppLegacyModelResult ()
{
return ModelLoadResult . FromModels ([ AIStudio . Provider . Model . SYSTEM_MODEL ]);
}
2026-09-04 15:48:07 +02:00
}