feat(secrets): encrypt sensitive store fields (AES-GCM, master.key 0600, api_key_env) + fast models-endpoint probing (fix zen backlog + source status) + UI cleanup (drop redundant parens labels, grid models 4/row)

This commit is contained in:
root
2026-08-10 10:33:03 +08:00
parent e0978db150
commit 839f33ba01
8 changed files with 405 additions and 56 deletions

View File

@ -180,9 +180,54 @@ func (p *Provider) Available() bool {
return p.health.available()
}
// Probe performs a lightweight reachability + auth check against the source
// using its best chat model (1-token). It records the result for Status().
// Probe performs a lightweight reachability + auth check against the source.
// It first tries GET <base>/models (fast, ~1s for OpenAI-compatible upstreams)
// and only falls back to a 1-token chat call when that endpoint is
// unavailable. It does NOT touch the health/backoff state so probing never
// disables a source.
func (p *Provider) Probe(ctx context.Context) (bool, string) {
ok, msg := p.probeModels(ctx)
if !ok && msg == "" {
ok, msg = p.probeChat(ctx)
}
p.mu.Lock()
p.lastProbe.ok = ok
p.lastProbe.err = msg
p.lastProbe.at = time.Now().Unix()
p.mu.Unlock()
return ok, msg
}
// probeModels GETs <base>/models. Returns (true,…) when reachable, (false,
// errortext) on an auth/permanent failure, and (false,"") when the endpoint
// simply isn't available so the caller can fall back to a chat probe.
func (p *Provider) probeModels(ctx context.Context) (bool, string) {
u := strings.TrimRight(p.cfg.BaseURL, "/") + "/models"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false, ""
}
if hdrs, herr := p.buildHeaders("{}", u); herr == nil {
req.Header = hdrs
}
resp, err := p.client.Do(req)
if err != nil {
return false, ""
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
switch {
case resp.StatusCode == 200:
return true, ""
case resp.StatusCode == 404 || resp.StatusCode == 405:
return false, ""
default:
return false, fmt.Sprintf("api error %d: %s", resp.StatusCode, truncate(string(raw), 300))
}
}
// probeChat sends a minimal single-token chat request to the chat endpoint.
func (p *Provider) probeChat(ctx context.Context) (bool, string) {
ok := false
msg := ""
model := p.bestChatModel()
@ -194,25 +239,32 @@ func (p *Provider) Probe(ctx context.Context) (bool, string) {
model = ms[0]
}
}
if model != "" {
_, err := p.Chat(ctx, &types.ChatRequest{
Model: model,
Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("hi")}},
MaxTokens: 1,
})
if model == "" {
msg = "no chat model configured"
} else {
probe := map[string]interface{}{
"model": model,
"messages": []map[string]interface{}{{"role": "user", "content": "hi"}},
"max_tokens": 1,
}
body, err := json.Marshal(probe)
if err == nil {
ok = true
var hdr http.Header
if hdrs, herr := p.buildHeaders(string(body), p.URL()); herr == nil {
hdr = hdrs
}
raw, status, derr := p.do(ctx, p.URL(), string(body), hdr)
if derr != nil {
msg = derr.Error()
} else if status == 200 {
ok = true
} else {
msg = fmt.Sprintf("api error %d: %s", status, truncate(raw, 500))
}
} else {
msg = err.Error()
}
} else {
msg = "no chat model configured"
}
p.mu.Lock()
p.lastProbe.ok = ok
p.lastProbe.err = msg
p.lastProbe.at = time.Now().Unix()
p.mu.Unlock()
return ok, msg
}

View File

@ -208,7 +208,7 @@ func (r *Registry) ProbeAll(ctx context.Context) {
wg.Add(1)
go func(p *Provider) {
defer wg.Done()
probeCtx, cancel := context.WithTimeout(ctx, 6*time.Second)
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
p.Probe(probeCtx)
}(p)