Files
goclaw/internal/http/provider_models.go
T
Duc Nguyen dc51018563 fix: subagent provider routing + api_base fallback (#262)
* 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>
2026-03-18 22:40:49 +07:00

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
}