1ee4c37235ec607a976096a16091816d81c30ad9
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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 {