Skip to content

Commit 083ff79

Browse files
authored
Merge pull request #90 from thushan/hotfix/endpoint/model-url-relative-paths
fix: Alternative method of resolving profile paths
2 parents 9358f2d + eebe4f1 commit 083ff79

File tree

7 files changed

+348
-22
lines changed

7 files changed

+348
-22
lines changed

internal/adapter/discovery/http_client.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,31 @@ func (c *HTTPModelDiscoveryClient) discoverWithAutoDetection(ctx context.Context
138138

139139
// discoverWithProfile performs discovery using a specific platform profile
140140
func (c *HTTPModelDiscoveryClient) discoverWithProfile(ctx context.Context, endpoint *domain.Endpoint, platformProfile domain.PlatformProfile, startTime time.Time) ([]*domain.ModelInfo, error) {
141-
// Prefer the endpoint's explicitly configured model URL if set, otherwise
142-
// fall back to the profile's default discovery path. This allows users to
143-
// override discovery URLs for non-standard deployments.
144-
// This allows backends like Llamacpp to have overridden discovery URLs (see https://github.com/thushan/olla/issues/86)
145-
discoveryURL := endpoint.ModelURLString
141+
// For specific profiles like OpenAI-compatible, skip model_url override by default
142+
// to avoid breaking existing configurations that rely on profile defaults
143+
// Issue #86 fix applies mainly to LlamaCpp, Ollama, etc. where users need custom paths
144+
var discoveryURL string
145+
146+
// Skip model_url override for OpenAI-compatible unless it's a custom path
147+
// This prevents issues with endpoints like Docker that have /models returning HTML
148+
if platformProfile.GetName() == domain.ProfileOpenAICompatible {
149+
if endpoint.ModelURLString != "" {
150+
// Only use override if it's not a common problematic path
151+
// /models often returns HTML in Docker/OpenWebUI setups, not model data
152+
if endpoint.ModelURLString != "/v1/models" && endpoint.ModelURLString != "/models" {
153+
discoveryURL = endpoint.ModelURLString
154+
}
155+
// If it's /models, use profile default (/v1/models) instead
156+
}
157+
// For all other cases, use profile default
158+
} else {
159+
// For other profiles (Ollama, LlamaCpp, etc.), respect the override
160+
// This fixes Issue #86 where users need custom discovery paths
161+
if endpoint.ModelURLString != "" {
162+
discoveryURL = endpoint.ModelURLString
163+
}
164+
}
165+
146166
if discoveryURL == "" {
147167
discoveryURL = platformProfile.GetModelDiscoveryURL(endpoint.URLString)
148168
}
@@ -176,7 +196,8 @@ func (c *HTTPModelDiscoveryClient) discoverWithProfile(ctx context.Context, endp
176196

177197
models, err := platformProfile.ParseModelsResponse(body)
178198
if err != nil {
179-
return nil, NewDiscoveryError(endpoint.URLString, platformProfile.GetName(), "parse_response", resp.StatusCode, duration, err)
199+
// Include the actual discovery URL in the error for debugging
200+
return nil, NewDiscoveryError(discoveryURL, platformProfile.GetName(), "parse_response", resp.StatusCode, duration, err)
180201
}
181202

182203
c.updateMetrics(func(m *DiscoveryMetrics) {

internal/adapter/discovery/http_client_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,88 @@ func TestModelURLFallbackToProfileDefault(t *testing.T) {
809809
}
810810
}
811811

812+
// TestOpenAICompatibleModelURLBehavior verifies special handling for OpenAI-compatible
813+
// profiles where /v1/models override is ignored to preserve existing configurations.
814+
func TestOpenAICompatibleModelURLBehavior(t *testing.T) {
815+
tests := []struct {
816+
name string
817+
modelURLConfig string // What user configures
818+
expectProfileDefault bool // Whether we expect the profile default to be used
819+
expectedPath string // Expected discovery path
820+
}{
821+
{
822+
name: "OpenAI-compatible with /v1/models uses profile default",
823+
modelURLConfig: "/v1/models",
824+
expectProfileDefault: true,
825+
expectedPath: "/v1/models",
826+
},
827+
{
828+
name: "OpenAI-compatible with custom path uses override",
829+
modelURLConfig: "/api/v2/models", // Path, will be combined with server URL
830+
expectProfileDefault: false,
831+
expectedPath: "/api/v2/models",
832+
},
833+
{
834+
name: "OpenAI-compatible with /models ignored (Docker/OpenWebUI case)",
835+
modelURLConfig: "/models", // Often returns HTML, not JSON
836+
expectProfileDefault: true,
837+
expectedPath: "/v1/models",
838+
},
839+
{
840+
name: "OpenAI-compatible with empty model_url uses profile default",
841+
modelURLConfig: "",
842+
expectProfileDefault: true,
843+
expectedPath: "/v1/models",
844+
},
845+
}
846+
847+
for _, tt := range tests {
848+
t.Run(tt.name, func(t *testing.T) {
849+
var requestedPath string
850+
851+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
852+
requestedPath = r.URL.Path
853+
t.Logf("Server received request at path: %s", r.URL.Path)
854+
855+
// Only respond to the expected path
856+
if r.URL.Path == tt.expectedPath {
857+
w.WriteHeader(http.StatusOK)
858+
w.Write([]byte(`{"object": "list", "data": [{"id": "test-model", "object": "model"}]}`))
859+
return
860+
}
861+
862+
w.WriteHeader(http.StatusNotFound)
863+
}))
864+
defer server.Close()
865+
866+
// For the override test, we need to build the full URL first
867+
modelURLString := tt.modelURLConfig
868+
if tt.modelURLConfig == "/api/v2/models" {
869+
modelURLString = server.URL + tt.modelURLConfig
870+
}
871+
872+
// Create endpoint with configured model URL
873+
endpoint := createTestEndpointWithModelURL(server.URL, domain.ProfileOpenAICompatible, modelURLString)
874+
875+
client := NewHTTPModelDiscoveryClientWithDefaults(createTestProfileFactory(t), createTestLogger())
876+
877+
models, err := client.DiscoverModels(context.Background(), endpoint)
878+
879+
if err != nil {
880+
t.Fatalf("Unexpected error: %v", err)
881+
}
882+
883+
if requestedPath != tt.expectedPath {
884+
t.Errorf("Expected path %q to be used, but server received request at %q", tt.expectedPath, requestedPath)
885+
}
886+
887+
if len(models) == 0 {
888+
t.Errorf("Expected models to be discovered, got 0")
889+
}
890+
})
891+
}
892+
}
893+
812894
type serverResponse struct {
813895
status int
814896
body string

internal/adapter/discovery/repository.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/thushan/olla/internal/adapter/registry/profile"
1111
"github.com/thushan/olla/internal/config"
1212
"github.com/thushan/olla/internal/core/domain"
13+
"github.com/thushan/olla/internal/util"
1314
)
1415

1516
const (
@@ -140,23 +141,25 @@ func (r *StaticEndpointRepository) LoadFromConfig(ctx context.Context, configs [
140141
return fmt.Errorf("invalid endpoint URL %q: %w", cfg.URL, err)
141142
}
142143

143-
healthCheckPath, err := url.Parse(cfg.HealthCheckURL)
144+
urlString := endpointURL.String()
145+
146+
// Build health check and model URLs using ResolveURLPath to preserve
147+
// the base URL's path prefix. This handles both relative paths and absolute URLs correctly,
148+
// preserving nested paths like http://localhost:12434/engines/llama.cpp/
149+
healthCheckURLString := util.ResolveURLPath(urlString, cfg.HealthCheckURL)
150+
modelURLString := util.ResolveURLPath(urlString, cfg.ModelURL)
151+
152+
healthCheckURL, err := url.Parse(healthCheckURLString)
144153
if err != nil {
145-
return fmt.Errorf("invalid health check URL %q: %w", cfg.HealthCheckURL, err)
154+
return fmt.Errorf("invalid health check URL %q: %w", healthCheckURLString, err)
146155
}
147156

148-
modelPath, err := url.Parse(cfg.ModelURL)
157+
modelURL, err := url.Parse(modelURLString)
149158
if err != nil {
150-
return fmt.Errorf("invalid model URL %q: %w", cfg.ModelURL, err)
159+
return fmt.Errorf("invalid model URL %q: %w", modelURLString, err)
151160
}
152161

153-
healthCheckURL := endpointURL.ResolveReference(healthCheckPath)
154-
modelURL := endpointURL.ResolveReference(modelPath)
155-
156-
urlString := endpointURL.String()
157-
healthCheckPathString := healthCheckPath.String()
158-
healthCheckURLString := healthCheckURL.String()
159-
modelURLString := modelURL.String()
162+
healthCheckPathString := cfg.HealthCheckURL
160163

161164
newEndpoint := &domain.Endpoint{
162165
Name: cfg.Name,

internal/adapter/discovery/repository_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,3 +168,51 @@ func TestStaticEndpointRepository_EmptyConfig(t *testing.T) {
168168
t.Errorf("Expected 0 endpoints for empty config, got %d", len(endpoints))
169169
}
170170
}
171+
172+
func TestStaticEndpointRepository_NestedPathURLs(t *testing.T) {
173+
// Test that endpoints with nested paths (like Docker) correctly preserve
174+
// the path prefix when building model discovery URLs
175+
repo := NewStaticEndpointRepository()
176+
ctx := context.Background()
177+
178+
configs := []config.EndpointConfig{
179+
{
180+
Name: "docker-llamacpp",
181+
URL: "http://localhost:12434/engines/llama.cpp/",
182+
Type: "openai-compatible",
183+
HealthCheckURL: "/health",
184+
ModelURL: "/v1/models",
185+
Priority: 100,
186+
CheckInterval: 5 * time.Second,
187+
CheckTimeout: 2 * time.Second,
188+
},
189+
}
190+
191+
err := repo.LoadFromConfig(ctx, configs)
192+
if err != nil {
193+
t.Fatalf("LoadFromConfig failed: %v", err)
194+
}
195+
196+
endpoints, err := repo.GetAll(ctx)
197+
if err != nil {
198+
t.Fatalf("GetAll failed: %v", err)
199+
}
200+
201+
if len(endpoints) != 1 {
202+
t.Fatalf("Expected 1 endpoint, got %d", len(endpoints))
203+
}
204+
205+
endpoint := endpoints[0]
206+
207+
// Verify the model URL string preserves the path prefix
208+
expectedModelURL := "http://localhost:12434/engines/llama.cpp/v1/models"
209+
if endpoint.ModelURLString != expectedModelURL {
210+
t.Errorf("ModelURLString = %q, expected %q", endpoint.ModelURLString, expectedModelURL)
211+
}
212+
213+
// Verify the health check URL string preserves the path prefix
214+
expectedHealthURL := "http://localhost:12434/engines/llama.cpp/health"
215+
if endpoint.HealthCheckURLString != expectedHealthURL {
216+
t.Errorf("HealthCheckURLString = %q, expected %q", endpoint.HealthCheckURLString, expectedHealthURL)
217+
}
218+
}

internal/adapter/registry/profile/configurable_profile.go

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"time"
77

88
"github.com/thushan/olla/internal/core/domain"
9+
"github.com/thushan/olla/internal/util"
910
"github.com/thushan/olla/internal/util/pattern"
1011
)
1112

@@ -51,11 +52,7 @@ func (p *ConfigurableProfile) GetPath(index int) string {
5152
}
5253

5354
func (p *ConfigurableProfile) GetModelDiscoveryURL(baseURL string) string {
54-
// avoid http://localhost//v1/models nonsense
55-
if baseURL != "" && baseURL[len(baseURL)-1] == '/' {
56-
baseURL = baseURL[:len(baseURL)-1]
57-
}
58-
return baseURL + p.config.API.ModelDiscoveryPath
55+
return util.ResolveURLPath(baseURL, p.config.API.ModelDiscoveryPath)
5956
}
6057

6158
func (p *ConfigurableProfile) GetHealthCheckPath() string {

internal/util/url.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package util
2+
3+
import (
4+
"net/url"
5+
"path"
6+
)
7+
8+
// ResolveURLPath resolves a path or absolute URL against a base URL.
9+
// If pathOrURL is already an absolute URL (has a scheme like http://), it is returned as-is.
10+
// Otherwise, pathOrURL is treated as a relative path and joined with the base URL's path,
11+
// preserving any path prefix in the base URL.
12+
//
13+
// This function uses url.Parse() and path.Join() from the standard library to handle
14+
// URL resolution correctly, avoiding the pitfalls of url.ResolveReference() which treats
15+
// paths starting with "/" as absolute references per RFC 3986 (replacing the entire path).
16+
//
17+
// Examples:
18+
// - ResolveURLPath("http://localhost:12434/api/", "/v1/models") -> "http://localhost:12434/api/v1/models"
19+
// - ResolveURLPath("http://localhost:12434/api/", "http://other:9000/models") -> "http://other:9000/models"
20+
func ResolveURLPath(baseURL, pathOrURL string) string {
21+
if baseURL == "" {
22+
return pathOrURL
23+
}
24+
if pathOrURL == "" {
25+
return baseURL
26+
}
27+
28+
// Check if pathOrURL is already an absolute URL
29+
if parsed, err := url.Parse(pathOrURL); err == nil && parsed.IsAbs() {
30+
return pathOrURL
31+
}
32+
33+
// Parse base URL
34+
base, err := url.Parse(baseURL)
35+
if err != nil {
36+
// If base URL is invalid, return pathOrURL as fallback
37+
return pathOrURL
38+
}
39+
40+
// Use path.Join to preserve the base path prefix when joining with relative paths
41+
// and to normalise redundant slashes
42+
base.Path = path.Join(base.Path, pathOrURL)
43+
return base.String()
44+
}

0 commit comments

Comments
 (0)