diff --git a/src/api/instances.zig b/src/api/instances.zig index dba6e2b..680fed5 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -671,9 +671,9 @@ fn probeProviderViaComponentHealth( model: []const u8, ) ProviderProbeResult { const args: []const []const u8 = if (model.len > 0) - &.{ "--probe-provider-health", "--provider", provider, "--model", model, "--timeout-secs", "10" } + &.{ "--probe-provider-health", "--provider", provider, "--model", model, "--timeout-secs", "30" } else - &.{ "--probe-provider-health", "--provider", provider, "--timeout-secs", "10" }; + &.{ "--probe-provider-health", "--provider", provider, "--timeout-secs", "30" }; const result = component_cli.runWithComponentHome( allocator, component, diff --git a/src/api/providers.zig b/src/api/providers.zig index 184669f..b814cfd 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 @@ -78,10 +85,8 @@ pub fn handleCreate( }) catch return try allocator.dupe(u8, "{\"error\":\"invalid JSON body\"}"); defer parsed.deinit(); - // 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). + // Custom providers (base_url set) use the OpenAI-compatible /models probe: + // the nullclaw probe only understands known provider names. const is_custom = parsed.value.base_url.len > 0; var validated_ok = false; var validated_with_buf: ?[]const u8 = null; @@ -109,6 +114,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,18 +134,29 @@ 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(); } + // 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 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(); @@ -214,17 +238,20 @@ pub fn handleUpdate( .last_validation_ok = true, }); } else { - // Custom provider: update fields directly without probe and clear - // stale probe metadata from any previous standard-provider state. + // 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 = "", - .validated_with = "", - .last_validation_at = "", - .last_validation_ok = false, + .validated_at = if (models_probe.live_ok) now else "", + .validated_with = if (models_probe.live_ok) "models-probe" else "", + .last_validation_at = now, + .last_validation_ok = models_probe.live_ok, }); } } else { @@ -235,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); @@ -259,11 +288,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 @@ -291,6 +330,302 @@ 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 { + if (self.model_ids_json.len > 0) 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}); +} + +fn emptyModelIdsJson(allocator: std.mem.Allocator) ![]u8 { + return allocator.dupe(u8, "[]"); +} + +/// 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 parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return emptyModelIdsJson(allocator); + defer parsed.deinit(); + + const data = switch (parsed.value) { + .object => |obj| obj.get("data") orelse return emptyModelIdsJson(allocator), + else => return emptyModelIdsJson(allocator), + }; + const items = switch (data) { + .array => |arr| arr.items, + else => return emptyModelIdsJson(allocator), + }; + + var out = std.array_list.Managed(u8).init(allocator); + errdefer out.deinit(); + try out.append('['); + 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) try out.append(','); + first = false; + try out.append('"'); + try appendEscaped(&out, id_str); + try out.append('"'); + } + try out.append(']'); + return out.toOwnedSlice(); +} + +/// 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 = emptyModelIdsJson(allocator) catch return .{ + .live_ok = false, + .reason = "alloc_failed", + .model_ids_json = &.{}, + }; + + 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(); + + var auth_header_value: ?[]u8 = null; + defer if (auth_header_value) |value| allocator.free(value); + var header_buf: [1]std.http.Header = undefined; + const extra_headers: []const std.http.Header = if (api_key.len > 0) blk: { + const value = std.fmt.allocPrint(allocator, "Bearer {s}", .{api_key}) catch + return .{ .live_ok = false, .reason = "alloc_failed", .model_ids_json = empty_models }; + auth_header_value = value; + header_buf[0] = .{ .name = "Authorization", .value = value }; + break :blk header_buf[0..]; + } else &.{}; + + const result = client.fetch(.{ + .location = .{ .url = url }, + .method = .GET, + .response_writer = &response_body.writer, + .extra_headers = extra_headers, + }) 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 }; + } + + const bytes = response_body.toOwnedSlice() catch return .{ + .live_ok = true, + .reason = "", + .model_ids_json = empty_models, + }; + defer allocator.free(bytes); + + const model_ids_json = parseModelIdsJson(allocator, bytes) catch return .{ + .live_ok = true, + .reason = "", + .model_ids_json = empty_models, + }; + allocator.free(empty_models); + + return .{ + .live_ok = true, + .reason = "", + .model_ids_json = model_ids_json, + }; +} + +fn handleProbeModelsFromValues( + allocator: std.mem.Allocator, + base_url: []const u8, + api_key: []const u8, +) ![]const u8 { + 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\":"); + if (probe.model_ids_json.len > 0) { + try buf.appendSlice(probe.model_ids_json); + } else { + try buf.appendSlice("[]"); + } + try buf.append('}'); + return buf.toOwnedSlice(); +} + +/// 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 + try allocator.dupe(u8, ""); + defer allocator.free(api_key); + + return handleProbeModelsFromValues(allocator, base_url, api_key); +} + +/// POST /api/providers/probe-models +/// Body: {"base_url":"...","api_key":"..."}; api_key may be empty for local endpoints. +pub fn handleProbeModelsBody(allocator: std.mem.Allocator, body: []const u8) ![]const u8 { + const parsed = std.json.parseFromSlice(struct { + base_url: []const u8, + api_key: []const u8 = "", + }, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return try allocator.dupe(u8, "{\"error\":\"invalid JSON body\"}"); + defer parsed.deinit(); + + if (parsed.value.base_url.len == 0) { + return try allocator.dupe(u8, "{\"error\":\"base_url is required\"}"); + } + + return handleProbeModelsFromValues(allocator, parsed.value.base_url, parsed.value.api_key); +} + +// ─── 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 }); + } else { + _ = provider_obj.orderedRemove("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); @@ -479,16 +814,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" { @@ -608,13 +943,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); } @@ -680,7 +1015,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 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"; std_compat.fs.deleteTreeAbsolute(tmp) catch {}; @@ -696,15 +1035,275 @@ test "handleValidate for custom provider returns probe-not-applicable message" { 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"); 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 (19999 is not running in tests) + try std.testing.expect(std.mem.indexOf(u8, json, "\"live_ok\":false") != 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 = try 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 = try 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 = try 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 allows missing api_key for local endpoints" { + const allocator = std.testing.allocator; + 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, "\"live_ok\":false") != null); - try std.testing.expect(std.mem.indexOf(u8, json, "custom endpoint") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"models\":[]") != null); +} + +test "handleProbeModelsBody returns error when base_url missing" { + const allocator = std.testing.allocator; + const json = try handleProbeModelsBody(allocator, "{\"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 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); +} + +// ─── 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 removes stale base_url when empty" { + const allocator = std.testing.allocator; + const tmp = "/tmp/nullhub-sync-test-clear-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("{\"models\":{\"providers\":{\"openrouter\":{\"api_key\":\"old\",\"base_url\":\"https://old.example.com/v1\"}}}}\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); + 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", ""); } test "handleUpdate custom provider clears stale validation metadata" { @@ -740,6 +1339,7 @@ test "handleUpdate custom provider clears stale validation metadata" { 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_at.len > 0); + try std.testing.expect(!std.mem.eql(u8, "2026-03-14T11:22:33Z", 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 b90de25..ceabc32 100644 --- a/src/api/wizard.zig +++ b/src/api/wizard.zig @@ -783,9 +783,9 @@ fn probeProviderViaComponentBinary( model: []const u8, ) ProviderProbeResult { const args: []const []const u8 = if (model.len > 0) - &.{ "--probe-provider-health", "--provider", provider, "--model", model, "--timeout-secs", "10" } + &.{ "--probe-provider-health", "--provider", provider, "--model", model, "--timeout-secs", "30" } else - &.{ "--probe-provider-health", "--provider", provider, "--timeout-secs", "10" }; + &.{ "--probe-provider-health", "--provider", provider, "--timeout-secs", "30" }; const result = component_cli.runWithComponentHome( allocator, diff --git a/src/core/state.zig b/src/core/state.zig index 1347fc5..9ffce8f 100644 --- a/src/core/state.zig +++ b/src/core/state.zig @@ -810,6 +810,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 }); } @@ -827,6 +828,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 }); } }; @@ -1182,7 +1187,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(); @@ -1196,7 +1201,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); } } @@ -1216,9 +1221,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" { @@ -1408,19 +1413,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(); } @@ -1431,11 +1436,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); } } @@ -1449,7 +1454,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", }); @@ -1472,9 +1477,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); @@ -1493,33 +1498,60 @@ 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 "find saved provider distinguishes 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 = "custom-llm", + .api_key = "key1", + .model = "test-model", + .base_url = "https://one.example.com/v1", + }); + try s.addSavedProvider(.{ + .provider = "custom-llm", + .api_key = "key1", + .model = "test-model", + .base_url = "https://two.example.com/v1", + }); + + try std.testing.expectEqual(@as(?u32, 1), s.findSavedProviderId("custom-llm", "key1", "test-model", "https://one.example.com/v1")); + try std.testing.expectEqual(@as(?u32, 2), s.findSavedProviderId("custom-llm", "key1", "test-model", "https://two.example.com/v1")); + try std.testing.expectEqual(@as(?u32, null), s.findSavedProviderId("custom-llm", "key1", "test-model", "https://three.example.com/v1")); } test "openai-compatible provider base_url defaults to empty" { @@ -1561,7 +1593,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(); @@ -1575,7 +1607,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); } } @@ -1595,9 +1627,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/src/installer/orchestrator.zig b/src/installer/orchestrator.zig index ac1b190..d4fa556 100644 --- a/src/installer/orchestrator.zig +++ b/src/installer/orchestrator.zig @@ -207,8 +207,18 @@ pub fn install( // 5. Run --from-json to generate config (component owns its config generation) // Inject the resolved port and instance home so generated configs align with supervisor state. - const answers_with_port = injectPortFields(allocator, opts.answers_json, port, managed_port) catch opts.answers_json; - defer if (answers_with_port.ptr != opts.answers_json.ptr) allocator.free(answers_with_port); + // If any selected provider is openai-compatible (has a base_url), strip only those + // entries before passing answers to the binary. The binary only knows standard + // provider names; custom credentials and fallback order are restored afterwards. + const custom_provider_result = extractCustomProviders(allocator, opts.answers_json) catch |err| blk: { + std.log.warn("extractCustomProviders failed: {s}", .{@errorName(err)}); + break :blk null; + }; + defer if (custom_provider_result) |cp| cp.deinit(allocator); + const answers_for_binary = if (custom_provider_result) |cp| cp.stripped_json else opts.answers_json; + + const answers_with_port = injectPortFields(allocator, answers_for_binary, port, managed_port) catch answers_for_binary; + defer if (answers_with_port.ptr != answers_for_binary.ptr) allocator.free(answers_with_port); const answers_with_home = injectHomeField(allocator, answers_with_port, inst_dir) catch answers_with_port; defer if (answers_with_home.ptr != answers_with_port.ptr) allocator.free(answers_with_home); @@ -234,6 +244,18 @@ pub fn install( return error.ConfigGenerationFailed; } + // If there were custom (openai-compatible) providers, patch their credentials + // and the original provider order into the generated config now that the binary has written it. + if (custom_provider_result) |cp| { + const config_path = p.instanceConfig(allocator, opts.component, opts.instance_name) catch null; + defer if (config_path) |path| allocator.free(path); + if (config_path) |path| { + patchCustomProvidersIntoConfig(allocator, path, cp.selections, cp.custom_providers) catch |err| { + std.log.warn("failed to inject custom providers into config: {s}", .{@errorName(err)}); + }; + } + } + _ = nullclaw_web_channel.ensureNullclawWebChannelConfig( allocator, p, @@ -542,6 +564,351 @@ fn injectHomeField(allocator: std.mem.Allocator, json: []const u8, home: []const return buf.toOwnedSlice(); } +// ─── Custom provider handling ──────────────────────────────────────────────── + +/// Extracted custom-provider fields stripped from wizard answers before they +/// reach the component binary. All slices are owned by the arena from the +/// parsed JSON value; callers must not free them individually. +const CustomProvider = struct { + provider: []const u8, + api_key: []const u8, + base_url: []const u8, + model: []const u8, +}; + +const ProviderSelection = struct { + provider: []const u8, + api_key: []const u8, + base_url: []const u8, + model: []const u8, +}; + +const CustomProvidersRewrite = struct { + custom_providers: []CustomProvider, + selections: []ProviderSelection, + stripped_json: []const u8, + + fn deinit(self: CustomProvidersRewrite, allocator: std.mem.Allocator) void { + freeCustomProviders(allocator, self.custom_providers); + freeProviderSelections(allocator, self.selections); + allocator.free(self.stripped_json); + } +}; + +fn freeCustomProviders(allocator: std.mem.Allocator, providers: []CustomProvider) void { + for (providers) |provider| { + allocator.free(provider.provider); + allocator.free(provider.api_key); + allocator.free(provider.base_url); + allocator.free(provider.model); + } + allocator.free(providers); +} + +fn deinitCustomProviderList(allocator: std.mem.Allocator, providers: *std.array_list.Managed(CustomProvider)) void { + for (providers.items) |provider| { + allocator.free(provider.provider); + allocator.free(provider.api_key); + allocator.free(provider.base_url); + allocator.free(provider.model); + } + providers.deinit(); +} + +fn freeProviderSelections(allocator: std.mem.Allocator, selections: []ProviderSelection) void { + for (selections) |selection| { + allocator.free(selection.provider); + allocator.free(selection.api_key); + allocator.free(selection.base_url); + allocator.free(selection.model); + } + allocator.free(selections); +} + +fn deinitProviderSelectionList(allocator: std.mem.Allocator, selections: *std.array_list.Managed(ProviderSelection)) void { + for (selections.items) |selection| { + allocator.free(selection.provider); + allocator.free(selection.api_key); + allocator.free(selection.base_url); + allocator.free(selection.model); + } + selections.deinit(); +} + +fn stringField(obj: *std.json.ObjectMap, key: []const u8) []const u8 { + return switch (obj.get(key) orelse .null) { + .string => |s| s, + else => "", + }; +} + +fn appendProviderSelection( + allocator: std.mem.Allocator, + selections: *std.array_list.Managed(ProviderSelection), + provider: []const u8, + api_key: []const u8, + base_url: []const u8, + model: []const u8, +) !void { + if (provider.len == 0) return; + const owned_provider = try allocator.dupe(u8, provider); + errdefer allocator.free(owned_provider); + const owned_api_key = try allocator.dupe(u8, api_key); + errdefer allocator.free(owned_api_key); + const owned_base_url = try allocator.dupe(u8, base_url); + errdefer allocator.free(owned_base_url); + const owned_model = try allocator.dupe(u8, model); + errdefer allocator.free(owned_model); + try selections.append(.{ + .provider = owned_provider, + .api_key = owned_api_key, + .base_url = owned_base_url, + .model = owned_model, + }); +} + +fn appendCustomProvider( + allocator: std.mem.Allocator, + providers: *std.array_list.Managed(CustomProvider), + provider: []const u8, + api_key: []const u8, + base_url: []const u8, + model: []const u8, +) !void { + if (provider.len == 0 or base_url.len == 0) return; + const owned_provider = try allocator.dupe(u8, provider); + errdefer allocator.free(owned_provider); + const owned_api_key = try allocator.dupe(u8, api_key); + errdefer allocator.free(owned_api_key); + const owned_base_url = try allocator.dupe(u8, base_url); + errdefer allocator.free(owned_base_url); + const owned_model = try allocator.dupe(u8, model); + errdefer allocator.free(owned_model); + try providers.append(.{ + .provider = owned_provider, + .api_key = owned_api_key, + .base_url = owned_base_url, + .model = owned_model, + }); +} + +fn neutralizeProviderObject(allocator: std.mem.Allocator, obj: *std.json.ObjectMap) !void { + try obj.put(allocator, "provider", .{ .string = "openai" }); + try obj.put(allocator, "api_key", .{ .string = "" }); + try obj.put(allocator, "model", .{ .string = "" }); + try obj.put(allocator, "base_url", .{ .string = "" }); +} + +/// If the wizard answers contain any provider with a non-empty `base_url` +/// (indicating an OpenAI-compatible / custom endpoint), return all custom +/// provider fields, the original provider order, and a NEW answers JSON string +/// with only custom entries neutralized so the component binary does not see +/// unknown provider names. +/// +/// Returns `null` when no custom provider is present (standard flow). +fn extractCustomProviders(allocator: std.mem.Allocator, json: []const u8) !?CustomProvidersRewrite { + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + if (parsed.value != .object) return null; + + const root = &parsed.value.object; + + var custom_providers = std.array_list.Managed(CustomProvider).init(allocator); + errdefer deinitCustomProviderList(allocator, &custom_providers); + var selections = std.array_list.Managed(ProviderSelection).init(allocator); + errdefer deinitProviderSelectionList(allocator, &selections); + + var saw_providers_array = false; + if (root.getPtr("providers")) |arr_val| { + if (arr_val.* == .array) { + saw_providers_array = true; + for (arr_val.array.items) |*item| { + if (item.* != .object) continue; + const provider = stringField(&item.object, "provider"); + const api_key = stringField(&item.object, "api_key"); + const base_url = stringField(&item.object, "base_url"); + const model = stringField(&item.object, "model"); + + try appendProviderSelection(allocator, &selections, provider, api_key, base_url, model); + + if (base_url.len > 0) { + try appendCustomProvider(allocator, &custom_providers, provider, api_key, base_url, model); + try neutralizeProviderObject(allocator, &item.object); + } + } + } + } + + const top_provider = stringField(root, "provider"); + const top_api_key = stringField(root, "api_key"); + const top_base_url = stringField(root, "base_url"); + const top_model = stringField(root, "model"); + + if (!saw_providers_array) { + try appendProviderSelection(allocator, &selections, top_provider, top_api_key, top_base_url, top_model); + if (top_base_url.len > 0) { + try appendCustomProvider(allocator, &custom_providers, top_provider, top_api_key, top_base_url, top_model); + } + } + + if (top_base_url.len > 0) { + try neutralizeProviderObject(allocator, root); + } + + if (custom_providers.items.len == 0) { + deinitCustomProviderList(allocator, &custom_providers); + deinitProviderSelectionList(allocator, &selections); + return null; + } + + const custom_slice = try custom_providers.toOwnedSlice(); + errdefer freeCustomProviders(allocator, custom_slice); + const selection_slice = try selections.toOwnedSlice(); + errdefer freeProviderSelections(allocator, selection_slice); + const stripped = try std.json.Stringify.valueAlloc(allocator, parsed.value, .{}); + return .{ + .custom_providers = custom_slice, + .selections = selection_slice, + .stripped_json = stripped, + }; +} + +fn selectionContainsProvider(selections: []const ProviderSelection, provider: []const u8) bool { + for (selections) |selection| { + if (std.mem.eql(u8, selection.provider, provider)) return true; + } + return false; +} + +fn putFallbackProviders( + allocator: std.mem.Allocator, + root: *std.json.ObjectMap, + selections: []const ProviderSelection, +) !void { + const reliability_obj = try ensureObjectInMap(allocator, root, "reliability"); + var fallbacks = std.json.Array.init(allocator); + errdefer fallbacks.deinit(); + + if (selections.len > 1) { + for (selections[1..]) |selection| { + if (selection.provider.len == 0) continue; + try fallbacks.append(.{ .string = selection.provider }); + } + } + + try reliability_obj.put(allocator, "fallback_providers", .{ .array = fallbacks }); +} + +/// Patch custom provider credentials and original provider order into an +/// existing instance config file after the component binary generates its base +/// config with placeholder OpenAI entries. +fn patchCustomProvidersIntoConfig( + allocator: std.mem.Allocator, + config_path: []const u8, + selections: []const ProviderSelection, + custom_providers: []const CustomProvider, +) !void { + 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; + + const models_obj = try ensureObjectInMap(ja, root, "models"); + const providers_obj = try ensureObjectInMap(ja, models_obj, "providers"); + + for (selections) |selection| { + const provider_obj = try ensureObjectInMap(ja, providers_obj, selection.provider); + try provider_obj.put(ja, "api_key", .{ .string = selection.api_key }); + if (selection.base_url.len > 0) { + try provider_obj.put(ja, "base_url", .{ .string = selection.base_url }); + } else { + _ = provider_obj.orderedRemove("base_url"); + } + } + + // Remove the placeholder unless the user's actual provider order includes OpenAI. + if (!selectionContainsProvider(selections, "openai")) { + _ = providers_obj.orderedRemove("openai"); + } + + if (selections.len > 0 and selections[0].model.len > 0) { + const primary = try std.fmt.allocPrint(ja, "{s}/{s}", .{ selections[0].provider, selections[0].model }); + const agents_obj = try ensureObjectInMap(ja, root, "agents"); + const defaults_obj = try ensureObjectInMap(ja, agents_obj, "defaults"); + const model_obj = try ensureObjectInMap(ja, defaults_obj, "model"); + try model_obj.put(ja, "primary", .{ .string = primary }); + } + + try putFallbackProviders(ja, root, selections); + + for (custom_providers) |custom| { + if (custom.model.len > 0) { + const agent_obj = try ensureObjectInMap(ja, root, "agent"); + const vd_gop = try agent_obj.getOrPut(ja, "vision_disabled_models"); + if (!vd_gop.found_existing) { + vd_gop.value_ptr.* = .{ .array = std.json.Array.init(ja) }; + } + if (vd_gop.value_ptr.* == .array) { + var already_present = false; + for (vd_gop.value_ptr.array.items) |item| { + if (item == .string and std.mem.eql(u8, item.string, custom.model)) { + already_present = true; + break; + } + } + if (!already_present) { + try vd_gop.value_ptr.array.append(.{ .string = custom.model }); + } + } + } + } + + 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 stageLocalBinary(allocator: std.mem.Allocator, p: paths_mod.Paths, component: []const u8) ?struct { version: []const u8, bin_path: []const u8 } { if (builtin.is_test) return null; const local_path = local_binary.find(allocator, component) orelse return null; @@ -992,3 +1359,147 @@ test "injectHomeField adds home to JSON object" { try std.testing.expect(std.mem.indexOf(u8, result, "\"home\":\"/tmp/inst\"") != null); try std.testing.expect(std.mem.indexOf(u8, result, "\"provider\":\"openrouter\"") != null); } + +test "extractCustomProviders neutralizes custom fallback while preserving standard primary" { + const allocator = std.testing.allocator; + const json = + \\{"provider":"openrouter","api_key":"sk-or","model":"openrouter/auto","providers":[{"provider":"openrouter","api_key":"sk-or","model":"openrouter/auto"},{"provider":"local-llm","api_key":"sk-local","model":"llama3","base_url":"http://127.0.0.1:5801/v1"}]} + ; + + const rewrite = (try extractCustomProviders(allocator, json)).?; + defer rewrite.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 1), rewrite.custom_providers.len); + try std.testing.expectEqualStrings("local-llm", rewrite.custom_providers[0].provider); + try std.testing.expectEqual(@as(usize, 2), rewrite.selections.len); + try std.testing.expectEqualStrings("openrouter", rewrite.selections[0].provider); + try std.testing.expectEqualStrings("local-llm", rewrite.selections[1].provider); + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, rewrite.stripped_json, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + const providers = parsed.value.object.get("providers").?.array.items; + try std.testing.expectEqualStrings("openrouter", providers[0].object.get("provider").?.string); + try std.testing.expectEqualStrings("openai", providers[1].object.get("provider").?.string); + try std.testing.expectEqualStrings("", providers[1].object.get("base_url").?.string); +} + +test "extractCustomProviders neutralizes primary custom without dropping standard fallback" { + const allocator = std.testing.allocator; + const json = + \\{"provider":"local-llm","api_key":"sk-local","model":"llama3","base_url":"http://127.0.0.1:5801/v1","providers":[{"provider":"local-llm","api_key":"sk-local","model":"llama3","base_url":"http://127.0.0.1:5801/v1"},{"provider":"openrouter","api_key":"sk-or","model":"openrouter/auto"}]} + ; + + const rewrite = (try extractCustomProviders(allocator, json)).?; + defer rewrite.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 1), rewrite.custom_providers.len); + try std.testing.expectEqualStrings("local-llm", rewrite.custom_providers[0].provider); + try std.testing.expectEqual(@as(usize, 2), rewrite.selections.len); + try std.testing.expectEqualStrings("local-llm", rewrite.selections[0].provider); + try std.testing.expectEqualStrings("openrouter", rewrite.selections[1].provider); + + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, rewrite.stripped_json, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + try std.testing.expectEqualStrings("openai", parsed.value.object.get("provider").?.string); + try std.testing.expectEqualStrings("", parsed.value.object.get("base_url").?.string); + const providers = parsed.value.object.get("providers").?.array.items; + try std.testing.expectEqualStrings("openai", providers[0].object.get("provider").?.string); + try std.testing.expectEqualStrings("openrouter", providers[1].object.get("provider").?.string); +} + +test "patchCustomProvidersIntoConfig restores custom fallback provider order" { + const allocator = std.testing.allocator; + const tmp_dir = "/tmp/test-orchestrator-custom-fallback-patch"; + std_compat.fs.deleteTreeAbsolute(tmp_dir) catch {}; + try std_compat.fs.makeDirAbsolute(tmp_dir); + defer std_compat.fs.deleteTreeAbsolute(tmp_dir) catch {}; + + const config_path = try std.fs.path.join(allocator, &.{ tmp_dir, "config.json" }); + defer allocator.free(config_path); + try writeFile(config_path, + \\{"models":{"providers":{"openrouter":{"api_key":"sk-or"},"openai":{"api_key":""}}},"agents":{"defaults":{"model":{"primary":"openrouter/openrouter-auto"}}},"reliability":{"fallback_providers":["openai"]}} + ); + + const selections = [_]ProviderSelection{ + .{ .provider = "openrouter", .api_key = "sk-or", .base_url = "", .model = "openrouter-auto" }, + .{ .provider = "local-llm", .api_key = "sk-local", .base_url = "http://127.0.0.1:5801/v1", .model = "llama3" }, + }; + const custom_providers = [_]CustomProvider{ + .{ .provider = "local-llm", .api_key = "sk-local", .base_url = "http://127.0.0.1:5801/v1", .model = "llama3" }, + }; + + try patchCustomProvidersIntoConfig(allocator, config_path, &selections, &custom_providers); + + const file = try std_compat.fs.openFileAbsolute(config_path, .{}); + defer file.close(); + const contents = try file.readToEndAlloc(allocator, 64 * 1024); + defer allocator.free(contents); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, contents, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + const providers = parsed.value.object.get("models").?.object.get("providers").?.object; + try std.testing.expect(providers.get("openai") == null); + try std.testing.expect(providers.get("openrouter") != null); + const local = providers.get("local-llm").?.object; + try std.testing.expectEqualStrings("sk-local", local.get("api_key").?.string); + try std.testing.expectEqualStrings("http://127.0.0.1:5801/v1", local.get("base_url").?.string); + + const primary = parsed.value.object.get("agents").?.object.get("defaults").?.object.get("model").?.object.get("primary").?.string; + try std.testing.expectEqualStrings("openrouter/openrouter-auto", primary); + const fallbacks = parsed.value.object.get("reliability").?.object.get("fallback_providers").?.array.items; + try std.testing.expectEqual(@as(usize, 1), fallbacks.len); + try std.testing.expectEqualStrings("local-llm", fallbacks[0].string); +} + +test "patchCustomProvidersIntoConfig restores primary custom and keeps standard fallback" { + const allocator = std.testing.allocator; + const tmp_dir = "/tmp/test-orchestrator-primary-custom-patch"; + std_compat.fs.deleteTreeAbsolute(tmp_dir) catch {}; + try std_compat.fs.makeDirAbsolute(tmp_dir); + defer std_compat.fs.deleteTreeAbsolute(tmp_dir) catch {}; + + const config_path = try std.fs.path.join(allocator, &.{ tmp_dir, "config.json" }); + defer allocator.free(config_path); + try writeFile(config_path, + \\{"models":{"providers":{"openai":{"api_key":""},"openrouter":{"api_key":"sk-or"}}},"agents":{"defaults":{"model":{"primary":"openai/"}}},"reliability":{"fallback_providers":["openrouter"]}} + ); + + const selections = [_]ProviderSelection{ + .{ .provider = "local-llm", .api_key = "sk-local", .base_url = "http://127.0.0.1:5801/v1", .model = "llama3" }, + .{ .provider = "openrouter", .api_key = "sk-or", .base_url = "", .model = "openrouter-auto" }, + }; + const custom_providers = [_]CustomProvider{ + .{ .provider = "local-llm", .api_key = "sk-local", .base_url = "http://127.0.0.1:5801/v1", .model = "llama3" }, + }; + + try patchCustomProvidersIntoConfig(allocator, config_path, &selections, &custom_providers); + + const file = try std_compat.fs.openFileAbsolute(config_path, .{}); + defer file.close(); + const contents = try file.readToEndAlloc(allocator, 64 * 1024); + defer allocator.free(contents); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, contents, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + const providers = parsed.value.object.get("models").?.object.get("providers").?.object; + try std.testing.expect(providers.get("openai") == null); + try std.testing.expect(providers.get("openrouter") != null); + try std.testing.expect(providers.get("local-llm") != null); + const primary = parsed.value.object.get("agents").?.object.get("defaults").?.object.get("model").?.object.get("primary").?.string; + try std.testing.expectEqualStrings("local-llm/llama3", primary); + const fallbacks = parsed.value.object.get("reliability").?.object.get("fallback_providers").?.array.items; + try std.testing.expectEqual(@as(usize, 1), fallbacks.len); + try std.testing.expectEqualStrings("openrouter", fallbacks[0].string); +} diff --git a/src/server.zig b/src/server.zig index bc3f506..9ab8785 100644 --- a/src/server.zig +++ b/src/server.zig @@ -55,7 +55,10 @@ pub const Server = struct { defer allocator.free(state_path); const state = try allocator.create(state_mod.State); - state.* = state_mod.State.load(allocator, state_path) catch state_mod.State.init(allocator, state_path); + state.* = state_mod.State.load(allocator, state_path) catch |err| blk: { + std.log.err("state.json load failed ({s}): starting with empty state — YOUR DATA MAY BE AT RISK", .{@errorName(err)}); + break :blk state_mod.State.init(allocator, state_path); + }; orchestrator.syncLocalUiModules(allocator, paths); @@ -919,6 +922,26 @@ pub const Server = struct { } return .{ .status = "405 Method Not Allowed", .content_type = "application/json", .body = "{\"error\":\"method not allowed\"}" }; } + // /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\"}" }; + } + } + if (std.mem.eql(u8, method, "POST")) { + if (providers_api.handleProbeModelsBody(allocator, body)) |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..2a2ef84 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -199,6 +199,11 @@ 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) => + request<{ live_ok: boolean; reason: string; models: string[] }>('/providers/probe-models', { + method: 'POST', + body: JSON.stringify({ base_url: baseUrl, api_key: apiKey }), + }), // Saved channels getSavedChannels: (reveal = false) => diff --git a/ui/src/lib/components/ProviderList.svelte b/ui/src/lib/components/ProviderList.svelte index b42c6a0..2ce08d4 100644 --- a/ui/src/lib/components/ProviderList.svelte +++ b/ui/src/lib/components/ProviderList.svelte @@ -1,6 +1,8 @@
@@ -243,15 +288,46 @@
{/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} @@ -272,7 +348,7 @@ - {#if isOpenAiCompatible(p)} + {#if isCustomProvider(p)}
@@ -286,7 +362,35 @@ {/if}
- + {#if isCustomProvider(p)} +
+ + +
+ {#if editProbeError} +
{editProbeError}
+ {/if} + {#if editProbedModels.length > 0} +
+ {#each editProbedModels as m} + + {/each} +
+ {/if} + {:else} + + {/if}
{#if editError}
{editError}
@@ -672,4 +776,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); + }