diff --git a/src/api/providers.zig b/src/api/providers.zig index de44426..184669f 100644 --- a/src/api/providers.zig +++ b/src/api/providers.zig @@ -71,47 +71,68 @@ 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); + } else { + try state.save(); + } // 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 +154,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 +164,69 @@ 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 = true, + }); + } else { + // 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, }); - 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 +259,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 +274,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 +373,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 +472,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 +587,159 @@ 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 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. + 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); +} + +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 765cc4d..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,50 +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, - .validated_with = component_name, + .base_url = prov.base_url, + .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; @@ -1183,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 3d44187..1347fc5 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); @@ -313,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, ""); @@ -326,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, @@ -534,6 +541,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 +552,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 +573,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 +610,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); @@ -631,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; } @@ -643,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; } @@ -1373,6 +1395,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..b42c6a0 100644 --- a/ui/src/lib/components/ProviderList.svelte +++ b/ui/src/lib/components/ProviderList.svelte @@ -12,6 +12,8 @@ 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; @@ -23,6 +25,8 @@ provider: string; api_key: string; model: string; + base_url: string; + provider_name: string; }; let savedProviders = $state([]); @@ -34,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>(); @@ -72,14 +81,20 @@ } 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) { + 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])) { @@ -101,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 = []; @@ -114,11 +133,11 @@ 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: "" }, + { provider: defaultProvider, api_key: "", model: "", base_url: "", provider_name: "" }, ]; emitChange(); } @@ -157,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); } @@ -181,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) { @@ -198,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; @@ -206,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) @@ -291,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"; } @@ -304,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."; } @@ -318,7 +366,7 @@
{i + 1}. - {#each [validationResults.find((r: any) => r.provider === entry.provider)] as result} + {#each [validationResultForEntry(entry)] as result} {#if result} @@ -326,9 +374,9 @@ {/each} 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..71de15c 100644 --- a/ui/src/lib/components/WizardRenderer.svelte +++ b/ui/src/lib/components/WizardRenderer.svelte @@ -23,6 +23,7 @@ let selectedVersion = $state("latest"); let channels = $state>>>({}); const instanceNameId = "wizard-instance-name"; + const OPENAI_COMPATIBLE_VALUE = "openai-compatible"; // Validation state let validating = $state(false); @@ -107,7 +108,7 @@ const defaultProvider = rec?.value || providerStep.options?.[0]?.value || ""; answers["_providers"] = JSON.stringify([ - { provider: defaultProvider, api_key: "", model: "" }, + { provider: defaultProvider, api_key: "", model: "", base_url: "", provider_name: "" }, ]); } } @@ -176,6 +177,36 @@ showAdvanced = false; }); + function customProviderError(entries: any[]) { + for (const entry of entries) { + if (entry.provider !== OPENAI_COMPATIBLE_VALUE) continue; + if (!(entry.provider_name || "").trim()) { + return "Provider name is required for OpenAI Compatible providers."; + } + if (!(entry.base_url || "").trim()) { + return "Base URL is required for OpenAI Compatible providers."; + } + } + return ""; + } + + function normalizeProviderEntries(entries: any[]) { + return entries.map((entry: any) => { + if (entry.provider === OPENAI_COMPATIBLE_VALUE) { + const { provider_name, ...rest } = entry; + return { + ...rest, + provider: (provider_name || "").trim(), + base_url: (entry.base_url || "").trim(), + }; + } + const rest = { ...entry }; + delete rest.provider_name; + delete rest.base_url; + return rest; + }); + } + async function validateProviders(): Promise { validating = true; validationError = ""; @@ -183,11 +214,17 @@ providerValidationResults = []; try { - const providers = JSON.parse(answers["_providers"] || "[]"); - if (providers.length === 0) { + const rawProviders = JSON.parse(answers["_providers"] || "[]"); + if (rawProviders.length === 0) { validationError = "Add at least one provider"; return false; } + const customError = customProviderError(rawProviders); + if (customError) { + validationError = customError; + return false; + } + const providers = normalizeProviderEntries(rawProviders); const result = await api.validateProviders(component, providers); providerValidationResults = result.results || []; validationWarning = result.saved_providers_warning || ""; @@ -270,12 +307,16 @@ }; if (_providers) { try { - const parsed = JSON.parse(_providers); + const rawProviders = JSON.parse(_providers); + const customError = customProviderError(rawProviders); + if (customError) throw new Error(customError); + const parsed = normalizeProviderEntries(rawProviders); 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..c546ae3 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,27 +34,21 @@ // 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(""); // Re-validate state let revalidatingId = $state(null); - let hasComponents = $state(false); - onMount(async () => { await loadProviders(); - try { - const status = await api.getStatus(); - hasComponents = Object.keys(status.instances || {}).length > 0; - } catch {} }); onDestroy(() => { @@ -86,13 +82,29 @@ addValidating = true; addError = ""; try { + const isCustom = addForm.provider === OPENAI_COMPATIBLE_VALUE; + const providerValue = addForm.provider === OPENAI_COMPATIBLE_VALUE + ? addForm.provider_name.trim() + : addForm.provider; + const baseUrl = addForm.base_url.trim(); + if (isCustom && !providerValue) { + addError = "Provider name is required for OpenAI Compatible providers."; + addValidating = false; + return; + } + if (isCustom && !baseUrl) { + 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: isCustom ? baseUrl : 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 +116,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 +131,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.trim(); await api.updateSavedProvider(id, payload); editingId = null; flashMessage("Provider updated"); @@ -158,6 +171,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; } @@ -186,11 +203,9 @@
{#if message} @@ -201,12 +216,7 @@
{error}
{/if} - {#if !hasComponents} -
-

Install a component first to add providers.

- Install Component -
- {:else if showAddForm} + {#if showAddForm}

Add Provider

@@ -217,6 +227,16 @@ {/each}
+ {#if addForm.provider === OPENAI_COMPATIBLE_VALUE} +
+ + +
+
+ + +
+ {/if} {#if !isLocal(addForm.provider)}
@@ -238,7 +258,7 @@ {#if loading}

Loading providers...

- {:else if providers.length === 0 && hasComponents} + {:else if providers.length === 0}

No saved providers yet. Add one above or install a component — providers are saved automatically during setup.

@@ -252,6 +272,12 @@
+ {#if isOpenAiCompatible(p)} +
+ + +
+ {/if} {#if !isLocal(p.provider)}
@@ -292,6 +318,12 @@ API Key {p.api_key}
+ {#if p.base_url} +
+ Base URL + {p.base_url} +
+ {/if}
Model {p.model || "No default model"} @@ -624,23 +656,6 @@ font-family: var(--font-mono); } - .link-btn { - color: var(--accent); - text-decoration: none; - border: 1px solid var(--accent); - padding: 0.5rem 1.25rem; - border-radius: 2px; - text-transform: uppercase; - letter-spacing: 1px; - font-size: 0.875rem; - transition: all 0.2s ease; - } - - .link-btn:hover { - background: color-mix(in srgb, var(--accent) 15%, transparent); - box-shadow: 0 0 10px var(--border-glow); - } - .loading { color: var(--fg-dim); font-family: var(--font-mono);