From 58948ba3d357eb6339978a2027f3c1f9fad39738 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:25:56 +0000 Subject: [PATCH 1/3] Accept "localhost:11434" and trailing slashes as the Ollama endpoint [patch] The endpoint was used exactly as typed. "localhost:11434", the address Ollama prints, parsed as a URI with the scheme "localhost" and crashed Scan with an unhandled NotSupportedException, and a trailing slash sent every describe request to "//api/generate". OllamaClient.NormalizeEndpoint now adds a missing http:// and drops trailing slashes, and rejects anything that isn't an http or https address. Configure saves the normalized value or keeps the old one, -e/--endpoint is normalized too, Scan prints an "invalid endpoint" error instead of crashing, and request URLs are built with new Uri(base, "api/generate") rather than string concatenation. Fixes ktsu-dev/ImageDescriber#157 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kj95GdQsZpSCcND7LqacT1 --- ImageDescriber.Test/OllamaClientTests.cs | 53 ++++++++++++++++++++++++ ImageDescriber.Test/ScanTests.cs | 23 ++++++++++ ImageDescriber/OllamaClient.cs | 52 +++++++++++++++++++++-- ImageDescriber/Verbs/BaseVerb.cs | 9 +++- ImageDescriber/Verbs/Configure.cs | 10 ++++- ImageDescriber/Verbs/Scan.cs | 6 +++ 6 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 ImageDescriber.Test/OllamaClientTests.cs diff --git a/ImageDescriber.Test/OllamaClientTests.cs b/ImageDescriber.Test/OllamaClientTests.cs new file mode 100644 index 0000000..4d65fb8 --- /dev/null +++ b/ImageDescriber.Test/OllamaClientTests.cs @@ -0,0 +1,53 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImageDescriber.Tests; + +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()); + + Assert.AreEqual("http://localhost:11434/api/generate", uri.AbsoluteUri); + } + + [TestMethod] + public void GenerateUriKeepsAnEndpointPathPrefix() + { + Uri uri = OllamaClient.GetGenerateUri("https://example.com/ollama/".As()); + + 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()).GetAwaiter().GetResult()); +} diff --git a/ImageDescriber.Test/ScanTests.cs b/ImageDescriber.Test/ScanTests.cs index af77c35..244b4ff 100644 --- a/ImageDescriber.Test/ScanTests.cs +++ b/ImageDescriber.Test/ScanTests.cs @@ -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()); + } + /// /// 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. diff --git a/ImageDescriber/OllamaClient.cs b/ImageDescriber/OllamaClient.cs index 2971060..8bc6af3 100644 --- a/ImageDescriber/OllamaClient.cs +++ b/ImageDescriber/OllamaClient.cs @@ -18,11 +18,46 @@ internal static class OllamaClient Timeout = TimeSpan.FromMinutes(10), }; + /// + /// Normalizes an endpoint as a user would type it: adds http:// when the scheme is + /// missing (Ollama prints its address as localhost:11434) and drops trailing slashes. + /// Returns unless the result is an absolute http or https URI. + /// + internal static string? NormalizeEndpoint(string? value) + { + string trimmed = value?.Trim() ?? string.Empty; + if (trimmed.Length == 0) + { + return null; + } + + string withScheme = trimmed.Contains("://", StringComparison.Ordinal) ? trimmed : $"http://{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('/'); + } + + /// + /// 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. + /// + 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 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) @@ -33,6 +68,18 @@ internal static async Task IsAvailableAsync(OllamaEndpoint endpoint) { return false; } + catch (ArgumentException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } } internal static async Task DescribeImageAsync(OllamaEndpoint endpoint, OllamaModelName model, string prompt, AbsoluteFilePath imagePath) @@ -68,8 +115,7 @@ private static async Task 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); diff --git a/ImageDescriber/Verbs/BaseVerb.cs b/ImageDescriber/Verbs/BaseVerb.cs index 574a750..00c9f44 100644 --- a/ImageDescriber/Verbs/BaseVerb.cs +++ b/ImageDescriber/Verbs/BaseVerb.cs @@ -24,7 +24,14 @@ internal abstract class BaseVerb : ICommand internal AbsoluteDirectoryPath Path => System.IO.Path.GetFullPath(PathString).As(); - internal OllamaEndpoint Endpoint => string.IsNullOrEmpty(EndpointString) ? Program.Settings.OllamaEndpoint : EndpointString.As(); + internal OllamaEndpoint Endpoint + { + get + { + string value = string.IsNullOrEmpty(EndpointString) ? Program.Settings.OllamaEndpoint.WeakString : EndpointString; + return (OllamaClient.NormalizeEndpoint(value) ?? value).As(); + } + } internal OllamaModelName Model => string.IsNullOrEmpty(ModelString) ? Program.Settings.OllamaModel : ModelString.As(); diff --git a/ImageDescriber/Verbs/Configure.cs b/ImageDescriber/Verbs/Configure.cs index aba6ae5..052b071 100644 --- a/ImageDescriber/Verbs/Configure.cs +++ b/ImageDescriber/Verbs/Configure.cs @@ -23,7 +23,15 @@ internal override void Run(Configure options) string? endpointInput = Console.ReadLine(); if (!string.IsNullOrWhiteSpace(endpointInput)) { - Program.Settings.OllamaEndpoint = endpointInput.Trim().As(); + string? endpoint = OllamaClient.NormalizeEndpoint(endpointInput); + if (endpoint is null) + { + Console.WriteLine($" \"{endpointInput.Trim()}\" is not an http or https address. Keeping {Program.Settings.OllamaEndpoint}."); + } + else + { + Program.Settings.OllamaEndpoint = endpoint.As(); + } } Console.Write($"Ollama Model [{Program.Settings.OllamaModel}]: "); diff --git a/ImageDescriber/Verbs/Scan.cs b/ImageDescriber/Verbs/Scan.cs index 3cfaafa..5436b38 100644 --- a/ImageDescriber/Verbs/Scan.cs +++ b/ImageDescriber/Verbs/Scan.cs @@ -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(); From 290334f13b37a2efaf4e8180edd9467632472627 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:32:32 +0000 Subject: [PATCH 2/3] Cover Configure's endpoint choice and drop unreachable catches SonarCloud failed the quality gate on coverage (69.2%) and on S5332 for the "http://" literal. Build the default scheme from Uri.UriSchemeHttp and Uri.SchemeDelimiter, move Configure's endpoint handling into the testable Configure.ChooseEndpoint, and remove the NotSupportedException and InvalidOperationException catches in IsAvailableAsync: GetBaseUri now rejects every endpoint that could raise them, as ArgumentException. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kj95GdQsZpSCcND7LqacT1 --- ImageDescriber.Test/OllamaClientTests.cs | 35 +++++++++++++++++++++++ ImageDescriber/OllamaClient.cs | 11 ++------ ImageDescriber/Verbs/Configure.cs | 36 ++++++++++++++++-------- 3 files changed, 61 insertions(+), 21 deletions(-) diff --git a/ImageDescriber.Test/OllamaClientTests.cs b/ImageDescriber.Test/OllamaClientTests.cs index 4d65fb8..e3bac30 100644 --- a/ImageDescriber.Test/OllamaClientTests.cs +++ b/ImageDescriber.Test/OllamaClientTests.cs @@ -2,6 +2,7 @@ namespace ktsu.ImageDescriber.Tests; +using ktsu.ImageDescriber.Verbs; using ktsu.Semantics.Strings; [TestClass] @@ -50,4 +51,38 @@ public void NormalizeEndpointRejectsAnythingButAnHttpAddress(string input) => [DataRow("not a url")] public void IsAvailableReturnsFalseForAnInvalidEndpoint(string endpoint) => Assert.IsFalse(OllamaClient.IsAvailableAsync(endpoint.As()).GetAwaiter().GetResult()); + + [TestMethod] + public void ConfigureSavesTheNormalizedEndpoint() + { + using StringWriter output = new(); + + OllamaEndpoint chosen = Configure.ChooseEndpoint(" localhost:11434/ ", "http://old:1".As(), 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(), 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(), output); + + Assert.AreEqual("http://old:1", chosen.WeakString); + StringAssert.Contains(output.ToString(), "\"not a url\" is not an http or https address. Keeping http://old:1."); + } } diff --git a/ImageDescriber/OllamaClient.cs b/ImageDescriber/OllamaClient.cs index 8bc6af3..a0de29c 100644 --- a/ImageDescriber/OllamaClient.cs +++ b/ImageDescriber/OllamaClient.cs @@ -31,7 +31,7 @@ internal static class OllamaClient return null; } - string withScheme = trimmed.Contains("://", StringComparison.Ordinal) ? trimmed : $"http://{trimmed}"; + 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) @@ -70,14 +70,7 @@ internal static async Task IsAvailableAsync(OllamaEndpoint endpoint) } catch (ArgumentException) { - return false; - } - catch (NotSupportedException) - { - return false; - } - catch (InvalidOperationException) - { + // GetBaseUri rejects anything NormalizeEndpoint can't turn into an http or https address return false; } } diff --git a/ImageDescriber/Verbs/Configure.cs b/ImageDescriber/Verbs/Configure.cs index 052b071..5cd8876 100644 --- a/ImageDescriber/Verbs/Configure.cs +++ b/ImageDescriber/Verbs/Configure.cs @@ -2,6 +2,8 @@ namespace ktsu.ImageDescriber.Verbs; +using System.IO; + using CommandLine; using ktsu.Semantics.Strings; @@ -21,18 +23,7 @@ internal override void Run(Configure options) Console.Write($"Ollama Endpoint [{Program.Settings.OllamaEndpoint}]: "); string? endpointInput = Console.ReadLine(); - if (!string.IsNullOrWhiteSpace(endpointInput)) - { - string? endpoint = OllamaClient.NormalizeEndpoint(endpointInput); - if (endpoint is null) - { - Console.WriteLine($" \"{endpointInput.Trim()}\" is not an http or https address. Keeping {Program.Settings.OllamaEndpoint}."); - } - else - { - Program.Settings.OllamaEndpoint = endpoint.As(); - } - } + Program.Settings.OllamaEndpoint = ChooseEndpoint(endpointInput, Program.Settings.OllamaEndpoint, Console.Out); Console.Write($"Ollama Model [{Program.Settings.OllamaModel}]: "); string? modelInput = Console.ReadLine(); @@ -72,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)]}..."); } + + /// + /// Returns the normalized endpoint the user typed, or when they + /// typed nothing or something that isn't an http or https address. + /// + 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(); + } } From 5dccd6567dfdc5f7c01a181e0520817574c22d1e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 05:34:40 +0000 Subject: [PATCH 3/3] Use Assert.Contains in the new Configure test (MSTEST0046) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Kj95GdQsZpSCcND7LqacT1 --- ImageDescriber.Test/OllamaClientTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ImageDescriber.Test/OllamaClientTests.cs b/ImageDescriber.Test/OllamaClientTests.cs index e3bac30..dd21b06 100644 --- a/ImageDescriber.Test/OllamaClientTests.cs +++ b/ImageDescriber.Test/OllamaClientTests.cs @@ -83,6 +83,6 @@ public void ConfigureKeepsTheCurrentEndpointAndSaysWhyWhenTheInputIsInvalid() OllamaEndpoint chosen = Configure.ChooseEndpoint("not a url", "http://old:1".As(), output); Assert.AreEqual("http://old:1", chosen.WeakString); - StringAssert.Contains(output.ToString(), "\"not a url\" is not an http or https address. Keeping http://old:1."); + Assert.Contains("\"not a url\" is not an http or https address. Keeping http://old:1.", output.ToString()); } }