Files
Bruno ClermontandBruno Clermont d856a58218 feat(providers): make provider request timeout configurable (default 30s) (#1400)
The provider verify call and the provider models-list call used hardcoded
timeouts (30s and 15s) that were too low for slow models. Introduce a
tenant-scoped, UI-editable setting `providers.request_timeout_sec`
(default 30) stored in system_configs, consumed by both handlers, and
overlaid from config.json like tts.timeout_ms. Adds a field to the System
Settings modal (all 4 locales) so operators can raise it without a
redeploy.

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
2026-07-09 04:54:15 +07:00

271 lines
8.9 KiB
Go

package http
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
// HandleVerifyProviderForTest invokes the verify handler directly without auth
// middleware. Integration tests must inject the desired tenant_id into the
// request context before calling. Production code MUST go through RegisterRoutes
// so the auth/locale/tenant pipeline runs first.
func (h *ProvidersHandler) HandleVerifyProviderForTest(w http.ResponseWriter, r *http.Request) {
h.handleVerifyProvider(w, r)
}
// handleVerifyProvider tests a provider+model combination with a minimal LLM call.
//
// POST /v1/providers/{id}/verify
// Body: {"model": "anthropic/claude-sonnet-4"}
// Response: {"valid": true} or {"valid": false, "error": "..."}
func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")})
return
}
var req struct {
Model string `json:"model"`
}
// Empty body == ping mode (connectivity check only). Truncated/malformed
// JSON still returns 400. io.EOF on Decode unambiguously means no body;
// io.ErrUnexpectedEOF is what truncated JSON returns.
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
if !errors.Is(err, io.EOF) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
return
}
// empty body — req.Model stays "" → pingMode below
}
pingMode := req.Model == ""
// Look up provider record from DB to get the provider name
p, err := h.store.GetProvider(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())})
return
}
// ACP: verify binary exists on the server (no LLM call needed)
if p.ProviderType == store.ProviderACP {
if pingMode {
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
return
}
binary := p.APIBase
if binary == "" {
binary = "claude"
}
// Validate binary against known allowlist (same check as registerACPFromDB)
if binary != "claude" && binary != "codex" && binary != "gemini" && !filepath.IsAbs(binary) {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "invalid binary path"})
return
}
if _, err := exec.LookPath(binary); err != nil {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "binary not found: " + binary})
} else {
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
}
return
}
// Claude CLI: validate model alias locally (no LLM call needed)
if p.ProviderType == "claude_cli" {
if pingMode {
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
return
}
validModels := map[string]bool{"sonnet": true, "opus": true, "haiku": true}
if validModels[req.Model] {
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
} else {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "Invalid model. Use: sonnet, opus, or haiku"})
}
return
}
if h.providerReg == nil {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "no provider registry available"})
return
}
// Use provider's own TenantID (not request context) so cross-tenant admins
// can verify providers belonging to other tenants.
provider, err := h.providerReg.GetForTenant(p.TenantID, p.Name)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": "provider not registered: " + p.Name})
return
}
if pingMode {
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
return
}
// Non-chat models (image/video generation) can't be verified via Chat API.
// Accept them if the provider is reachable (already validated above).
if isNonChatModel(req.Model) {
writeJSON(w, http.StatusOK, map[string]any{"valid": true, "note": "generation model accepted (not verifiable via chat)"})
return
}
timeoutSec := loadProviderRequestTimeoutSec(r.Context(), h.sysConfigStore)
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSec)*time.Second)
defer cancel()
ctx = store.WithTenantID(ctx, p.TenantID)
reqChat := providers.ChatRequest{
Messages: []providers.Message{
{Role: "user", Content: "hi"},
},
Model: req.Model,
Options: map[string]any{
// Use a small but safe value — reasoning models need headroom beyond 1 token.
"max_tokens": 50,
},
}
_, err = h.usageCaps.Chat(ctx, provider, reqChat, usagecaps.ChatOptions{
TenantID: p.TenantID,
ProviderName: p.Name,
ModelID: req.Model,
Purpose: "provider-verify",
MaxOutputTokens: 50,
})
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"valid": false, "error": friendlyVerifyError(err)})
return
}
writeJSON(w, http.StatusOK, map[string]any{"valid": true})
}
// handleClaudeCLIAuthStatus checks whether the Claude CLI is authenticated on the server.
//
// GET /v1/providers/claude-cli/auth-status
// Response: {"logged_in": true, "email": "...", "subscription_type": "max"}
// or: {"logged_in": false, "error": "..."}
func (h *ProvidersHandler) handleClaudeCLIAuthStatus(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Try to find CLI path from existing Claude CLI provider in DB
cliPath := "claude"
if existing, err := h.store.ListProviders(r.Context()); err == nil {
for _, p := range existing {
if p.ProviderType == "claude_cli" && p.APIBase != "" {
cliPath = p.APIBase
break
}
}
}
inDocker := config.InDocker()
status, err := providers.CheckClaudeAuthStatus(ctx, cliPath)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{
"logged_in": false,
"error": err.Error(),
"in_docker": inDocker,
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"logged_in": status.LoggedIn,
"email": status.Email,
"subscription_type": status.SubscriptionType,
"in_docker": inDocker,
})
}
// isNonChatModel returns true for models that cannot be verified via Chat API
// (image/video generation models).
func isNonChatModel(model string) bool {
nonChatPrefixes := []string{
"veo-", "google/veo-",
"dall-e-", "imagen-", "google/imagen-",
"gemini-2.5-flash-image", "google/gemini-2.5-flash-image",
"grok-imagine", // xAI video generation (grok-imagine-video)
"grok-2-image", // xAI image generation
}
m := strings.ToLower(model)
for _, prefix := range nonChatPrefixes {
if strings.HasPrefix(m, prefix) || strings.Contains(m, "/"+prefix) {
return true
}
}
return false
}
// friendlyVerifyError extracts a human-readable message from provider errors.
// Raw errors often contain JSON blobs like: `HTTP 400: minimax: {"type":"error","error":{"type":"bad_request_error","message":"unknown model ..."}}`
func friendlyVerifyError(err error) string {
// Timeout / context cancellation → user-friendly message
if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "context deadline exceeded") {
return "Verification timed out — the provider took too long to respond. Please try again."
}
if errors.Is(err, context.Canceled) {
return "Verification was cancelled. Please try again."
}
msg := err.Error()
// Try to extract "message" field from embedded JSON
if idx := strings.Index(msg, `"message"`); idx >= 0 {
// Find the value after "message":
rest := msg[idx:]
// Look for :"<value>"
start := strings.Index(rest, `:`)
if start >= 0 {
rest = strings.TrimLeft(rest[start+1:], " ")
if len(rest) > 0 && rest[0] == '"' {
rest = rest[1:]
if before, _, ok := strings.Cut(rest, `"`); ok {
extracted := before
if extracted != "" {
return extracted
}
}
}
}
}
// Fallback: strip "HTTP NNN: provider: " prefix for cleaner display.
// Use the FIRST ": " after "HTTP NNN" to avoid splitting inside JSON values.
if idx := strings.Index(msg, ": "); idx >= 0 && idx < len(msg)-2 {
suffix := msg[idx+2:]
// Skip one more ": " to strip "provider: " prefix (e.g. "xai: {...}")
if idx2 := strings.Index(suffix, ": "); idx2 >= 0 && idx2 < 30 {
suffix = suffix[idx2+2:]
}
// If the remainder looks like JSON, say "invalid model"
if strings.HasPrefix(suffix, "{") {
return "Model not recognized by provider"
}
// Strip leaked JSON quotes/braces from partial extraction
suffix = strings.TrimRight(suffix, "{}[]")
suffix = strings.Trim(suffix, `"`)
if suffix != "" {
return suffix
}
}
return msg
}