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
98 changes: 98 additions & 0 deletions api/ApplyTrack.Api.Tests/LlmConfigTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark

using ApplyTrack.Api.Data;
using ApplyTrack.Api.Llm;

namespace ApplyTrack.Api.Tests;

public class LlmConfigTests
{
private static readonly LlmOptions Instance = new()
{
BaseUrl = "https://api.instance.example/v1",
Model = "instance-model",
ApiKey = "instance-key",
TimeoutSeconds = 42,
};

[Fact]
public void Tenant_url_only_does_not_inherit_the_instance_key()
{
var cfg = EffectiveLlmConfig.Resolve(
Instance,
new LlmOverride("https://tenant.example/v1", "", null));

Assert.Equal("https://tenant.example/v1", cfg.BaseUrl);
Assert.Equal("instance-model", cfg.Model);
Assert.Null(cfg.ApiKey);
Assert.True(cfg.TenantBaseUrl);
}

[Fact]
public void Tenant_model_only_inherits_the_instance_url_and_key()
{
var cfg = EffectiveLlmConfig.Resolve(
Instance,
new LlmOverride("", "tenant-model", null));

Assert.Equal("https://api.instance.example/v1", cfg.BaseUrl);
Assert.Equal("tenant-model", cfg.Model);
Assert.Equal("instance-key", cfg.ApiKey);
Assert.False(cfg.TenantBaseUrl);
}

[Fact]
public void Tenant_key_only_replaces_the_instance_key_for_the_instance_url()
{
var cfg = EffectiveLlmConfig.Resolve(
Instance,
new LlmOverride("", "", "tenant-key"));

Assert.Equal("https://api.instance.example/v1", cfg.BaseUrl);
Assert.Equal("instance-model", cfg.Model);
Assert.Equal("tenant-key", cfg.ApiKey);
Assert.False(cfg.TenantBaseUrl);
}

[Fact]
public void Full_tenant_override_uses_only_tenant_values()
{
var cfg = EffectiveLlmConfig.Resolve(
Instance,
new LlmOverride("https://tenant.example/v1", "tenant-model", "tenant-key"));

Assert.Equal("https://tenant.example/v1", cfg.BaseUrl);
Assert.Equal("tenant-model", cfg.Model);
Assert.Equal("tenant-key", cfg.ApiKey);
Assert.True(cfg.TenantBaseUrl);
}

[Theory]
[InlineData("")]
[InlineData("https://api.openai.com/v1")]
[InlineData("http://93.184.216.34/v1")]
public void Tenant_endpoint_policy_accepts_blank_or_public_urls(string url)
{
LlmEndpointPolicy.ValidateTenantBaseUrl(url);
}

[Theory]
[InlineData("not a url")]
[InlineData("ftp://example.com/v1")]
[InlineData("http://localhost:11434/v1")]
[InlineData("http://host.docker.internal:11434/v1")]
[InlineData("http://model.local/v1")]
[InlineData("http://model.internal/v1")]
[InlineData("http://127.0.0.1:11434/v1")]
[InlineData("http://10.0.0.5/v1")]
[InlineData("http://172.16.0.10/v1")]
[InlineData("http://192.168.1.10/v1")]
[InlineData("http://169.254.169.254/latest/meta-data")]
[InlineData("http://[::1]:11434/v1")]
[InlineData("http://[fd00::1]/v1")]
public void Tenant_endpoint_policy_rejects_invalid_or_internal_urls(string url)
{
Assert.Throws<AppValidationException>(() => LlmEndpointPolicy.ValidateTenantBaseUrl(url));
}
}
39 changes: 34 additions & 5 deletions api/ApplyTrack.Api.Tests/MaterialsEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,45 @@ public async Task Llm_settings_default_view_reports_no_key_and_no_secrets_suppor
}

[Fact]
public async Task Llm_settings_put_stores_the_endpoint_without_a_key()
public async Task Llm_settings_put_stores_a_public_endpoint_without_a_key()
{
var put = await ReadJson(await _client.PutAsync("/api/llm-settings",
Json("""{"base_url":"http://localhost:11434/v1","model":"llama3.1"}""")));
Assert.Equal("http://localhost:11434/v1", put.GetProperty("base_url").GetString());
Assert.Equal("llama3.1", put.GetProperty("model").GetString());
Json("""{"base_url":"https://api.openai.com/v1","model":"gpt-4o-mini"}""")));
Assert.Equal("https://api.openai.com/v1", put.GetProperty("base_url").GetString());
Assert.Equal("gpt-4o-mini", put.GetProperty("model").GetString());
Assert.False(put.GetProperty("has_api_key").GetBoolean());

