Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 87 additions & 4 deletions catalog/live/fetchers_providers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package live

import (
"bytes"
"context"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -550,7 +551,10 @@ func FetchOllama(env map[string]string) ([]Entry, error) {
var entries []Entry
for _, raw := range payload.Models {
var m struct {
Name string `json:"name"`
Name string `json:"name"`
Details struct {
ContextLength int `json:"context_length"`
} `json:"details"`
}
if err := json.Unmarshal(raw, &m); err != nil {
continue
Expand All @@ -559,14 +563,93 @@ func FetchOllama(env map[string]string) ([]Entry, error) {
if id == "" {
continue
}
entries = append(entries, Entry{
entry := Entry{
ID: id, DisplayName: id,
RawJSON: append(json.RawMessage(nil), raw...),
})
ContextWindow: m.Details.ContextLength,
RawJSON: append(json.RawMessage(nil), raw...),
}
enrichOllamaEntry(root, &entry)
entries = append(entries, entry)
}
return entries, nil
}

type ollamaShowResponse struct {
Capabilities []string `json:"capabilities"`
Details struct {
ContextLength int `json:"context_length"`
} `json:"details"`
ModelInfo map[string]json.RawMessage `json:"model_info"`
}

// enrichOllamaEntry reads the model capabilities that Ollama exposes only from
// /api/show. Older Ollama versions or restricted gateways may not implement the
// endpoint, so the tag listing remains usable when enrichment fails.
func enrichOllamaEntry(root string, entry *Entry) {
if entry == nil || strings.TrimSpace(entry.ID) == "" {
return
}
body, err := json.Marshal(struct {
Name string `json:"name"`
}{Name: entry.ID})
if err != nil {
return
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, root+"/api/show", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "eyrie-model-catalog/1.0")

resp, err := httpClient.Do(req)
if err != nil {
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return
}
var show ollamaShowResponse
if err := decodeJSONLimited(resp.Body, &show); err != nil {
return
}
for _, capability := range show.Capabilities {
switch strings.ToLower(strings.TrimSpace(capability)) {
case "tools", "function_calling", "function-calling":
entry.Features = appendOllamaFeature(entry.Features, "tools")
case "thinking":
entry.Features = appendOllamaFeature(entry.Features, "thinking:enabled")
case "vision":
entry.Features = appendOllamaFeature(entry.Features, "vision")
}
}
if show.Details.ContextLength > 0 {
entry.ContextWindow = show.Details.ContextLength
return
}
for key, raw := range show.ModelInfo {
if !strings.HasSuffix(key, ".context_length") {
continue
}
var contextWindow int
if err := json.Unmarshal(raw, &contextWindow); err == nil && contextWindow > 0 {
entry.ContextWindow = contextWindow
return
}
}
}

func appendOllamaFeature(features []string, feature string) []string {
for _, existing := range features {
if existing == feature {
return features
}
}
return append(features, feature)
}

// FetchDeepSeek lists models from the DeepSeek OpenAI-compatible API.
func FetchDeepSeek(env map[string]string) ([]Entry, error) {
entries, err := fetchOpenAICompatModels(
Expand Down
60 changes: 60 additions & 0 deletions catalog/live/live_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,63 @@ func TestFetchOllama_EmptyModels(t *testing.T) {
t.Fatalf("expected 0 entries, got %d", len(entries))
}
}

func TestFetchOllama_EnrichesCapabilities(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{
"models": []any{map[string]any{"name": "deepseek-r1:7b"}},
})
case "/api/show":
_ = json.NewEncoder(w).Encode(map[string]any{
"capabilities": []string{"completion", "thinking", "tools"},
"model_info": map[string]any{"qwen2.context_length": 131072},
})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()

entries, err := FetchOllama(map[string]string{"OLLAMA_BASE_URL": srv.URL + "/v1"})
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
entry := entries[0]
if entry.ContextWindow != 131072 {
t.Fatalf("context window = %d, want 131072", entry.ContextWindow)
}
if len(entry.Features) != 2 || entry.Features[0] != "thinking:enabled" || entry.Features[1] != "tools" {
t.Fatalf("features = %v, want [thinking:enabled tools]", entry.Features)
}
}

func TestFetchOllama_UsesTagsWhenShowUnavailable(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/tags" {
_ = json.NewEncoder(w).Encode(map[string]any{
"models": []any{map[string]any{"name": "text-model"}},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()

entries, err := FetchOllama(map[string]string{"OLLAMA_BASE_URL": srv.URL + "/v1"})
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].ID != "text-model" {
t.Fatalf("entries = %+v", entries)
}
if len(entries[0].Features) != 0 {
t.Fatalf("unexpected features after unavailable show endpoint: %v", entries[0].Features)
}
}
Loading