Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions src/api/providers.zig
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,10 @@ pub fn handleCreate(
.validated_with = component_name,
});

// Update validated_at on the just-added provider
// Record both the last successful validation and the latest validation attempt.
const providers = state.savedProviders();
const new_id = providers[providers.len - 1].id;
const now = try nowIso8601(allocator);
defer allocator.free(now);
_ = try state.updateSavedProvider(new_id, .{ .validated_at = now });

try state.save();
try persistValidationAttempt(allocator, state, new_id, component_name, true);

// Return the saved provider
const sp = state.getSavedProvider(new_id).?;
Expand Down Expand Up @@ -162,7 +158,14 @@ pub fn handleUpdate(

const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, effective_key, effective_model, "");
defer probe_result.deinit(allocator);
const now = try nowIso8601(allocator);
defer allocator.free(now);
if (!probe_result.live_ok) {
_ = try state.updateSavedProvider(id, .{
.last_validation_at = now,
.last_validation_ok = false,
});
try state.save();
var buf = std.array_list.Managed(u8).init(allocator);
errdefer buf.deinit();
try buf.appendSlice("{\"error\":\"Provider validation failed: ");
Expand All @@ -171,15 +174,14 @@ pub fn handleUpdate(
return buf.toOwnedSlice();
}

const now = nowIso8601(allocator) catch "";
defer if (now.len > 0) allocator.free(now);

_ = try state.updateSavedProvider(id, .{
.name = parsed.value.name,
.api_key = parsed.value.api_key,
.model = parsed.value.model,
.validated_at = now,
.validated_with = component_name,
.last_validation_at = now,
.last_validation_ok = true,
});
} else {
// Name-only update
Expand Down Expand Up @@ -224,12 +226,7 @@ pub fn handleValidate(
const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, existing.api_key, existing.model, "");
defer probe_result.deinit(allocator);

if (probe_result.live_ok) {
const now = try nowIso8601(allocator);
defer allocator.free(now);
_ = try state.updateSavedProvider(id, .{ .validated_at = now, .validated_with = component_name });
try state.save();
}
try persistValidationAttempt(allocator, state, id, component_name, probe_result.live_ok);

var buf = std.array_list.Managed(u8).init(allocator);
errdefer buf.deinit();
Expand Down Expand Up @@ -289,6 +286,25 @@ fn maskApiKey(buf: *std.array_list.Managed(u8), key: []const u8) !void {
}
}

fn persistValidationAttempt(
allocator: std.mem.Allocator,
state: *state_mod.State,
id: u32,
component_name: []const u8,
live_ok: bool,
) !void {
const now = try nowIso8601(allocator);
defer allocator.free(now);

_ = try state.updateSavedProvider(id, .{
.validated_at = if (live_ok) now else null,
.validated_with = if (live_ok) component_name else null,
.last_validation_at = now,
.last_validation_ok = live_ok,
});
try state.save();
}

