diff --git a/internal/providers/azure/azure.go b/internal/providers/azure/azure.go index 7b3eac28..95c3f97a 100644 --- a/internal/providers/azure/azure.go +++ b/internal/providers/azure/azure.go @@ -2,6 +2,7 @@ package azure import ( "context" + "log/slog" "net/http" "net/url" "strconv" @@ -30,6 +31,7 @@ type Provider struct { resourceProvider *openai.CompatibleProvider openAIResourceProvider *openai.CompatibleProvider apiVersion string + configuredModels []string } func New(providerCfg providers.ProviderConfig, opts providers.ProviderOptions) core.Provider { @@ -37,10 +39,12 @@ func New(providerCfg providers.ProviderConfig, opts providers.ProviderOptions) c apiVersion := providers.ResolveAPIVersion(providerCfg.APIVersion, defaultAPIVersion) p := &Provider{apiVersion: apiVersion} clientCfg := openai.CompatibleProviderConfig{ - ProviderName: "azure", - BaseURL: baseURL, - SetHeaders: setHeaders, + ProviderName: "azure", + BaseURL: baseURL, + SetHeaders: setHeaders, + ConfiguredModels: opts.Models, } + p.configuredModels = opts.Models p.CompatibleProvider = openai.NewCompatibleProvider(providerCfg.APIKey, opts, clientCfg) p.resourceProvider = openai.NewCompatibleProvider(providerCfg.APIKey, opts, clientCfg) p.openAIResourceProvider = openai.NewCompatibleProvider(providerCfg.APIKey, opts, clientCfg) @@ -80,7 +84,31 @@ func (p *Provider) ListModels(ctx context.Context) (*core.ModelsResponse, error) Method: http.MethodGet, Endpoint: "/openai/models", }, &resp); err != nil { - return nil, err + if len(p.configuredModels) == 0 { + return nil, err + } + + slog.Warn("azure upstream ListModels failed, using configured models fallback", + "error", err, + "configured_models", len(p.configuredModels), + ) + + data := make([]core.Model, 0, len(p.configuredModels)) + for _, modelID := range p.configuredModels { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + data = append(data, core.Model{ + ID: modelID, + Object: "model", + OwnedBy: "azure", + }) + } + return &core.ModelsResponse{ + Object: "list", + Data: data, + }, nil } return &resp, nil } diff --git a/internal/providers/openai/compatible_provider.go b/internal/providers/openai/compatible_provider.go index e8e3da9a..be11c4de 100644 --- a/internal/providers/openai/compatible_provider.go +++ b/internal/providers/openai/compatible_provider.go @@ -3,9 +3,11 @@ package openai import ( "context" "io" + "log/slog" "net/http" "net/url" "strconv" + "strings" "gomodel/internal/core" "gomodel/internal/llmclient" @@ -15,24 +17,27 @@ import ( type RequestMutator func(*llmclient.Request) type CompatibleProviderConfig struct { - ProviderName string - BaseURL string - SetHeaders func(*http.Request, string) - RequestMutator RequestMutator + ProviderName string + BaseURL string + SetHeaders func(*http.Request, string) + RequestMutator RequestMutator + ConfiguredModels []string } type CompatibleProvider struct { - client *llmclient.Client - apiKey string - providerName string - requestMutator RequestMutator + client *llmclient.Client + apiKey string + providerName string + requestMutator RequestMutator + configuredModels []string } func NewCompatibleProvider(apiKey string, opts providers.ProviderOptions, cfg CompatibleProviderConfig) *CompatibleProvider { p := &CompatibleProvider{ - apiKey: apiKey, - providerName: cfg.ProviderName, - requestMutator: cfg.RequestMutator, + apiKey: apiKey, + providerName: cfg.ProviderName, + requestMutator: cfg.RequestMutator, + configuredModels: normalizeConfiguredModels(cfg.ConfiguredModels), } clientCfg := llmclient.Config{ ProviderName: cfg.ProviderName, @@ -54,9 +59,10 @@ func NewCompatibleProviderWithHTTPClient(apiKey string, httpClient *http.Client, httpClient = http.DefaultClient } p := &CompatibleProvider{ - apiKey: apiKey, - providerName: cfg.ProviderName, - requestMutator: cfg.RequestMutator, + apiKey: apiKey, + providerName: cfg.ProviderName, + requestMutator: cfg.RequestMutator, + configuredModels: normalizeConfiguredModels(cfg.ConfiguredModels), } clientCfg := llmclient.DefaultConfig(cfg.ProviderName, cfg.BaseURL) clientCfg.Hooks = hooks @@ -127,6 +133,53 @@ func (p *CompatibleProvider) StreamChatCompletion(ctx context.Context, req *core } func (p *CompatibleProvider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + if len(p.configuredModels) == 0 { + return p.doListModels(ctx) + } + + resp, err := p.doListModels(ctx) + if err != nil { + slog.Warn("openai-compatible upstream ListModels failed, using configured models fallback", + "provider", p.providerName, + "error", err, + "configured_models", len(p.configuredModels), + ) + } + + byID := make(map[string]core.Model, len(p.configuredModels)) + if resp != nil { + for _, model := range resp.Data { + byID[strings.TrimSpace(model.ID)] = model + } + } + + data := make([]core.Model, 0, len(p.configuredModels)) + for _, modelID := range p.configuredModels { + model, ok := byID[modelID] + if !ok { + model = core.Model{ + ID: modelID, + Object: "model", + OwnedBy: p.providerName, + } + } else { + if strings.TrimSpace(model.Object) == "" { + model.Object = "model" + } + if strings.TrimSpace(model.OwnedBy) == "" { + model.OwnedBy = p.providerName + } + } + data = append(data, model) + } + + return &core.ModelsResponse{ + Object: "list", + Data: data, + }, nil +} + +func (p *CompatibleProvider) doListModels(ctx context.Context) (*core.ModelsResponse, error) { var resp core.ModelsResponse err := p.Do(ctx, llmclient.Request{ Method: http.MethodGet, @@ -492,3 +545,28 @@ func responseInputItemsEndpoint(id string, params core.ResponseInputItemsParams) } return endpoint } + +// normalizeConfiguredModels deduplicates and trims model names. +func normalizeConfiguredModels(models []string) []string { + if len(models) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(models)) + normalized := make([]string, 0, len(models)) + for _, model := range models { + model = strings.TrimSpace(model) + if model == "" { + continue + } + if _, exists := seen[model]; exists { + continue + } + seen[model] = struct{}{} + normalized = append(normalized, model) + } + if len(normalized) == 0 { + return nil + } + return normalized +} diff --git a/internal/providers/openai/compatible_provider_test.go b/internal/providers/openai/compatible_provider_test.go new file mode 100644 index 00000000..5343c2bd --- /dev/null +++ b/internal/providers/openai/compatible_provider_test.go @@ -0,0 +1,272 @@ +package openai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gomodel/internal/core" + "gomodel/internal/llmclient" +) + +func TestCompatibleProvider_ListModels_UsesConfiguredFallbackWhenUpstreamFailsWithHTML(t *testing.T) { + htmlBody := `ErrorNot Found` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/models" { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(htmlBody)) + } + })) + defer server.Close() + + provider := NewCompatibleProviderWithHTTPClient( + "test-key", + server.Client(), + llmclient.Hooks{}, + CompatibleProviderConfig{ + ProviderName: "opencode-go", + BaseURL: server.URL, + ConfiguredModels: []string{"glm-5.1", "glm-5", "kimi-k2.5"}, + }, + ) + + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if resp == nil { + t.Fatal("expected response, got nil") + return + } + if len(resp.Data) != 3 { + t.Fatalf("len(resp.Data) = %d, want 3", len(resp.Data)) + } + expected := []string{"glm-5.1", "glm-5", "kimi-k2.5"} + for i, id := range expected { + if resp.Data[i].ID != id { + t.Errorf("resp.Data[%d].ID = %q, want %q", i, resp.Data[i].ID, id) + } + if resp.Data[i].Object != "model" { + t.Errorf("resp.Data[%d].Object = %q, want model", i, resp.Data[i].Object) + } + if resp.Data[i].OwnedBy != "opencode-go" { + t.Errorf("resp.Data[%d].OwnedBy = %q, want opencode-go", i, resp.Data[i].OwnedBy) + } + } +} + +func TestCompatibleProvider_ListModels_UsesConfiguredFallbackWhenUpstreamReturnsJSONError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/models" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"Invalid API key"}}`)) + } + })) + defer server.Close() + + provider := NewCompatibleProviderWithHTTPClient( + "test-key", + server.Client(), + llmclient.Hooks{}, + CompatibleProviderConfig{ + ProviderName: "my-provider", + BaseURL: server.URL, + ConfiguredModels: []string{"custom-model-v1"}, + }, + ) + + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if resp == nil { + t.Fatal("expected response, got nil") + return + } + if len(resp.Data) != 1 || resp.Data[0].ID != "custom-model-v1" { + t.Fatalf("unexpected models: %+v", resp.Data) + } +} + +func TestCompatibleProvider_ListModels_MergesUpstreamMetadataWhenAvailable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(core.ModelsResponse{ + Object: "list", + Data: []core.Model{ + {ID: "shared-model", Object: "model", OwnedBy: "upstream", Created: 999}, + }, + }) + })) + defer server.Close() + + provider := NewCompatibleProviderWithHTTPClient( + "test-key", + server.Client(), + llmclient.Hooks{}, + CompatibleProviderConfig{ + ProviderName: "merged-provider", + BaseURL: server.URL, + ConfiguredModels: []string{ + "shared-model", + "only-configured", + }, + }, + ) + + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(resp.Data) != 2 { + t.Fatalf("len(resp.Data) = %d, want 2", len(resp.Data)) + } + + // shared-model should carry upstream metadata + var shared, onlyConfigured core.Model + for i := range resp.Data { + if resp.Data[i].ID == "shared-model" { + shared = resp.Data[i] + } + if resp.Data[i].ID == "only-configured" { + onlyConfigured = resp.Data[i] + } + } + if shared.ID != "shared-model" || shared.Object != "model" { + t.Errorf("shared model: id=%q, object=%q", shared.ID, shared.Object) + } + // Shared model keeps upstream OwnedBy since it's non-empty + if shared.OwnedBy != "upstream" { + t.Errorf("shared-model.OwnedBy = %q, want upstream", shared.OwnedBy) + } + + if onlyConfigured.ID != "only-configured" { + t.Fatalf("only-configured model: id=%q", onlyConfigured.ID) + } + if onlyConfigured.Object != "model" { + t.Errorf("only-configured.Object = %q, want model", onlyConfigured.Object) + } + if onlyConfigured.OwnedBy != "merged-provider" { + t.Errorf("only-configured.OwnedBy = %q, want merged-provider", onlyConfigured.OwnedBy) + } +} + +func TestCompatibleProvider_ListModels_NoConfiguredModels_OriginalBehaviorWhenUpstreamFails(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + provider := NewCompatibleProviderWithHTTPClient( + "test-key", + server.Client(), + llmclient.Hooks{}, + CompatibleProviderConfig{ + ProviderName: "test-provider", + BaseURL: server.URL, + ConfiguredModels: nil, + }, + ) + + _, err := provider.ListModels(context.Background()) + if err == nil { + t.Fatal("expected error when upstream fails and no configured models, got nil") + } + gatewayErr, ok := err.(*core.GatewayError) + if !ok { + t.Fatalf("error type = %T, want *core.GatewayError", err) + } + // Upstream returns 404 so error type is not_found_error; the important + // invariant is that an error (not a fallback) is returned when no + // configured models are present. + if gatewayErr.Type != core.ErrorTypeProvider && gatewayErr.Type != core.ErrorTypeNotFound { + t.Errorf("gatewayErr.Type = %q, want provider_error or not_found_error", gatewayErr.Type) + } +} + +func TestCompatibleProvider_ListModels_NoConfiguredModels_ReturnsUpstreamOnSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"gpt-4o","object":"model","owned_by":"openai"}]}`)) + })) + defer server.Close() + + provider := NewCompatibleProviderWithHTTPClient( + "test-key", + server.Client(), + llmclient.Hooks{}, + CompatibleProviderConfig{ + ProviderName: "upstream-only", + BaseURL: server.URL, + ConfiguredModels: nil, + }, + ) + + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(resp.Data) != 1 || resp.Data[0].ID != "gpt-4o" { + t.Fatalf("unexpected models: %+v", resp.Data) + } +} + +func TestCompatibleProvider_ListModels_EmptyConfiguredModels_OriginalBehavior(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"gpt-4o","object":"model"}]}`)) + })) + defer server.Close() + + provider := NewCompatibleProviderWithHTTPClient( + "test-key", + server.Client(), + llmclient.Hooks{}, + CompatibleProviderConfig{ + ProviderName: "test", + BaseURL: server.URL, + ConfiguredModels: []string{}, // explicitly empty + }, + ) + + resp, err := provider.ListModels(context.Background()) + if err != nil { + t.Fatalf("ListModels() error = %v", err) + } + if len(resp.Data) != 1 || resp.Data[0].ID != "gpt-4o" { + t.Fatalf("unexpected models: %+v", resp.Data) + } +} + +func TestNormalizeConfiguredModels(t *testing.T) { + got := normalizeConfiguredModels([]string{ + " glm-5.1 ", + "", + "glm-5", + "glm-5.1", // duplicate + " ", // whitespace only + }) + + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if got[0] != "glm-5.1" || got[1] != "glm-5" { + t.Fatalf("got = %v, want [glm-5.1 glm-5]", got) + } +} + +func TestNormalizeConfiguredModels_AllEmpty(t *testing.T) { + got := normalizeConfiguredModels([]string{"", " ", ""}) + if got != nil { + t.Fatalf("got = %v, want nil", got) + } +} + +func TestNormalizeConfiguredModels_NilInput(t *testing.T) { + got := normalizeConfiguredModels(nil) + if got != nil { + t.Fatalf("got = %v, want nil", got) + } +} diff --git a/internal/providers/openai/openai.go b/internal/providers/openai/openai.go index bae594ef..3429355a 100644 --- a/internal/providers/openai/openai.go +++ b/internal/providers/openai/openai.go @@ -35,9 +35,10 @@ func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Prov baseURL := providers.ResolveBaseURL(cfg.BaseURL, defaultBaseURL) return &Provider{ CompatibleProvider: NewCompatibleProvider(cfg.APIKey, opts, CompatibleProviderConfig{ - ProviderName: "openai", - BaseURL: baseURL, - SetHeaders: setHeaders, + ProviderName: "openai", + BaseURL: baseURL, + SetHeaders: setHeaders, + ConfiguredModels: opts.Models, }), } } diff --git a/internal/providers/openrouter/openrouter.go b/internal/providers/openrouter/openrouter.go index b7f4230a..178c32b9 100644 --- a/internal/providers/openrouter/openrouter.go +++ b/internal/providers/openrouter/openrouter.go @@ -39,9 +39,10 @@ func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Prov appName: envOrDefault("OPENROUTER_APP_NAME", defaultAppName), } p.CompatibleProvider = openai.NewCompatibleProvider(cfg.APIKey, opts, openai.CompatibleProviderConfig{ - ProviderName: "openrouter", - BaseURL: baseURL, - SetHeaders: setHeaders, + ProviderName: "openrouter", + BaseURL: baseURL, + SetHeaders: setHeaders, + ConfiguredModels: opts.Models, }) p.SetRequestMutator(p.mutateRequest) return p diff --git a/internal/providers/vllm/vllm.go b/internal/providers/vllm/vllm.go index 765b5857..1ca7a29a 100644 --- a/internal/providers/vllm/vllm.go +++ b/internal/providers/vllm/vllm.go @@ -38,9 +38,10 @@ func New(cfg providers.ProviderConfig, opts providers.ProviderOptions) core.Prov rootBaseURL := passthroughBaseURL(baseURL) return &Provider{ compatible: openai.NewCompatibleProvider(cfg.APIKey, opts, openai.CompatibleProviderConfig{ - ProviderName: "vllm", - BaseURL: baseURL, - SetHeaders: setHeaders, + ProviderName: "vllm", + BaseURL: baseURL, + SetHeaders: setHeaders, + ConfiguredModels: opts.Models, }), rootClient: llmclient.New(llmclient.Config{ ProviderName: "vllm",