mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 18:16:09 +00:00
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>
271 lines
8.9 KiB
Go
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
|
|
}
|