fn appendProviderJson(buf: *std.array_list.Managed(u8), sp: state_mod.SavedProvider, reveal: bool) !void {
try buf.appendSlice("{\"id\":\"sp_");
var id_buf: [16]u8 = undefined;
Expand All @@ -311,7 +327,11 @@ fn appendProviderJson(buf: *std.array_list.Managed(u8), sp: state_mod.SavedProvi
try appendEscaped(buf, sp.validated_at);
try buf.appendSlice("\",\"validated_with\":\"");
try appendEscaped(buf, sp.validated_with);
try buf.appendSlice("\"}");
try buf.appendSlice("\",\"last_validation_at\":\"");
try appendEscaped(buf, sp.last_validation_at);
try buf.appendSlice("\",\"last_validation_ok\":");
try buf.appendSlice(if (sp.last_validation_ok) "true" else "false");
try buf.appendSlice("}");
}

pub fn nowIso8601(allocator: std.mem.Allocator) ![]const u8 {
Expand Down
30 changes: 24 additions & 6 deletions src/api/wizard.zig
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,23 @@ pub fn handleValidateProviders(
var did_save = false;
for (parsed.value.providers, 0..) |prov, idx| {
if (idx < probe_results.items.len and probe_results.items[idx].live_ok) {
if (!state.hasSavedProvider(prov.provider, prov.api_key, prov.model)) {
const now = providers_api.nowIso8601(allocator) catch "";
defer if (now.len > 0) allocator.free(now);

if (state.findSavedProviderId(prov.provider, prov.api_key, prov.model)) |existing_id| {
if (now.len > 0) {
_ = state.updateSavedProvider(existing_id, .{
.validated_at = now,
.validated_with = component_name,
.last_validation_at = now,
.last_validation_ok = true,
}) catch {
saved_providers_warning = "validated providers could not be fully saved";
continue;
};
did_save = true;
}
} else {
state.addSavedProvider(.{
.provider = prov.provider,
.api_key = prov.api_key,
Expand All @@ -671,18 +687,20 @@ pub fn handleValidateProviders(
saved_providers_warning = "validated providers could not be saved";
continue;
};
// Set validated_at on the just-added provider
// Set both the last successful validation and the latest validation attempt.
const providers_list = state.savedProviders();
if (providers_list.len > 0) {
const new_id = providers_list[providers_list.len - 1].id;
const now = providers_api.nowIso8601(allocator) catch "";
if (now.len > 0) {
_ = state.updateSavedProvider(new_id, .{ .validated_at = now }) catch {
_ = state.updateSavedProvider(new_id, .{
.validated_at = now,
.validated_with = component_name,
.last_validation_at = now,
.last_validation_ok = true,
}) catch {
saved_providers_warning = "validated providers could not be fully saved";
allocator.free(now);
continue;
};
allocator.free(now);
}
}
did_save = true;
Expand Down
194 changes: 194 additions & 0 deletions src/api_cli.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
const std = @import("std");
const cli = @import("cli.zig");

pub const ExecuteError = error{
InvalidMethod,
InvalidTarget,
};

pub const Result = struct {
status: std.http.Status,
body: []u8,

pub fn deinit(self: *Result, allocator: std.mem.Allocator) void {
allocator.free(self.body);
self.* = undefined;
}
};

pub fn run(allocator: std.mem.Allocator, opts: cli.ApiOptions) !void {
var result = try execute(allocator, opts);
defer result.deinit(allocator);

const formatted = if (opts.pretty)
try prettyBody(allocator, result.body)
else
try allocator.dupe(u8, result.body);
defer allocator.free(formatted);

if (formatted.len > 0) {
try writeAll(std.fs.File.stdout(), formatted);
if (formatted[formatted.len - 1] != '\n') {
try writeAll(std.fs.File.stdout(), "\n");
}
}

const code = @intFromEnum(result.status);
if (code < 200 or code >= 300) {
var buf: [64]u8 = undefined;
const line = try std.fmt.bufPrint(&buf, "HTTP {d}\n", .{code});
try writeAll(std.fs.File.stderr(), line);
return error.RequestFailed;
}
}

pub fn execute(allocator: std.mem.Allocator, opts: cli.ApiOptions) !Result {
const method = parseMethod(opts.method) orelse return ExecuteError.InvalidMethod;
const target = try normalizeTargetAlloc(allocator, opts.target);
defer allocator.free(target);

const url = try std.fmt.allocPrint(allocator, "http://{s}:{d}{s}", .{ opts.host, opts.port, target });
defer allocator.free(url);

const request_body = try loadBodyAlloc(allocator, opts);
defer if (request_body.owned) allocator.free(request_body.bytes);

var auth_header: ?[]u8 = null;
defer if (auth_header) |value| allocator.free(value);

var header_storage: [2]std.http.Header = undefined;
var header_count: usize = 0;
if (request_body.bytes.len > 0) {
header_storage[header_count] = .{ .name = "Content-Type", .value = opts.content_type };
header_count += 1;
}
if (opts.token) |token| {
auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token});
header_storage[header_count] = .{ .name = "Authorization", .value = auth_header.? };
header_count += 1;
}

var client: std.http.Client = .{ .allocator = allocator };
defer client.deinit();

var response_body: std.io.Writer.Allocating = .init(allocator);
defer response_body.deinit();

const result = try client.fetch(.{
.location = .{ .url = url },
.method = method,
.payload = payloadForFetch(method, request_body.bytes),
.response_writer = &response_body.writer,
.extra_headers = header_storage[0..header_count],
});

return .{
.status = result.status,
.body = try response_body.toOwnedSlice(),
};
}

fn writeAll(file: std.fs.File, bytes: []const u8) !void {
var buf: [4096]u8 = undefined;
var writer = file.writer(&buf);
try writer.interface.writeAll(bytes);
try writer.interface.flush();
}

fn parseMethod(raw: []const u8) ?std.http.Method {
if (std.ascii.eqlIgnoreCase(raw, "GET")) return .GET;
if (std.ascii.eqlIgnoreCase(raw, "POST")) return .POST;
if (std.ascii.eqlIgnoreCase(raw, "PUT")) return .PUT;
if (std.ascii.eqlIgnoreCase(raw, "DELETE")) return .DELETE;
if (std.ascii.eqlIgnoreCase(raw, "PATCH")) return .PATCH;
if (std.ascii.eqlIgnoreCase(raw, "HEAD")) return .HEAD;
if (std.ascii.eqlIgnoreCase(raw, "OPTIONS")) return .OPTIONS;
return null;
}

fn normalizeTargetAlloc(allocator: std.mem.Allocator, raw: []const u8) ![]u8 {
if (raw.len == 0) return ExecuteError.InvalidTarget;
if (std.mem.startsWith(u8, raw, "http://") or std.mem.startsWith(u8, raw, "https://")) {
return ExecuteError.InvalidTarget;
}
if (raw[0] == '/') return allocator.dupe(u8, raw);
if (std.mem.startsWith(u8, raw, "api/")) return std.fmt.allocPrint(allocator, "/{s}", .{raw});
if (std.mem.eql(u8, raw, "health")) return allocator.dupe(u8, "/health");
return std.fmt.allocPrint(allocator, "/api/{s}", .{raw});
}

const LoadedBody = struct {
bytes: []const u8,
owned: bool = false,
};

fn loadBodyAlloc(allocator: std.mem.Allocator, opts: cli.ApiOptions) !LoadedBody {
if (opts.body_file) |path| {
if (std.mem.eql(u8, path, "-")) {
const bytes = try std.fs.File.stdin().readToEndAlloc(allocator, 8 * 1024 * 1024);
return .{ .bytes = bytes, .owned = true };
}
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const bytes = try file.readToEndAlloc(allocator, 8 * 1024 * 1024);
return .{ .bytes = bytes, .owned = true };
}
if (opts.body) |body| return .{ .bytes = body, .owned = false };
return .{ .bytes = "", .owned = false };
}

fn payloadForFetch(method: std.http.Method, body: []const u8) ?[]const u8 {
if (body.len > 0) return body;
if (method.requestHasBody()) return body;
return null;
}

fn prettyBody(allocator: std.mem.Allocator, body: []const u8) ![]u8 {
if (body.len == 0) return allocator.dupe(u8, body);

const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{
.allocate = .alloc_always,
.ignore_unknown_fields = true,
}) catch return allocator.dupe(u8, body);
defer parsed.deinit();

return std.json.Stringify.valueAlloc(allocator, parsed.value, .{
.whitespace = .indent_2,
});
}

test "normalizeTargetAlloc keeps explicit API path" {
const value = try normalizeTargetAlloc(std.testing.allocator, "/api/status");
defer std.testing.allocator.free(value);
try std.testing.expectEqualStrings("/api/status", value);
}

test "normalizeTargetAlloc prefixes api namespace" {
const value = try normalizeTargetAlloc(std.testing.allocator, "instances/nullclaw/demo");
defer std.testing.allocator.free(value);
try std.testing.expectEqualStrings("/api/instances/nullclaw/demo", value);
}

test "normalizeTargetAlloc supports health shorthand" {
const value = try normalizeTargetAlloc(std.testing.allocator, "health");
defer std.testing.allocator.free(value);
try std.testing.expectEqualStrings("/health", value);
}

test "parseMethod accepts common verbs case-insensitively" {
try std.testing.expectEqual(std.http.Method.DELETE, parseMethod("delete").?);
try std.testing.expectEqual(std.http.Method.PATCH, parseMethod("PATCH").?);
try std.testing.expect(parseMethod("TRACE") == null);
}

test "prettyBody indents JSON output" {
const value = try prettyBody(std.testing.allocator, "{\"ok\":true}");
defer std.testing.allocator.free(value);
try std.testing.expect(std.mem.indexOf(u8, value, "\n") != null);
try std.testing.expect(std.mem.indexOf(u8, value, " \"ok\"") != null);
}

test "payloadForFetch keeps empty body for POST" {
try std.testing.expect(payloadForFetch(.POST, "") != null);
try std.testing.expect(payloadForFetch(.GET, "") == null);
}
Loading
Loading