Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions ImageDescriber.Test/OllamaClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImageDescriber.Tests;

using ktsu.ImageDescriber.Verbs;
using ktsu.Semantics.Strings;

[TestClass]
public class OllamaClientTests
{
[TestMethod]
[DataRow("localhost:11434")]
[DataRow("http://localhost:11434")]
[DataRow("http://localhost:11434/")]
[DataRow(" http://localhost:11434// ")]
public void GenerateUriIsUnderTheEndpointWhateverFormItWasTypedIn(string endpoint)
{
Uri uri = OllamaClient.GetGenerateUri(endpoint.As<OllamaEndpoint>());

Assert.AreEqual("http://localhost:11434/api/generate", uri.AbsoluteUri);
}

[TestMethod]
public void GenerateUriKeepsAnEndpointPathPrefix()
{
Uri uri = OllamaClient.GetGenerateUri("https://example.com/ollama/".As<OllamaEndpoint>());

Assert.AreEqual("https://example.com/ollama/api/generate", uri.AbsoluteUri);
}

[TestMethod]
[DataRow("localhost:11434", "http://localhost:11434")]
[DataRow("http://localhost:11434/", "http://localhost:11434")]
[DataRow("https://ollama.example.com", "https://ollama.example.com")]
[DataRow("192.168.1.5:11434", "http://192.168.1.5:11434")]
public void NormalizeEndpointAddsAMissingSchemeAndDropsTrailingSlashes(string input, string expected) =>
Assert.AreEqual(expected, OllamaClient.NormalizeEndpoint(input));

[TestMethod]
[DataRow("")]
[DataRow(" ")]
[DataRow("http://")]
[DataRow("not a url")]
[DataRow("ftp://localhost:11434")]
[DataRow("http://localhost:11434/?x=1")]
public void NormalizeEndpointRejectsAnythingButAnHttpAddress(string input) =>
Assert.IsNull(OllamaClient.NormalizeEndpoint(input));

[TestMethod]
[DataRow("http://")]
[DataRow("not a url")]
public void IsAvailableReturnsFalseForAnInvalidEndpoint(string endpoint) =>
Assert.IsFalse(OllamaClient.IsAvailableAsync(endpoint.As<OllamaEndpoint>()).GetAwaiter().GetResult());

[TestMethod]
public void ConfigureSavesTheNormalizedEndpoint()
{
using StringWriter output = new();

OllamaEndpoint chosen = Configure.ChooseEndpoint(" localhost:11434/ ", "http://old:1".As<OllamaEndpoint>(), output);

Assert.AreEqual("http://localhost:11434", chosen.WeakString);
Assert.AreEqual(string.Empty, output.ToString());
}

[TestMethod]
[DataRow(null)]
[DataRow(" ")]
public void ConfigureKeepsTheCurrentEndpointWhenNothingIsTyped(string? input)
{
using StringWriter output = new();

OllamaEndpoint chosen = Configure.ChooseEndpoint(input, "http://old:1".As<OllamaEndpoint>(), output);

Assert.AreEqual("http://old:1", chosen.WeakString);
}

[TestMethod]
public void ConfigureKeepsTheCurrentEndpointAndSaysWhyWhenTheInputIsInvalid()
{
using StringWriter output = new();

OllamaEndpoint chosen = Configure.ChooseEndpoint("not a url", "http://old:1".As<OllamaEndpoint>(), output);

Assert.AreEqual("http://old:1", chosen.WeakString);
Assert.Contains("\"not a url\" is not an http or https address. Keeping http://old:1.", output.ToString());
}
}
23 changes: 23 additions & 0 deletions ImageDescriber.Test/ScanTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,29 @@ public void ScanReportsFailedImagesAndCompletes()
Assert.Contains("Scan complete.", text);
}

[TestMethod]
public void ScanReportsAnInvalidEndpointInsteadOfCrashing()
{
PersistentState originalSettings = Program.Settings;
TextWriter originalOut = Console.Out;
using StringWriter output = new();
try
{
Program.Settings = new PersistentState();
Console.SetOut(output);

Scan scan = new() { PathString = Path.GetTempPath(), EndpointString = "not a url", ModelString = "test-model" };
scan.Run(scan);
}
finally
{
Console.SetOut(originalOut);
Program.Settings = originalSettings;
}

Assert.Contains("Error: Invalid Ollama endpoint \"not a url\"", output.ToString());
}

/// <summary>
/// Serves /api/generate like Ollama, except that a request mentioning bad.jpg gets an HTML
/// page, as a proxy or the wrong service on the endpoint would return.
Expand Down
45 changes: 42 additions & 3 deletions ImageDescriber/OllamaClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,46 @@ internal static class OllamaClient
Timeout = TimeSpan.FromMinutes(10),
};

