1package models
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "net/http"
8 "time"
9
10 "shelley.exe.dev/db"
11 "shelley.exe.dev/db/generated"
12 "shelley.exe.dev/llm"
13 "shelley.exe.dev/llm/ant"
14 "shelley.exe.dev/llm/gem"
15 "shelley.exe.dev/llm/llmhttp"
16 "shelley.exe.dev/llm/oai"
17 "shelley.exe.dev/loop"
18)
19
20// Provider represents an LLM provider
21type Provider string
22
23const (
24 ProviderOpenAI Provider = "openai"
25 ProviderAnthropic Provider = "anthropic"
26 ProviderFireworks Provider = "fireworks"
27 ProviderGemini Provider = "gemini"
28 ProviderBuiltIn Provider = "builtin"
29)
30
31// ModelSource describes where a model's configuration comes from
32type ModelSource string
33
34const (
35 SourceGateway ModelSource = "exe.dev gateway"
36 SourceEnvVar ModelSource = "env" // Will be combined with env var name
37 SourceCustom ModelSource = "custom" // User-configured custom model
38)
39
40// Model represents a configured LLM model in Shelley
41type Model struct {
42 // ID is the user-facing identifier for this model
43 ID string
44
45 // Provider is the LLM provider (OpenAI, Anthropic, etc.)
46 Provider Provider
47
48 // Description is a human-readable description
49 Description string
50
51 // Tags is a comma-separated list of tags (e.g., "slug")
52 Tags string
53
54 // RequiredEnvVars are the environment variables required for this model
55 RequiredEnvVars []string
56
57 // GatewayEnabled indicates whether this model is available when using a gateway
58 GatewayEnabled bool
59
60 // Factory creates an llm.Service instance for this model
61 Factory func(config *Config, httpc *http.Client) (llm.Service, error)
62}
63
64// Source returns a human-readable description of where this model's configuration comes from.
65// For example: "exe.dev gateway", "$ANTHROPIC_API_KEY", etc.
66func (m Model) Source(cfg *Config) string {
67 // Predictable model has no source
68 if m.ID == "predictable" {
69 return ""
70 }
71
72 // Check if using gateway with implicit keys
73 if cfg.Gateway != "" {
74 // Gateway is configured - check if this model is using gateway (implicit key)
75 switch m.Provider {
76 case ProviderAnthropic:
77 if cfg.AnthropicAPIKey == "implicit" {
78 return string(SourceGateway)
79 }
80 return "$ANTHROPIC_API_KEY"
81 case ProviderOpenAI:
82 if cfg.OpenAIAPIKey == "implicit" {
83 return string(SourceGateway)
84 }
85 return "$OPENAI_API_KEY"
86 case ProviderFireworks:
87 if cfg.FireworksAPIKey == "implicit" {
88 return string(SourceGateway)
89 }
90 return "$FIREWORKS_API_KEY"
91 case ProviderGemini:
92 if cfg.GeminiAPIKey == "implicit" {
93 return string(SourceGateway)
94 }
95 return "$GEMINI_API_KEY"
96 }
97 }
98
99 // No gateway - use env var names based on RequiredEnvVars
100 if len(m.RequiredEnvVars) > 0 {
101 return "$" + m.RequiredEnvVars[0]
102 }
103 return ""
104}
105
106// Config holds the configuration needed to create LLM services
107type Config struct {
108 // API keys for each provider
109 AnthropicAPIKey string
110 OpenAIAPIKey string
111 GeminiAPIKey string
112 FireworksAPIKey string
113
114 // Gateway is the base URL of the LLM gateway (optional)
115 // If set, model-specific suffixes will be appended
116 Gateway string
117
118 Logger *slog.Logger
119
120 // Database for recording LLM requests (optional)
121 DB *db.DB
122}
123
124// getAnthropicURL returns the Anthropic API URL, with gateway suffix if gateway is set
125func (c *Config) getAnthropicURL() string {
126 if c.Gateway != "" {
127 return c.Gateway + "/_/gateway/anthropic/v1/messages"
128 }
129 return "" // use default from ant package
130}
131
132// getOpenAIURL returns the OpenAI API URL, with gateway suffix if gateway is set
133func (c *Config) getOpenAIURL() string {
134 if c.Gateway != "" {
135 return c.Gateway + "/_/gateway/openai/v1"
136 }
137 return "" // use default from oai package
138}
139
140// getGeminiURL returns the Gemini API URL, with gateway suffix if gateway is set
141func (c *Config) getGeminiURL() string {
142 if c.Gateway != "" {
143 return c.Gateway + "/_/gateway/gemini/v1/models/generate"
144 }
145 return "" // use default from gem package
146}
147
148// getFireworksURL returns the Fireworks API URL, with gateway suffix if gateway is set
149func (c *Config) getFireworksURL() string {
150 if c.Gateway != "" {
151 return c.Gateway + "/_/gateway/fireworks/inference/v1"
152 }
153 return "" // use default from oai package
154}
155
156// All returns all available models in Shelley
157func All() []Model {
158 return []Model{
159 {
160 ID: "claude-opus-4.5",
161 Provider: ProviderAnthropic,
162 Description: "Claude Opus 4.5 (default)",
163 RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
164 GatewayEnabled: true,
165 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
166 if config.AnthropicAPIKey == "" {
167 return nil, fmt.Errorf("claude-opus-4.5 requires ANTHROPIC_API_KEY")
168 }
169 svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Opus, HTTPC: httpc}
170 if url := config.getAnthropicURL(); url != "" {
171 svc.URL = url
172 }
173 return svc, nil
174 },
175 },
176 {
177 ID: "claude-sonnet-4.5",
178 Provider: ProviderAnthropic,
179 Description: "Claude Sonnet 4.5",
180 RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
181 GatewayEnabled: true,
182 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
183 if config.AnthropicAPIKey == "" {
184 return nil, fmt.Errorf("claude-sonnet-4.5 requires ANTHROPIC_API_KEY")
185 }
186 svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Sonnet, HTTPC: httpc}
187 if url := config.getAnthropicURL(); url != "" {
188 svc.URL = url
189 }
190 return svc, nil
191 },
192 },
193 {
194 ID: "claude-haiku-4.5",
195 Provider: ProviderAnthropic,
196 Description: "Claude Haiku 4.5",
197 RequiredEnvVars: []string{"ANTHROPIC_API_KEY"},
198 GatewayEnabled: true,
199 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
200 if config.AnthropicAPIKey == "" {
201 return nil, fmt.Errorf("claude-haiku-4.5 requires ANTHROPIC_API_KEY")
202 }
203 svc := &ant.Service{APIKey: config.AnthropicAPIKey, Model: ant.Claude45Haiku, HTTPC: httpc}
204 if url := config.getAnthropicURL(); url != "" {
205 svc.URL = url
206 }
207 return svc, nil
208 },
209 },
210 {
211 ID: "glm-4.7-fireworks",
212 Provider: ProviderFireworks,
213 Description: "GLM-4.7 on Fireworks",
214 RequiredEnvVars: []string{"FIREWORKS_API_KEY"},
215 GatewayEnabled: true,
216 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
217 if config.FireworksAPIKey == "" {
218 return nil, fmt.Errorf("glm-4.7-fireworks requires FIREWORKS_API_KEY")
219 }
220 svc := &oai.Service{Model: oai.GLM47Fireworks, APIKey: config.FireworksAPIKey, HTTPC: httpc}
221 if url := config.getFireworksURL(); url != "" {
222 svc.ModelURL = url
223 }
224 return svc, nil
225 },
226 },
227 {
228 ID: "gpt-5.2-codex",
229 Provider: ProviderOpenAI,
230 Description: "GPT-5.2 Codex",
231 RequiredEnvVars: []string{"OPENAI_API_KEY"},
232 GatewayEnabled: true,
233 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
234 if config.OpenAIAPIKey == "" {
235 return nil, fmt.Errorf("gpt-5.2-codex requires OPENAI_API_KEY")
236 }
237 svc := &oai.ResponsesService{Model: oai.GPT52Codex, APIKey: config.OpenAIAPIKey, HTTPC: httpc}
238 if url := config.getOpenAIURL(); url != "" {
239 svc.ModelURL = url
240 }
241 return svc, nil
242 },
243 },
244 {
245 ID: "qwen3-coder-fireworks",
246 Provider: ProviderFireworks,
247 Description: "Qwen3 Coder 480B on Fireworks",
248 Tags: "slug",
249 RequiredEnvVars: []string{"FIREWORKS_API_KEY"},
250 GatewayEnabled: true,
251 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
252 if config.FireworksAPIKey == "" {
253 return nil, fmt.Errorf("qwen3-coder-fireworks requires FIREWORKS_API_KEY")
254 }
255 svc := &oai.Service{Model: oai.Qwen3CoderFireworks, APIKey: config.FireworksAPIKey, HTTPC: httpc}
256 if url := config.getFireworksURL(); url != "" {
257 svc.ModelURL = url
258 }
259 return svc, nil
260 },
261 },
262 {
263 ID: "glm-4p6-fireworks",
264 Provider: ProviderFireworks,
265 Description: "GLM-4P6 on Fireworks",
266 RequiredEnvVars: []string{"FIREWORKS_API_KEY"},
267 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
268 if config.FireworksAPIKey == "" {
269 return nil, fmt.Errorf("glm-4p6-fireworks requires FIREWORKS_API_KEY")
270 }
271 svc := &oai.Service{Model: oai.GLM4P6Fireworks, APIKey: config.FireworksAPIKey, HTTPC: httpc}
272 if url := config.getFireworksURL(); url != "" {
273 svc.ModelURL = url
274 }
275 return svc, nil
276 },
277 },
278 {
279 ID: "gemini-3-pro",
280 Provider: ProviderGemini,
281 Description: "Gemini 3 Pro",
282 RequiredEnvVars: []string{"GEMINI_API_KEY"},
283 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
284 if config.GeminiAPIKey == "" {
285 return nil, fmt.Errorf("gemini-3-pro requires GEMINI_API_KEY")
286 }
287 svc := &gem.Service{APIKey: config.GeminiAPIKey, Model: "gemini-3-pro-preview", HTTPC: httpc}
288 if url := config.getGeminiURL(); url != "" {
289 svc.URL = url
290 }
291 return svc, nil
292 },
293 },
294 {
295 ID: "gemini-3-flash",
296 Provider: ProviderGemini,
297 Description: "Gemini 3 Flash",
298 RequiredEnvVars: []string{"GEMINI_API_KEY"},
299 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
300 if config.GeminiAPIKey == "" {
301 return nil, fmt.Errorf("gemini-3-flash requires GEMINI_API_KEY")
302 }
303 svc := &gem.Service{APIKey: config.GeminiAPIKey, Model: "gemini-3-flash-preview", HTTPC: httpc}
304 if url := config.getGeminiURL(); url != "" {
305 svc.URL = url
306 }
307 return svc, nil
308 },
309 },
310 {
311 ID: "predictable",
312 Provider: ProviderBuiltIn,
313 Description: "Deterministic test model (no API key)",
314 // Used for testing; should be harmless.
315 GatewayEnabled: true,
316 RequiredEnvVars: []string{},
317 Factory: func(config *Config, httpc *http.Client) (llm.Service, error) {
318 return loop.NewPredictableService(), nil
319 },
320 },
321 }
322}
323
324// ByID returns the model with the given ID, or nil if not found
325func ByID(id string) *Model {
326 for _, m := range All() {
327 if m.ID == id {
328 return &m
329 }
330 }
331 return nil
332}
333
334// IDs returns all model IDs (not including aliases)
335func IDs() []string {
336 models := All()
337 ids := make([]string, len(models))
338 for i, m := range models {
339 ids[i] = m.ID
340 }
341 return ids
342}
343
344// Default returns the default model
345func Default() Model {
346 return All()[0] // claude-opus-4.5
347}
348
349// Manager manages LLM services for all configured models
350type Manager struct {
351 services map[string]serviceEntry
352 modelOrder []string // ordered list of model IDs (built-in first, then custom)
353 logger *slog.Logger
354 db *db.DB // for custom models and LLM request recording
355 httpc *http.Client // HTTP client with recording middleware
356 cfg *Config // retained for refreshing custom models
357}
358
359type serviceEntry struct {
360 service llm.Service
361 provider Provider
362 modelID string
363 source string // Human-readable source (e.g., "exe.dev gateway", "$ANTHROPIC_API_KEY")
364 displayName string // For custom models, the user-provided display name
365 tags string // For custom models, user-provided tags
366}
367
368// ConfigInfo is an optional interface that services can implement to provide configuration details for logging
369type ConfigInfo interface {
370 // ConfigDetails returns human-readable configuration info (e.g., URL, model name)
371 ConfigDetails() map[string]string
372}
373
374// loggingService wraps an llm.Service to log request completion with usage information
375type loggingService struct {
376 service llm.Service
377 logger *slog.Logger
378 modelID string
379 provider Provider
380 db *db.DB
381}
382
383// Do wraps the underlying service's Do method with logging and database recording
384func (l *loggingService) Do(ctx context.Context, request *llm.Request) (*llm.Response, error) {
385 start := time.Now()
386
387 // Add model ID and provider to context for the HTTP transport
388 ctx = llmhttp.WithModelID(ctx, l.modelID)
389 ctx = llmhttp.WithProvider(ctx, string(l.provider))
390
391 // Call the underlying service
392 response, err := l.service.Do(ctx, request)
393
394 duration := time.Since(start)
395 durationSeconds := duration.Seconds()
396
397 // Log the completion with usage information
398 if err != nil {
399 logAttrs := []any{
400 "model", l.modelID,
401 "duration_seconds", durationSeconds,
402 }
403
404 // Add configuration details if available
405 if configProvider, ok := l.service.(ConfigInfo); ok {
406 for k, v := range configProvider.ConfigDetails() {
407 logAttrs = append(logAttrs, k, v)
408 }
409 }
410
411 logAttrs = append(logAttrs, "error", err)
412 l.logger.Error("LLM request failed", logAttrs...)
413 } else {
414 // Log successful completion with usage info
415 logAttrs := []any{
416 "model", l.modelID,
417 "duration_seconds", durationSeconds,
418 }
419
420 // Add usage information if available
421 if !response.Usage.IsZero() {
422 logAttrs = append(logAttrs,
423 "input_tokens", response.Usage.InputTokens,
424 "output_tokens", response.Usage.OutputTokens,
425 "cost_usd", response.Usage.CostUSD,
426 )
427 if response.Usage.CacheCreationInputTokens > 0 {
428 logAttrs = append(logAttrs, "cache_creation_input_tokens", response.Usage.CacheCreationInputTokens)
429 }
430 if response.Usage.CacheReadInputTokens > 0 {
431 logAttrs = append(logAttrs, "cache_read_input_tokens", response.Usage.CacheReadInputTokens)
432 }
433 }
434
435 l.logger.Info("LLM request completed", logAttrs...)
436 }
437
438 return response, err
439}
440
441// TokenContextWindow delegates to the underlying service
442func (l *loggingService) TokenContextWindow() int {
443 return l.service.TokenContextWindow()
444}
445
446// MaxImageDimension delegates to the underlying service
447func (l *loggingService) MaxImageDimension() int {
448 return l.service.MaxImageDimension()
449}
450
451// UseSimplifiedPatch delegates to the underlying service if it supports it
452func (l *loggingService) UseSimplifiedPatch() bool {
453 if sp, ok := l.service.(llm.SimplifiedPatcher); ok {
454 return sp.UseSimplifiedPatch()
455 }
456 return false
457}
458
459// NewManager creates a new Manager with all models configured
460func NewManager(cfg *Config) (*Manager, error) {
461 manager := &Manager{
462 services: make(map[string]serviceEntry),
463 logger: cfg.Logger,
464 db: cfg.DB,
465 }
466
467 // Create HTTP client with recording if database is available
468 var httpc *http.Client
469 if cfg.DB != nil {
470 recorder := func(ctx context.Context, url string, requestBody, responseBody []byte, statusCode int, err error, duration time.Duration) {
471 modelID := llmhttp.ModelIDFromContext(ctx)
472 provider := llmhttp.ProviderFromContext(ctx)
473 conversationID := llmhttp.ConversationIDFromContext(ctx)
474
475 var convIDPtr *string
476 if conversationID != "" {
477 convIDPtr = &conversationID
478 }
479
480 var reqBodyPtr, respBodyPtr *string
481 if len(requestBody) > 0 {
482 s := string(requestBody)
483 reqBodyPtr = &s
484 }
485 if len(responseBody) > 0 {
486 s := string(responseBody)
487 respBodyPtr = &s
488 }
489
490 var statusCodePtr *int64
491 if statusCode != 0 {
492 sc := int64(statusCode)
493 statusCodePtr = &sc
494 }
495
496 var errPtr *string
497 if err != nil {
498 s := err.Error()
499 errPtr = &s
500 }
501
502 durationMs := duration.Milliseconds()
503 durationMsPtr := &durationMs
504
505 // Insert into database (fire and forget, don't block the request)
506 go func() {
507 _, insertErr := cfg.DB.InsertLLMRequest(context.Background(), generated.InsertLLMRequestParams{
508 ConversationID: convIDPtr,
509 Model: modelID,
510 Provider: provider,
511 Url: url,
512 RequestBody: reqBodyPtr,
513 ResponseBody: respBodyPtr,
514 StatusCode: statusCodePtr,
515 Error: errPtr,
516 DurationMs: durationMsPtr,
517 })
518 if insertErr != nil && cfg.Logger != nil {
519 cfg.Logger.Warn("Failed to record LLM request", "error", insertErr)
520 }
521 }()
522 }
523 httpc = llmhttp.NewClient(nil, recorder)
524 } else {
525 // Still use the custom transport for headers, just without recording
526 httpc = llmhttp.NewClient(nil, nil)
527 }
528
529 // Store the HTTP client and config for use with custom models
530 manager.httpc = httpc
531 manager.cfg = cfg
532
533 // Load built-in models first
534 useGateway := cfg.Gateway != ""
535 for _, model := range All() {
536 // Skip non-gateway-enabled models when using a gateway
537 if useGateway && !model.GatewayEnabled {
538 continue
539 }
540 svc, err := model.Factory(cfg, httpc)
541 if err != nil {
542 // Model not available (e.g., missing API key) - skip it
543 continue
544 }
545
546 manager.services[model.ID] = serviceEntry{
547 service: svc,
548 provider: model.Provider,
549 modelID: model.ID,
550 source: model.Source(cfg),
551 displayName: model.ID, // built-in models use ID as display name
552 tags: model.Tags,
553 }
554 manager.modelOrder = append(manager.modelOrder, model.ID)
555 }
556
557 // Load custom models from database
558 if err := manager.loadCustomModels(); err != nil && cfg.Logger != nil {
559 cfg.Logger.Warn("Failed to load custom models", "error", err)
560 }
561
562 return manager, nil
563}
564
565// loadCustomModels loads custom models from the database into the manager.
566// It adds them after built-in models in the order.
567func (m *Manager) loadCustomModels() error {
568 if m.db == nil {
569 return nil
570 }
571
572 dbModels, err := m.db.GetModels(context.Background())
573 if err != nil {
574 return err
575 }
576
577 for _, model := range dbModels {
578 // Skip if this model ID is already registered (built-in takes precedence)
579 if _, exists := m.services[model.ModelID]; exists {
580 continue
581 }
582
583 svc := m.createServiceFromModel(&model)
584 if svc == nil {
585 continue
586 }
587
588 m.services[model.ModelID] = serviceEntry{
589 service: svc,
590 provider: Provider(model.ProviderType),
591 modelID: model.ModelID,
592 source: string(SourceCustom),
593 displayName: model.DisplayName,
594 tags: model.Tags,
595 }
596 m.modelOrder = append(m.modelOrder, model.ModelID)
597 }
598
599 return nil
600}
601
602// RefreshCustomModels reloads custom models from the database.
603// Call this after adding or removing custom models via the UI.
604func (m *Manager) RefreshCustomModels() error {
605 if m.db == nil {
606 return nil
607 }
608
609 // Remove existing custom models from services and modelOrder
610 newOrder := make([]string, 0, len(m.modelOrder))
611 for _, id := range m.modelOrder {
612 entry, ok := m.services[id]
613 if ok && entry.source != string(SourceCustom) {
614 newOrder = append(newOrder, id)
615 } else {
616 delete(m.services, id)
617 }
618 }
619 m.modelOrder = newOrder
620
621 // Reload custom models
622 return m.loadCustomModels()
623}
624
625// GetService returns the LLM service for the given model ID, wrapped with logging
626func (m *Manager) GetService(modelID string) (llm.Service, error) {
627 entry, ok := m.services[modelID]
628 if !ok {
629 return nil, fmt.Errorf("unsupported model: %s", modelID)
630 }
631
632 // Wrap with logging if we have a logger
633 if m.logger != nil {
634 return &loggingService{
635 service: entry.service,
636 logger: m.logger,
637 modelID: entry.modelID,
638 provider: entry.provider,
639 db: m.db,
640 }, nil
641 }
642 return entry.service, nil
643}
644
645// GetAvailableModels returns a list of available model IDs.
646// Returns union of built-in models (in order) followed by custom models.
647func (m *Manager) GetAvailableModels() []string {
648 // Return a copy to prevent external modification
649 result := make([]string, len(m.modelOrder))
650 copy(result, m.modelOrder)
651 return result
652}
653
654// HasModel reports whether the manager has a service for the given model ID
655func (m *Manager) HasModel(modelID string) bool {
656 _, ok := m.services[modelID]
657 return ok
658}
659
660// ModelInfo contains display name, tags, and source for a model
661type ModelInfo struct {
662 DisplayName string
663 Tags string
664 Source string // Human-readable source (e.g., "exe.dev gateway", "$ANTHROPIC_API_KEY", "custom")
665}
666
667// GetModelInfo returns the display name, tags, and source for a model
668func (m *Manager) GetModelInfo(modelID string) *ModelInfo {
669 entry, ok := m.services[modelID]
670 if !ok {
671 return nil
672 }
673 return &ModelInfo{
674 DisplayName: entry.displayName,
675 Tags: entry.tags,
676 Source: entry.source,
677 }
678}
679
680// createServiceFromModel creates an LLM service from a database model configuration
681func (m *Manager) createServiceFromModel(model *generated.Model) llm.Service {
682 switch model.ProviderType {
683 case "anthropic":
684 return &ant.Service{
685 APIKey: model.ApiKey,
686 URL: model.Endpoint,
687 Model: model.ModelName,
688 HTTPC: m.httpc,
689 }
690 case "openai":
691 return &oai.Service{
692 APIKey: model.ApiKey,
693 ModelURL: model.Endpoint,
694 Model: oai.Model{
695 ModelName: model.ModelName,
696 URL: model.Endpoint,
697 },
698 MaxTokens: int(model.MaxTokens),
699 HTTPC: m.httpc,
700 }
701 case "openai-responses":
702 return &oai.ResponsesService{
703 APIKey: model.ApiKey,
704 ModelURL: model.Endpoint,
705 Model: oai.Model{
706 ModelName: model.ModelName,
707 URL: model.Endpoint,
708 },
709 MaxTokens: int(model.MaxTokens),
710 HTTPC: m.httpc,
711 }
712 case "gemini":
713 return &gem.Service{
714 APIKey: model.ApiKey,
715 URL: model.Endpoint,
716 Model: model.ModelName,
717 HTTPC: m.httpc,
718 }
719 default:
720 if m.logger != nil {
721 m.logger.Error("Unknown provider type for model", "model_id", model.ModelID, "provider_type", model.ProviderType)
722 }
723 return nil
724 }
725}