1ee4c37235ec607a976096a16091816d81c30ad9

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

cmd: report service availability read-only

- internal/availability: bounded GET probes for LLM/TTS (/models) and STT
  (host root); any HTTP response counts as reachable, connection errors
  reported with the configured URL and error text
- jp run prints a per-service status report before exiting

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/jp/main.go b/cmd/jp/main.go
  2index 99e4880c4456e374dc1ba0dc9505499e78a12e49..e91d23991a6f9ef3c6edaa1786d655f2ac57a5e5 100644
  3--- a/cmd/jp/main.go
  4+++ b/cmd/jp/main.go
  5@@ -2,11 +2,14 @@
  6 package main
  7 
  8 import (
  9+	"context"
 10 	"flag"
 11 	"fmt"
 12 	"io"
 13 	"os"
 14+	"time"
 15 
 16+	"japanese/internal/availability"
 17 	"japanese/internal/config"
 18 )
 19 
 20@@ -34,9 +37,19 @@ func runCmd(args []string) {
 21 	var cfg config.Config
 22 	cfg.RegisterFlags(fs)
 23 	fs.Parse(args)
 24+	reportServiceStatus(cfg)
 25 	fmt.Fprintln(os.Stderr, "jp run: not implemented yet")
 26 }
 27 
 28+func reportServiceStatus(cfg config.Config) {
 29+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 30+	defer cancel()
 31+	fmt.Fprintln(os.Stderr, "service status:")
 32+	for _, s := range availability.CheckAll(ctx, cfg) {
 33+		fmt.Fprintln(os.Stderr, "  "+s.String())
 34+	}
 35+}
 36+
 37 func personaCmd(args []string) {
 38 	if len(args) == 0 || args[0] != "gen" {
 39 		fmt.Fprintln(os.Stderr, "jp persona: expected subcommand 'gen'")
 40diff --git a/internal/availability/availability.go b/internal/availability/availability.go
 41new file mode 100644
 42index 0000000000000000000000000000000000000000..5907843bee3e6acc2fd5dbdf6aad5090e377ff82
 43--- /dev/null
 44+++ b/internal/availability/availability.go
 45@@ -0,0 +1,151 @@
 46+// Package availability reports whether the configured model services are
 47+// reachable using only safe, bounded HTTP checks. It never sends an inference
 48+// request and never starts, stops, or reconfigures a service.
 49+package availability
 50+
 51+import (
 52+	"context"
 53+	"encoding/json"
 54+	"fmt"
 55+	"io"
 56+	"net/http"
 57+	"net/url"
 58+	"strings"
 59+	"time"
 60+
 61+	"japanese/internal/config"
 62+)
 63+
 64+// checkTimeout bounds each probe so an unreachable service cannot hang startup.
 65+const checkTimeout = 5 * time.Second
 66+
 67+type Service struct {
 68+	Name   string
 69+	URL    string
 70+	Up     bool
 71+	Detail string
 72+}
 73+
 74+func (s Service) String() string {
 75+	state := "down"
 76+	if s.Up {
 77+		state = "up"
 78+	}
 79+	line := fmt.Sprintf("%s %s %s", s.Name, state, s.URL)
 80+	if s.Detail != "" {
 81+		line += "  (" + s.Detail + ")"
 82+	}
 83+	return line
 84+}
 85+
 86+// CheckAll probes every configured service and returns one status per service.
 87+func CheckAll(ctx context.Context, cfg config.Config) []Service {
 88+	client := &http.Client{Timeout: checkTimeout}
 89+	return []Service{
 90+		checkLLM(ctx, client, cfg.LLMBaseURL),
 91+		checkTTS(ctx, client, cfg.TTSBaseURL),
 92+		checkSTT(ctx, client, cfg.STTURL),
 93+	}
 94+}
 95+
 96+func checkLLM(ctx context.Context, client *http.Client, base string) Service {
 97+	s := Service{Name: "LLM", URL: base}
 98+	modelsURL := strings.TrimRight(base, "/") + "/models"
 99+	resp, body, err := doGet(ctx, client, modelsURL)
100+	if err != nil {
101+		s.Detail = err.Error()
102+		return s
103+	}
104+	s.Up = true
105+	if ids := modelIDs(body); len(ids) > 0 {
106+		s.Detail = "models: " + strings.Join(ids, ", ")
107+	} else {
108+		s.Detail = fmt.Sprintf("HTTP %d", resp.StatusCode)
109+	}
110+	return s
111+}
112+
113+func checkTTS(ctx context.Context, client *http.Client, base string) Service {
114+	s := Service{Name: "TTS", URL: base}
115+	modelsURL := strings.TrimRight(base, "/") + "/models"
116+	resp, _, err := doGet(ctx, client, modelsURL)
117+	if err != nil {
118+		s.Detail = err.Error()
119+		return s
120+	}
121+	s.Up = true
122+	if resp.StatusCode == http.StatusNotFound {
123+		s.Detail = "server responded; /models not available (HTTP 404)"
124+	} else {
125+		s.Detail = fmt.Sprintf("HTTP %d", resp.StatusCode)
126+	}
127+	return s
128+}
129+
130+func checkSTT(ctx context.Context, client *http.Client, sttURL string) Service {
131+	s := Service{Name: "STT", URL: sttURL}
132+	root, err := hostRoot(sttURL)
133+	if err != nil {
134+		s.Detail = err.Error()
135+		return s
136+	}
137+	resp, _, err := doGet(ctx, client, root)
138+	if err != nil {
139+		s.Detail = err.Error()
140+		return s
141+	}
142+	s.Up = true
143+	s.Detail = fmt.Sprintf("HTTP %d", resp.StatusCode)
144+	return s
145diff --git a/internal/availability/availability_test.go b/internal/availability/availability_test.go
146new file mode 100644
147index 0000000000000000000000000000000000000000..5e1450eb0866b0d2e885cb82f47a7b9b84dbf3bf
148--- /dev/null
149+++ b/internal/availability/availability_test.go
150@@ -0,0 +1,133 @@
151+package availability
152+
153+import (
154+	"context"
155+	"net/http"
156+	"net/http/httptest"
157+	"strings"
158+	"testing"
159+	"time"
160+
161+	"japanese/internal/config"
162+)
163+
164+func testClient() *http.Client { return &http.Client{Timeout: 2 * time.Second} }
165+
166+// deadURL starts a server and closes it, leaving a URL that refuses connections.
167+func deadURL(t *testing.T) string {
168+	t.Helper()
169+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
170+	u := srv.URL
171+	srv.Close()
172+	return u
173+}
174+
175+func TestCheckLLMUp(t *testing.T) {
176+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
177+		if r.URL.Path == "/models" {
178+			w.Header().Set("Content-Type", "application/json")
179+			_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"jp"}]}`))
180+			return
181+		}
182+		http.NotFound(w, r)
183+	}))
184+	defer srv.Close()
185+
186+	s := checkLLM(context.Background(), testClient(), srv.URL)
187+	if !s.Up {
188+		t.Fatalf("expected up, got down (%s)", s.Detail)
189+	}
190+	if s.Detail != "models: jp" {
191+		t.Errorf("detail = %q, want %q", s.Detail, "models: jp")
192+	}
193+}
194+
195+func TestCheckLLMDown(t *testing.T) {
196+	s := checkLLM(context.Background(), testClient(), deadURL(t))
197+	if s.Up {
198+		t.Fatalf("expected down, got up")
199+	}
200+	if s.Detail == "" {
201+		t.Error("expected an error detail")
202+	}
203+}
204+
205+func TestCheckTTSUp(t *testing.T) {
206+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
207+		_, _ = w.Write([]byte(`{"data":[]}`))
208+	}))
209+	defer srv.Close()
210+
211+	s := checkTTS(context.Background(), testClient(), srv.URL)
212+	if !s.Up {
213+		t.Fatalf("expected up, got down (%s)", s.Detail)
214+	}
215+}
216+
217+func TestCheckTTSUpModels404(t *testing.T) {
218+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
219+		http.NotFound(w, r)
220+	}))
221+	defer srv.Close()
222+
223+	s := checkTTS(context.Background(), testClient(), srv.URL)
224+	if !s.Up {
225+		t.Fatalf("expected up (server responded), got down (%s)", s.Detail)
226+	}
227+	if !strings.Contains(s.Detail, "404") {
228+		t.Errorf("detail = %q, want a 404 note", s.Detail)
229+	}
230+}
231+
232+func TestCheckTTSDown(t *testing.T) {
233+	s := checkTTS(context.Background(), testClient(), deadURL(t))
234+	if s.Up {
235+		t.Fatalf("expected down, got up")
236+	}
237+	if s.Detail == "" {
238+		t.Error("expected an error detail")
239+	}
240+}
241+
242+func TestCheckSTTUp(t *testing.T) {
243+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
244+		w.WriteHeader(http.StatusOK)
245+	}))
246+	defer srv.Close()
247+
248+	s := checkSTT(context.Background(), testClient(), srv.URL+"/inference")
249+	if !s.Up {