Skip to content

Commit ccefa47

Browse files
committed
ui: make openrouter provider status reflect live auth health
1 parent b50ab86 commit ccefa47

3 files changed

Lines changed: 388 additions & 8 deletions

File tree

src/api/instances.zig

Lines changed: 281 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,108 @@ fn readPortFromConfig(allocator: std.mem.Allocator, paths: paths_mod.Paths, comp
5151
}
5252
}
5353

54+
const ProviderHealthConfig = struct {
55+
agents: ?struct {
56+
defaults: ?struct {
57+
model: ?struct {
58+
primary: ?[]const u8 = null,
59+
} = null,
60+
} = null,
61+
} = null,
62+
models: ?struct {
63+
providers: ?std.json.ArrayHashMap(struct {
64+
api_key: ?[]const u8 = null,
65+
}) = null,
66+
} = null,
67+
};
68+
69+
const OpenRouterProbeResult = struct {
70+
live_ok: bool,
71+
status_code: ?u16 = null,
72+
reason: []const u8,
73+
};
74+
75+
fn parseTrailingHttpStatusCode(s: []const u8) ?u16 {
76+
if (s.len == 0) return null;
77+
78+
var end = s.len;
79+
while (end > 0) : (end -= 1) {
80+
const c = s[end - 1];
81+
if (c != '\n' and c != '\r' and c != ' ' and c != '\t') break;
82+
}
83+
if (end == 0) return null;
84+
85+
var start = end;
86+
while (start > 0) : (start -= 1) {
87+
const c = s[start - 1];
88+
if (c == '\n' or c == '\r') break;
89+
}
90+
const line = std.mem.trim(u8, s[start..end], " \t\r\n");
91+
if (line.len != 3) return null;
92+
return std.fmt.parseInt(u16, line, 10) catch null;
93+
}
94+
95+
fn probeOpenRouter(allocator: std.mem.Allocator, api_key: []const u8) OpenRouterProbeResult {
96+
if (api_key.len == 0) {
97+
return .{ .live_ok = false, .reason = "missing_api_key" };
98+
}
99+
100+
const auth_header = std.fmt.allocPrint(allocator, "Authorization: Bearer {s}", .{api_key}) catch {
101+
return .{ .live_ok = false, .reason = "allocation_failed" };
102+
};
103+
defer allocator.free(auth_header);
104+
105+
const result = std.process.Child.run(.{
106+
.allocator = allocator,
107+
.argv = &.{
108+
"curl",
109+
"-sS",
110+
"--max-time",
111+
"10",
112+
"-H",
113+
auth_header,
114+
"-w",
115+
"\n%{http_code}\n",
116+
"https://openrouter.ai/api/v1/auth/key",
117+
},
118+
}) catch {
119+
return .{ .live_ok = false, .reason = "curl_exec_failed" };
120+
};
121+
defer allocator.free(result.stdout);
122+
defer allocator.free(result.stderr);
123+
124+
const status_code = parseTrailingHttpStatusCode(result.stdout);
125+
const exited_ok = switch (result.term) {
126+
.Exited => |code| code == 0,
127+
else => false,
128+
};
129+
130+
if (exited_ok) {
131+
if (status_code) |code| {
132+
if (code >= 200 and code < 300) {
133+
return .{ .live_ok = true, .status_code = code, .reason = "ok" };
134+
}
135+
}
136+
}
137+
138+
if (status_code) |code| {
139+
return switch (code) {
140+
401 => .{ .live_ok = false, .status_code = code, .reason = "invalid_api_key" },
141+
403 => .{ .live_ok = false, .status_code = code, .reason = "forbidden" },
142+
429 => .{ .live_ok = false, .status_code = code, .reason = "rate_limited" },
143+
else => if (code >= 500 and code <= 599)
144+
.{ .live_ok = false, .status_code = code, .reason = "provider_unavailable" }
145+
else
146+
.{ .live_ok = false, .status_code = code, .reason = "auth_check_failed" },
147+
};
148+
}
149+
150+
if (result.stderr.len > 0) {
151+
return .{ .live_ok = false, .reason = "network_error" };
152+
}
153+
return .{ .live_ok = false, .reason = "unknown_error" };
154+
}
155+
54156
// ─── Path Parsing ────────────────────────────────────────────────────────────
55157