var v = await ReadJson(await _client.GetAsync("/api/llm-settings"));
Assert.Equal("llama3.1", v.GetProperty("model").GetString());
Assert.Equal("gpt-4o-mini", v.GetProperty("model").GetString());
}

[Fact]
public async Task Llm_settings_refuses_tenant_localhost_endpoint()
{
var res = await _client.PutAsync("/api/llm-settings",
Json("""{"base_url":"http://localhost:11434/v1","model":"llama3.1"}"""));
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
Assert.Contains("local or internal", (await ReadJson(res)).GetProperty("detail").GetString());

var v = await ReadJson(await _client.GetAsync("/api/llm-settings"));
Assert.Equal("", v.GetProperty("base_url").GetString());
Assert.Equal("", v.GetProperty("model").GetString());
}

[Fact]
public async Task Instance_default_can_still_point_at_a_local_model()
{
using var client = await AuthedClientAsync(NewFactory(b =>
{
b.UseSetting("Llm:BaseUrl", "http://localhost:11434/v1");
b.UseSetting("Llm:Model", "llama3.1");
}));

var v = await ReadJson(await client.GetAsync("/api/llm-settings"));
var instance = v.GetProperty("instance");
Assert.Equal("http://localhost:11434/v1", instance.GetProperty("base_url").GetString());
Assert.Equal("llama3.1", instance.GetProperty("model").GetString());
Assert.False(v.GetProperty("has_api_key").GetBoolean());
}

[Fact]
Expand Down
2 changes: 1 addition & 1 deletion api/ApplyTrack.Api/ApplyTrack.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>ApplyTrack.Api</RootNamespace>
<Version>1.7.7</Version>
<Version>1.7.8</Version>
<Authors>Aaron K. Clark</Authors>
<Copyright>Copyright 2026 Aaron K. Clark</Copyright>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
Expand Down
1 change: 1 addition & 0 deletions api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app)
{
var baseUrl = GetString(payload, "base_url");
var model = GetString(payload, "model");
LlmEndpointPolicy.ValidateTenantBaseUrl(baseUrl);

// Distinguish "api_key omitted" (leave the stored key alone) from
// "api_key present" (set it, or clear it when blank).
Expand Down
22 changes: 13 additions & 9 deletions api/ApplyTrack.Api/Llm/LlmConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,31 @@ public sealed record LlmOverride(string BaseUrl, string Model, string? ApiKey);

/// <summary>
/// The endpoint settings actually used for a draft — a tenant's override merged
/// over the instance defaults, field by field (a tenant may override just the
/// model and keep the instance URL).
/// over the instance defaults, field by field. <c>TenantBaseUrl</c> tracks whether
/// the URL came from tenant-controlled settings; those URLs get stricter network
/// handling and must never inherit an operator-owned instance API key.
/// </summary>
public sealed record EffectiveLlmConfig(string BaseUrl, string Model, string? ApiKey, int TimeoutSeconds)
public sealed record EffectiveLlmConfig(
string BaseUrl, string Model, string? ApiKey, int TimeoutSeconds, bool TenantBaseUrl = false)
{
/// <summary>True once there is somewhere to send the request and a model to ask for.</summary>
public bool IsConfigured => BaseUrl.Length > 0 && Model.Length > 0;

public static EffectiveLlmConfig Resolve(LlmOptions instance, LlmOverride? ovr)
{
string Pick(string? over, string fallback) =>
string.IsNullOrWhiteSpace(over) ? fallback.Trim() : over.Trim();
var tenantBaseUrl = !string.IsNullOrWhiteSpace(ovr?.BaseUrl);
var baseUrl = tenantBaseUrl ? ovr!.BaseUrl.Trim() : instance.BaseUrl.Trim();
var model = string.IsNullOrWhiteSpace(ovr?.Model) ? instance.Model.Trim() : ovr!.Model.Trim();

var apiKey = !string.IsNullOrEmpty(ovr?.ApiKey)
? ovr.ApiKey
: string.IsNullOrEmpty(instance.ApiKey) ? null : instance.ApiKey;
: tenantBaseUrl || string.IsNullOrEmpty(instance.ApiKey) ? null : instance.ApiKey;

return new EffectiveLlmConfig(
Pick(ovr?.BaseUrl, instance.BaseUrl),
Pick(ovr?.Model, instance.Model),
baseUrl,
model,
apiKey,
instance.TimeoutSeconds);
instance.TimeoutSeconds,
tenantBaseUrl);
}
}
44 changes: 44 additions & 0 deletions api/ApplyTrack.Api/Llm/LlmEndpointPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark

