Skip to content

Commit df2c88f

Browse files
authored
feat(runtime): centralize provider selection and direct fallback (#54)
1 parent 072fcdf commit df2c88f

8 files changed

Lines changed: 1095 additions & 10 deletions

File tree

client/lazy_provider.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package client
2+
3+
import "context"
4+
5+
// LazyProvider adapts EyrieClient to the Provider interface without eagerly
6+
// resolving credentials or constructing a concrete provider.
7+
type LazyProvider struct {
8+
client *EyrieClient
9+
provider string
10+
}
11+
12+
// NewLazyProvider creates a provider wrapper that resolves the concrete
13+
// provider only when chat or ping operations are invoked.
14+
func NewLazyProvider(cfg *EyrieConfig) *LazyProvider {
15+
c := Client(cfg)
16+
provider := c.defaultProvider
17+
if cfg != nil && cfg.Provider != "" {
18+
provider = cfg.Provider
19+
}
20+
return &LazyProvider{
21+
client: c,
22+
provider: provider,
23+
}
24+
}
25+
26+
func (p *LazyProvider) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) {
27+
if opts.Provider == "" {
28+
opts.Provider = p.provider
29+
}
30+
return p.client.Chat(ctx, messages, opts)
31+
}
32+
33+
func (p *LazyProvider) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) {
34+
if opts.Provider == "" {
35+
opts.Provider = p.provider
36+
}
37+
return p.client.StreamChat(ctx, messages, opts)
38+
}
39+
40+
func (p *LazyProvider) Ping(ctx context.Context) error {
41+
return p.client.Ping(ctx, p.provider)
42+
}
43+
44+
func (p *LazyProvider) Name() string {
45+
return p.provider
46+
}
47+
48+
func (p *LazyProvider) SetAPIKey(provider, apiKey string) {
49+
p.client.SetAPIKey(provider, apiKey)
50+
}

runtime/default_provider.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,38 @@ package runtime
22

33
import (
44
"context"
5+
"os"
56
"sort"
7+
"strings"
8+
"time"
69

710
"github.com/GrayCodeAI/eyrie/catalog"
811
"github.com/GrayCodeAI/eyrie/config"
12+
"github.com/GrayCodeAI/eyrie/credentials"
913
)
1014

15+
var chatProviderPreferenceOrder = []string{
16+
"openai",
17+
"anthropic",
18+
"openrouter",
19+
"grok",
20+
"gemini",
21+
"vertex",
22+
"bedrock",
23+
"zai_coding",
24+
"zai_payg",
25+
"canopywave",
26+
"deepseek",
27+
"azure",
28+
"opencodego",
29+
"kimi",
30+
"xiaomi_mimo_payg",
31+
"xiaomi_mimo_token_plan",
32+
"minimax_token_plan",
33+
"minimax_payg",
34+
"ollama",
35+
}
36+
1137
// DefaultModelProviderFilter returns the catalog provider id to use when listing models
1238
// with no explicit provider (e.g. /config model picker after paste-key).
1339
// Order: provider.json default → first configured deployment (stable sort by id).
@@ -35,3 +61,143 @@ func DefaultModelProviderFilter(ctx context.Context) string {
3561
}
3662
return ""
3763
}
64+
65+
// PreferredProvider returns the runtime-owned provider choice when a host has
66+
// not pinned one explicitly. Active selection wins first, then inferred model
67+
// ownership, then configured providers ordered by runtime preference, and
68+
// finally credential detection as a last resort.
69+
func PreferredProvider(ctx context.Context) string {
70+
if ctx == nil {
71+
ctx = context.Background()
72+
}
73+
if provider := normalizeRuntimeProviderID(ActiveProvider(ctx)); provider != "" && providerConfigured(ctx, provider) {
74+
return provider
75+
}
76+
if model := ActiveModel(ctx); model != "" {
77+
if provider := inferProviderForModel(ctx, model); provider != "" && providerConfigured(ctx, provider) {
78+
return provider
79+
}
80+
}
81+
if provider := preferredConfiguredProvider(ctx); provider != "" {
82+
return provider
83+
}
84+
return preferredDetectedProvider()
85+
}
86+
87+
func preferredConfiguredProvider(ctx context.Context) string {
88+
rt, err := Load(ctx)
89+
if err != nil || rt == nil {
90+
return ""
91+
}
92+
rows, err := rt.DeploymentRows()
93+
if err != nil || len(rows) == 0 {
94+
return ""
95+
}
96+
configured := make(map[string]struct{}, len(rows))
97+
for _, row := range rows {
98+
if !row.Configured {
99+
continue
100+
}
101+
if provider := catalog.CanonicalProviderID(row.ProviderID); provider != "" {
102+
configured[provider] = struct{}{}
103+
}
104+
}
105+
for _, provider := range chatProviderPreferenceOrder {
106+
if _, ok := configured[provider]; ok {
107+
return provider
108+
}
109+
}
110+
111+
ordered := make([]string, 0, len(configured))
112+
for provider := range configured {
113+
ordered = append(ordered, provider)
114+
}
115+
sort.Strings(ordered)
116+
if len(ordered) == 0 {
117+
return ""
118+
}
119+
return ordered[0]
120+
}
121+
122+
func preferredDetectedProvider() string {
123+
for _, provider := range chatProviderPreferenceOrder {
124+
switch provider {
125+
case "ollama":
126+
if runtimeEnvValue("OLLAMA_BASE_URL") != "" {
127+
return provider
128+
}
129+
default:
130+
profile, ok := runtimeProfileForProvider(provider)
131+
if !ok {
132+
continue
133+
}
134+
ready := true
135+
for _, envKey := range profile.DetectionEnv {
136+
if runtimeEnvValue(envKey) == "" {
137+
ready = false
138+
break
139+
}
140+
}
141+
if ready {
142+
return provider
143+
}
144+
}
145+
}
146+
return ""
147+
}
148+
149+
func runtimeProfileForProvider(provider string) (config.RuntimeProviderProfile, bool) {
150+
switch provider {
151+
case "anthropic":
152+
return config.AnthropicRuntimeProfile, true
153+
case "openai":
154+
return config.OpenAIRuntimeProfile, true
155+
case "openrouter":
156+
return config.OpenRouterRuntimeProfile, true
157+
case "grok":
158+
return config.GrokRuntimeProfile, true
159+
case "gemini":
160+
return config.GeminiRuntimeProfile, true
161+
case "vertex":
162+
return config.VertexRuntimeProfile, true
163+
case "bedrock":
164+
return config.BedrockRuntimeProfile, true
165+
case "zai_coding":
166+
return config.ZAICodingRuntimeProfile, true
167+
case "zai_payg":
168+
return config.ZAIPaygRuntimeProfile, true
169+
case "canopywave":
170+
return config.CanopyWaveRuntimeProfile, true
171+
case "deepseek":
172+
return config.DeepSeekRuntimeProfile, true
173+
case "azure":
174+
return config.AzureRuntimeProfile, true
175+
case "opencodego":
176+
return config.OpenCodeGoRuntimeProfile, true
177+
case "kimi":
178+
return config.KimiRuntimeProfile, true
179+
case "xiaomi_mimo_payg":
180+
return config.XiaomiPaygRuntimeProfile, true
181+
case "xiaomi_mimo_token_plan":
182+
return config.XiaomiTokenPlanRuntimeProfile, true
183+
case "minimax_token_plan":
184+
return config.MiniMaxTokenPlanRuntimeProfile, true
185+
case "minimax_payg":
186+
return config.MiniMaxPaygRuntimeProfile, true
187+
default:
188+
return config.RuntimeProviderProfile{}, false
189+
}
190+
}
191+
192+
func runtimeEnvValue(key string) string {
193+
key = strings.TrimSpace(key)
194+
if key == "" {
195+
return ""
196+
}
197+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
198+
defer cancel()
199+
if value := credentials.LookupSecret(ctx, key); value != "" {
200+
return value
201+
}
202+
return strings.TrimSpace(os.Getenv(key))
203+
}

0 commit comments

Comments
 (0)