Changeset 0.29.1 (#196)
This commit is contained in:
132
server/providers/custom.go
Normal file
132
server/providers/custom.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Package providers — custom.go
|
||||
//
|
||||
// v0.29.1 CS4: Config-file provider types. Registers OpenAI-compatible
|
||||
// provider types from a JSON file at startup. No code deploy needed to
|
||||
// add Ollama, LiteLLM, vLLM, or any other OpenAI-compatible endpoint.
|
||||
//
|
||||
// File format (array of objects):
|
||||
//
|
||||
// [
|
||||
// {
|
||||
// "id": "ollama",
|
||||
// "name": "Ollama",
|
||||
// "description": "Local Ollama instance",
|
||||
// "default_endpoint": "http://localhost:11434/v1",
|
||||
// "api": "openai"
|
||||
// },
|
||||
// {
|
||||
// "id": "litellm",
|
||||
// "name": "LiteLLM Proxy",
|
||||
// "description": "LiteLLM unified proxy",
|
||||
// "default_endpoint": "http://litellm:4000/v1",
|
||||
// "api": "openai"
|
||||
// }
|
||||
// ]
|
||||
//
|
||||
// Fields:
|
||||
// - id: Unique provider type ID (used in provider_configs.provider column)
|
||||
// - name: Human-readable label for admin UI
|
||||
// - description: Help text shown in provider creation form
|
||||
// - default_endpoint: Pre-filled endpoint URL for new configs
|
||||
// - api: Wire protocol — "openai" (required, only supported value for now)
|
||||
//
|
||||
// The api field determines which Provider implementation handles
|
||||
// requests. Currently only "openai" is supported (covers Ollama,
|
||||
// LiteLLM, vLLM, LocalAI, and any other OpenAI-compatible API).
|
||||
// Future: "anthropic" for Anthropic-compatible proxies.
|
||||
//
|
||||
// Built-in provider IDs (openai, anthropic, venice, openrouter) cannot
|
||||
// be overridden by config-file entries — they are silently skipped.
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// CustomProviderType represents one entry in the provider types JSON file.
|
||||
type CustomProviderType struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
DefaultEndpoint string `json:"default_endpoint"`
|
||||
API string `json:"api"` // "openai" (required)
|
||||
}
|
||||
|
||||
// builtinIDs is the set of provider IDs registered by Init().
|
||||
// Config-file entries with these IDs are silently skipped.
|
||||
var builtinIDs = map[string]bool{
|
||||
"openai": true,
|
||||
"anthropic": true,
|
||||
"venice": true,
|
||||
"openrouter": true,
|
||||
}
|
||||
|
||||
// LoadCustomTypes reads a JSON file of provider type definitions and
|
||||
// registers each as a new provider type. Only OpenAI-compatible APIs
|
||||
// are currently supported (api="openai").
|
||||
//
|
||||
// Returns the number of providers registered. Errors are non-fatal —
|
||||
// individual invalid entries are logged and skipped.
|
||||
//
|
||||
// Call after Init() so that built-in types are already registered
|
||||
// and can be detected for skip logic.
|
||||
func LoadCustomTypes(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading provider types file: %w", err)
|
||||
}
|
||||
|
||||
var entries []CustomProviderType
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return 0, fmt.Errorf("parsing provider types file: %w", err)
|
||||
}
|
||||
|
||||
registered := 0
|
||||
for _, entry := range entries {
|
||||
// Validate required fields
|
||||
if entry.ID == "" {
|
||||
log.Printf("⚠️ custom provider: skipping entry with empty id")
|
||||
continue
|
||||
}
|
||||
if entry.Name == "" {
|
||||
log.Printf("⚠️ custom provider %q: skipping — name is required", entry.ID)
|
||||
continue
|
||||
}
|
||||
if entry.API == "" {
|
||||
log.Printf("⚠️ custom provider %q: skipping — api is required", entry.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip built-in provider IDs
|
||||
if builtinIDs[entry.ID] {
|
||||
log.Printf("⚠️ custom provider %q: skipping — conflicts with built-in provider", entry.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Resolve API implementation
|
||||
var impl Provider
|
||||
switch entry.API {
|
||||
case "openai":
|
||||
impl = &OpenAIProvider{}
|
||||
default:
|
||||
log.Printf("⚠️ custom provider %q: unsupported api %q (only \"openai\" supported)", entry.ID, entry.API)
|
||||
continue
|
||||
}
|
||||
|
||||
RegisterType(ProviderTypeMeta{
|
||||
ID: entry.ID,
|
||||
Name: entry.Name,
|
||||
Description: entry.Description,
|
||||
DefaultEndpoint: entry.DefaultEndpoint,
|
||||
ProfileSchema: GetProfileSchema(entry.API), // inherit schema from API type
|
||||
}, impl)
|
||||
|
||||
registered++
|
||||
log.Printf(" 📦 Custom provider type: %s (%s) → %s", entry.ID, entry.Name, entry.DefaultEndpoint)
|
||||
}
|
||||
|
||||
return registered, nil
|
||||
}
|
||||
161
server/providers/custom_test.go
Normal file
161
server/providers/custom_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeTempJSON(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "provider_types.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write temp file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_Valid(t *testing.T) {
|
||||
path := writeTempJSON(t, `[
|
||||
{
|
||||
"id": "test-ollama",
|
||||
"name": "Test Ollama",
|
||||
"description": "Local test",
|
||||
"default_endpoint": "http://localhost:11434/v1",
|
||||
"api": "openai"
|
||||
},
|
||||
{
|
||||
"id": "test-litellm",
|
||||
"name": "Test LiteLLM",
|
||||
"description": "LiteLLM proxy",
|
||||
"default_endpoint": "http://litellm:4000/v1",
|
||||
"api": "openai"
|
||||
}
|
||||
]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Errorf("registered = %d, want 2", n)
|
||||
}
|
||||
|
||||
// Verify they're in the registry
|
||||
p, err := Get("test-ollama")
|
||||
if err != nil {
|
||||
t.Errorf("test-ollama not registered: %v", err)
|
||||
}
|
||||
if p == nil {
|
||||
t.Error("test-ollama provider is nil")
|
||||
}
|
||||
|
||||
// Verify type metadata
|
||||
types := ListTypes()
|
||||
found := false
|
||||
for _, m := range types {
|
||||
if m.ID == "test-litellm" {
|
||||
found = true
|
||||
if m.DefaultEndpoint != "http://litellm:4000/v1" {
|
||||
t.Errorf("endpoint = %q", m.DefaultEndpoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("test-litellm not in ListTypes()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_SkipsBuiltins(t *testing.T) {
|
||||
path := writeTempJSON(t, `[
|
||||
{
|
||||
"id": "openai",
|
||||
"name": "Override OpenAI",
|
||||
"description": "Should be skipped",
|
||||
"default_endpoint": "http://evil.com",
|
||||
"api": "openai"
|
||||
},
|
||||
{
|
||||
"id": "test-custom-ok",
|
||||
"name": "Valid Custom",
|
||||
"description": "Should register",
|
||||
"default_endpoint": "http://ok.com",
|
||||
"api": "openai"
|
||||
}
|
||||
]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("registered = %d, want 1 (openai skipped)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_SkipsInvalid(t *testing.T) {
|
||||
path := writeTempJSON(t, `[
|
||||
{"id": "", "name": "No ID", "api": "openai"},
|
||||
{"id": "test-no-name", "name": "", "api": "openai"},
|
||||
{"id": "test-no-api", "name": "No API", "api": ""},
|
||||
{"id": "test-bad-api", "name": "Bad API", "api": "grpc", "default_endpoint": "http://x"},
|
||||
{"id": "test-valid-entry", "name": "Valid", "description": "ok", "default_endpoint": "http://ok", "api": "openai"}
|
||||
]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("registered = %d, want 1 (4 invalid skipped)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_EmptyArray(t *testing.T) {
|
||||
path := writeTempJSON(t, `[]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("registered = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_InvalidJSON(t *testing.T) {
|
||||
path := writeTempJSON(t, `not json`)
|
||||
|
||||
_, err := LoadCustomTypes(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_FileNotFound(t *testing.T) {
|
||||
_, err := LoadCustomTypes("/nonexistent/path/providers.json")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomTypes_NoDefaultEndpoint(t *testing.T) {
|
||||
// default_endpoint is optional — should still register
|
||||
path := writeTempJSON(t, `[
|
||||
{
|
||||
"id": "test-no-endpoint",
|
||||
"name": "No Endpoint",
|
||||
"description": "User must fill in endpoint",
|
||||
"api": "openai"
|
||||
}
|
||||
]`)
|
||||
|
||||
n, err := LoadCustomTypes(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("registered = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user