package model import ( "errors" "fmt" "log/slog" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) // Provider names accepted by the provider-aware factory. These are the only two // protocols the factory understands today; adding a vendor means adding a case // here plus a matching adapter, not touching the rest of the package. const ( // ProviderOpenAI selects the OpenAI-compatible /chat/completions protocol. ProviderOpenAI = "openai" // ProviderAnthropic selects the Anthropic Messages /v1/messages protocol. ProviderAnthropic = "anthropic" ) // ErrUnknownProvider is returned by the provider-aware factory when the // configured provider name is not recognized. var ErrUnknownProvider = errors.New("model: unknown provider") // NewChatAdapter builds a single provider adapter selected by cfg.Provider. The // empty provider defaults to openai for backward compatibility. An unrecognized // provider returns ErrUnknownProvider. func NewChatAdapter(cfg config.LLMConfig) (Adapter, error) { switch cfg.Provider { case ProviderOpenAI, "": return NewOpenAIChat(cfg), nil case ProviderAnthropic: return NewAnthropicChat(cfg), nil default: return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider) } } // NewChatChain builds a fallback Chain for the configured provider: the primary // model first, then the provider's fallback model when set and distinct. Each // step is served by an adapter of the selected protocol. The empty provider // defaults to openai. An unrecognized provider returns ErrUnknownProvider. func NewChatChain(cfg config.LLMConfig) (*Chain, error) { return NewChatChainWithLog(cfg, nil) } // NewChatChainWithLog is NewChatChain with a logger attached to every adapter it // builds, so a provider-level reasoning decision (a thinking budget raised to fit // max_tokens, a reasoning_effort value downgraded or ignored) is reported with // its module source. A nil logger leaves the adapters' discard default in place. // // It is also the only builder that appends the optional local third rung // (裁决 03.4.3, primary → fallback → local), because that rung may speak a // different protocol than the active group and therefore needs the raw logger to // build its own adapter. It is appended only when the local rung is enabled AND // fully configured; otherwise the chain keeps its two-level shape and the reason // is reported. func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) { var chain *Chain var err error switch cfg.Provider { case ProviderOpenAI, "": chain, err = newOpenAIChain(cfg, WithOpenAILogger(log)) case ProviderAnthropic: chain, err = newAnthropicChain(cfg, WithAnthropicLogger(log)) default: return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider) } if err != nil { return nil, err } if step, ok := localChainStep(cfg, log); ok { chain.steps = append(chain.steps, step) } return chain, nil } // localChainStep builds the local third degradation rung as a chain step. It // returns ok=false (and, when the rung was asked for but is incomplete, a WARN // naming the missing field) when the rung is off, unconfigured or its provider is // unknown — a half-configured step would only ever fail, which is worse than not // having it. func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) { local := cfg.Local // Configured() already requires Enabled, so the rung is off by default and a // disabled rung can never become a chain step. The warning is emitted only // when the rung was actually asked for but is incomplete. if !local.Configured() { if local.Enabled { warn(log, "local model rung enabled but not configured; skipping it", "base_url_set", strings.TrimSpace(local.BaseURL) != "", "model_set", strings.TrimSpace(local.Model) != "") } return Step{}, false } localCfg := config.LLMConfig{ Provider: local.Provider, BaseURL: local.BaseURL, APIKey: local.APIKey, Model: local.Model, } switch local.Provider { case ProviderAnthropic: return Step{Model: local.Model, Adapter: NewAnthropicChat(localCfg, WithAnthropicLogger(log))}, true case ProviderOpenAI, "": return Step{Model: local.Model, Adapter: NewOpenAIChat(localCfg, WithOpenAILogger(log))}, true default: warn(log, "local model rung skipped: unknown provider", "provider", local.Provider, "field", "LLM_LOCAL_PROVIDER") return Step{}, false } } // newOpenAIChain builds a fallback chain from the OpenAI-compatible config block. // Each step is an OpenAIChatAdapter against the configured endpoint. func newOpenAIChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) { if cfg.BaseURL == "" || cfg.Model == "" { return nil, ErrNoModel } steps := []Step{{Model: cfg.Model, Adapter: NewOpenAIChat(cfg, opts...)}} if cfg.Fallback != "" && cfg.Fallback != cfg.Model { steps = append(steps, Step{Model: cfg.Fallback, Adapter: NewOpenAIChat(cfg, opts...)}) } return NewChain(steps...), nil } // newAnthropicChain builds a fallback chain from the Anthropic config block. Each // step is an AnthropicChatAdapter against the configured /v1/messages endpoint. func newAnthropicChain(cfg config.LLMConfig, opts ...AnthropicOption) (*Chain, error) { if cfg.AnthropicBaseURL == "" || cfg.AnthropicModel == "" { return nil, ErrNoModel } steps := []Step{{Model: cfg.AnthropicModel, Adapter: NewAnthropicChat(cfg, opts...)}} if cfg.AnthropicFallback != "" && cfg.AnthropicFallback != cfg.AnthropicModel { steps = append(steps, Step{Model: cfg.AnthropicFallback, Adapter: NewAnthropicChat(cfg, opts...)}) } return NewChain(steps...), nil } // DefaultModelForProvider returns the primary model name for the configured // provider: the flat OpenAI Model for "openai", the Anthropic model for // "anthropic". Used by callers (e.g. cmd/cyrene) to seed a Router's default. func DefaultModelForProvider(cfg config.LLMConfig) string { if cfg.Provider == ProviderAnthropic { return cfg.AnthropicModel } return cfg.Model } // warn reports through the injected logger, falling back to a discard logger that // still carries the module source so an unsourced record can never be emitted // (project rule §10). It exists because a chain may be built without a logger // (NewChatChain) while a skipped rung must still be reported. func warn(log *slog.Logger, msg string, args ...any) { if log == nil { log = discardLogger("model") } log.Warn(msg, args...) }