/// <summary>
/// Normalizes an endpoint as a user would type it: adds <c>http://</c> when the scheme is
/// missing (Ollama prints its address as <c>localhost:11434</c>) and drops trailing slashes.
/// Returns <see langword="null"/> unless the result is an absolute http or https URI.
/// </summary>
internal static string? NormalizeEndpoint(string? value)
{
string trimmed = value?.Trim() ?? string.Empty;
if (trimmed.Length == 0)
{
return null;
}

string withScheme = trimmed.Contains(Uri.SchemeDelimiter, StringComparison.Ordinal) ? trimmed : $"{Uri.UriSchemeHttp}{Uri.SchemeDelimiter}{trimmed}";
if (!Uri.TryCreate(withScheme, UriKind.Absolute, out Uri? uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|| string.IsNullOrEmpty(uri.Host)
|| !string.IsNullOrEmpty(uri.Query)
|| !string.IsNullOrEmpty(uri.Fragment))
{
return null;
}

return uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
}

/// <summary>
/// Returns the endpoint as a base URI ending in exactly one slash, so relative request paths
/// resolve under it rather than doubling or replacing its last segment.
/// </summary>
internal static Uri GetBaseUri(OllamaEndpoint endpoint) =>
new($"{NormalizeEndpoint(endpoint.WeakString) ?? throw new ArgumentException($"Invalid Ollama endpoint: {endpoint}", nameof(endpoint))}/");

internal static Uri GetGenerateUri(OllamaEndpoint endpoint) => new(GetBaseUri(endpoint), "api/generate");

internal static async Task<bool> IsAvailableAsync(OllamaEndpoint endpoint)
{
try
{
using HttpResponseMessage response = await HttpClient.GetAsync(new Uri(endpoint.WeakString)).ConfigureAwait(false);
using HttpResponseMessage response = await HttpClient.GetAsync(GetBaseUri(endpoint)).ConfigureAwait(false);
return response.IsSuccessStatusCode;
}
catch (HttpRequestException)
Expand All @@ -33,6 +68,11 @@ internal static async Task<bool> IsAvailableAsync(OllamaEndpoint endpoint)
{
return false;
}
catch (ArgumentException)
{
// GetBaseUri rejects anything NormalizeEndpoint can't turn into an http or https address
return false;
}
}

internal static async Task<string> DescribeImageAsync(OllamaEndpoint endpoint, OllamaModelName model, string prompt, AbsoluteFilePath imagePath)
Expand Down Expand Up @@ -68,8 +108,7 @@ private static async Task<string> SendRequestAsync(OllamaEndpoint endpoint, Olla
string jsonContent = JsonSerializer.Serialize(request, OllamaJsonContext.Default.OllamaRequest);
using StringContent content = new(jsonContent, Encoding.UTF8, "application/json");

Uri requestUri = new($"{endpoint}/api/generate");
using HttpResponseMessage response = await HttpClient.PostAsync(requestUri, content).ConfigureAwait(false);
using HttpResponseMessage response = await HttpClient.PostAsync(GetGenerateUri(endpoint), content).ConfigureAwait(false);
response.EnsureSuccessStatusCode();

string responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
Expand Down
9 changes: 8 additions & 1 deletion ImageDescriber/Verbs/BaseVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ internal abstract class BaseVerb : ICommand

internal AbsoluteDirectoryPath Path => System.IO.Path.GetFullPath(PathString).As<AbsoluteDirectoryPath>();

internal OllamaEndpoint Endpoint => string.IsNullOrEmpty(EndpointString) ? Program.Settings.OllamaEndpoint : EndpointString.As<OllamaEndpoint>();
internal OllamaEndpoint Endpoint
{
get
{
string value = string.IsNullOrEmpty(EndpointString) ? Program.Settings.OllamaEndpoint.WeakString : EndpointString;
return (OllamaClient.NormalizeEndpoint(value) ?? value).As<OllamaEndpoint>();
}
}

internal OllamaModelName Model => string.IsNullOrEmpty(ModelString) ? Program.Settings.OllamaModel : ModelString.As<OllamaModelName>();

Expand Down
28 changes: 24 additions & 4 deletions ImageDescriber/Verbs/Configure.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace ktsu.ImageDescriber.Verbs;

using System.IO;

using CommandLine;

using ktsu.Semantics.Strings;
Expand All @@ -21,10 +23,7 @@ internal override void Run(Configure options)

Console.Write($"Ollama Endpoint [{Program.Settings.OllamaEndpoint}]: ");
string? endpointInput = Console.ReadLine();
if (!string.IsNullOrWhiteSpace(endpointInput))
{
Program.Settings.OllamaEndpoint = endpointInput.Trim().As<OllamaEndpoint>();
}
Program.Settings.OllamaEndpoint = ChooseEndpoint(endpointInput, Program.Settings.OllamaEndpoint, Console.Out);

Console.Write($"Ollama Model [{Program.Settings.OllamaModel}]: ");
string? modelInput = Console.ReadLine();
Expand Down Expand Up @@ -64,4 +63,25 @@ internal override void Run(Configure options)
Console.WriteLine($" Prompt: {Program.Settings.DescriptionPrompt[..Math.Min(60, Program.Settings.DescriptionPrompt.Length)]}...");
Console.WriteLine($" Filename Prompt: {Program.Settings.SuggestedFileNamePrompt[..Math.Min(60, Program.Settings.SuggestedFileNamePrompt.Length)]}...");
}

/// <summary>
/// Returns the normalized endpoint the user typed, or <paramref name="current"/> when they
/// typed nothing or something that isn't an http or https address.
/// </summary>
internal static OllamaEndpoint ChooseEndpoint(string? input, OllamaEndpoint current, TextWriter output)
{
if (string.IsNullOrWhiteSpace(input))
{
return current;
}

string? endpoint = OllamaClient.NormalizeEndpoint(input);
if (endpoint is null)
{
output.WriteLine($" \"{input.Trim()}\" is not an http or https address. Keeping {current}.");
return current;
}

return endpoint.As<OllamaEndpoint>();
}
}
6 changes: 6 additions & 0 deletions ImageDescriber/Verbs/Scan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ internal override void Run(Scan options)
Console.WriteLine($"Model: {options.Model}");
Console.WriteLine();

if (OllamaClient.NormalizeEndpoint(options.Endpoint.WeakString) is null)
{
Console.WriteLine($"Error: Invalid Ollama endpoint \"{options.Endpoint}\". Expected an http or https address such as http://localhost:11434.");
return;
}

// Step 1: Check Ollama availability
Console.WriteLine("Checking Ollama availability...");
bool isAvailable = OllamaClient.IsAvailableAsync(options.Endpoint).GetAwaiter().GetResult();
Expand Down
Loading