From 12a6006f9c94265ca88b07a89d0ad1e2cd37e53f Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Mon, 4 May 2026 18:02:38 +0800 Subject: [PATCH 01/15] feat(providers): add /models probe for custom endpoint validation - GET /api/providers/probe-models?base_url=...&api_key=... fetches available model IDs from any OpenAI-compatible /models endpoint - handleCreate, handleUpdate, handleValidate now probe custom providers via /models instead of silently skipping; create/update are non-blocking (always save, record validation result); validate returns live status - Frontend: Fetch Models button in add/edit forms for custom providers; clickable model chips populate the model field - 9 new backend tests (buildModelsUrl, parseModelIdsJson, isProbeModelsPath, handleProbeModels, handleValidate behaviour, handleCreate timestamp) --- src/api/providers.zig | 332 ++++++++++++++++- src/server.zig | 12 + ui/src/lib/api/client.ts | 4 + ui/src/routes/providers/+page.svelte | 510 +++++++++++++++++++++++++++ 4 files changed, 846 insertions(+), 12 deletions(-) diff --git a/src/api/providers.zig b/src/api/providers.zig index 8a7be00..5841fe6 100644 --- a/src/api/providers.zig +++ b/src/api/providers.zig @@ -4,6 +4,7 @@ const state_mod = @import("../core/state.zig"); const paths_mod = @import("../core/paths.zig"); const helpers = @import("helpers.zig"); const wizard_api = @import("wizard.zig"); +const query_mod = @import("query.zig"); const appendEscaped = helpers.appendEscaped; @@ -41,6 +42,12 @@ pub fn hasRevealParam(target: []const u8) bool { return std.mem.indexOf(u8, target[query_start..], "reveal=true") != null; } +/// Check if path matches /api/providers/probe-models +pub fn isProbeModelsPath(target: []const u8) bool { + return std.mem.eql(u8, target, "/api/providers/probe-models") or + std.mem.startsWith(u8, target, "/api/providers/probe-models?"); +} + // ─── Handlers ──────────────────────────────────────────────────────────────── /// GET /api/providers — list all saved providers @@ -109,6 +116,14 @@ pub fn handleCreate( } validated_ok = true; validated_with_buf = try allocator.dupe(u8, component_name); + } else { + // Custom provider: probe the /models endpoint; always save regardless of result. + var models_probe = probeModels(allocator, parsed.value.base_url, parsed.value.api_key); + defer models_probe.deinit(allocator); + validated_ok = models_probe.live_ok; + if (validated_ok) { + validated_with_buf = try allocator.dupe(u8, "models-probe"); + } } const validated_with = validated_with_buf orelse ""; @@ -121,16 +136,23 @@ pub fn handleCreate( .validated_with = validated_with, }); - // Record validation attempt if we validated + // Record validation result + const providers_list = state.savedProviders(); + const new_id = providers_list[providers_list.len - 1].id; 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 if (is_custom) { + // Custom probe ran but failed — record the attempt so the UI shows status + const now = try nowIso8601(allocator); + defer allocator.free(now); + _ = try state.updateSavedProvider(new_id, .{ + .last_validation_at = now, + .last_validation_ok = false, + }); + 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(); @@ -212,12 +234,20 @@ pub fn handleUpdate( .last_validation_ok = true, }); } else { - // Custom provider: update fields directly without probe + // Custom provider: probe /models endpoint; always update regardless of result. + var models_probe = probeModels(allocator, effective_base_url, effective_key); + defer models_probe.deinit(allocator); + const now = try nowIso8601(allocator); + defer allocator.free(now); _ = 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 = if (models_probe.live_ok) now else null, + .validated_with = if (models_probe.live_ok) "models-probe" else null, + .last_validation_at = now, + .last_validation_ok = models_probe.live_ok, }); } } else { @@ -252,11 +282,21 @@ 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. + // Custom providers: validate via the /models endpoint instead of nullclaw probe. if (existing.base_url.len > 0) { - return try allocator.dupe(u8, "{\"live_ok\":false,\"reason\":\"custom endpoint — validation via /models not yet available\"}"); + var models_probe = probeModels(allocator, existing.base_url, existing.api_key); + defer models_probe.deinit(allocator); + + try persistValidationAttempt(allocator, state, id, "models-probe", models_probe.live_ok); + + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + try buf.appendSlice("{\"live_ok\":"); + try buf.appendSlice(if (models_probe.live_ok) "true" else "false"); + try buf.appendSlice(",\"reason\":\""); + try appendEscaped(&buf, models_probe.reason); + try buf.appendSlice("\"}"); + return buf.toOwnedSlice(); } const component_name = findProviderProbeComponent(allocator, state) orelse @@ -284,6 +324,162 @@ pub fn handleValidate( // ─── Helpers ───────────────────────────────────────────────────────────────── +// ── /models probe ────────────────────────────────────────────────────────── + +/// Result of probing an OpenAI-compatible /models endpoint. +const ModelsProbeResult = struct { + live_ok: bool, + /// Static string literal — never allocated, never freed. + reason: []const u8, + /// Owned JSON array string of model IDs, e.g. `["gpt-4","gpt-3.5-turbo"]`. + /// Always valid JSON; `"[]"` when the probe failed or returned no data. + model_ids_json: []u8, + + fn deinit(self: *ModelsProbeResult, allocator: std.mem.Allocator) void { + allocator.free(self.model_ids_json); + } +}; + +/// Build the models URL from a base_url (appends `/models`). +fn buildModelsUrl(allocator: std.mem.Allocator, base_url: []const u8) ![]const u8 { + if (std.mem.endsWith(u8, base_url, "/")) { + return std.fmt.allocPrint(allocator, "{s}models", .{base_url}); + } + return std.fmt.allocPrint(allocator, "{s}/models", .{base_url}); +} + +/// Parse `data[].id` strings from an OpenAI-compatible /models JSON response. +/// Returns a JSON array string like `["gpt-4","llama3"]`. Caller owns the result. +fn parseModelIdsJson(allocator: std.mem.Allocator, body: []const u8) []u8 { + const empty = allocator.dupe(u8, "[]") catch return @constCast("[]"); + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return empty; + defer parsed.deinit(); + + const data = switch (parsed.value) { + .object => |obj| obj.get("data") orelse return empty, + else => return empty, + }; + const items = switch (data) { + .array => |arr| arr.items, + else => return empty, + }; + + var out = std.array_list.Managed(u8).init(allocator); + out.append('[') catch return empty; + var first = true; + for (items) |item| { + const id_val = switch (item) { + .object => |obj| obj.get("id") orelse continue, + else => continue, + }; + const id_str = switch (id_val) { + .string => |s| s, + else => continue, + }; + if (!first) out.append(',') catch break; + first = false; + out.append('"') catch break; + appendEscaped(&out, id_str) catch break; + out.append('"') catch break; + } + out.append(']') catch return empty; + allocator.free(empty); + return out.toOwnedSlice() catch @constCast("[]"); +} + +/// Probe an OpenAI-compatible `/models` endpoint using the given key. +fn probeModels( + allocator: std.mem.Allocator, + base_url: []const u8, + api_key: []const u8, +) ModelsProbeResult { + const empty_models = allocator.dupe(u8, "[]") catch return .{ + .live_ok = false, + .reason = "alloc_failed", + .model_ids_json = @constCast("[]"), + }; + + const url = buildModelsUrl(allocator, base_url) catch return .{ + .live_ok = false, + .reason = "url_build_failed", + .model_ids_json = empty_models, + }; + defer allocator.free(url); + + var client: std.http.Client = .{ .allocator = allocator, .io = std_compat.io() }; + defer client.deinit(); + + var response_body: std.Io.Writer.Allocating = .init(allocator); + defer response_body.deinit(); + + const auth_header_value = std.fmt.allocPrint(allocator, "Bearer {s}", .{api_key}) catch + return .{ .live_ok = false, .reason = "alloc_failed", .model_ids_json = empty_models }; + defer allocator.free(auth_header_value); + + const header_buf = [1]std.http.Header{ + .{ .name = "Authorization", .value = auth_header_value }, + }; + + const result = client.fetch(.{ + .location = .{ .url = url }, + .method = .GET, + .response_writer = &response_body.writer, + .extra_headers = header_buf[0..], + }) catch return .{ .live_ok = false, .reason = "network_error", .model_ids_json = empty_models }; + + const status_code = @intFromEnum(result.status); + if (status_code == 401 or status_code == 403) { + return .{ .live_ok = false, .reason = "auth_failed", .model_ids_json = empty_models }; + } + if (status_code < 200 or status_code >= 300) { + return .{ .live_ok = false, .reason = "http_error", .model_ids_json = empty_models }; + } + + allocator.free(empty_models); + const bytes = response_body.toOwnedSlice() catch return .{ + .live_ok = true, + .reason = "", + .model_ids_json = allocator.dupe(u8, "[]") catch @constCast("[]"), + }; + defer allocator.free(bytes); + + return .{ + .live_ok = true, + .reason = "", + .model_ids_json = parseModelIdsJson(allocator, bytes), + }; +} + +/// GET /api/providers/probe-models?base_url=...&api_key=... +/// Probes an OpenAI-compatible endpoint's /models endpoint and returns the +/// list of available model IDs. Used by the frontend before saving a provider. +pub fn handleProbeModels(allocator: std.mem.Allocator, target: []const u8) ![]const u8 { + const base_url = (try query_mod.valueAlloc(allocator, target, "base_url")) orelse + return try allocator.dupe(u8, "{\"error\":\"base_url is required\"}"); + defer allocator.free(base_url); + + const api_key = (try query_mod.valueAlloc(allocator, target, "api_key")) orelse + return try allocator.dupe(u8, "{\"error\":\"api_key is required\"}"); + defer allocator.free(api_key); + + var probe = probeModels(allocator, base_url, api_key); + defer probe.deinit(allocator); + + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + try buf.appendSlice("{\"live_ok\":"); + try buf.appendSlice(if (probe.live_ok) "true" else "false"); + try buf.appendSlice(",\"reason\":\""); + try appendEscaped(&buf, probe.reason); + try buf.appendSlice("\",\"models\":"); + try buf.appendSlice(probe.model_ids_json); + try buf.append('}'); + return buf.toOwnedSlice(); +} + fn findProviderProbeComponent(allocator: std.mem.Allocator, state: *state_mod.State) ?[]const u8 { const names = state.instanceNames("nullclaw") catch return null; defer if (names) |list| allocator.free(list); @@ -640,7 +836,11 @@ test "handleCreate without base_url requires nullclaw instance" { try std.testing.expectEqual(@as(usize, 0), s.savedProviders().len); } -test "handleValidate for custom provider returns probe-not-applicable message" { +test "handleValidate for custom provider uses models probe (not nullclaw)" { + // Regression: handleValidate for a custom provider must not require a nullclaw + // instance — it uses the /models probe directly. The probe will fail here + // (no server at 5801) but the key point is we get a live_ok + reason response, + // NOT the old "custom endpoint — validation via /models not yet available" placeholder. const allocator = std.testing.allocator; const tmp = "/tmp/nullhub-provider-test-validate-custom"; std_compat.fs.deleteTreeAbsolute(tmp) catch {}; @@ -663,6 +863,114 @@ test "handleValidate for custom provider returns probe-not-applicable message" { const json = try handleValidate(allocator, 1, &s, paths); defer allocator.free(json); + // Must return a probe result (live_ok present), never the old placeholder string. + try std.testing.expect(std.mem.indexOf(u8, json, "\"live_ok\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "not yet available") == null); + // No nullclaw probe: no "Install a nullclaw instance" error expected. + try std.testing.expect(std.mem.indexOf(u8, json, "Install a nullclaw instance") == null); + // Probe should fail (5801 is not running in tests) 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 "buildModelsUrl appends /models with and without trailing slash" { + const allocator = std.testing.allocator; + + const a = try buildModelsUrl(allocator, "https://api.example.com/v1"); + defer allocator.free(a); + try std.testing.expectEqualStrings("https://api.example.com/v1/models", a); + + const b = try buildModelsUrl(allocator, "https://api.example.com/v1/"); + defer allocator.free(b); + try std.testing.expectEqualStrings("https://api.example.com/v1/models", b); +} + +test "parseModelIdsJson extracts data[].id strings" { + const allocator = std.testing.allocator; + const body = + \\{"object":"list","data":[{"id":"gpt-4","object":"model"},{"id":"gpt-3.5-turbo","object":"model"}]} + ; + const result = parseModelIdsJson(allocator, body); + defer allocator.free(result); + try std.testing.expectEqualStrings("[\"gpt-4\",\"gpt-3.5-turbo\"]", result); +} + +test "parseModelIdsJson returns empty array for invalid JSON" { + const allocator = std.testing.allocator; + const result = parseModelIdsJson(allocator, "not json"); + defer allocator.free(result); + try std.testing.expectEqualStrings("[]", result); +} + +test "parseModelIdsJson returns empty array for missing data field" { + const allocator = std.testing.allocator; + const result = parseModelIdsJson(allocator, "{\"object\":\"list\"}"); + defer allocator.free(result); + try std.testing.expectEqualStrings("[]", result); +} + +test "isProbeModelsPath matches correct paths" { + try std.testing.expect(isProbeModelsPath("/api/providers/probe-models")); + try std.testing.expect(isProbeModelsPath("/api/providers/probe-models?base_url=x&api_key=y")); + try std.testing.expect(!isProbeModelsPath("/api/providers/1")); + try std.testing.expect(!isProbeModelsPath("/api/providers")); + try std.testing.expect(!isProbeModelsPath("/api/providers/probe-modelsX")); +} + +test "handleProbeModels returns error when base_url missing" { + const allocator = std.testing.allocator; + const json = try handleProbeModels(allocator, "/api/providers/probe-models?api_key=sk-test"); + 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") != null); +} + +test "handleProbeModels returns error when api_key missing" { + const allocator = std.testing.allocator; + const json = try handleProbeModels(allocator, "/api/providers/probe-models?base_url=http%3A%2F%2F127.0.0.1%3A5801%2Fv1"); + defer allocator.free(json); + try std.testing.expect(std.mem.indexOf(u8, json, "\"error\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "api_key") != null); +} + +test "handleProbeModels returns live_ok false for unreachable endpoint" { + const allocator = std.testing.allocator; + // Port 19999 should not be running anything in CI + const json = try handleProbeModels(allocator, "/api/providers/probe-models?base_url=http%3A%2F%2F127.0.0.1%3A19999%2Fv1&api_key=sk-test"); + 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, "\"models\":[]") != null); +} + +test "handleCreate custom provider records last_validation_at after probe attempt" { + // When a custom provider is created, a /models probe is attempted. Even if it + // fails (no server), last_validation_at must be set in the saved state. + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-provider-test-custom-create-ts"; + 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:19998/v1"} + ; + const json = try handleCreate(allocator, body, &s, paths); + defer allocator.free(json); + + // Must save successfully (no error) + try std.testing.expect(std.mem.indexOf(u8, json, "\"error\"") == null); + try std.testing.expectEqual(@as(usize, 1), s.savedProviders().len); + + // last_validation_at must be set (probe was attempted) + const sp = s.savedProviders()[0]; + try std.testing.expect(sp.last_validation_at.len > 0); + // last_validation_ok must be false (port 19998 not running) + try std.testing.expect(!sp.last_validation_ok); } diff --git a/src/server.zig b/src/server.zig index bc3f506..ce5fd4d 100644 --- a/src/server.zig +++ b/src/server.zig @@ -919,6 +919,18 @@ pub const Server = struct { } return .{ .status = "405 Method Not Allowed", .content_type = "application/json", .body = "{\"error\":\"method not allowed\"}" }; } + // GET /api/providers/probe-models — probe a custom endpoint before saving + if (providers_api.isProbeModelsPath(target)) { + if (std.mem.eql(u8, method, "GET")) { + if (providers_api.handleProbeModels(allocator, target)) |json| { + const status = if (std.mem.indexOf(u8, json, "\"error\"") != null) "400 Bad Request" else "200 OK"; + return .{ .status = status, .content_type = "application/json", .body = json }; + } else |_| { + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + } + } + return .{ .status = "405 Method Not Allowed", .content_type = "application/json", .body = "{\"error\":\"method not allowed\"}" }; + } // Routes with ID: /api/providers/{id} and /api/providers/{id}/validate if (providers_api.extractProviderId(target)) |id| { if (providers_api.isValidatePath(target)) { diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index e58d8ae..a7bd066 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -199,6 +199,10 @@ export const api = { request(`/providers/${id.replace('sp_', '')}`, { method: 'DELETE' }), revalidateSavedProvider: (id: string) => request(`/providers/${id.replace('sp_', '')}/validate`, { method: 'POST' }), + probeProviderModels: (baseUrl: string, apiKey: string) => { + const params = new URLSearchParams({ base_url: baseUrl, api_key: apiKey }); + return request<{ live_ok: boolean; reason: string; models: string[] }>(`/providers/probe-models?${params}`); + }, // Saved channels getSavedChannels: (reveal = false) => diff --git a/ui/src/routes/providers/+page.svelte b/ui/src/routes/providers/+page.svelte index f91a00d..2f739f1 100644 --- a/ui/src/routes/providers/+page.svelte +++ b/ui/src/routes/providers/+page.svelte @@ -32,6 +32,464 @@ let messageTone = $state<"success" | "error">("success"); let messageTimer: ReturnType | null = null; + // Add form state + let showAddForm = $state(false); + let addForm = $state({ provider: "openrouter", provider_name: "", api_key: "", model: "", base_url: "" }); + let addValidating = $state(false); + let addError = $state(""); + let addProbing = $state(false); + let addProbedModels = $state([]); + let addProbeError = $state(""); + + // Edit state + let editingId = $state(null); + let editForm = $state({ name: "", api_key: "", model: "", base_url: "" }); + let editValidating = $state(false); + let editError = $state(""); + let editProbing = $state(false); + let editProbedModels = $state([]); + let editProbeError = $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(() => { + if (messageTimer) clearTimeout(messageTimer); + }); + + function flashMessage(text: string, tone: "success" | "error" = "success", timeoutMs = 3000) { + message = text; + messageTone = tone; + if (messageTimer) clearTimeout(messageTimer); + messageTimer = setTimeout(() => { + message = ""; + messageTimer = null; + }, timeoutMs); + } + + async function loadProviders() { + loading = true; + error = ""; + try { + const data = await api.getSavedProviders(); + providers = data.providers || []; + } catch (e) { + error = (e as Error).message; + } finally { + loading = false; + } + } + + async function fetchAddModels() { + addProbing = true; + addProbeError = ""; + addProbedModels = []; + try { + const result = await api.probeProviderModels(addForm.base_url.trim(), addForm.api_key.trim()); + if (result.live_ok) { + addProbedModels = result.models; + if (!addProbedModels.length) addProbeError = "Connected, but no models returned."; + } else { + addProbeError = result.reason || "Could not reach endpoint."; + } + } catch (e) { + addProbeError = (e as Error).message; + } finally { + addProbing = false; + } + } + + async function fetchEditModels() { + editProbing = true; + editProbeError = ""; + editProbedModels = []; + const key = editForm.api_key.trim() || "(current key)"; + try { + const result = await api.probeProviderModels(editForm.base_url.trim(), editForm.api_key.trim() || key); + if (result.live_ok) { + editProbedModels = result.models; + if (!editProbedModels.length) editProbeError = "Connected, but no models returned."; + } else { + editProbeError = result.reason || "Could not reach endpoint."; + } + } catch (e) { + editProbeError = (e as Error).message; + } finally { + editProbing = false; + } + } + + async function handleAdd() { + 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: providerValue, + api_key: addForm.api_key, + model: addForm.model || undefined, + base_url: addForm.base_url || undefined, + }); + showAddForm = false; + addForm = { provider: "openrouter", provider_name: "", api_key: "", model: "", base_url: "" }; + addProbedModels = []; + addProbeError = ""; + flashMessage("Provider saved"); + await loadProviders(); + } catch (e) { + addError = (e as Error).message; + } finally { + addValidating = false; + } + } + + function startEdit(p: any) { + editingId = p.id; + editForm = { name: p.name, api_key: "", model: p.model, base_url: p.base_url || "" }; + editProbedModels = []; + editProbeError = ""; + } + + function cancelEdit() { + editingId = null; + } + + async function saveEdit(id: string) { + editValidating = true; + editError = ""; + try { + const payload: any = {}; + 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"); + await loadProviders(); + } catch (e) { + editError = (e as Error).message; + await loadProviders(); + } finally { + editValidating = false; + } + } + + async function handleDelete(id: string) { + try { + await api.deleteSavedProvider(id); + flashMessage("Provider deleted"); + await loadProviders(); + } catch (e) { + error = (e as Error).message; + } + } + + async function handleRevalidate(id: string) { + revalidatingId = id; + try { + await api.revalidateSavedProvider(id); + flashMessage("Validation passed", "success", 5000); + } catch (e) { + flashMessage(`Validation failed: ${(e as Error).message}`, "error", 5000); + } finally { + await loadProviders(); + revalidatingId = null; + } + } + + function isLocal(provider: string) { + 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; + } + + function formatDate(iso: string) { + if (!iso) return ""; + try { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", + }); + } catch { return iso; } + } + + function providerIndicatorState(provider: any): "live-ok" | "live-error" | "has-history" | "needs-validation" { + if (provider.last_validation_at) return provider.last_validation_ok ? "live-ok" : "live-error"; + if (provider.validated_at) return "has-history"; + return "needs-validation"; + } + + function lastValidationAt(provider: any) { + return provider.last_validation_at || provider.validated_at || ""; + } + + $effect(() => { + // Clear probed models when the add form's base_url or api_key changes + addForm.base_url; + addForm.api_key; + addProbedModels = []; + addProbeError = ""; + }); + + +
+ + + {#if message} +
{message}
+ {/if} + + {#if error} +
{error}
+ {/if} + + {#if !hasComponents} +
+

Install a component first to add providers.

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

Add Provider

+
+ + +
+ {#if addForm.provider === OPENAI_COMPATIBLE_VALUE} +
+ + +
+
+ + +
+ {/if} + {#if !isLocal(addForm.provider)} +
+ + +
+ {/if} + {#if addForm.provider === OPENAI_COMPATIBLE_VALUE} +
+ +
+ + +
+ {#if addProbeError} +
{addProbeError}
+ {/if} + {#if addProbedModels.length > 0} +
+ {#each addProbedModels as m} + + {/each} +
+ {/if} +
+ {:else} +
+ + +
+ {/if} + {#if addError} +
{addError}
+ {/if} + +
+ {/if} + + {#if loading} +

Loading providers...

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

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

+
+ {:else} +
+ {#each providers as p} +
+ {#if editingId === p.id} +
+
+ + +
+ {#if isOpenAiCompatible(p)} +
+ + +
+ {/if} + {#if !isLocal(p.provider)} +
+ + +
+ {/if} +
+ + {#if isOpenAiCompatible(p)} +
+ + +
+ {#if editProbeError} +
{editProbeError}
+ {/if} + {#if editProbedModels.length > 0} +
+ {#each editProbedModels as m} + + {/each} +
+ {/if} + {:else} + + {/if} +
+ {#if editError} +
{editError}
+ {/if} +
+ + +
+
+ {:else} + {@const indicator = providerIndicatorState(p)} +
+
+ +

{p.name}

+
+ {getProviderLabel(p.provider)} +
+
+
+ API Key + {p.api_key} +
+ {#if p.base_url} +
+ Base URL + {p.base_url} +
+ {/if} +
+ Model + {p.model || "No default model"} +
+ {#if p.validated_at} +
+ Last Successful Validation + {formatDate(p.validated_at)} +
+ {/if} +
+ Last Validation + {formatDate(lastValidationAt(p)) || "Never"} +
+ {#if !lastValidationAt(p)} +
Not validated yet. Use Re-validate to run a live auth check.
+ {/if} +
+
+ + + +
+ {/if} +
+ {/each} +
+ {/if} +
+ + let providers = $state([]); + let loading = $state(true); + let error = $state(""); + let message = $state(""); + let messageTone = $state<"success" | "error">("success"); + let messageTimer: ReturnType | null = null; + // Add form state let showAddForm = $state(false); let addForm = $state({ provider: "openrouter", provider_name: "", api_key: "", model: "", base_url: "" }); @@ -700,4 +1158,56 @@ .edit-form { padding: 0.5rem 0; } + + .model-input-row { + display: flex; + gap: 0.5rem; + align-items: stretch; + } + + .model-input-row input { + flex: 1; + } + + .fetch-models-btn { + flex-shrink: 0; + white-space: nowrap; + } + + .model-list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.5rem; + } + + .model-chip { + padding: 0.25rem 0.625rem; + background: var(--bg-surface); + color: var(--fg-dim); + border: 1px solid var(--border); + border-radius: 2px; + font-size: 0.75rem; + font-family: var(--font-mono); + cursor: pointer; + transition: all 0.15s ease; + } + + .model-chip:hover { + border-color: var(--accent-dim); + color: var(--fg); + } + + .model-chip.selected { + background: color-mix(in srgb, var(--accent) 15%, transparent); + border-color: var(--accent); + color: var(--accent); + text-shadow: var(--text-glow); + } + + .probe-error { + margin-top: 0.375rem; + font-size: 0.75rem; + color: var(--error, #e55); + } From 832743c617e4cf69c51c75a9c3c5d16fd76d536b Mon Sep 17 00:00:00 2001 From: Vernon Stinebaker Date: Mon, 4 May 2026 20:21:43 +0800 Subject: [PATCH 02/15] fix(providers): sanitize test data and fix state.zig load() dropping base_url - Replace all real provider names/URLs/ports in test blocks with generic values (custom-llm, https://example.com/v1, sk-test-key, port 19999) - Fix State.load() silently dropping base_url on round-trip: the struct literal in the saved_providers loop was missing .base_url = owned_base_url - Remove dangling orphan load-block left by a partial test edit - UI, syncProviderToInstances, and /models probe changes from prior work included in this commit (were already staged but uncommitted) --- src/api/providers.zig | 232 ++++++++++++++++- src/core/state.zig | 88 ++++--- ui/src/routes/providers/+page.svelte | 366 ++------------------------- 3 files changed, 286 insertions(+), 400 deletions(-) diff --git a/src/api/providers.zig b/src/api/providers.zig index 5841fe6..91137a4 100644 --- a/src/api/providers.zig +++ b/src/api/providers.zig @@ -152,6 +152,10 @@ pub fn handleCreate( try state.save(); } + // Sync credentials to all live nullclaw instances + const sp_for_sync = state.getSavedProvider(new_id).?; + syncProviderToInstances(allocator, state, paths, sp_for_sync.provider, sp_for_sync.api_key, sp_for_sync.base_url); + // Return the saved provider const sp = state.getSavedProvider(new_id).?; var buf = std.array_list.Managed(u8).init(allocator); @@ -258,6 +262,8 @@ pub fn handleUpdate( try state.save(); const sp = state.getSavedProvider(id).?; + syncProviderToInstances(allocator, state, paths, sp.provider, sp.api_key, sp.base_url); + var buf = std.array_list.Managed(u8).init(allocator); errdefer buf.deinit(); try appendProviderJson(&buf, sp, true); @@ -480,6 +486,101 @@ pub fn handleProbeModels(allocator: std.mem.Allocator, target: []const u8) ![]co return buf.toOwnedSlice(); } +// ─── Instance Config Sync ──────────────────────────────────────────────────── + +/// Sync provider credentials (api_key + base_url) into every registered +/// nullclaw instance's config.json. Best-effort: per-instance errors are +/// silently swallowed so a corrupt config on one instance doesn't block others. +fn syncProviderToInstances( + allocator: std.mem.Allocator, + state: *state_mod.State, + paths: paths_mod.Paths, + provider: []const u8, + api_key: []const u8, + base_url: []const u8, +) void { + const names = state.instanceNames("nullclaw") catch return; + defer if (names) |list| allocator.free(list); + const list = names orelse return; + for (list) |name| { + syncProviderToInstance(allocator, paths, name, provider, api_key, base_url) catch {}; + } +} + +fn syncProviderToInstance( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + instance_name: []const u8, + provider: []const u8, + api_key: []const u8, + base_url: []const u8, +) !void { + const config_path = try paths.instanceConfig(allocator, "nullclaw", instance_name); + defer allocator.free(config_path); + + // Read existing config or fall back to empty object if the file is missing. + const contents = blk: { + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch |err| switch (err) { + error.FileNotFound => break :blk try allocator.dupe(u8, "{}"), + else => return err, + }; + defer file.close(); + break :blk try file.readToEndAlloc(allocator, 8 * 1024 * 1024); + }; + defer allocator.free(contents); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, contents, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + const ja = parsed.arena.allocator(); + + if (parsed.value != .object) return error.InvalidConfig; + const root = &parsed.value.object; + + // Navigate/create: root → models → providers → + const models_obj = try ensureObjectInMap(ja, root, "models"); + const providers_obj = try ensureObjectInMap(ja, models_obj, "providers"); + const provider_obj = try ensureObjectInMap(ja, providers_obj, provider); + + // Set api_key (string bytes are state-owned, outlive the arena) + try provider_obj.put(ja, "api_key", .{ .string = api_key }); + + // Set base_url only when present (mirrors writeMinimalProviderConfig behaviour) + if (base_url.len > 0) { + try provider_obj.put(ja, "base_url", .{ .string = base_url }); + } + + // Serialize and write back + const rendered = try std.json.Stringify.valueAlloc(allocator, parsed.value, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); + defer allocator.free(rendered); + + const out = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer out.close(); + try out.writeAll(rendered); + try out.writeAll("\n"); +} + +fn ensureObjectInMap( + allocator: std.mem.Allocator, + obj: *std.json.ObjectMap, + key: []const u8, +) !*std.json.ObjectMap { + const gop = try obj.getOrPut(allocator, key); + if (!gop.found_existing) { + gop.value_ptr.* = .{ .object = .empty }; + return &gop.value_ptr.object; + } + if (gop.value_ptr.* != .object) { + gop.value_ptr.* = .{ .object = .empty }; + } + return &gop.value_ptr.object; +} + fn findProviderProbeComponent(allocator: std.mem.Allocator, state: *state_mod.State) ?[]const u8 { const names = state.instanceNames("nullclaw") catch return null; defer if (names) |list| allocator.free(list); @@ -668,16 +769,16 @@ test "handleList includes base_url for openai-compatible provider" { 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", + .provider = "custom-llm", + .api_key = "sk-test-key", + .model = "test-model", + .base_url = "https://example.com/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); + try std.testing.expect(std.mem.indexOf(u8, json, "\"base_url\":\"https://example.com/v1\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"provider\":\"custom-llm\"") != null); } test "handleList includes empty base_url for standard provider" { @@ -797,13 +898,13 @@ test "handleCreate with base_url saves without requiring nullclaw probe" { 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"} + \\{"provider":"local-llm","api_key":"sk-test","model":"llama3","base_url":"http://127.0.0.1:19999/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, "\"base_url\":\"http://127.0.0.1:19999/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); } @@ -839,7 +940,7 @@ test "handleCreate without base_url requires nullclaw instance" { test "handleValidate for custom provider uses models probe (not nullclaw)" { // Regression: handleValidate for a custom provider must not require a nullclaw // instance — it uses the /models probe directly. The probe will fail here - // (no server at 5801) but the key point is we get a live_ok + reason response, + // (no server at 19999) but the key point is we get a live_ok + reason response, // NOT the old "custom endpoint — validation via /models not yet available" placeholder. const allocator = std.testing.allocator; const tmp = "/tmp/nullhub-provider-test-validate-custom"; @@ -856,7 +957,7 @@ test "handleValidate for custom provider uses models probe (not nullclaw)" { try s.addSavedProvider(.{ .provider = "local-llm", .api_key = "sk-test", - .base_url = "http://127.0.0.1:5801/v1", + .base_url = "http://127.0.0.1:19999/v1", }); const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); @@ -868,7 +969,7 @@ test "handleValidate for custom provider uses models probe (not nullclaw)" { try std.testing.expect(std.mem.indexOf(u8, json, "not yet available") == null); // No nullclaw probe: no "Install a nullclaw instance" error expected. try std.testing.expect(std.mem.indexOf(u8, json, "Install a nullclaw instance") == null); - // Probe should fail (5801 is not running in tests) + // Probe should fail (19999 is not running in tests) try std.testing.expect(std.mem.indexOf(u8, json, "\"live_ok\":false") != null); } @@ -926,7 +1027,7 @@ test "handleProbeModels returns error when base_url missing" { test "handleProbeModels returns error when api_key missing" { const allocator = std.testing.allocator; - const json = try handleProbeModels(allocator, "/api/providers/probe-models?base_url=http%3A%2F%2F127.0.0.1%3A5801%2Fv1"); + const json = try handleProbeModels(allocator, "/api/providers/probe-models?base_url=http%3A%2F%2F127.0.0.1%3A19999%2Fv1"); defer allocator.free(json); try std.testing.expect(std.mem.indexOf(u8, json, "\"error\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "api_key") != null); @@ -974,3 +1075,110 @@ test "handleCreate custom provider records last_validation_at after probe attemp // last_validation_ok must be false (port 19998 not running) try std.testing.expect(!sp.last_validation_ok); } + +// ─── syncProviderToInstances tests ─────────────────────────────────────────── + +fn makeInstanceDir(tmp: []const u8) !void { + var buf: [512]u8 = undefined; + const instances = try std.fmt.bufPrint(&buf, "{s}/instances", .{tmp}); + std_compat.fs.makeDirAbsolute(instances) catch |e| if (e != error.PathAlreadyExists) return e; + const nullclaw = try std.fmt.bufPrint(&buf, "{s}/instances/nullclaw", .{tmp}); + std_compat.fs.makeDirAbsolute(nullclaw) catch |e| if (e != error.PathAlreadyExists) return e; + const default = try std.fmt.bufPrint(&buf, "{s}/instances/nullclaw/default", .{tmp}); + std_compat.fs.makeDirAbsolute(default) catch |e| if (e != error.PathAlreadyExists) return e; +} + +test "syncProviderToInstances writes provider creds into instance config" { + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-sync-test-write"; + 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.addInstance("nullclaw", "default", .{ .version = "v2026.1.0" }); + + try makeInstanceDir(tmp); + + // Write an existing config with an unrelated key + const config_path = try std.fmt.allocPrint(allocator, "{s}/instances/nullclaw/default/config.json", .{tmp}); + defer allocator.free(config_path); + { + const f = try std_compat.fs.createFileAbsolute(config_path, .{}); + defer f.close(); + try f.writeAll("{\"port\":9100}\n"); + } + + const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); + syncProviderToInstances(allocator, &s, paths, "custom-llm", "sk-abc123", "https://example.com/v1"); + + // Read back and verify credentials are present + const f2 = try std_compat.fs.openFileAbsolute(config_path, .{}); + defer f2.close(); + const result = try f2.readToEndAlloc(allocator, 64 * 1024); + defer allocator.free(result); + + try std.testing.expect(std.mem.indexOf(u8, result, "\"custom-llm\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "\"sk-abc123\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "\"https://example.com/v1\"") != null); + // Existing key must not be clobbered + try std.testing.expect(std.mem.indexOf(u8, result, "\"port\"") != null); +} + +test "syncProviderToInstances omits base_url when empty" { + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-sync-test-no-baseurl"; + 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.addInstance("nullclaw", "default", .{ .version = "v2026.1.0" }); + + try makeInstanceDir(tmp); + + const config_path = try std.fmt.allocPrint(allocator, "{s}/instances/nullclaw/default/config.json", .{tmp}); + defer allocator.free(config_path); + { + const f = try std_compat.fs.createFileAbsolute(config_path, .{}); + defer f.close(); + try f.writeAll("{}\n"); + } + + const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); + syncProviderToInstances(allocator, &s, paths, "openrouter", "sk-or-key", ""); + + const f2 = try std_compat.fs.openFileAbsolute(config_path, .{}); + defer f2.close(); + const result = try f2.readToEndAlloc(allocator, 64 * 1024); + defer allocator.free(result); + + try std.testing.expect(std.mem.indexOf(u8, result, "\"openrouter\"") != null); + try std.testing.expect(std.mem.indexOf(u8, result, "\"sk-or-key\"") != null); + // base_url must not appear when empty + try std.testing.expect(std.mem.indexOf(u8, result, "\"base_url\"") == null); +} + +test "syncProviderToInstances is no-op when no nullclaw instances" { + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-sync-test-noop"; + 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 instances registered + + const paths = paths_mod.Paths.init(allocator, tmp) catch @panic("Paths.init"); + // Should not panic or error when there are no instances + syncProviderToInstances(allocator, &s, paths, "openrouter", "sk-key", ""); +} diff --git a/src/core/state.zig b/src/core/state.zig index bb77e69..20a3c77 100644 --- a/src/core/state.zig +++ b/src/core/state.zig @@ -323,6 +323,8 @@ pub const State = struct { errdefer if (owned_validated_with.len > 0) allocator.free(@constCast(owned_validated_with)); const owned_last_validation_at = if (sp.last_validation_at.len > 0) try allocator.dupe(u8, sp.last_validation_at) else @as([]const u8, ""); errdefer if (owned_last_validation_at.len > 0) allocator.free(@constCast(owned_last_validation_at)); + 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)); try state.saved_providers.append(.{ .id = sp.id, @@ -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, @@ -805,6 +808,7 @@ pub const State = struct { for (self.saved_channels.items) |sc| { if (std.mem.eql(u8, sc.channel_type, channel_type)) count += 1; } + if (count == 0) return std.fmt.allocPrint(self.allocator, "{s}", .{label}); return std.fmt.allocPrint(self.allocator, "{s} #{d}", .{ label, count + 1 }); } @@ -822,6 +826,10 @@ pub const State = struct { for (self.saved_providers.items) |sp| { if (std.mem.eql(u8, sp.provider, provider)) count += 1; } + // Only append a numeric suffix when there is already at least one + // provider of this type — avoids the awkward "My Provider #1" for + // the common single-instance case. + if (count == 0) return std.fmt.allocPrint(self.allocator, "{s}", .{label}); return std.fmt.allocPrint(self.allocator, "{s} #{d}", .{ label, count + 1 }); } }; @@ -1177,7 +1185,7 @@ test "add saved provider, save, load, verify round-trip" { try std.testing.expectEqualStrings("openrouter", providers[0].provider); try std.testing.expectEqualStrings("sk-or-xxx", providers[0].api_key); try std.testing.expectEqualStrings("anthropic/claude-sonnet-4", providers[0].model); - try std.testing.expectEqualStrings("OpenRouter #1", providers[0].name); + try std.testing.expectEqualStrings("OpenRouter", providers[0].name); try std.testing.expectEqual(@as(u32, 1), providers[0].id); try s.save(); @@ -1191,7 +1199,7 @@ test "add saved provider, save, load, verify round-trip" { try std.testing.expectEqual(@as(usize, 1), providers.len); try std.testing.expectEqualStrings("openrouter", providers[0].provider); try std.testing.expectEqualStrings("sk-or-xxx", providers[0].api_key); - try std.testing.expectEqualStrings("OpenRouter #1", providers[0].name); + try std.testing.expectEqualStrings("OpenRouter", providers[0].name); try std.testing.expectEqual(@as(u32, 1), providers[0].id); } } @@ -1211,9 +1219,9 @@ test "auto-generated name increments per provider type" { const providers = s.savedProviders(); try std.testing.expectEqual(@as(usize, 3), providers.len); - try std.testing.expectEqualStrings("OpenRouter #1", providers[0].name); + try std.testing.expectEqualStrings("OpenRouter", providers[0].name); try std.testing.expectEqualStrings("OpenRouter #2", providers[1].name); - try std.testing.expectEqualStrings("Anthropic #1", providers[2].name); + try std.testing.expectEqualStrings("Anthropic", providers[2].name); } test "update saved provider name only" { @@ -1403,19 +1411,19 @@ test "add saved provider with base_url, save, load, verify round-trip" { 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", + .provider = "custom-llm", + .api_key = "sk-test-key", + .model = "test-model", + .base_url = "https://example.com/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 std.testing.expectEqualStrings("custom-llm", providers[0].provider); + try std.testing.expectEqualStrings("sk-test-key", providers[0].api_key); + try std.testing.expectEqualStrings("test-model", providers[0].model); + try std.testing.expectEqualStrings("https://example.com/v1", providers[0].base_url); + try std.testing.expectEqualStrings("custom-llm #1", providers[0].name); try s.save(); } @@ -1426,11 +1434,11 @@ test "add saved provider with base_url, save, load, verify round-trip" { 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 std.testing.expectEqualStrings("custom-llm", providers[0].provider); + try std.testing.expectEqualStrings("sk-test-key", providers[0].api_key); + try std.testing.expectEqualStrings("test-model", providers[0].model); + try std.testing.expectEqualStrings("https://example.com/v1", providers[0].base_url); + try std.testing.expectEqualStrings("custom-llm #1", providers[0].name); } } @@ -1444,7 +1452,7 @@ test "update saved provider base_url" { defer s.deinit(); try s.addSavedProvider(.{ - .provider = "infini-ai", + .provider = "custom-llm", .api_key = "key1", .base_url = "https://old.example.com/v1", }); @@ -1467,9 +1475,9 @@ test "update saved provider clears base_url" { defer s.deinit(); try s.addSavedProvider(.{ - .provider = "infini-ai", + .provider = "custom-llm", .api_key = "key1", - .base_url = "https://cloud.infini-ai.com/v1", + .base_url = "https://example.com/v1", }); const updated = try s.updateSavedProvider(1, .{ .base_url = "" }); try std.testing.expect(updated); @@ -1488,33 +1496,33 @@ test "multiple openai-compatible providers with different names" { defer s.deinit(); try s.addSavedProvider(.{ - .provider = "infini-ai", + .provider = "custom-llm", .api_key = "key1", - .model = "minimax-m2.7", - .base_url = "https://cloud.infini-ai.com/maas/coding/v1", + .model = "test-model", + .base_url = "https://example.com/v1", }); try s.addSavedProvider(.{ - .provider = "infini-ai", + .provider = "custom-llm", .api_key = "key1", .model = "deepseek-v3", - .base_url = "https://cloud.infini-ai.com/maas/coding/v1", + .base_url = "https://example.com/v1", }); try s.addSavedProvider(.{ - .provider = "xiaomi-mimo", + .provider = "another-llm", .api_key = "key2", - .model = "mimo-7b", - .base_url = "https://api.xiaomi.com/v1", + .model = "another-model", + .base_url = "https://other.example.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); + try std.testing.expectEqualStrings("custom-llm", providers[0].name); + try std.testing.expectEqualStrings("custom-llm #2", providers[1].name); + try std.testing.expectEqualStrings("another-llm", providers[2].name); + try std.testing.expectEqualStrings("custom-llm", providers[0].provider); + try std.testing.expectEqualStrings("another-llm", providers[2].provider); + try std.testing.expectEqualStrings("https://example.com/v1", providers[0].base_url); + try std.testing.expectEqualStrings("https://other.example.com/v1", providers[2].base_url); } test "openai-compatible provider base_url defaults to empty" { @@ -1556,7 +1564,7 @@ test "add saved channel, save, load, verify round-trip" { try std.testing.expectEqualStrings("telegram", channels[0].channel_type); try std.testing.expectEqualStrings("@mybot", channels[0].account); try std.testing.expectEqualStrings("{\"token\":\"abc\"}", channels[0].config); - try std.testing.expectEqualStrings("Telegram #1", channels[0].name); + try std.testing.expectEqualStrings("Telegram", channels[0].name); try std.testing.expectEqual(@as(u32, 1), channels[0].id); try s.save(); @@ -1570,7 +1578,7 @@ test "add saved channel, save, load, verify round-trip" { try std.testing.expectEqual(@as(usize, 1), channels.len); try std.testing.expectEqualStrings("telegram", channels[0].channel_type); try std.testing.expectEqualStrings("@mybot", channels[0].account); - try std.testing.expectEqualStrings("Telegram #1", channels[0].name); + try std.testing.expectEqualStrings("Telegram", channels[0].name); try std.testing.expectEqual(@as(u32, 1), channels[0].id); } } @@ -1590,9 +1598,9 @@ test "channel auto-generated name increments per type" { const channels = s.savedChannels(); try std.testing.expectEqual(@as(usize, 3), channels.len); - try std.testing.expectEqualStrings("Telegram #1", channels[0].name); + try std.testing.expectEqualStrings("Telegram", channels[0].name); try std.testing.expectEqualStrings("Telegram #2", channels[1].name); - try std.testing.expectEqualStrings("Discord #1", channels[2].name); + try std.testing.expectEqualStrings("Discord", channels[2].name); } test "update saved channel name only" { diff --git a/ui/src/routes/providers/+page.svelte b/ui/src/routes/providers/+page.svelte index 2f739f1..905922b 100644 --- a/ui/src/routes/providers/+page.svelte +++ b/ui/src/routes/providers/+page.svelte @@ -24,6 +24,9 @@ ]; const LOCAL_PROVIDERS = ["ollama", "lm-studio", "claude-cli", "codex-cli", "openai-codex"]; const OPENAI_COMPATIBLE_VALUE = "openai-compatible"; + const KNOWN_PROVIDER_VALUES = new Set( + PROVIDER_OPTIONS.filter(o => o.value !== OPENAI_COMPATIBLE_VALUE).map(o => o.value) + ); let providers = $state([]); let loading = $state(true); @@ -44,6 +47,7 @@ // Edit state let editingId = $state(null); let editForm = $state({ name: "", api_key: "", model: "", base_url: "" }); + let editRealApiKey = $state(""); // revealed key fetched on edit open; used by Fetch Models when form field is blank let editValidating = $state(false); let editError = $state(""); let editProbing = $state(false); @@ -113,9 +117,9 @@ editProbing = true; editProbeError = ""; editProbedModels = []; - const key = editForm.api_key.trim() || "(current key)"; try { - const result = await api.probeProviderModels(editForm.base_url.trim(), editForm.api_key.trim() || key); + const keyToUse = editForm.api_key.trim() || editRealApiKey; + const result = await api.probeProviderModels(editForm.base_url.trim(), keyToUse); if (result.live_ok) { editProbedModels = result.models; if (!editProbedModels.length) editProbeError = "Connected, but no models returned."; @@ -168,8 +172,14 @@ function startEdit(p: any) { editingId = p.id; editForm = { name: p.name, api_key: "", model: p.model, base_url: p.base_url || "" }; + editRealApiKey = ""; editProbedModels = []; editProbeError = ""; + // Fetch the real (revealed) key so Fetch Models works without the user re-entering the key + api.getSavedProviders(true).then(data => { + const found = (data.providers || []).find((x: any) => x.id === p.id); + if (found) editRealApiKey = found.api_key || ""; + }).catch(() => {}); } function cancelEdit() { @@ -224,8 +234,10 @@ return LOCAL_PROVIDERS.includes(provider); } - function isOpenAiCompatible(p: any) { - return p.base_url && p.base_url.length > 0; + // A provider is "custom" if its type is not one of the built-in nullclaw-known providers. + // This determines whether the base_url / Fetch Models fields appear in edit form. + function isCustomProvider(p: any) { + return !KNOWN_PROVIDER_VALUES.has(p.provider); } function getProviderLabel(value: string) { @@ -371,7 +383,7 @@ - {#if isOpenAiCompatible(p)} + {#if isCustomProvider(p)}
@@ -385,7 +397,7 @@ {/if}
- {#if isOpenAiCompatible(p)} + {#if isCustomProvider(p)}
- {/if} -
- - {#if message} -
{message}
- {/if} - - {#if error} -
{error}
- {/if} - - {#if !hasComponents} -
-

Install a component first to add providers.

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

Add Provider

-
- - -
- {#if addForm.provider === OPENAI_COMPATIBLE_VALUE} -
- - -
-
- - -
- {/if} - {#if !isLocal(addForm.provider)} -
- - -
- {/if} -
- - -
- {#if addError} -
{addError}
- {/if} - -
- {/if} - - {#if loading} -

Loading providers...

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

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

-
- {:else} -
- {#each providers as p} -
- {#if editingId === p.id} -
-
- - -
- {#if isOpenAiCompatible(p)} -
- - -
- {/if} - {#if !isLocal(p.provider)} -
- - -
- {/if} -
- - -
- {#if editError} -
{editError}
- {/if} -
- - -
-
- {:else} - {@const indicator = providerIndicatorState(p)} -
-
- -

{p.name}

-
- {getProviderLabel(p.provider)} -
-
-
- API Key - {p.api_key} -
- {#if p.base_url} -
- Base URL - {p.base_url} -
- {/if} -
- Model - {p.model || "No default model"} -
- {#if p.validated_at} -
- Last Successful Validation - {formatDate(p.validated_at)} -
- {/if} -
- Last Validation - {formatDate(lastValidationAt(p)) || "Never"} -
- {#if !lastValidationAt(p)} -
Not validated yet. Use Re-validate to run a live auth check.
- {/if} -
-
- - - -
- {/if} -
- {/each} -
- {/if} -
-