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",