From 78a21520537d5b94fe6743842e050319110247fb Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 3 Apr 2026 15:18:54 +0700 Subject: [PATCH] refactor(http): split provider_models.go into focused modules - provider_models.go (154L): types, handler, reasoning helpers - provider_models_fetch.go (191L): remote API fetchers (Anthropic, Gemini, OpenAI, Ollama) - provider_models_catalog.go (103L): hardcoded model lists All files under 200-line guideline. No logic changes. --- internal/http/provider_models.go | 288 +---------------------- internal/http/provider_models_catalog.go | 103 ++++++++ internal/http/provider_models_fetch.go | 191 +++++++++++++++ 3 files changed, 296 insertions(+), 286 deletions(-) create mode 100644 internal/http/provider_models_catalog.go create mode 100644 internal/http/provider_models_fetch.go diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index 3fd9373c..790aa001 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -2,16 +2,12 @@ package http import ( "context" - "encoding/json" - "fmt" - "io" "log/slog" "net/http" "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" @@ -19,8 +15,8 @@ import ( // ModelInfo is a normalized model entry returned by the list-models endpoint. type ModelInfo struct { - ID string `json:"id"` - Name string `json:"name,omitempty"` + ID string `json:"id"` + Name string `json:"name,omitempty"` Reasoning *providers.ReasoningCapability `json:"reasoning,omitempty"` } @@ -147,286 +143,6 @@ func reasoningDefaultsForModels( return nil } -// 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.7", Name: "MiniMax M2.7"}, - {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 -} - -func chatGPTOAuthModels() []ModelInfo { - return withReasoningCapabilities([]ModelInfo{ - {ID: "gpt-5.4", Name: "GPT-5.4"}, - {ID: "gpt-5.4-mini", Name: "GPT-5.4 Mini"}, - {ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex"}, - {ID: "gpt-5.3-codex-spark", Name: "GPT-5.3 Codex Spark"}, - {ID: "gpt-5.2-codex", Name: "GPT-5.2 Codex"}, - {ID: "gpt-5.2", Name: "GPT-5.2"}, - {ID: "gpt-5.1-codex", Name: "GPT-5.1 Codex"}, - {ID: "gpt-5.1-codex-max", Name: "GPT-5.1 Codex Max"}, - {ID: "gpt-5.1-codex-mini", Name: "GPT-5.1 Codex Mini"}, - {ID: "gpt-5.1", Name: "GPT-5.1"}, - }) -} - -// fetchOllamaModels calls Ollama's native /api/tags endpoint to get model metadata -// including parameter size, quantization level, and model family. -// The api_base may include a /v1 suffix (from issue #654 normalization) — strip it -// before appending /api/tags since /api/tags lives at the root, not under /v1. -func (h *ProvidersHandler) fetchOllamaModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { - // Strip /v1 suffix if present, then trim trailing slash. - base := strings.TrimRight(strings.TrimSuffix(strings.TrimRight(apiBase, "/"), "/v1"), "/") - url := config.DockerLocalhost(base + "/api/tags") - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, err - } - if apiKey != "" { - 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("ollama /api/tags returned %d: %s", resp.StatusCode, string(body)) - } - - var result struct { - Models []struct { - Name string `json:"name"` - Details struct { - Family string `json:"family"` - ParameterSize string `json:"parameter_size"` - QuantizationLevel string `json:"quantization_level"` - } `json:"details"` - } `json:"models"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed to decode ollama response: %w", err) - } - - models := make([]ModelInfo, 0, len(result.Models)) - for _, m := range result.Models { - // Build a human-readable display name: "family paramSize quantLevel" e.g. "gemma4 8.0B Q4_K_M" - var parts []string - if m.Details.Family != "" { - parts = append(parts, m.Details.Family) - } - if m.Details.ParameterSize != "" { - parts = append(parts, m.Details.ParameterSize) - } - if m.Details.QuantizationLevel != "" { - parts = append(parts, m.Details.QuantizationLevel) - } - name := m.Name - if len(parts) > 0 { - name = strings.Join(parts, " ") - } - models = append(models, ModelInfo{ID: m.Name, Name: name}) - } - return models, nil -} - func withReasoningCapabilities(models []ModelInfo) []ModelInfo { result := make([]ModelInfo, 0, len(models)) for _, model := range models { diff --git a/internal/http/provider_models_catalog.go b/internal/http/provider_models_catalog.go new file mode 100644 index 00000000..75c3611e --- /dev/null +++ b/internal/http/provider_models_catalog.go @@ -0,0 +1,103 @@ +package http + +// 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.7", Name: "MiniMax M2.7"}, + {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"}, + } +} + +// chatGPTOAuthModels returns models available via ChatGPT OAuth integration. +func chatGPTOAuthModels() []ModelInfo { + return withReasoningCapabilities([]ModelInfo{ + {ID: "gpt-5.4", Name: "GPT-5.4"}, + {ID: "gpt-5.4-mini", Name: "GPT-5.4 Mini"}, + {ID: "gpt-5.3-codex", Name: "GPT-5.3 Codex"}, + {ID: "gpt-5.3-codex-spark", Name: "GPT-5.3 Codex Spark"}, + {ID: "gpt-5.2-codex", Name: "GPT-5.2 Codex"}, + {ID: "gpt-5.2", Name: "GPT-5.2"}, + {ID: "gpt-5.1-codex", Name: "GPT-5.1 Codex"}, + {ID: "gpt-5.1-codex-max", Name: "GPT-5.1 Codex Max"}, + {ID: "gpt-5.1-codex-mini", Name: "GPT-5.1 Codex Mini"}, + {ID: "gpt-5.1", Name: "GPT-5.1"}, + }) +} diff --git a/internal/http/provider_models_fetch.go b/internal/http/provider_models_fetch.go new file mode 100644 index 00000000..04cd85f3 --- /dev/null +++ b/internal/http/provider_models_fetch.go @@ -0,0 +1,191 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +// 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 +} + +// 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 +} + +// fetchOllamaModels calls Ollama's native /api/tags endpoint to get model metadata +// including parameter size, quantization level, and model family. +// The api_base may include a /v1 suffix (from issue #654 normalization) — strip it +// before appending /api/tags since /api/tags lives at the root, not under /v1. +func (h *ProvidersHandler) fetchOllamaModels(ctx context.Context, apiBase, apiKey string) ([]ModelInfo, error) { + // Strip /v1 suffix if present, then trim trailing slash. + base := strings.TrimRight(strings.TrimSuffix(strings.TrimRight(apiBase, "/"), "/v1"), "/") + url := config.DockerLocalhost(base + "/api/tags") + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + if apiKey != "" { + 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("ollama /api/tags returned %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Models []struct { + Name string `json:"name"` + Details struct { + Family string `json:"family"` + ParameterSize string `json:"parameter_size"` + QuantizationLevel string `json:"quantization_level"` + } `json:"details"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode ollama response: %w", err) + } + + models := make([]ModelInfo, 0, len(result.Models)) + for _, m := range result.Models { + // Build a human-readable display name: "family paramSize quantLevel" e.g. "gemma4 8.0B Q4_K_M" + var parts []string + if m.Details.Family != "" { + parts = append(parts, m.Details.Family) + } + if m.Details.ParameterSize != "" { + parts = append(parts, m.Details.ParameterSize) + } + if m.Details.QuantizationLevel != "" { + parts = append(parts, m.Details.QuantizationLevel) + } + name := m.Name + if len(parts) > 0 { + name = strings.Join(parts, " ") + } + models = append(models, ModelInfo{ID: m.Name, Name: name}) + } + return models, nil +}