models.go

  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}