56158
pub const ParsedPath = struct {
@@ -238,6 +340,139 @@ pub fn handleRestart(allocator: std.mem.Allocator, s: *state_mod.State, manager:
238340
return handleStart(allocator, s, manager, paths, component, name, body);
239341
}
240342

343+
/// GET /api/instances/{component}/{name}/provider-health
344+
/// Performs a live provider credential probe for known providers.
345+
pub fn handleProviderHealth(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths, component: []const u8, name: []const u8) ApiResponse {
346+
_ = s.getInstance(component, name) orelse return notFound();
347+
348+
const config_path = paths.instanceConfig(allocator, component, name) catch return helpers.serverError();
349+
defer allocator.free(config_path);
350+
351+
const file = std.fs.openFileAbsolute(config_path, .{}) catch return .{
352+
.status = "404 Not Found",
353+
.content_type = "application/json",
354+
.body = "{\"error\":\"config not found\"}",
355+
};
356+
defer file.close();
357+
358+
const contents = file.readToEndAlloc(allocator, 4 * 1024 * 1024) catch return helpers.serverError();
359+
defer allocator.free(contents);
360+
361+
const parsed = std.json.parseFromSlice(ProviderHealthConfig, allocator, contents, .{
362+
.allocate = .alloc_always,
363+
.ignore_unknown_fields = true,
364+
}) catch return badRequest("{\"error\":\"invalid config JSON\"}");
365+
defer parsed.deinit();
366+
367+
var provider: []const u8 = "";
368+
var model: []const u8 = "";
369+
var api_key: []const u8 = "";
370+
var configured = false;
371+
372+
if (parsed.value.agents) |agents| {
373+
if (agents.defaults) |defaults| {
374+
if (defaults.model) |model_cfg| {
375+
if (model_cfg.primary) |primary| {
376+
if (primary.len > 0) {
377+
if (std.mem.indexOfScalar(u8, primary, '/')) |sep| {
378+
provider = primary[0..sep];
379+
model = primary[sep + 1 ..];
380+
} else {
381+
provider = primary;
382+
model = primary;
383+
}
384+
}
385+
}
386+
}
387+
}
388+
}
389+
390+
if (parsed.value.models) |models_cfg| {
391+
if (models_cfg.providers) |providers| {
392+
if (provider.len > 0) {
393+
if (providers.map.get(provider)) |entry| {
394+
if (entry.api_key) |k| {
395+
if (k.len > 0) {
396+
configured = true;
397+
api_key = k;
398+
}
399+
}
400+
}
401+
}
402+
if (!configured) {
403+
var it = providers.map.iterator();
404+
while (it.next()) |entry| {
405+
if (entry.value_ptr.api_key) |k| {
406+
if (k.len > 0) {
407+
provider = entry.key_ptr.*;
408+
configured = true;
409+
api_key = k;
410+
break;
411+
}
412+
}
413+
}
414+
}
415+
}
416+
}
417+
418+
const running = blk: {
419+
if (manager.getStatus(component, name)) |st| {
420+
break :blk st.status == .running;
421+
}
422+
break :blk false;
423+
};
424+
425+
var status: []const u8 = "unknown";
426+
var reason: []const u8 = "not_probed";
427+
var live_ok = false;
428+
var status_code: ?u16 = null;
429+
430+
if (provider.len == 0) {
431+
status = "error";
432+
reason = "provider_not_detected";
433+
} else if (!configured) {
434+
status = "error";
435+
reason = "missing_api_key";
436+
} else if (!running) {
437+
status = "error";
438+
reason = "instance_not_running";
439+
} else if (std.mem.eql(u8, provider, "openrouter")) {
440+
const probe = probeOpenRouter(allocator, api_key);
441+
live_ok = probe.live_ok;
442+
status_code = probe.status_code;
443+
status = if (probe.live_ok) "ok" else "error";
444+
reason = probe.reason;
445+
} else {
446+
// Preserve compatibility for other providers: only openrouter has live probe for now.
447+
live_ok = configured and running;
448+
status = if (live_ok) "ok" else "unknown";
449+
reason = "probe_not_implemented";
450+
}
451+
452+
var buf = std.array_list.Managed(u8).init(allocator);
453+
buf.appendSlice("{\"provider\":\"") catch return helpers.serverError();
454+
appendEscaped(&buf, provider) catch return helpers.serverError();
455+
buf.appendSlice("\",\"model\":\"") catch return helpers.serverError();
456+
appendEscaped(&buf, model) catch return helpers.serverError();
457+
buf.appendSlice("\",\"configured\":") catch return helpers.serverError();
458+
buf.appendSlice(if (configured) "true" else "false") catch return helpers.serverError();
459+
buf.appendSlice(",\"running\":") catch return helpers.serverError();
460+
buf.appendSlice(if (running) "true" else "false") catch return helpers.serverError();
461+
buf.appendSlice(",\"live_ok\":") catch return helpers.serverError();
462+
buf.appendSlice(if (live_ok) "true" else "false") catch return helpers.serverError();
463+
buf.appendSlice(",\"status\":\"") catch return helpers.serverError();
464+
appendEscaped(&buf, status) catch return helpers.serverError();
465+
buf.appendSlice("\",\"reason\":\"") catch return helpers.serverError();
466+
appendEscaped(&buf, reason) catch return helpers.serverError();
467+
buf.appendSlice("\"") catch return helpers.serverError();
468+
if (status_code) |code| {
469+
buf.writer().print(",\"status_code\":{d}", .{code}) catch return helpers.serverError();
470+
}
471+
buf.appendSlice("}") catch return helpers.serverError();
472+
473+
return jsonOk(buf.items);
474+
}
475+
241476
/// DELETE /api/instances/{component}/{name}
242477
pub fn handleDelete(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths, component: []const u8, name: []const u8) ApiResponse {
243478
if (s.getInstance(component, name) == null) return notFound();
@@ -366,7 +601,12 @@ pub fn dispatch(allocator: std.mem.Allocator, s: *state_mod.State, manager: *man
366601
const parsed = parsePath(target) orelse return null;
367602

368603
if (parsed.action) |action| {
369-
// Only POST is valid for actions.
604+
if (std.mem.eql(u8, action, "provider-health")) {
605+
if (!std.mem.eql(u8, method, "GET")) return methodNotAllowed();
606+
return handleProviderHealth(allocator, s, manager, paths, parsed.component, parsed.name);
607+
}
608+
609+
// Remaining actions are POST-only.
370610
if (!std.mem.eql(u8, method, "POST")) return methodNotAllowed();
371611

372612
if (std.mem.eql(u8, action, "start")) return handleStart(allocator, s, manager, paths, parsed.component, parsed.name, body);
@@ -423,6 +663,19 @@ test "parsePath: component, name, and action" {
423663
try std.testing.expectEqualStrings("start", p.action.?);
424664
}
425665

666+
test "parsePath: provider-health action" {
667+
const p = parsePath("/api/instances/nullclaw/default/provider-health").?;
668+
try std.testing.expectEqualStrings("nullclaw", p.component);
669+
try std.testing.expectEqualStrings("default", p.name);
670+
try std.testing.expectEqualStrings("provider-health", p.action.?);
671+
}
672+
673+
test "parseTrailingHttpStatusCode parses status in curl trailer" {
674+
try std.testing.expectEqual(@as(?u16, 200), parseTrailingHttpStatusCode("{\"x\":1}\n200\n"));
675+
try std.testing.expectEqual(@as(?u16, 401), parseTrailingHttpStatusCode("\n401\n"));
676+
try std.testing.expectEqual(@as(?u16, null), parseTrailingHttpStatusCode("not-a-code"));
677+
}
678+
426679
test "parsePath: rejects bare /api/instances/" {
427680
try std.testing.expect(parsePath("/api/instances/") == null);
428681
}
@@ -695,6 +948,33 @@ test "dispatch routes POST start action" {
695948
try std.testing.expectEqualStrings("500 Internal Server Error", resp.status);
696949
}
697950

951+
test "dispatch routes GET provider-health action" {
952+
const allocator = std.testing.allocator;
953+
var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json");
954+
defer s.deinit();
955+
var mctx = TestManagerCtx.init(allocator);
956+
defer mctx.deinit(allocator);
957+
958+
try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0" });
959+
960+
// No config file exists in this test fixture, so health action returns 404.
961+
const resp = dispatch(allocator, &s, &mctx.manager, mctx.paths, "GET", "/api/instances/nullclaw/my-agent/provider-health", "").?;
962+
try std.testing.expectEqualStrings("404 Not Found", resp.status);
963+
}
964+
965+
test "dispatch provider-health rejects POST" {
966+
const allocator = std.testing.allocator;
967+
var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json");
968+
defer s.deinit();
969+
var mctx = TestManagerCtx.init(allocator);
970+
defer mctx.deinit(allocator);
971+
972+
try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0" });
973+
974+
const resp = dispatch(allocator, &s, &mctx.manager, mctx.paths, "POST", "/api/instances/nullclaw/my-agent/provider-health", "").?;
975+
try std.testing.expectEqualStrings("405 Method Not Allowed", resp.status);
976+
}
977+
698978
test "dispatch returns null for non-matching path" {
699979
const allocator = std.testing.allocator;
700980
var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json");

ui/src/lib/api/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export const api = {
3333
deleteInstance: (c: string, n: string) =>
3434
request<any>(`/instances/${c}/${n}`, { method: 'DELETE' }),
3535
getConfig: (c: string, n: string) => request<any>(`/instances/${c}/${n}/config`),
36+
getProviderHealth: (c: string, n: string) =>
37+
request<any>(`/instances/${c}/${n}/provider-health`),
3638
putConfig: (c: string, n: string, config: any) =>
3739
request<any>(`/instances/${c}/${n}/config`, { method: 'PUT', body: JSON.stringify(config) }),
3840
getLogs: (c: string, n: string, lines = 100) =>

0 commit comments

Comments
 (0)