From 460fc04855b8be0e2ec1afb86bb1656632376bbf Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Mon, 4 May 2026 17:41:09 +0800 Subject: [PATCH 1/2] feat(providers): add OpenAI-compatible provider support with base_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom providers (those with a base_url) now bypass the nullclaw probe on create and update. The probe is designed for known providers and can misclassify valid HTTP 200 responses from arbitrary OpenAI-compatible endpoints (e.g. when the endpoint returns an empty completion body). Standard providers (no base_url) continue to use the nullclaw probe as before. The re-validate endpoint returns a clear message for custom providers explaining that /models-based validation is coming in a follow-up. Removes the skip_validation workaround that was auto-retried on the frontend — the fix is now in the backend dispatch logic. Adds base_url and provider_name fields to the provider CRUD API, state, and UI (add form, edit form, card display, wizard step, config editor). Adds 'OpenAI Compatible (custom endpoint)' to the provider picker. Validation: - zig build test -Dbuild-ui=false --summary all: all tests pass, 0 leaks - Manual test against local llmserverplus (http://127.0.0.1:5801/v1): custom provider saves without probe, update skips re-probe, re-validate returns expected not-available message Notes: - Credential validation for custom endpoints via /models endpoint is tracked separately (PR 2) --- src/api/providers.zig | 277 ++++++++++++++++---- src/api/wizard.zig | 1 + src/core/state.zig | 159 +++++++++++ ui/src/lib/api/client.ts | 4 +- ui/src/lib/components/ConfigEditorUI.svelte | 12 + ui/src/lib/components/ProviderList.svelte | 33 ++- ui/src/lib/components/WizardRenderer.svelte | 11 +- ui/src/routes/providers/+page.svelte | 53 +++- 8 files changed, 483 insertions(+), 67 deletions(-) diff --git a/src/api/providers.zig b/src/api/providers.zig index de44426..8a7be00 100644 --- a/src/api/providers.zig +++ b/src/api/providers.zig @@ -71,47 +71,66 @@ pub fn handleCreate( provider: []const u8, api_key: []const u8 = "", model: []const u8 = "", + base_url: []const u8 = "", }, allocator, body, .{ .allocate = .alloc_always, .ignore_unknown_fields = true, }) catch return try allocator.dupe(u8, "{\"error\":\"invalid JSON body\"}"); defer parsed.deinit(); - // Find an installed component binary - const component_name = findProviderProbeComponent(allocator, state) orelse - return try allocator.dupe(u8, "{\"error\":\"Install a nullclaw instance first to validate providers\"}"); - defer allocator.free(component_name); + // Custom providers (base_url set) bypass the nullclaw probe: the probe is + // designed for known providers and can misclassify valid responses from + // arbitrary OpenAI-compatible endpoints. Credential validation for custom + // endpoints will be handled via the /models probe (added in a follow-up). + const is_custom = parsed.value.base_url.len > 0; + var validated_ok = false; + var validated_with_buf: ?[]const u8 = null; + defer if (validated_with_buf) |s| allocator.free(s); + + if (!is_custom) { + // Standard provider: validate via nullclaw probe + const component_name = findProviderProbeComponent(allocator, state) orelse + return try allocator.dupe(u8, "{\"error\":\"Install a nullclaw instance first to validate providers\"}"); + defer allocator.free(component_name); - const bin_path = wizard_api.findOrFetchComponentBinaryPub(allocator, component_name, paths) orelse - return try allocator.dupe(u8, "{\"error\":\"component binary not found\"}"); - defer allocator.free(bin_path); + const bin_path = wizard_api.findOrFetchComponentBinaryPub(allocator, component_name, paths) orelse + return try allocator.dupe(u8, "{\"error\":\"component binary not found\"}"); + defer allocator.free(bin_path); - // Validate via probe - const probe_result = probeProvider(allocator, component_name, bin_path, parsed.value.provider, parsed.value.api_key, parsed.value.model, ""); - defer probe_result.deinit(allocator); - if (!probe_result.live_ok) { - var buf = std.array_list.Managed(u8).init(allocator); - errdefer buf.deinit(); - try buf.appendSlice("{\"error\":\"Provider validation failed: "); - try appendEscaped(&buf, probe_result.reason); - try buf.appendSlice("\"}"); - return buf.toOwnedSlice(); + const probe_result = probeProvider(allocator, component_name, bin_path, parsed.value.provider, parsed.value.api_key, parsed.value.model, parsed.value.base_url); + defer probe_result.deinit(allocator); + if (!probe_result.live_ok) { + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + try buf.appendSlice("{\"error\":\"Provider validation failed: "); + try appendEscaped(&buf, probe_result.reason); + try buf.appendSlice("\"}"); + return buf.toOwnedSlice(); + } + validated_ok = true; + validated_with_buf = try allocator.dupe(u8, component_name); } - // Save to state + const validated_with = validated_with_buf orelse ""; + try state.addSavedProvider(.{ .provider = parsed.value.provider, .api_key = parsed.value.api_key, .model = parsed.value.model, - .validated_with = component_name, + .base_url = parsed.value.base_url, + .validated_with = validated_with, }); - // Record both the last successful validation and the latest validation attempt. - const providers = state.savedProviders(); - const new_id = providers[providers.len - 1].id; - try persistValidationAttempt(allocator, state, new_id, component_name, true); + // Record validation attempt if we validated + if (validated_ok) { + const providers = state.savedProviders(); + const new_id = providers[providers.len - 1].id; + try persistValidationAttempt(allocator, state, new_id, validated_with, true); + } // Return the saved provider + const providers = state.savedProviders(); + const new_id = providers[providers.len - 1].id; const sp = state.getSavedProvider(new_id).?; var buf = std.array_list.Managed(u8).init(allocator); errdefer buf.deinit(); @@ -133,6 +152,7 @@ pub fn handleUpdate( name: ?[]const u8 = null, api_key: ?[]const u8 = null, model: ?[]const u8 = null, + base_url: ?[]const u8 = null, }, allocator, body, .{ .allocate = .alloc_always, .ignore_unknown_fields = true, @@ -142,48 +162,64 @@ pub fn handleUpdate( const credentials_changed = (parsed.value.api_key != null and !std.mem.eql(u8, parsed.value.api_key.?, existing.api_key)) or (parsed.value.model != null and - !std.mem.eql(u8, parsed.value.model.?, existing.model)); + !std.mem.eql(u8, parsed.value.model.?, existing.model)) or + (parsed.value.base_url != null and + !std.mem.eql(u8, parsed.value.base_url.?, existing.base_url)); if (credentials_changed) { - // Re-validate - const component_name = findProviderProbeComponent(allocator, state) orelse - return try allocator.dupe(u8, "{\"error\":\"Install a nullclaw instance first to validate providers\"}"); - defer allocator.free(component_name); - - const bin_path = wizard_api.findOrFetchComponentBinaryPub(allocator, component_name, paths) orelse - return try allocator.dupe(u8, "{\"error\":\"component binary not found\"}"); - defer allocator.free(bin_path); - const effective_key = parsed.value.api_key orelse existing.api_key; const effective_model = parsed.value.model orelse existing.model; + const effective_base_url = parsed.value.base_url orelse existing.base_url; + + // Custom providers (base_url set) bypass the nullclaw probe — see handleCreate. + const is_custom = effective_base_url.len > 0; + if (!is_custom) { + // Standard provider: re-validate via nullclaw probe + const component_name = findProviderProbeComponent(allocator, state) orelse + return try allocator.dupe(u8, "{\"error\":\"Install a nullclaw instance first to validate providers\"}"); + defer allocator.free(component_name); + + const bin_path = wizard_api.findOrFetchComponentBinaryPub(allocator, component_name, paths) orelse + return try allocator.dupe(u8, "{\"error\":\"component binary not found\"}"); + defer allocator.free(bin_path); + + const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, effective_key, effective_model, effective_base_url); + defer probe_result.deinit(allocator); + const now = try nowIso8601(allocator); + defer allocator.free(now); + if (!probe_result.live_ok) { + _ = try state.updateSavedProvider(id, .{ + .last_validation_at = now, + .last_validation_ok = false, + }); + try state.save(); + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + try buf.appendSlice("{\"error\":\"Provider validation failed: "); + try appendEscaped(&buf, probe_result.reason); + try buf.appendSlice("\"}"); + return buf.toOwnedSlice(); + } - const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, effective_key, effective_model, ""); - defer probe_result.deinit(allocator); - const now = try nowIso8601(allocator); - defer allocator.free(now); - if (!probe_result.live_ok) { _ = try state.updateSavedProvider(id, .{ + .name = parsed.value.name, + .api_key = parsed.value.api_key, + .model = parsed.value.model, + .base_url = parsed.value.base_url, + .validated_at = now, + .validated_with = component_name, .last_validation_at = now, - .last_validation_ok = false, + .last_validation_ok = true, + }); + } else { + // Custom provider: update fields directly without probe + _ = try state.updateSavedProvider(id, .{ + .name = parsed.value.name, + .api_key = parsed.value.api_key, + .model = parsed.value.model, + .base_url = parsed.value.base_url, }); - try state.save(); - var buf = std.array_list.Managed(u8).init(allocator); - errdefer buf.deinit(); - try buf.appendSlice("{\"error\":\"Provider validation failed: "); - try appendEscaped(&buf, probe_result.reason); - try buf.appendSlice("\"}"); - return buf.toOwnedSlice(); } - - _ = try state.updateSavedProvider(id, .{ - .name = parsed.value.name, - .api_key = parsed.value.api_key, - .model = parsed.value.model, - .validated_at = now, - .validated_with = component_name, - .last_validation_at = now, - .last_validation_ok = true, - }); } else { // Name-only update _ = try state.updateSavedProvider(id, .{ .name = parsed.value.name }); @@ -216,6 +252,13 @@ pub fn handleValidate( ) ![]const u8 { const existing = state.getSavedProvider(id) orelse return try allocator.dupe(u8, "{\"error\":\"provider not found\"}"); + // Custom providers are validated via the /models endpoint (not yet implemented). + // Return a clear response rather than running the nullclaw probe against an + // arbitrary endpoint that the probe was not designed for. + if (existing.base_url.len > 0) { + return try allocator.dupe(u8, "{\"live_ok\":false,\"reason\":\"custom endpoint — validation via /models not yet available\"}"); + } + const component_name = findProviderProbeComponent(allocator, state) orelse return try allocator.dupe(u8, "{\"error\":\"Install a nullclaw instance first to validate providers\"}"); defer allocator.free(component_name); @@ -224,7 +267,7 @@ pub fn handleValidate( return try allocator.dupe(u8, "{\"error\":\"component binary not found\"}"); defer allocator.free(bin_path); - const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, existing.api_key, existing.model, ""); + const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, existing.api_key, existing.model, existing.base_url); defer probe_result.deinit(allocator); try persistValidationAttempt(allocator, state, id, component_name, probe_result.live_ok); @@ -323,6 +366,8 @@ fn appendProviderJson(buf: *std.array_list.Managed(u8), sp: state_mod.SavedProvi } try buf.appendSlice("\",\"model\":\""); try appendEscaped(buf, sp.model); + try buf.appendSlice("\",\"base_url\":\""); + try appendEscaped(buf, sp.base_url); try buf.appendSlice("\",\"validated_at\":\""); try appendEscaped(buf, sp.validated_at); try buf.appendSlice("\",\"validated_with\":\""); @@ -420,6 +465,38 @@ test "handleList reveals api_key when requested" { try std.testing.expect(std.mem.indexOf(u8, json, "sk-or-1234567890abcdef") != null); } +test "handleList includes base_url for openai-compatible provider" { + const allocator = std.testing.allocator; + const path = "/tmp/nullhub-provider-test-baseurl.json"; + var s = state_mod.State.init(allocator, path); + defer s.deinit(); + + try s.addSavedProvider(.{ + .provider = "infini-ai", + .api_key = "sk-cp-test", + .model = "minimax-m2.7", + .base_url = "https://cloud.infini-ai.com/maas/coding/v1", + }); + + const json = try handleList(allocator, &s, true); + defer allocator.free(json); + try std.testing.expect(std.mem.indexOf(u8, json, "\"base_url\":\"https://cloud.infini-ai.com/maas/coding/v1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"provider\":\"infini-ai\"") != null); +} + +test "handleList includes empty base_url for standard provider" { + const allocator = std.testing.allocator; + const path = "/tmp/nullhub-provider-test-baseurl-empty.json"; + var s = state_mod.State.init(allocator, path); + defer s.deinit(); + + try s.addSavedProvider(.{ .provider = "openrouter", .api_key = "sk-or-xxx" }); + + const json = try handleList(allocator, &s, true); + defer allocator.free(json); + try std.testing.expect(std.mem.indexOf(u8, json, "\"base_url\":\"\"") != null); +} + test "findProviderProbeComponent prefers installed nullclaw" { const allocator = std.testing.allocator; const path = "/tmp/nullhub-provider-test-probe-component.json"; @@ -503,3 +580,89 @@ test "nowIso8601 returns valid format" { try std.testing.expect(ts[10] == 'T'); try std.testing.expect(ts[19] == 'Z'); } + +test "handleCreate with base_url saves without requiring nullclaw probe" { + // Regression: custom providers with a base_url must not block on the + // nullclaw probe — the probe is designed for known providers and can + // misclassify valid responses from arbitrary OpenAI-compatible endpoints. + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-provider-test-custom-create"; + std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + std_compat.fs.makeDirAbsolute(tmp) catch {}; + defer std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + + const state_path = try std.fmt.allocPrint(allocator, "{s}/state.json", .{tmp}); + defer allocator.free(state_path); + + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + + // No nullclaw instance installed — would normally block standard providers + const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); + + const body = + \\{"provider":"local-llm","api_key":"sk-test","model":"llama3","base_url":"http://127.0.0.1:5801/v1"} + ; + const json = try handleCreate(allocator, body, &s, paths); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"error\"") == null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"base_url\":\"http://127.0.0.1:5801/v1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"provider\":\"local-llm\"") != null); + try std.testing.expectEqual(@as(usize, 1), s.savedProviders().len); +} + +test "handleCreate without base_url requires nullclaw instance" { + // Standard providers (no base_url) must require an installed nullclaw + // instance to run the probe. + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-provider-test-standard-create"; + std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + std_compat.fs.makeDirAbsolute(tmp) catch {}; + defer std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + + const state_path = try std.fmt.allocPrint(allocator, "{s}/state.json", .{tmp}); + defer allocator.free(state_path); + + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + + // No nullclaw instance installed + const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); + + const body = + \\{"provider":"openrouter","api_key":"sk-or-test"} + ; + const json = try handleCreate(allocator, body, &s, paths); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"error\"") != null); + try std.testing.expectEqual(@as(usize, 0), s.savedProviders().len); +} + +test "handleValidate for custom provider returns probe-not-applicable message" { + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-provider-test-validate-custom"; + std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + std_compat.fs.makeDirAbsolute(tmp) catch {}; + defer std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + + const state_path = try std.fmt.allocPrint(allocator, "{s}/state.json", .{tmp}); + defer allocator.free(state_path); + + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + + try s.addSavedProvider(.{ + .provider = "local-llm", + .api_key = "sk-test", + .base_url = "http://127.0.0.1:5801/v1", + }); + + const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); + const json = try handleValidate(allocator, 1, &s, paths); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"live_ok\":false") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "custom endpoint") != null); +} diff --git a/src/api/wizard.zig b/src/api/wizard.zig index 765cc4d..a251fe7 100644 --- a/src/api/wizard.zig +++ b/src/api/wizard.zig @@ -669,6 +669,7 @@ pub fn handleValidateProviders( .provider = prov.provider, .api_key = prov.api_key, .model = prov.model, + .base_url = prov.base_url, .validated_with = component_name, }) catch { saved_providers_warning = "validated providers could not be saved"; diff --git a/src/core/state.zig b/src/core/state.zig index 3d44187..bb77e69 100644 --- a/src/core/state.zig +++ b/src/core/state.zig @@ -16,6 +16,7 @@ pub const SavedProvider = struct { provider: []const u8, api_key: []const u8, model: []const u8 = "", + base_url: []const u8 = "", validated_at: []const u8 = "", validated_with: []const u8 = "", last_validation_at: []const u8 = "", @@ -26,6 +27,7 @@ pub const SavedProviderInput = struct { provider: []const u8, api_key: []const u8, model: []const u8 = "", + base_url: []const u8 = "", validated_with: []const u8 = "", }; @@ -33,6 +35,7 @@ pub const SavedProviderUpdate = struct { name: ?[]const u8 = null, api_key: ?[]const u8 = null, model: ?[]const u8 = null, + base_url: ?[]const u8 = null, validated_at: ?[]const u8 = null, validated_with: ?[]const u8 = null, last_validation_at: ?[]const u8 = null, @@ -205,6 +208,7 @@ pub const State = struct { self.allocator.free(sp.provider); self.allocator.free(sp.api_key); if (sp.model.len > 0) self.allocator.free(sp.model); + if (sp.base_url.len > 0) self.allocator.free(sp.base_url); if (sp.validated_at.len > 0) self.allocator.free(sp.validated_at); if (sp.validated_with.len > 0) self.allocator.free(sp.validated_with); if (sp.last_validation_at.len > 0) self.allocator.free(sp.last_validation_at); @@ -534,6 +538,8 @@ pub const State = struct { errdefer self.allocator.free(api_key); const model = if (input.model.len > 0) try self.allocator.dupe(u8, input.model) else @as([]const u8, ""); errdefer if (model.len > 0) self.allocator.free(@constCast(model)); + const base_url = if (input.base_url.len > 0) try self.allocator.dupe(u8, input.base_url) else @as([]const u8, ""); + errdefer if (base_url.len > 0) self.allocator.free(@constCast(base_url)); const validated_with = if (input.validated_with.len > 0) try self.allocator.dupe(u8, input.validated_with) else @as([]const u8, ""); errdefer if (validated_with.len > 0) self.allocator.free(@constCast(validated_with)); @@ -543,6 +549,7 @@ pub const State = struct { .provider = provider, .api_key = api_key, .model = model, + .base_url = base_url, .validated_at = "", .validated_with = validated_with, .last_validation_at = "", @@ -563,6 +570,11 @@ pub const State = struct { else null; errdefer if (new_model) |m| if (m.len > 0) self.allocator.free(@constCast(m)); + const new_base_url = if (update.base_url) |base_url| + if (base_url.len > 0) try self.allocator.dupe(u8, base_url) else @as([]const u8, "") + else + null; + errdefer if (new_base_url) |u| if (u.len > 0) self.allocator.free(@constCast(u)); const new_validated_at = if (update.validated_at) |validated_at| if (validated_at.len > 0) try self.allocator.dupe(u8, validated_at) else @as([]const u8, "") else @@ -595,6 +607,11 @@ pub const State = struct { if (sp.model.len > 0) self.allocator.free(sp.model); sp.model = m; } + if (update.base_url != null) { + const u = new_base_url.?; + if (sp.base_url.len > 0) self.allocator.free(sp.base_url); + sp.base_url = u; + } if (update.validated_at != null) { const t = new_validated_at.?; if (sp.validated_at.len > 0) self.allocator.free(sp.validated_at); @@ -1373,6 +1390,148 @@ test "next provider id after removals" { try std.testing.expectEqual(@as(u32, 3), providers[1].id); } +// ─── OpenAI-Compatible Provider Tests ──────────────────────────────────────── + +test "add saved provider with base_url, save, load, verify round-trip" { + const allocator = std.testing.allocator; + const path = try testPath(allocator, "state.json"); + defer allocator.free(path); + defer cleanupTestDir(); + + { + var s = State.init(allocator, path); + defer s.deinit(); + + try s.addSavedProvider(.{ + .provider = "infini-ai", + .api_key = "sk-cp-test", + .model = "minimax-m2.7", + .base_url = "https://cloud.infini-ai.com/maas/coding/v1", + }); + + const providers = s.savedProviders(); + try std.testing.expectEqual(@as(usize, 1), providers.len); + try std.testing.expectEqualStrings("infini-ai", providers[0].provider); + try std.testing.expectEqualStrings("sk-cp-test", providers[0].api_key); + try std.testing.expectEqualStrings("minimax-m2.7", providers[0].model); + try std.testing.expectEqualStrings("https://cloud.infini-ai.com/maas/coding/v1", providers[0].base_url); + try std.testing.expectEqualStrings("infini-ai #1", providers[0].name); + + try s.save(); + } + + { + var s = try State.load(allocator, path); + defer s.deinit(); + + const providers = s.savedProviders(); + try std.testing.expectEqual(@as(usize, 1), providers.len); + try std.testing.expectEqualStrings("infini-ai", providers[0].provider); + try std.testing.expectEqualStrings("sk-cp-test", providers[0].api_key); + try std.testing.expectEqualStrings("minimax-m2.7", providers[0].model); + try std.testing.expectEqualStrings("https://cloud.infini-ai.com/maas/coding/v1", providers[0].base_url); + try std.testing.expectEqualStrings("infini-ai #1", providers[0].name); + } +} + +test "update saved provider base_url" { + const allocator = std.testing.allocator; + const path = try testPath(allocator, "state.json"); + defer allocator.free(path); + defer cleanupTestDir(); + + var s = State.init(allocator, path); + defer s.deinit(); + + try s.addSavedProvider(.{ + .provider = "infini-ai", + .api_key = "key1", + .base_url = "https://old.example.com/v1", + }); + const updated = try s.updateSavedProvider(1, .{ + .base_url = "https://new.example.com/v1", + }); + try std.testing.expect(updated); + + const providers = s.savedProviders(); + try std.testing.expectEqualStrings("https://new.example.com/v1", providers[0].base_url); +} + +test "update saved provider clears base_url" { + const allocator = std.testing.allocator; + const path = try testPath(allocator, "state.json"); + defer allocator.free(path); + defer cleanupTestDir(); + + var s = State.init(allocator, path); + defer s.deinit(); + + try s.addSavedProvider(.{ + .provider = "infini-ai", + .api_key = "key1", + .base_url = "https://cloud.infini-ai.com/v1", + }); + const updated = try s.updateSavedProvider(1, .{ .base_url = "" }); + try std.testing.expect(updated); + + const providers = s.savedProviders(); + try std.testing.expectEqualStrings("", providers[0].base_url); +} + +test "multiple openai-compatible providers with different names" { + const allocator = std.testing.allocator; + const path = try testPath(allocator, "state.json"); + defer allocator.free(path); + defer cleanupTestDir(); + + var s = State.init(allocator, path); + defer s.deinit(); + + try s.addSavedProvider(.{ + .provider = "infini-ai", + .api_key = "key1", + .model = "minimax-m2.7", + .base_url = "https://cloud.infini-ai.com/maas/coding/v1", + }); + try s.addSavedProvider(.{ + .provider = "infini-ai", + .api_key = "key1", + .model = "deepseek-v3", + .base_url = "https://cloud.infini-ai.com/maas/coding/v1", + }); + try s.addSavedProvider(.{ + .provider = "xiaomi-mimo", + .api_key = "key2", + .model = "mimo-7b", + .base_url = "https://api.xiaomi.com/v1", + }); + + const providers = s.savedProviders(); + try std.testing.expectEqual(@as(usize, 3), providers.len); + try std.testing.expectEqualStrings("infini-ai #1", providers[0].name); + try std.testing.expectEqualStrings("infini-ai #2", providers[1].name); + try std.testing.expectEqualStrings("xiaomi-mimo #1", providers[2].name); + try std.testing.expectEqualStrings("infini-ai", providers[0].provider); + try std.testing.expectEqualStrings("xiaomi-mimo", providers[2].provider); + try std.testing.expectEqualStrings("https://cloud.infini-ai.com/maas/coding/v1", providers[0].base_url); + try std.testing.expectEqualStrings("https://api.xiaomi.com/v1", providers[2].base_url); +} + +test "openai-compatible provider base_url defaults to empty" { + const allocator = std.testing.allocator; + const path = try testPath(allocator, "state.json"); + defer allocator.free(path); + defer cleanupTestDir(); + + var s = State.init(allocator, path); + defer s.deinit(); + + try s.addSavedProvider(.{ .provider = "openrouter", .api_key = "key1" }); + + const providers = s.savedProviders(); + try std.testing.expectEqualStrings("", providers[0].base_url); +} + // ─── SavedChannel Tests ───────────────────────────────────────────────────── test "add saved channel, save, load, verify round-trip" { diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 73dfbb9..e58d8ae 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -191,9 +191,9 @@ export const api = { // Saved providers getSavedProviders: (reveal = false) => request(`/providers${reveal ? '?reveal=true' : ''}`), - createSavedProvider: (data: { provider: string; api_key: string; model?: string }) => + createSavedProvider: (data: { provider: string; api_key: string; model?: string; base_url?: string }) => request('/providers', { method: 'POST', body: JSON.stringify(data) }), - updateSavedProvider: (id: string, data: { name?: string; api_key?: string; model?: string }) => + updateSavedProvider: (id: string, data: { name?: string; api_key?: string; model?: string; base_url?: string }) => request(`/providers/${id.replace('sp_', '')}`, { method: 'PUT', body: JSON.stringify(data) }), deleteSavedProvider: (id: string) => request(`/providers/${id.replace('sp_', '')}`, { method: 'DELETE' }), diff --git a/ui/src/lib/components/ConfigEditorUI.svelte b/ui/src/lib/components/ConfigEditorUI.svelte index 9b7d415..44be914 100644 --- a/ui/src/lib/components/ConfigEditorUI.svelte +++ b/ui/src/lib/components/ConfigEditorUI.svelte @@ -155,6 +155,7 @@ {#each providers as provider} {@const apiKeyId = fieldId(`models.providers.${provider}.api_key`)} + {@const baseUrlId = fieldId(`models.providers.${provider}.base_url`)}
{provider}
@@ -166,6 +167,17 @@ oninput={(e) => updateField(`models.providers.${provider}.api_key`, e.currentTarget.value)} />
+ {#if getPath(config, `models.providers.${provider}.base_url`)} +
+ + updateField(`models.providers.${provider}.base_url`, e.currentTarget.value)} + /> +
+ {/if}
{/each} diff --git a/ui/src/lib/components/ProviderList.svelte b/ui/src/lib/components/ProviderList.svelte index 8f0ebf1..7b735dd 100644 --- a/ui/src/lib/components/ProviderList.svelte +++ b/ui/src/lib/components/ProviderList.svelte @@ -12,6 +12,7 @@ const LOCAL_PROVIDERS = ["ollama", "lm-studio", "claude-cli", "codex-cli", "openai-codex"]; const MODEL_RESULTS_LIMIT = 80; + const OPENAI_COMPATIBLE_VALUE = "openai-compatible"; type ProviderOption = { value: string; @@ -23,6 +24,8 @@ provider: string; api_key: string; model: string; + base_url: string; + provider_name: string; }; let savedProviders = $state([]); @@ -76,10 +79,13 @@ } function useSaved(sp: any) { + const isCompat = sp.base_url && sp.base_url.length > 0; const savedEntry = { - provider: sp.provider, + provider: isCompat ? OPENAI_COMPATIBLE_VALUE : sp.provider, api_key: sp.api_key, model: sp.model || "", + base_url: sp.base_url || "", + provider_name: isCompat ? sp.provider : "", }; if (entries.length === 1 && isPlaceholderEntry(entries[0])) { @@ -118,7 +124,7 @@ const defaultProvider = rec?.value || providers[0]?.value || ""; entries = [ ...entries, - { provider: defaultProvider, api_key: "", model: "" }, + { provider: defaultProvider, api_key: "", model: "", base_url: "", provider_name: "" }, ]; emitChange(); } @@ -368,6 +374,29 @@ {/if} + {#if entry.provider === OPENAI_COMPATIBLE_VALUE} +
+ + updateEntry(i, "provider_name", e.currentTarget.value)} + placeholder="e.g. infini-ai, xiaomi-mimo" + /> +
+
+ + updateEntry(i, "base_url", e.currentTarget.value)} + placeholder="https://api.example.com/v1" + /> +
+ {/if} +
diff --git a/ui/src/lib/components/WizardRenderer.svelte b/ui/src/lib/components/WizardRenderer.svelte index 9d7c8f7..2744c50 100644 --- a/ui/src/lib/components/WizardRenderer.svelte +++ b/ui/src/lib/components/WizardRenderer.svelte @@ -270,12 +270,21 @@ }; if (_providers) { try { - const parsed = JSON.parse(_providers); + let parsed = JSON.parse(_providers); + // Transform openai-compatible entries: use provider_name as the actual provider + parsed = parsed.map((entry: any) => { + if (entry.provider === "openai-compatible") { + const { provider_name, ...rest } = entry; + return { ...rest, provider: provider_name || entry.provider }; + } + return entry; + }); payload.providers = parsed; if (parsed.length > 0) { payload.provider = parsed[0].provider; payload.api_key = parsed[0].api_key || ""; payload.model = parsed[0].model || ""; + if (parsed[0].base_url) payload.base_url = parsed[0].base_url; } } catch {} } diff --git a/ui/src/routes/providers/+page.svelte b/ui/src/routes/providers/+page.svelte index be3a69a..f91a00d 100644 --- a/ui/src/routes/providers/+page.svelte +++ b/ui/src/routes/providers/+page.svelte @@ -20,8 +20,10 @@ { value: "claude-cli", label: "Claude CLI (local)" }, { value: "codex-cli", label: "Codex CLI (local CLI)" }, { value: "openai-codex", label: "OpenAI Codex (ChatGPT login)" }, + { value: "openai-compatible", label: "OpenAI Compatible (custom endpoint)" }, ]; const LOCAL_PROVIDERS = ["ollama", "lm-studio", "claude-cli", "codex-cli", "openai-codex"]; + const OPENAI_COMPATIBLE_VALUE = "openai-compatible"; let providers = $state([]); let loading = $state(true); @@ -32,13 +34,13 @@ // Add form state let showAddForm = $state(false); - let addForm = $state({ provider: "openrouter", api_key: "", model: "" }); + let addForm = $state({ provider: "openrouter", provider_name: "", api_key: "", model: "", base_url: "" }); let addValidating = $state(false); let addError = $state(""); // Edit state let editingId = $state(null); - let editForm = $state({ name: "", api_key: "", model: "" }); + let editForm = $state({ name: "", api_key: "", model: "", base_url: "" }); let editValidating = $state(false); let editError = $state(""); @@ -86,13 +88,27 @@ addValidating = true; addError = ""; try { + const providerValue = addForm.provider === OPENAI_COMPATIBLE_VALUE + ? addForm.provider_name.trim() + : addForm.provider; + if (addForm.provider === OPENAI_COMPATIBLE_VALUE && !providerValue) { + addError = "Provider name is required for OpenAI Compatible providers."; + addValidating = false; + return; + } + if (addForm.provider === OPENAI_COMPATIBLE_VALUE && !addForm.base_url.trim()) { + addError = "Base URL is required for OpenAI Compatible providers."; + addValidating = false; + return; + } await api.createSavedProvider({ - provider: addForm.provider, + provider: providerValue, api_key: addForm.api_key, model: addForm.model || undefined, + base_url: addForm.base_url || undefined, }); showAddForm = false; - addForm = { provider: "openrouter", api_key: "", model: "" }; + addForm = { provider: "openrouter", provider_name: "", api_key: "", model: "", base_url: "" }; flashMessage("Provider saved"); await loadProviders(); } catch (e) { @@ -104,7 +120,7 @@ function startEdit(p: any) { editingId = p.id; - editForm = { name: p.name, api_key: "", model: p.model }; + editForm = { name: p.name, api_key: "", model: p.model, base_url: p.base_url || "" }; } function cancelEdit() { @@ -119,6 +135,7 @@ if (editForm.name) payload.name = editForm.name; if (editForm.api_key) payload.api_key = editForm.api_key; payload.model = editForm.model; + payload.base_url = editForm.base_url; await api.updateSavedProvider(id, payload); editingId = null; flashMessage("Provider updated"); @@ -158,6 +175,10 @@ return LOCAL_PROVIDERS.includes(provider); } + function isOpenAiCompatible(p: any) { + return p.base_url && p.base_url.length > 0; + } + function getProviderLabel(value: string) { return PROVIDER_OPTIONS.find((p) => p.value === value)?.label || value; } @@ -217,6 +238,16 @@ {/each}
+ {#if addForm.provider === OPENAI_COMPATIBLE_VALUE} +
+ + +
+
+ + +
+ {/if} {#if !isLocal(addForm.provider)}
@@ -252,6 +283,12 @@
+ {#if isOpenAiCompatible(p)} +
+ + +
+ {/if} {#if !isLocal(p.provider)}
@@ -292,6 +329,12 @@ API Key {p.api_key}
+ {#if p.base_url} +
+ Base URL + {p.base_url} +
+ {/if}
Model {p.model || "No default model"} From 8509f1cec19f832b0a560b87f56deaa7252a137a Mon Sep 17 00:00:00 2001 From: Igor Somov Date: Mon, 4 May 2026 22:25:55 -0300 Subject: [PATCH 2/2] fix(providers): harden custom endpoint handling --- src/api/providers.zig | 79 ++++++++++- src/api/wizard.zig | 150 ++++++++++++++------ src/core/state.zig | 13 +- ui/src/lib/components/ProviderList.svelte | 60 ++++++-- ui/src/lib/components/WizardRenderer.svelte | 56 ++++++-- ui/src/routes/providers/+page.svelte | 50 ++----- 6 files changed, 300 insertions(+), 108 deletions(-) diff --git a/src/api/providers.zig b/src/api/providers.zig index 8a7be00..184669f 100644 --- a/src/api/providers.zig +++ b/src/api/providers.zig @@ -126,6 +126,8 @@ pub fn handleCreate( const providers = state.savedProviders(); const new_id = providers[providers.len - 1].id; try persistValidationAttempt(allocator, state, new_id, validated_with, true); + } else { + try state.save(); } // Return the saved provider @@ -212,12 +214,17 @@ pub fn handleUpdate( .last_validation_ok = true, }); } else { - // Custom provider: update fields directly without probe + // Custom provider: update fields directly without probe and clear + // stale probe metadata from any previous standard-provider state. _ = try state.updateSavedProvider(id, .{ .name = parsed.value.name, .api_key = parsed.value.api_key, .model = parsed.value.model, .base_url = parsed.value.base_url, + .validated_at = "", + .validated_with = "", + .last_validation_at = "", + .last_validation_ok = false, }); } } else { @@ -612,6 +619,39 @@ test "handleCreate with base_url saves without requiring nullclaw probe" { try std.testing.expectEqual(@as(usize, 1), s.savedProviders().len); } +test "handleCreate with base_url persists custom provider" { + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-provider-test-custom-create-persist"; + std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + std_compat.fs.makeDirAbsolute(tmp) catch {}; + defer std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + + const state_path = try std.fmt.allocPrint(allocator, "{s}/state.json", .{tmp}); + defer allocator.free(state_path); + + { + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + + const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); + const body = + \\{"provider":"local-llm","api_key":"sk-test","model":"llama3","base_url":"http://127.0.0.1:5801/v1"} + ; + const json = try handleCreate(allocator, body, &s, paths); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"error\"") == null); + } + + var loaded = try state_mod.State.load(allocator, state_path); + defer loaded.deinit(); + + const providers = loaded.savedProviders(); + try std.testing.expectEqual(@as(usize, 1), providers.len); + try std.testing.expectEqualStrings("local-llm", providers[0].provider); + try std.testing.expectEqualStrings("http://127.0.0.1:5801/v1", providers[0].base_url); +} + test "handleCreate without base_url requires nullclaw instance" { // Standard providers (no base_url) must require an installed nullclaw // instance to run the probe. @@ -666,3 +706,40 @@ test "handleValidate for custom provider returns probe-not-applicable message" { try std.testing.expect(std.mem.indexOf(u8, json, "\"live_ok\":false") != null); try std.testing.expect(std.mem.indexOf(u8, json, "custom endpoint") != null); } + +test "handleUpdate custom provider clears stale validation metadata" { + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-provider-test-update-custom-clears-validation"; + std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + std_compat.fs.makeDirAbsolute(tmp) catch {}; + defer std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + + const state_path = try std.fmt.allocPrint(allocator, "{s}/state.json", .{tmp}); + defer allocator.free(state_path); + + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + + try s.addSavedProvider(.{ + .provider = "local-llm", + .api_key = "old-key", + .base_url = "http://127.0.0.1:5801/v1", + .validated_with = "nullclaw", + }); + _ = try s.updateSavedProvider(1, .{ + .validated_at = "2026-03-11T18:59:00Z", + .last_validation_at = "2026-03-14T11:22:33Z", + .last_validation_ok = true, + }); + + const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); + const json = try handleUpdate(allocator, 1, "{\"api_key\":\"new-key\"}", &s, paths); + defer allocator.free(json); + + const provider = s.getSavedProvider(1).?; + try std.testing.expectEqualStrings("new-key", provider.api_key); + try std.testing.expectEqualStrings("", provider.validated_at); + try std.testing.expectEqualStrings("", provider.validated_with); + try std.testing.expectEqualStrings("", provider.last_validation_at); + try std.testing.expect(!provider.last_validation_ok); +} diff --git a/src/api/wizard.zig b/src/api/wizard.zig index a251fe7..b90de25 100644 --- a/src/api/wizard.zig +++ b/src/api/wizard.zig @@ -592,22 +592,34 @@ pub fn handleValidateProviders( ) ?[]const u8 { if (registry.findKnownComponent(component_name) == null) return null; + const ProviderInput = struct { + provider: []const u8, + api_key: []const u8 = "", + model: []const u8 = "", + base_url: []const u8 = "", + }; const parsed = std.json.parseFromSlice(struct { - providers: []const struct { - provider: []const u8, - api_key: []const u8 = "", - model: []const u8 = "", - base_url: []const u8 = "", - }, + providers: []const ProviderInput, }, allocator, body, .{ .allocate = .alloc_always, .ignore_unknown_fields = true, }) catch return allocator.dupe(u8, "{\"error\":\"invalid JSON body\"}") catch null; defer parsed.deinit(); - const bin_path = findOrFetchComponentBinary(allocator, component_name, paths) orelse - return allocator.dupe(u8, "{\"error\":\"component binary not found\"}") catch null; - defer allocator.free(bin_path); + var needs_probe = false; + for (parsed.value.providers) |prov| { + if (prov.provider.len > 0 and prov.base_url.len == 0) { + needs_probe = true; + break; + } + } + + const bin_path = if (needs_probe) + findOrFetchComponentBinary(allocator, component_name, paths) orelse + return allocator.dupe(u8, "{\"error\":\"component binary not found\"}") catch null + else + null; + defer if (bin_path) |path| allocator.free(path); // Create temp directory for probes const tmp_dir = paths_mod.uniqueTempPathAlloc(allocator, "nullhub-wizard-validate", "") catch return null; @@ -622,21 +634,33 @@ pub fn handleValidateProviders( buf.appendSlice("{\"results\":[") catch return null; // Track validation results for auto-save - const ProbeResult = struct { live_ok: bool }; - var probe_results = std.array_list.Managed(ProbeResult).init(allocator); + const ValidationResult = struct { live_ok: bool, skipped_probe: bool = false }; + var probe_results = std.array_list.Managed(ValidationResult).init(allocator); defer probe_results.deinit(); var saved_providers_warning: ?[]const u8 = null; for (parsed.value.providers, 0..) |prov, idx| { if (idx > 0) buf.append(',') catch return null; + if (prov.provider.len == 0) { + appendProviderResult(&buf, prov.provider, false, "provider_required") catch return null; + probe_results.append(.{ .live_ok = false }) catch return null; + continue; + } + + if (prov.base_url.len > 0) { + appendProviderResult(&buf, prov.provider, true, "custom_endpoint_validation_skipped") catch return null; + probe_results.append(.{ .live_ok = true, .skipped_probe = true }) catch return null; + continue; + } + writeMinimalProviderConfig(allocator, tmp_dir, prov.provider, prov.api_key, prov.base_url) catch { appendProviderResult(&buf, prov.provider, false, "config_write_failed") catch return null; probe_results.append(.{ .live_ok = false }) catch return null; continue; }; - const result = probeProviderViaComponentBinary(allocator, component_name, bin_path, tmp_dir, prov.provider, prov.model); + const result = probeProviderViaComponentBinary(allocator, component_name, bin_path.?, tmp_dir, prov.provider, prov.model); defer result.deinit(allocator); appendProviderResult(&buf, prov.provider, result.live_ok, result.reason) catch return null; probe_results.append(.{ .live_ok = result.live_ok }) catch return null; @@ -644,51 +668,60 @@ pub fn handleValidateProviders( buf.appendSlice("]") catch return null; - // Auto-save validated providers + // Auto-save validated providers. Custom endpoints are saved, but they do + // not receive validation metadata because the live probe was intentionally + // skipped. var did_save = false; for (parsed.value.providers, 0..) |prov, idx| { if (idx < probe_results.items.len and probe_results.items[idx].live_ok) { - const now = providers_api.nowIso8601(allocator) catch ""; - defer if (now.len > 0) allocator.free(now); + const is_custom = probe_results.items[idx].skipped_probe; - if (state.findSavedProviderId(prov.provider, prov.api_key, prov.model)) |existing_id| { - if (now.len > 0) { - _ = state.updateSavedProvider(existing_id, .{ - .validated_at = now, - .validated_with = component_name, - .last_validation_at = now, - .last_validation_ok = true, - }) catch { - saved_providers_warning = "validated providers could not be fully saved"; - continue; - }; - did_save = true; - } + if (state.findSavedProviderId(prov.provider, prov.api_key, prov.model, prov.base_url)) |existing_id| { + if (is_custom) continue; + + const now = providers_api.nowIso8601(allocator) catch ""; + defer if (now.len > 0) allocator.free(now); + if (now.len == 0) continue; + + _ = state.updateSavedProvider(existing_id, .{ + .validated_at = now, + .validated_with = component_name, + .last_validation_at = now, + .last_validation_ok = true, + }) catch { + saved_providers_warning = "validated providers could not be fully saved"; + continue; + }; + did_save = true; } else { state.addSavedProvider(.{ .provider = prov.provider, .api_key = prov.api_key, .model = prov.model, .base_url = prov.base_url, - .validated_with = component_name, + .validated_with = if (is_custom) "" else component_name, }) catch { saved_providers_warning = "validated providers could not be saved"; continue; }; - // Set both the last successful validation and the latest validation attempt. - const providers_list = state.savedProviders(); - if (providers_list.len > 0) { - const new_id = providers_list[providers_list.len - 1].id; - if (now.len > 0) { - _ = state.updateSavedProvider(new_id, .{ - .validated_at = now, - .validated_with = component_name, - .last_validation_at = now, - .last_validation_ok = true, - }) catch { - saved_providers_warning = "validated providers could not be fully saved"; - continue; - }; + if (!is_custom) { + // Set both the last successful validation and the latest validation attempt. + const providers_list = state.savedProviders(); + if (providers_list.len > 0) { + const new_id = providers_list[providers_list.len - 1].id; + const now = providers_api.nowIso8601(allocator) catch ""; + defer if (now.len > 0) allocator.free(now); + if (now.len > 0) { + _ = state.updateSavedProvider(new_id, .{ + .validated_at = now, + .validated_with = component_name, + .last_validation_at = now, + .last_validation_ok = true, + }) catch { + saved_providers_warning = "validated providers could not be fully saved"; + continue; + }; + } } } did_save = true; @@ -1184,6 +1217,37 @@ test "extractComponentName parses validate-providers path" { try std.testing.expectEqualStrings("nullclaw", name.?); } +test "handleValidateProviders skips probe for custom base_url and saves provider" { + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-wizard-test-custom-provider"; + std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + std_compat.fs.makeDirAbsolute(tmp) catch {}; + defer std_compat.fs.deleteTreeAbsolute(tmp) catch {}; + + const state_path = try std.fmt.allocPrint(allocator, "{s}/state.json", .{tmp}); + defer allocator.free(state_path); + + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + + const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); + const body = + \\{"providers":[{"provider":"local-llm","api_key":"sk-test","model":"llama3","base_url":"http://127.0.0.1:5801/v1"}]} + ; + const json = handleValidateProviders(allocator, "nullclaw", body, paths, &s) orelse @panic("expected response"); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"live_ok\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "custom_endpoint_validation_skipped") != null); + + const providers = s.savedProviders(); + try std.testing.expectEqual(@as(usize, 1), providers.len); + try std.testing.expectEqualStrings("local-llm", providers[0].provider); + try std.testing.expectEqualStrings("http://127.0.0.1:5801/v1", providers[0].base_url); + try std.testing.expectEqualStrings("", providers[0].validated_at); + try std.testing.expectEqualStrings("", providers[0].last_validation_at); +} + test "extractComponentName parses validate-channels path" { const name = extractComponentName("/api/wizard/nullclaw/validate-channels"); try std.testing.expect(name != null); diff --git a/src/core/state.zig b/src/core/state.zig index bb77e69..1347fc5 100644 --- a/src/core/state.zig +++ b/src/core/state.zig @@ -317,6 +317,8 @@ pub const State = struct { errdefer allocator.free(owned_api_key); const owned_model = if (sp.model.len > 0) try allocator.dupe(u8, sp.model) else @as([]const u8, ""); errdefer if (owned_model.len > 0) allocator.free(@constCast(owned_model)); + const owned_base_url = if (sp.base_url.len > 0) try allocator.dupe(u8, sp.base_url) else @as([]const u8, ""); + errdefer if (owned_base_url.len > 0) allocator.free(@constCast(owned_base_url)); const owned_validated_at = if (sp.validated_at.len > 0) try allocator.dupe(u8, sp.validated_at) else @as([]const u8, ""); errdefer if (owned_validated_at.len > 0) allocator.free(@constCast(owned_validated_at)); const owned_validated_with = if (sp.validated_with.len > 0) try allocator.dupe(u8, sp.validated_with) else @as([]const u8, ""); @@ -330,6 +332,7 @@ pub const State = struct { .provider = owned_provider, .api_key = owned_api_key, .model = owned_model, + .base_url = owned_base_url, .validated_at = owned_validated_at, .validated_with = owned_validated_with, .last_validation_at = owned_last_validation_at, @@ -648,11 +651,12 @@ pub const State = struct { return false; } - pub fn hasSavedProvider(self: *State, provider: []const u8, api_key: []const u8, model: []const u8) bool { + pub fn hasSavedProvider(self: *State, provider: []const u8, api_key: []const u8, model: []const u8, base_url: []const u8) bool { for (self.saved_providers.items) |sp| { if (std.mem.eql(u8, sp.provider, provider) and std.mem.eql(u8, sp.api_key, api_key) and - std.mem.eql(u8, sp.model, model)) + std.mem.eql(u8, sp.model, model) and + std.mem.eql(u8, sp.base_url, base_url)) { return true; } @@ -660,11 +664,12 @@ pub const State = struct { return false; } - pub fn findSavedProviderId(self: *State, provider: []const u8, api_key: []const u8, model: []const u8) ?u32 { + pub fn findSavedProviderId(self: *State, provider: []const u8, api_key: []const u8, model: []const u8, base_url: []const u8) ?u32 { for (self.saved_providers.items) |sp| { if (std.mem.eql(u8, sp.provider, provider) and std.mem.eql(u8, sp.api_key, api_key) and - std.mem.eql(u8, sp.model, model)) + std.mem.eql(u8, sp.model, model) and + std.mem.eql(u8, sp.base_url, base_url)) { return sp.id; } diff --git a/ui/src/lib/components/ProviderList.svelte b/ui/src/lib/components/ProviderList.svelte index 7b735dd..b42c6a0 100644 --- a/ui/src/lib/components/ProviderList.svelte +++ b/ui/src/lib/components/ProviderList.svelte @@ -13,6 +13,7 @@ const LOCAL_PROVIDERS = ["ollama", "lm-studio", "claude-cli", "codex-cli", "openai-codex"]; const MODEL_RESULTS_LIMIT = 80; const OPENAI_COMPATIBLE_VALUE = "openai-compatible"; + const OPENAI_COMPATIBLE_OPTION = { value: OPENAI_COMPATIBLE_VALUE, label: "OpenAI Compatible (custom endpoint)" }; type ProviderOption = { value: string; @@ -37,6 +38,11 @@ let modelLoadedByKey = $state>({}); let modelOptionsByKey = $state>({}); let modelErrorsByKey = $state>({}); + let providerOptions = $derived( + providers.some((p: any) => p.value === OPENAI_COMPATIBLE_VALUE) + ? providers + : [...providers, OPENAI_COMPATIBLE_OPTION], + ); const modelBlurTimers = new Map>(); @@ -75,7 +81,10 @@ } function isPlaceholderEntry(entry: ProviderEntry) { - return entry.api_key.trim().length === 0 && entry.model.trim().length === 0; + return entry.api_key.trim().length === 0 && + entry.model.trim().length === 0 && + (entry.base_url || "").trim().length === 0 && + (entry.provider_name || "").trim().length === 0; } function useSaved(sp: any) { @@ -107,7 +116,11 @@ try { const parsed = JSON.parse(value); if (Array.isArray(parsed)) { - entries = parsed; + entries = parsed.map((entry: any) => ({ + ...entry, + base_url: entry.base_url || "", + provider_name: entry.provider_name || "", + })); } } catch { entries = []; @@ -120,8 +133,8 @@ function addEntry() { // Find recommended provider or first available - const rec = providers.find((p: any) => p.recommended); - const defaultProvider = rec?.value || providers[0]?.value || ""; + const rec = providerOptions.find((p: any) => p.recommended); + const defaultProvider = rec?.value || providerOptions[0]?.value || ""; entries = [ ...entries, { provider: defaultProvider, api_key: "", model: "", base_url: "", provider_name: "" }, @@ -163,6 +176,17 @@ emitChange(); } + function updateProvider(index: number, provider: string) { + entries = entries.map((e: any, i: number) => { + if (i !== index) return e; + if (provider === OPENAI_COMPATIBLE_VALUE) { + return { ...e, provider, base_url: e.base_url || "", provider_name: e.provider_name || "" }; + } + return { ...e, provider, base_url: "", provider_name: "" }; + }); + emitChange(); + } + function isLocal(provider: string) { return LOCAL_PROVIDERS.includes(provider); } @@ -187,7 +211,18 @@ } function modelKey(entry: ProviderEntry) { - return `${entry.provider}\u0000${entry.api_key}`; + return `${actualProvider(entry)}\u0000${entry.base_url || ""}\u0000${entry.api_key}`; + } + + function actualProvider(entry: ProviderEntry) { + return entry.provider === OPENAI_COMPATIBLE_VALUE + ? (entry.provider_name || "").trim() + : entry.provider; + } + + function validationResultForEntry(entry: ProviderEntry) { + const provider = actualProvider(entry) || entry.provider; + return validationResults.find((r: any) => r.provider === provider || r.provider === entry.provider); } function getModelOptions(entry: ProviderEntry) { @@ -204,6 +239,7 @@ async function ensureModelOptions(entry: ProviderEntry) { if (!component || !entry.provider) return; + if (entry.provider === OPENAI_COMPATIBLE_VALUE) return; const key = modelKey(entry); if (modelLoadingByKey[key] || modelLoadedByKey[key]) return; @@ -212,7 +248,7 @@ modelErrorsByKey = { ...modelErrorsByKey, [key]: "" }; try { - const data = await api.getWizardModels(component, entry.provider, entry.api_key || ""); + const data = await api.getWizardModels(component, actualProvider(entry), entry.api_key || ""); const models = Array.isArray(data) ? data : Array.isArray(data?.models) @@ -297,6 +333,9 @@ } function modelPlaceholder(entry: ProviderEntry) { + if (entry.provider === OPENAI_COMPATIBLE_VALUE) { + return "e.g. gpt-4o-mini"; + } if (entry.provider === "codex-cli" || entry.provider === "openai-codex") { return "e.g. gpt-5.4"; } @@ -310,6 +349,9 @@ if (entry.provider === "openai-codex") { return "Uses ChatGPT/Codex auth from ~/.codex/auth.json. No API key required here."; } + if (entry.provider === OPENAI_COMPATIBLE_VALUE) { + return "Type a model name manually."; + } return "Click to load models, then filter as you type."; } @@ -324,7 +366,7 @@
{i + 1}. - {#each [validationResults.find((r: any) => r.provider === entry.provider)] as result} + {#each [validationResultForEntry(entry)] as result} {#if result} @@ -332,9 +374,9 @@ {/each}