mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-10 10:10:49 +00:00
dc51018563
* fix(subagent): inherit parent agent's provider instead of alphabetical fallback Subagents previously used a fixed provider (alphabetically first from the registry, often "anthropic") regardless of which provider the parent agent used. This caused invalid combos like anthropic/glm-5 when a zai-coding agent spawned subagents. - Pass provider registry to SubagentManager for runtime resolution - Inject parent provider name into context (WithParentProvider) - Resolve activeProvider from parent context before LLM call - Fix trace spans to show actual resolved provider, not default * fix(providers): api_base fallback from config/env for DB providers DB providers with empty api_base now inherit from config/env vars (e.g., GOCLAW_ANTHROPIC_BASE_URL). Prevents proxy API keys from being sent to the real provider API endpoint. - Add APIBaseForType() method on ProvidersConfig - registerProvidersFromDB falls back to config when api_base is empty - ProvidersHandler uses resolveAPIBase() for model listing - Add api_base, display_name, settings to provider validation whitelist * fix(tracing): pass resolved provider name to subagent span emitters - emitSubagentSpanStart now accepts providerName param instead of reading sm.provider.Name() — ensures root subagent span reflects the inherited parent provider, not the fallback default - registerInMemory now uses resolveAPIBase() so DB providers with empty api_base inherit the config/env fallback (same as startup path) --------- Co-authored-by: viettranx <viettranx@gmail.com>
295 lines
8.8 KiB
Go
295 lines
8.8 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// ModelInfo is a normalized model entry returned by the list-models endpoint.
|
|
type ModelInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// handleListProviderModels proxies to the upstream provider API to list
|
|
// available models for the given provider.
|
|
//
|
|
// GET /v1/providers/{id}/models
|
|
func (h *ProvidersHandler) handleListProviderModels(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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Claude CLI doesn't need an API key — return hardcoded models
|
|
if p.ProviderType == store.ProviderClaudeCLI {
|
|
writeJSON(w, http.StatusOK, map[string]any{"models": claudeCLIModels()})
|
|
return
|
|
}
|
|
|
|
// ACP agents don't need an API key — return hardcoded models
|
|
if p.ProviderType == store.ProviderACP {
|
|
writeJSON(w, http.StatusOK, map[string]any{"models": acpModels()})
|
|
return
|
|
}
|
|
|
|
if p.APIKey == "" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "API key")})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
var models []ModelInfo
|
|
|
|
switch p.ProviderType {
|
|
case "anthropic_native":
|
|
models, err = fetchAnthropicModels(ctx, p.APIKey, h.resolveAPIBase(p))
|
|
case "gemini_native":
|
|
models, err = fetchGeminiModels(ctx, p.APIKey)
|
|
case "bailian":
|
|
models = bailianModels()
|
|
case "dashscope":
|
|
models = dashScopeModels()
|
|
case "minimax_native":
|
|
models = minimaxModels()
|
|
case "suno":
|
|
models = sunoModels()
|
|
default:
|
|
// All other types use OpenAI-compatible /models endpoint
|
|
apiBase := strings.TrimRight(h.resolveAPIBase(p), "/")
|
|
if apiBase == "" {
|
|
apiBase = "https://api.openai.com/v1"
|
|
}
|
|
models, err = fetchOpenAIModels(ctx, apiBase, p.APIKey)
|
|
}
|
|
|
|
if err != nil {
|
|
slog.Warn("providers.models", "provider", p.Name, "error", err)
|
|
// Return empty list instead of error — provider may not support /models
|
|
writeJSON(w, http.StatusOK, map[string]any{"models": []ModelInfo{}})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"models": models})
|
|
}
|
|
|
|
// fetchAnthropicModels calls the Anthropic models API.
|
|
func fetchAnthropicModels(ctx context.Context, apiKey, apiBase string) ([]ModelInfo, error) {
|
|
base := strings.TrimRight(apiBase, "/")
|
|
if base == "" {
|
|
base = "https://api.anthropic.com/v1"
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, "GET", base+"/models", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("x-api-key", apiKey)
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("anthropic API returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode anthropic response: %w", err)
|
|
}
|
|
|
|
models := make([]ModelInfo, 0, len(result.Data))
|
|
for _, m := range result.Data {
|
|
models = append(models, ModelInfo{ID: m.ID, Name: m.DisplayName})
|
|
}
|
|
return models, nil
|
|
}
|
|
|
|
// fetchGeminiModels calls the Google Gemini models API.
|
|
// Gemini uses a different format: GET /v1beta/models?key=API_KEY
|
|
func fetchGeminiModels(ctx context.Context, apiKey string) ([]ModelInfo, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", "https://generativelanguage.googleapis.com/v1beta/models?key="+apiKey, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("gemini API returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Models []struct {
|
|
Name string `json:"name"` // e.g. "models/gemini-2.0-flash"
|
|
DisplayName string `json:"displayName"` // e.g. "Gemini 2.0 Flash"
|
|
} `json:"models"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode gemini response: %w", err)
|
|
}
|
|
|
|
models := make([]ModelInfo, 0, len(result.Models))
|
|
for _, m := range result.Models {
|
|
// Strip "models/" prefix to get the usable model ID
|
|
id := strings.TrimPrefix(m.Name, "models/")
|
|
models = append(models, ModelInfo{ID: id, Name: m.DisplayName})
|
|
}
|
|
return models, nil
|
|
}
|
|
|
|
// bailianModels returns a hardcoded list of models available on the
|
|
// Bailian Coding platform (coding-intl.dashscope.aliyuncs.com).
|
|
// The platform does not expose a /v1/models endpoint.
|
|
func bailianModels() []ModelInfo {
|
|
return []ModelInfo{
|
|
{ID: "qwen3.5-plus", Name: "Qwen 3.5 Plus"},
|
|
{ID: "kimi-k2.5", Name: "Kimi K2.5"},
|
|
{ID: "GLM-5", Name: "GLM-5"},
|
|
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
|
|
{ID: "qwen3-max-2026-01-23", Name: "Qwen 3 Max (2026-01-23)"},
|
|
{ID: "qwen3-coder-next", Name: "Qwen 3 Coder Next"},
|
|
{ID: "qwen3-coder-plus", Name: "Qwen 3 Coder Plus"},
|
|
{ID: "glm-4.7", Name: "GLM 4.7"},
|
|
}
|
|
}
|
|
|
|
// minimaxModels returns a hardcoded list of MiniMax models.
|
|
// MiniMax does not expose a /v1/models endpoint.
|
|
func minimaxModels() []ModelInfo {
|
|
return []ModelInfo{
|
|
// Chat / text
|
|
{ID: "MiniMax-Text-01", Name: "MiniMax Text 01"},
|
|
{ID: "MiniMax-M1", Name: "MiniMax M1"},
|
|
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
|
|
// Image generation
|
|
{ID: "image-01", Name: "Image 01"},
|
|
// Video generation
|
|
{ID: "MiniMax-Hailuo-2.3", Name: "Hailuo Video 2.3"},
|
|
{ID: "MiniMax-Hailuo-2", Name: "Hailuo Video 2"},
|
|
{ID: "T2V-01-Director", Name: "T2V-01 Director"},
|
|
// Music generation
|
|
{ID: "music-2.5+", Name: "Music 2.5+"},
|
|
{ID: "music-2.5", Name: "Music 2.5"},
|
|
// TTS
|
|
{ID: "speech-02-hd", Name: "Speech 02 HD"},
|
|
{ID: "speech-02-turbo", Name: "Speech 02 Turbo"},
|
|
}
|
|
}
|
|
|
|
// dashScopeModels returns a hardcoded list of DashScope (Qwen) models.
|
|
// DashScope does not expose a standard /v1/models endpoint.
|
|
func dashScopeModels() []ModelInfo {
|
|
return []ModelInfo{
|
|
// Qwen3.5 series — Text Generation + Deep Thinking + Visual Understanding
|
|
{ID: "qwen3.5-plus", Name: "Qwen 3.5 Plus"},
|
|
{ID: "qwen3.5-turbo", Name: "Qwen 3.5 Turbo"},
|
|
// Qwen3 hosted series — Text + Thinking
|
|
{ID: "qwen3-max", Name: "Qwen 3 Max"},
|
|
{ID: "qwen3-plus", Name: "Qwen 3 Plus"},
|
|
{ID: "qwen3-turbo", Name: "Qwen 3 Turbo"},
|
|
// Image generation
|
|
{ID: "wan2.6-image", Name: "Wan 2.6 Image"},
|
|
{ID: "wan2.1-image", Name: "Wan 2.1 Image"},
|
|
// Video generation
|
|
{ID: "wan2.6-video", Name: "Wan 2.6 Video"},
|
|
}
|
|
}
|
|
|
|
// sunoModels returns a hardcoded list of Suno music generation models.
|
|
func sunoModels() []ModelInfo {
|
|
return []ModelInfo{
|
|
{ID: "v4.5", Name: "Suno V4.5"},
|
|
{ID: "v4", Name: "Suno V4"},
|
|
{ID: "v3.5", Name: "Suno V3.5"},
|
|
}
|
|
}
|
|
|
|
// claudeCLIModels returns the model aliases accepted by the Claude CLI.
|
|
func claudeCLIModels() []ModelInfo {
|
|
return []ModelInfo{
|
|
{ID: "sonnet", Name: "Sonnet"},
|
|
{ID: "opus", Name: "Opus"},
|
|
{ID: "haiku", Name: "Haiku"},
|
|
}
|
|
}
|
|
|
|
// acpModels returns the model aliases for ACP-compatible coding agents.
|
|
func acpModels() []ModelInfo {
|
|
return []ModelInfo{
|
|
{ID: "claude", Name: "Claude"},
|
|
{ID: "codex", Name: "Codex"},
|
|
{ID: "gemini", Name: "Gemini"},
|
|
}
|
|
}
|
|
|
|
// fetchOpenAIModels calls an OpenAI-compatible /models endpoint.
|
|
func fetchOpenAIModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) {
|
|
req, err := http.NewRequestWithContext(ctx, "GET", apiBase+"/models", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return nil, fmt.Errorf("provider API returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
OwnedBy string `json:"owned_by"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("failed to decode provider response: %w", err)
|
|
}
|
|
|
|
models := make([]ModelInfo, 0, len(result.Data))
|
|
for _, m := range result.Data {
|
|
models = append(models, ModelInfo{ID: m.ID, Name: m.ID})
|
|
}
|
|
return models, nil
|
|
}
|