feat: multi-provider support — Ollama Cloud + OpenCode Go
- Account.Provider field (ollama-cloud | opencode-go), backward compatible - Model-based routing: common models served by combined pool, unique models routed to their provider only - /go/v1/* path forces OpenCode Go provider (prefix stripped upstream) - Merged /v1/models endpoint returns union of both catalogs (44 models) - Failover: 429/402 → cooldown + failover; 5xx → retry without cooldown - CLI: accounts add --provider flag, list shows provider column - Body buffering: request body buffered (8 MiB cap) for failover replay - opencode integration: unified provider 'oc' with all merged models - 64 tests pass (unit + integration) - Verified: glm-5 → ollama, mimo-v2.5 → go, gpt-oss:20b → ollama
This commit is contained in:
parent
98bbc96bf5
commit
4fe15324c8
11 changed files with 973 additions and 125 deletions
|
|
@ -14,13 +14,44 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
// Account is a single Ollama Cloud API key with an optional alias.
|
||||
// ProviderType identifies which upstream service an account belongs to.
|
||||
type ProviderType string
|
||||
|
||||
const (
|
||||
// ProviderOllamaCloud is the Ollama Cloud API at https://ollama.com.
|
||||
ProviderOllamaCloud ProviderType = "ollama-cloud"
|
||||
// ProviderOpenCodeGo is the OpenCode Go API at https://opencode.ai/zen/go/v1.
|
||||
ProviderOpenCodeGo ProviderType = "opencode-go"
|
||||
)
|
||||
|
||||
// DefaultProviderBaseURL returns the canonical upstream root for a provider.
|
||||
func DefaultProviderBaseURL(p ProviderType) string {
|
||||
switch p {
|
||||
case ProviderOpenCodeGo:
|
||||
return "https://opencode.ai/zen/go"
|
||||
default:
|
||||
return "https://ollama.com"
|
||||
}
|
||||
}
|
||||
|
||||
// ValidProvider reports whether p is a recognised provider type.
|
||||
func ValidProvider(p ProviderType) bool {
|
||||
switch p {
|
||||
case ProviderOllamaCloud, ProviderOpenCodeGo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Account is a single upstream API key with an optional alias.
|
||||
type Account struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url,omitempty"` // overrides ServerConfig.BaseURL; empty = use default
|
||||
Created time.Time `json:"created"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Provider ProviderType `json:"provider,omitempty"` // empty = ollama-cloud (backward compat)
|
||||
APIKey string `json:"api_key"`
|
||||
BaseURL string `json:"base_url,omitempty"` // overrides the provider default; empty = use default
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
// AccountsFile is the on-disk JSON structure.
|
||||
|
|
@ -73,6 +104,13 @@ func LoadAccounts() (*AccountsFile, error) {
|
|||
if err := json.Unmarshal(data, &af); err != nil {
|
||||
return nil, fmt.Errorf("parse accounts: %w", err)
|
||||
}
|
||||
// Backward compat: accounts without an explicit provider default to
|
||||
// ollama-cloud (the original behaviour before multi-provider support).
|
||||
for i := range af.Accounts {
|
||||
if af.Accounts[i].Provider == "" {
|
||||
af.Accounts[i].Provider = ProviderOllamaCloud
|
||||
}
|
||||
}
|
||||
return &af, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,60 @@ func TestValidateName(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProviderType_Constants(t *testing.T) {
|
||||
if ProviderOllamaCloud != "ollama-cloud" {
|
||||
t.Errorf("ProviderOllamaCloud = %q", ProviderOllamaCloud)
|
||||
}
|
||||
if ProviderOpenCodeGo != "opencode-go" {
|
||||
t.Errorf("ProviderOpenCodeGo = %q", ProviderOpenCodeGo)
|
||||
}
|
||||
if !ValidProvider(ProviderOllamaCloud) || !ValidProvider(ProviderOpenCodeGo) {
|
||||
t.Error("ValidProvider returned false for known providers")
|
||||
}
|
||||
if ValidProvider(ProviderType("bogus")) {
|
||||
t.Error("ValidProvider returned true for bogus provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultProviderBaseURL(t *testing.T) {
|
||||
if DefaultProviderBaseURL(ProviderOllamaCloud) != "https://ollama.com" {
|
||||
t.Errorf("ollama base url = %q", DefaultProviderBaseURL(ProviderOllamaCloud))
|
||||
}
|
||||
if DefaultProviderBaseURL(ProviderOpenCodeGo) != "https://opencode.ai/zen/go" {
|
||||
t.Errorf("go base url = %q", DefaultProviderBaseURL(ProviderOpenCodeGo))
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadAccounts_BackwardCompatProvider verifies that accounts.json entries
|
||||
// without a "provider" field are normalised to ollama-cloud on load.
|
||||
func TestLoadAccounts_BackwardCompatProvider(t *testing.T) {
|
||||
p := withTempAccountsPath(t)
|
||||
// Write an old-style file with no provider field.
|
||||
old := `{
|
||||
"base_url": "https://ollama.com",
|
||||
"accounts": [
|
||||
{"id":"abc","name":"legacy","api_key":"sk-xyz","created":"2026-06-19T10:00:00Z"},
|
||||
{"id":"def","name":"go1","provider":"opencode-go","api_key":"sk-go","created":"2026-06-19T10:00:00Z"}
|
||||
]
|
||||
}`
|
||||
if err := os.WriteFile(p, []byte(old), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
af, err := LoadAccounts()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(af.Accounts) != 2 {
|
||||
t.Fatalf("len = %d", len(af.Accounts))
|
||||
}
|
||||
if af.Accounts[0].Name != "legacy" || af.Accounts[0].Provider != ProviderOllamaCloud {
|
||||
t.Errorf("legacy account provider = %q, want ollama-cloud", af.Accounts[0].Provider)
|
||||
}
|
||||
if af.Accounts[1].Provider != ProviderOpenCodeGo {
|
||||
t.Errorf("go account provider = %q, want opencode-go", af.Accounts[1].Provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAccounts_MissingFile(t *testing.T) {
|
||||
withTempAccountsPath(t)
|
||||
af, err := LoadAccounts()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue