@@ -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
56158pub 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}
242477pub 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}\n 200\n " ));
675+ try std .testing .expectEqual (@as (? u16 , 401 ), parseTrailingHttpStatusCode ("\n 401\n " ));
676+ try std .testing .expectEqual (@as (? u16 , null ), parseTrailingHttpStatusCode ("not-a-code" ));
677+ }
678+
426679test "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+
698978test "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" );
0 commit comments