using System.Net;
using ApplyTrack.Api.Data;
using ApplyTrack.Api.Scrape;

namespace ApplyTrack.Api.Llm;

/// <summary>
/// Validation for tenant-supplied LLM endpoints. Operator-configured instance
/// defaults are allowed to point at local/private models; tenant overrides are not.
/// The runtime client also validates resolved IPs at connect time to close DNS
/// rebinding between save and use.
/// </summary>
public static class LlmEndpointPolicy
{
public static void ValidateTenantBaseUrl(string baseUrl)
{
if (string.IsNullOrWhiteSpace(baseUrl))
return;

if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|| uri.Host.Length == 0)
{
throw new AppValidationException("LLM base URL must be an absolute http(s) URL");
}

var host = uri.Host.Trim('[', ']').ToLowerInvariant();
if (host is "localhost" or "host.docker.internal"
|| host.EndsWith(".localhost", StringComparison.Ordinal)
|| host.EndsWith(".local", StringComparison.Ordinal)
|| host.EndsWith(".internal", StringComparison.Ordinal)
|| host.EndsWith(".lan", StringComparison.Ordinal)
|| host.EndsWith(".home", StringComparison.Ordinal))
{
throw new AppValidationException("tenant LLM base URL may not point at a local or internal host");
}

if (IPAddress.TryParse(host, out var ip) && JobPageFetcher.IsBlockedAddress(ip))
throw new AppValidationException("tenant LLM base URL may not point at a private or internal address");
}
}
43 changes: 42 additions & 1 deletion api/ApplyTrack.Api/Llm/OpenAiCompatibleLlmClient.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Net.Sockets;
using System.Text.Json;
using ApplyTrack.Api.Data;
using ApplyTrack.Api.Scrape;

namespace ApplyTrack.Api.Llm;

Expand Down Expand Up @@ -46,7 +49,12 @@ public async Task<string> CompleteAsync(
},
};

var http = _factory.CreateClient("llm");
using var guarded = cfg.TenantBaseUrl ? new SocketsHttpHandler
{
ConnectCallback = ConnectToPublicAddressOnlyAsync,
} : null;
using var guardedClient = guarded is null ? null : new HttpClient(guarded);
var http = guardedClient ?? _factory.CreateClient("llm");
http.Timeout = TimeSpan.FromSeconds(cfg.TimeoutSeconds);

using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload) };
Expand All @@ -58,6 +66,10 @@ public async Task<string> CompleteAsync(
{
res = await http.SendAsync(req, ct);
}
catch (Exception ex) when (FindValidation(ex) is { } validation)
{
throw validation;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // genuine caller cancellation, not a timeout
Expand Down Expand Up @@ -105,4 +117,33 @@ private static async Task<string> SafeReadAsync(HttpResponseMessage res, Cancell
return "(no body)";
}
}

private static async ValueTask<Stream> ConnectToPublicAddressOnlyAsync(
SocketsHttpConnectionContext ctx, CancellationToken ct)
{
var addresses = await Dns.GetHostAddressesAsync(ctx.DnsEndPoint.Host, ct);
var publicAddresses = Array.FindAll(addresses, a => !JobPageFetcher.IsBlockedAddress(a));
if (publicAddresses.Length == 0)
throw new AppValidationException("tenant LLM base URL points at a private or internal address");

var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(publicAddresses, ctx.DnsEndPoint.Port, ct);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}

private static AppValidationException? FindValidation(Exception ex)
{
for (var e = ex; e is not null; e = e.InnerException!)
if (e is AppValidationException v)
return v;
return null;
}
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "applytrack-poller"
version = "1.7.7"
version = "1.7.8"
description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres."
requires-python = ">=3.10"
license = { text = "Apache-2.0" }
Expand Down
Loading