Files
coder/coderd/chatd/chatprovider/chatprovider.go
T
Kyle Carberry edee917d88 feat: add experimental agents support (#22290)
feat: add AI chat system with agent tools and chat UI

Introduce the chatd subsystem and Agents UI for AI-powered chat
within Coder workspaces.

- Add chatd package with chat loop, message compaction, prompt
  management, and LLM provider integration (OpenAI, Anthropic)
- Add agent tools: create workspace, list/read templates, read/write/
  edit files, execute commands
- Add chat API endpoints with streaming, message editing, and
  durable reconnection
- Add database schema and migrations for chats, chat messages, chat
  providers, and chat model configs
- Add RBAC policies and dbauthz enforcement for chat resources
- Add Agents UI pages with conversation timeline, queued messages
  list, diff viewer, and model configuration panel
- Add comprehensive test coverage including coderd integration tests,
  chatd unit tests, and Storybook stories
- Gate feature behind experiments flag

---------

Co-authored-by: Cian Johnston <cian@coder.com>
Co-authored-by: Danielle Maywood <danielle@themaywoods.com>
Co-authored-by: Jeremy Ruppel <jeremy@coder.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 16:50:56 +00:00

1331 lines
39 KiB
Go

package chatprovider
import (
"context"
"sort"
"strings"
"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
fantasyazure "charm.land/fantasy/providers/azure"
fantasybedrock "charm.land/fantasy/providers/bedrock"
fantasygoogle "charm.land/fantasy/providers/google"
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
fantasyopenrouter "charm.land/fantasy/providers/openrouter"
fantasyvercel "charm.land/fantasy/providers/vercel"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/codersdk"
)
var supportedProviderNames = []string{
fantasyanthropic.Name,
fantasyazure.Name,
fantasybedrock.Name,
fantasygoogle.Name,
fantasyopenai.Name,
fantasyopenaicompat.Name,
fantasyopenrouter.Name,
fantasyvercel.Name,
}
var envPresetProviderNames = []string{
fantasyopenai.Name,
fantasyanthropic.Name,
}
var providerDisplayNameByName = map[string]string{
fantasyanthropic.Name: "Anthropic",
fantasyazure.Name: "Azure OpenAI",
fantasybedrock.Name: "AWS Bedrock",
fantasygoogle.Name: "Google",
fantasyopenai.Name: "OpenAI",
fantasyopenaicompat.Name: "OpenAI Compatible",
fantasyopenrouter.Name: "OpenRouter",
fantasyvercel.Name: "Vercel AI Gateway",
}
// SupportedProviders returns all chat providers supported by Fantasy.
func SupportedProviders() []string {
return append([]string(nil), supportedProviderNames...)
}
// IsEnvPresetProvider reports whether provider supports env presets.
func IsEnvPresetProvider(provider string) bool {
normalized := NormalizeProvider(provider)
for _, candidate := range envPresetProviderNames {
if candidate == normalized {
return true
}
}
return false
}
// ProviderDisplayName returns a default display name for a provider.
func ProviderDisplayName(provider string) string {
normalized := NormalizeProvider(provider)
if displayName, ok := providerDisplayNameByName[normalized]; ok {
return displayName
}
return normalized
}
// ProviderAPIKeys contains API keys for provider calls.
type ProviderAPIKeys struct {
OpenAI string
Anthropic string
ByProvider map[string]string
BaseURLByProvider map[string]string
}
// ConfiguredProvider is an enabled provider loaded from database config.
type ConfiguredProvider struct {
Provider string
APIKey string
BaseURL string
}
// ConfiguredModel is an enabled model loaded from database config.
type ConfiguredModel struct {
Provider string
Model string
DisplayName string
}
// APIKey returns the effective API key for a provider.
func (k ProviderAPIKeys) APIKey(provider string) string {
normalized := NormalizeProvider(provider)
if normalized == "" {
return ""
}
if k.ByProvider != nil {
if key := strings.TrimSpace(k.ByProvider[normalized]); key != "" {
return key
}
}
switch normalized {
case fantasyopenai.Name:
return strings.TrimSpace(k.OpenAI)
case fantasyanthropic.Name:
return strings.TrimSpace(k.Anthropic)
default:
return ""
}
}
//nolint:revive // Intentional: apiKey is the unexported helper for APIKey.
func (k ProviderAPIKeys) apiKey(provider string) string {
return k.APIKey(provider)
}
// BaseURL returns the configured base URL for a provider.
func (k ProviderAPIKeys) BaseURL(provider string) string {
normalized := NormalizeProvider(provider)
if normalized == "" || k.BaseURLByProvider == nil {
return ""
}
return strings.TrimSpace(k.BaseURLByProvider[normalized])
}
// MergeProviderAPIKeys overlays configured provider keys over fallback keys.
func MergeProviderAPIKeys(fallback ProviderAPIKeys, providers []ConfiguredProvider) ProviderAPIKeys {
merged := ProviderAPIKeys{
OpenAI: strings.TrimSpace(fallback.OpenAI),
Anthropic: strings.TrimSpace(fallback.Anthropic),
ByProvider: map[string]string{},
BaseURLByProvider: map[string]string{},
}
for provider, apiKey := range fallback.ByProvider {
normalizedProvider := NormalizeProvider(provider)
if normalizedProvider == "" {
continue
}
if key := strings.TrimSpace(apiKey); key != "" {
merged.ByProvider[normalizedProvider] = key
}
}
for provider, baseURL := range fallback.BaseURLByProvider {
normalizedProvider := NormalizeProvider(provider)
if normalizedProvider == "" {
continue
}
if url := strings.TrimSpace(baseURL); url != "" {
merged.BaseURLByProvider[normalizedProvider] = url
}
}
if merged.OpenAI != "" {
merged.ByProvider[fantasyopenai.Name] = merged.OpenAI
}
if merged.Anthropic != "" {
merged.ByProvider[fantasyanthropic.Name] = merged.Anthropic
}
for _, provider := range providers {
normalizedProvider := NormalizeProvider(provider.Provider)
if normalizedProvider == "" {
continue
}
if key := strings.TrimSpace(provider.APIKey); key != "" {
merged.ByProvider[normalizedProvider] = key
}
if url := strings.TrimSpace(provider.BaseURL); url != "" {
merged.BaseURLByProvider[normalizedProvider] = url
}
switch normalizedProvider {
case fantasyopenai.Name:
if key := strings.TrimSpace(provider.APIKey); key != "" {
merged.OpenAI = key
}
case fantasyanthropic.Name:
if key := strings.TrimSpace(provider.APIKey); key != "" {
merged.Anthropic = key
}
}
}
return merged
}
type ModelCatalog struct {
keys ProviderAPIKeys
}
func NewModelCatalog(keys ProviderAPIKeys) *ModelCatalog {
return &ModelCatalog{
keys: keys,
}
}
// ListConfiguredModels returns a model catalog from enabled DB-backed model
// configs. The second return value reports whether DB-backed models were used.
func (c *ModelCatalog) ListConfiguredModels(
configuredProviders []ConfiguredProvider,
configuredModels []ConfiguredModel,
) (codersdk.ChatModelsResponse, bool) {
if len(configuredModels) == 0 {
return codersdk.ChatModelsResponse{}, false
}
modelsByProvider := make(map[string][]codersdk.ChatModel)
seenByProvider := make(map[string]map[string]struct{})
providerSet := make(map[string]struct{})
for _, provider := range configuredProviders {
normalized := normalizeProvider(provider.Provider)
if normalized == "" {
continue
}
providerSet[normalized] = struct{}{}
}
for _, model := range configuredModels {
provider, modelID, err := ResolveModelWithProviderHint(model.Model, model.Provider)
if err != nil {
continue
}
providerSet[provider] = struct{}{}
if seenByProvider[provider] == nil {
seenByProvider[provider] = make(map[string]struct{})
}
normalizedModelID := strings.ToLower(strings.TrimSpace(modelID))
if _, ok := seenByProvider[provider][normalizedModelID]; ok {
continue
}
seenByProvider[provider][normalizedModelID] = struct{}{}
modelsByProvider[provider] = append(
modelsByProvider[provider],
newChatModel(provider, modelID, model.DisplayName),
)
}
providers := orderProviders(providerSet)
if len(providers) == 0 {
return codersdk.ChatModelsResponse{}, false
}
keys := MergeProviderAPIKeys(c.keys, configuredProviders)
response := codersdk.ChatModelsResponse{
Providers: make([]codersdk.ChatModelProvider, 0, len(providers)),
}
for _, provider := range providers {
models := modelsByProvider[provider]
sortChatModels(models)
result := codersdk.ChatModelProvider{
Provider: provider,
Models: models,
}
if keys.apiKey(provider) == "" {
result.Available = false
result.UnavailableReason = codersdk.ChatModelProviderUnavailableMissingAPIKey
} else {
result.Available = true
}
response.Providers = append(response.Providers, result)
}
return response, true
}
// ListConfiguredProviderAvailability returns provider availability derived from
// deployment/env keys merged with enabled DB provider keys.
func (c *ModelCatalog) ListConfiguredProviderAvailability(
configuredProviders []ConfiguredProvider,
) codersdk.ChatModelsResponse {
keys := MergeProviderAPIKeys(c.keys, configuredProviders)
response := codersdk.ChatModelsResponse{
Providers: make([]codersdk.ChatModelProvider, 0, len(supportedProviderNames)),
}
for _, provider := range supportedProviderNames {
result := codersdk.ChatModelProvider{
Provider: provider,
Models: []codersdk.ChatModel{},
}
if keys.apiKey(provider) == "" {
result.Available = false
result.UnavailableReason = codersdk.ChatModelProviderUnavailableMissingAPIKey
} else {
result.Available = true
}
response.Providers = append(response.Providers, result)
}
return response
}
func newChatModel(provider, modelID, displayName string) codersdk.ChatModel {
name := strings.TrimSpace(displayName)
if name == "" {
name = modelID
}
return codersdk.ChatModel{
ID: canonicalModelID(provider, modelID),
Provider: provider,
Model: modelID,
DisplayName: name,
}
}
func sortChatModels(models []codersdk.ChatModel) {
sort.Slice(models, func(i, j int) bool {
return models[i].Model < models[j].Model
})
}
func canonicalModelID(provider, modelID string) string {
return NormalizeProvider(provider) + ":" + strings.TrimSpace(modelID)
}
func orderProviders(providerSet map[string]struct{}) []string {
if len(providerSet) == 0 {
return nil
}
ordered := make([]string, 0, len(providerSet))
for _, provider := range supportedProviderNames {
if _, ok := providerSet[provider]; ok {
ordered = append(ordered, provider)
}
}
// Unknown providers are dropped. The providerSet keys are
// already normalized, so any provider not in
// supportedProviderNames is silently excluded.
return ordered
}
// NormalizeProvider canonicalizes a provider name.
func NormalizeProvider(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case fantasyanthropic.Name:
return fantasyanthropic.Name
case fantasyazure.Name:
return fantasyazure.Name
case fantasybedrock.Name:
return fantasybedrock.Name
case fantasygoogle.Name:
return fantasygoogle.Name
case fantasyopenai.Name:
return fantasyopenai.Name
case fantasyopenaicompat.Name:
return fantasyopenaicompat.Name
case fantasyopenrouter.Name:
return fantasyopenrouter.Name
case fantasyvercel.Name:
return fantasyvercel.Name
default:
return ""
}
}
//nolint:revive // Intentional: normalizeProvider is the unexported helper for NormalizeProvider.
func normalizeProvider(provider string) string {
return NormalizeProvider(provider)
}
func ResolveModelWithProviderHint(modelName, providerHint string) (provider string, model string, err error) {
modelName = strings.TrimSpace(modelName)
if modelName == "" {
return "", "", xerrors.New("model is required")
}
if provider, modelID, ok := parseCanonicalModelRef(modelName); ok {
return provider, modelID, nil
}
if provider := normalizeProvider(providerHint); provider != "" {
return provider, modelName, nil
}
normalized := strings.ToLower(modelName)
switch normalized {
case "claude-opus-4-6":
return fantasyanthropic.Name, "claude-opus-4-6", nil
case "gpt-5.2":
return fantasyopenai.Name, "gpt-5.2", nil
case "gemini-2.5-flash":
return fantasygoogle.Name, "gemini-2.5-flash", nil
}
if isChatModelForProvider(fantasyanthropic.Name, normalized) {
return fantasyanthropic.Name, modelName, nil
}
if isChatModelForProvider(fantasyopenai.Name, normalized) {
return fantasyopenai.Name, modelName, nil
}
return "", "", xerrors.Errorf("unknown model %q", modelName)
}
func parseCanonicalModelRef(modelRef string) (provider string, model string, ok bool) {
modelRef = strings.TrimSpace(modelRef)
if modelRef == "" {
return "", "", false
}
for _, separator := range []string{":", "/"} {
parts := strings.SplitN(modelRef, separator, 2)
if len(parts) != 2 {
continue
}
provider := normalizeProvider(parts[0])
modelID := strings.TrimSpace(parts[1])
if provider != "" && modelID != "" {
return provider, modelID, true
}
}
return "", "", false
}
func isChatModelForProvider(provider, modelID string) bool {
normalizedProvider := normalizeProvider(provider)
normalizedModel := strings.ToLower(strings.TrimSpace(modelID))
switch normalizedProvider {
case fantasyopenai.Name:
return strings.HasPrefix(normalizedModel, "gpt-") ||
strings.HasPrefix(normalizedModel, "chatgpt-") ||
isOpenAIReasoningModel(normalizedModel)
case fantasyanthropic.Name:
return strings.HasPrefix(normalizedModel, "claude-")
case fantasygoogle.Name:
return strings.HasPrefix(normalizedModel, "gemini-") ||
strings.HasPrefix(normalizedModel, "gemma-")
default:
return false
}
}
func isOpenAIReasoningModel(modelID string) bool {
if len(modelID) < 2 || modelID[0] != 'o' {
return false
}
index := 1
for index < len(modelID) && modelID[index] >= '0' && modelID[index] <= '9' {
index++
}
if index == 1 {
return false
}
if index == len(modelID) {
return true
}
return modelID[index] == '-' || modelID[index] == '.'
}
// ReasoningEffortFromChat normalizes chat-config reasoning effort values for a
// provider and returns the canonical provider effort value.
func ReasoningEffortFromChat(provider string, value *string) *string {
if value == nil {
return nil
}
normalized := strings.ToLower(strings.TrimSpace(*value))
if normalized == "" {
return nil
}
switch NormalizeProvider(provider) {
case fantasyopenai.Name:
return normalizedEnumValue(
normalized,
string(fantasyopenai.ReasoningEffortMinimal),
string(fantasyopenai.ReasoningEffortLow),
string(fantasyopenai.ReasoningEffortMedium),
string(fantasyopenai.ReasoningEffortHigh),
)
case fantasyanthropic.Name:
return normalizedEnumValue(
normalized,
string(fantasyanthropic.EffortLow),
string(fantasyanthropic.EffortMedium),
string(fantasyanthropic.EffortHigh),
string(fantasyanthropic.EffortMax),
)
case fantasyopenrouter.Name:
return normalizedEnumValue(
normalized,
string(fantasyopenrouter.ReasoningEffortLow),
string(fantasyopenrouter.ReasoningEffortMedium),
string(fantasyopenrouter.ReasoningEffortHigh),
)
case fantasyvercel.Name:
return normalizedEnumValue(
normalized,
string(fantasyvercel.ReasoningEffortNone),
string(fantasyvercel.ReasoningEffortMinimal),
string(fantasyvercel.ReasoningEffortLow),
string(fantasyvercel.ReasoningEffortMedium),
string(fantasyvercel.ReasoningEffortHigh),
string(fantasyvercel.ReasoningEffortXHigh),
)
default:
return nil
}
}
// OpenAITextVerbosityFromChat normalizes chat-config text verbosity values for
// OpenAI and returns the canonical provider verbosity value.
func OpenAITextVerbosityFromChat(value *string) *fantasyopenai.TextVerbosity {
if value == nil {
return nil
}
normalized := strings.ToLower(strings.TrimSpace(*value))
if normalized == "" {
return nil
}
verbosity := normalizedEnumValue(
normalized,
string(fantasyopenai.TextVerbosityLow),
string(fantasyopenai.TextVerbosityMedium),
string(fantasyopenai.TextVerbosityHigh),
)
if verbosity == nil {
return nil
}
valueCopy := fantasyopenai.TextVerbosity(*verbosity)
return &valueCopy
}
func normalizedEnumValue(value string, allowed ...string) *string {
for _, candidate := range allowed {
if value == strings.ToLower(candidate) {
match := candidate
return &match
}
}
return nil
}
// MergeMissingCallConfig fills unset call config values from defaults.
func MergeMissingCallConfig(
dst *codersdk.ChatModelCallConfig,
defaults codersdk.ChatModelCallConfig,
) {
if dst.MaxOutputTokens == nil {
dst.MaxOutputTokens = defaults.MaxOutputTokens
}
if dst.Temperature == nil {
dst.Temperature = defaults.Temperature
}
if dst.TopP == nil {
dst.TopP = defaults.TopP
}
if dst.TopK == nil {
dst.TopK = defaults.TopK
}
if dst.PresencePenalty == nil {
dst.PresencePenalty = defaults.PresencePenalty
}
if dst.FrequencyPenalty == nil {
dst.FrequencyPenalty = defaults.FrequencyPenalty
}
MergeMissingProviderOptions(&dst.ProviderOptions, defaults.ProviderOptions)
}
// MergeMissingProviderOptions fills unset provider option fields from defaults.
func MergeMissingProviderOptions(
dst **codersdk.ChatModelProviderOptions,
defaults *codersdk.ChatModelProviderOptions,
) {
if defaults == nil {
return
}
if *dst == nil {
copied := *defaults
*dst = &copied
return
}
current := *dst
for _, provider := range []string{
fantasyopenai.Name,
fantasyanthropic.Name,
fantasygoogle.Name,
fantasyopenaicompat.Name,
fantasyopenrouter.Name,
fantasyvercel.Name,
} {
switch provider {
case fantasyopenai.Name:
if defaults.OpenAI == nil {
continue
}
if current.OpenAI == nil {
copied := *defaults.OpenAI
current.OpenAI = &copied
continue
}
dstOpenAI := current.OpenAI
defaultOpenAI := defaults.OpenAI
if dstOpenAI.Include == nil {
dstOpenAI.Include = defaultOpenAI.Include
}
if dstOpenAI.Instructions == nil {
dstOpenAI.Instructions = defaultOpenAI.Instructions
}
if dstOpenAI.LogitBias == nil {
dstOpenAI.LogitBias = defaultOpenAI.LogitBias
}
if dstOpenAI.LogProbs == nil {
dstOpenAI.LogProbs = defaultOpenAI.LogProbs
}
if dstOpenAI.TopLogProbs == nil {
dstOpenAI.TopLogProbs = defaultOpenAI.TopLogProbs
}
if dstOpenAI.MaxToolCalls == nil {
dstOpenAI.MaxToolCalls = defaultOpenAI.MaxToolCalls
}
if dstOpenAI.ParallelToolCalls == nil {
dstOpenAI.ParallelToolCalls = defaultOpenAI.ParallelToolCalls
}
if dstOpenAI.User == nil {
dstOpenAI.User = defaultOpenAI.User
}
if dstOpenAI.ReasoningEffort == nil {
dstOpenAI.ReasoningEffort = defaultOpenAI.ReasoningEffort
}
if dstOpenAI.ReasoningSummary == nil {
dstOpenAI.ReasoningSummary = defaultOpenAI.ReasoningSummary
}
if dstOpenAI.MaxCompletionTokens == nil {
dstOpenAI.MaxCompletionTokens = defaultOpenAI.MaxCompletionTokens
}
if dstOpenAI.TextVerbosity == nil {
dstOpenAI.TextVerbosity = defaultOpenAI.TextVerbosity
}
if dstOpenAI.Prediction == nil {
dstOpenAI.Prediction = defaultOpenAI.Prediction
}
if dstOpenAI.Store == nil {
dstOpenAI.Store = defaultOpenAI.Store
}
if dstOpenAI.Metadata == nil {
dstOpenAI.Metadata = defaultOpenAI.Metadata
}
if dstOpenAI.PromptCacheKey == nil {
dstOpenAI.PromptCacheKey = defaultOpenAI.PromptCacheKey
}
if dstOpenAI.SafetyIdentifier == nil {
dstOpenAI.SafetyIdentifier = defaultOpenAI.SafetyIdentifier
}
if dstOpenAI.ServiceTier == nil {
dstOpenAI.ServiceTier = defaultOpenAI.ServiceTier
}
if dstOpenAI.StructuredOutputs == nil {
dstOpenAI.StructuredOutputs = defaultOpenAI.StructuredOutputs
}
if dstOpenAI.StrictJSONSchema == nil {
dstOpenAI.StrictJSONSchema = defaultOpenAI.StrictJSONSchema
}
case fantasyanthropic.Name:
if defaults.Anthropic == nil {
continue
}
if current.Anthropic == nil {
copied := *defaults.Anthropic
current.Anthropic = &copied
continue
}
dstAnthropic := current.Anthropic
defaultAnthropic := defaults.Anthropic
if dstAnthropic.SendReasoning == nil {
dstAnthropic.SendReasoning = defaultAnthropic.SendReasoning
}
if dstAnthropic.Thinking == nil {
dstAnthropic.Thinking = defaultAnthropic.Thinking
} else if defaultAnthropic.Thinking != nil &&
dstAnthropic.Thinking.BudgetTokens == nil {
dstAnthropic.Thinking.BudgetTokens = defaultAnthropic.Thinking.BudgetTokens
}
if dstAnthropic.Effort == nil {
dstAnthropic.Effort = defaultAnthropic.Effort
}
if dstAnthropic.DisableParallelToolUse == nil {
dstAnthropic.DisableParallelToolUse = defaultAnthropic.DisableParallelToolUse
}
case fantasygoogle.Name:
if defaults.Google == nil {
continue
}
if current.Google == nil {
copied := *defaults.Google
current.Google = &copied
continue
}
dstGoogle := current.Google
defaultGoogle := defaults.Google
if dstGoogle.ThinkingConfig == nil {
dstGoogle.ThinkingConfig = defaultGoogle.ThinkingConfig
} else if defaultGoogle.ThinkingConfig != nil {
if dstGoogle.ThinkingConfig.ThinkingBudget == nil {
dstGoogle.ThinkingConfig.ThinkingBudget = defaultGoogle.ThinkingConfig.ThinkingBudget
}
if dstGoogle.ThinkingConfig.IncludeThoughts == nil {
dstGoogle.ThinkingConfig.IncludeThoughts = defaultGoogle.ThinkingConfig.IncludeThoughts
}
}
if strings.TrimSpace(dstGoogle.CachedContent) == "" {
dstGoogle.CachedContent = defaultGoogle.CachedContent
}
if dstGoogle.SafetySettings == nil {
dstGoogle.SafetySettings = defaultGoogle.SafetySettings
}
if strings.TrimSpace(dstGoogle.Threshold) == "" {
dstGoogle.Threshold = defaultGoogle.Threshold
}
case fantasyopenaicompat.Name:
if defaults.OpenAICompat == nil {
continue
}
if current.OpenAICompat == nil {
copied := *defaults.OpenAICompat
current.OpenAICompat = &copied
continue
}
dstCompat := current.OpenAICompat
defaultCompat := defaults.OpenAICompat
if dstCompat.User == nil {
dstCompat.User = defaultCompat.User
}
if dstCompat.ReasoningEffort == nil {
dstCompat.ReasoningEffort = defaultCompat.ReasoningEffort
}
case fantasyopenrouter.Name:
if defaults.OpenRouter == nil {
continue
}
if current.OpenRouter == nil {
copied := *defaults.OpenRouter
current.OpenRouter = &copied
continue
}
dstRouter := current.OpenRouter
defaultRouter := defaults.OpenRouter
if dstRouter.Reasoning == nil {
dstRouter.Reasoning = defaultRouter.Reasoning
} else if defaultRouter.Reasoning != nil {
if dstRouter.Reasoning.Enabled == nil {
dstRouter.Reasoning.Enabled = defaultRouter.Reasoning.Enabled
}
if dstRouter.Reasoning.Exclude == nil {
dstRouter.Reasoning.Exclude = defaultRouter.Reasoning.Exclude
}
if dstRouter.Reasoning.MaxTokens == nil {
dstRouter.Reasoning.MaxTokens = defaultRouter.Reasoning.MaxTokens
}
if dstRouter.Reasoning.Effort == nil {
dstRouter.Reasoning.Effort = defaultRouter.Reasoning.Effort
}
}
if dstRouter.ExtraBody == nil {
dstRouter.ExtraBody = defaultRouter.ExtraBody
}
if dstRouter.IncludeUsage == nil {
dstRouter.IncludeUsage = defaultRouter.IncludeUsage
}
if dstRouter.LogitBias == nil {
dstRouter.LogitBias = defaultRouter.LogitBias
}
if dstRouter.LogProbs == nil {
dstRouter.LogProbs = defaultRouter.LogProbs
}
if dstRouter.ParallelToolCalls == nil {
dstRouter.ParallelToolCalls = defaultRouter.ParallelToolCalls
}
if dstRouter.User == nil {
dstRouter.User = defaultRouter.User
}
if dstRouter.Provider == nil {
dstRouter.Provider = defaultRouter.Provider
} else if defaultRouter.Provider != nil {
if dstRouter.Provider.Order == nil {
dstRouter.Provider.Order = defaultRouter.Provider.Order
}
if dstRouter.Provider.AllowFallbacks == nil {
dstRouter.Provider.AllowFallbacks = defaultRouter.Provider.AllowFallbacks
}
if dstRouter.Provider.RequireParameters == nil {
dstRouter.Provider.RequireParameters = defaultRouter.Provider.RequireParameters
}
if dstRouter.Provider.DataCollection == nil {
dstRouter.Provider.DataCollection = defaultRouter.Provider.DataCollection
}
if dstRouter.Provider.Only == nil {
dstRouter.Provider.Only = defaultRouter.Provider.Only
}
if dstRouter.Provider.Ignore == nil {
dstRouter.Provider.Ignore = defaultRouter.Provider.Ignore
}
if dstRouter.Provider.Quantizations == nil {
dstRouter.Provider.Quantizations = defaultRouter.Provider.Quantizations
}
if dstRouter.Provider.Sort == nil {
dstRouter.Provider.Sort = defaultRouter.Provider.Sort
}
}
case fantasyvercel.Name:
if defaults.Vercel == nil {
continue
}
if current.Vercel == nil {
copied := *defaults.Vercel
current.Vercel = &copied
continue
}
dstVercel := current.Vercel
defaultVercel := defaults.Vercel
if dstVercel.Reasoning == nil {
dstVercel.Reasoning = defaultVercel.Reasoning
} else if defaultVercel.Reasoning != nil {
if dstVercel.Reasoning.Enabled == nil {
dstVercel.Reasoning.Enabled = defaultVercel.Reasoning.Enabled
}
if dstVercel.Reasoning.MaxTokens == nil {
dstVercel.Reasoning.MaxTokens = defaultVercel.Reasoning.MaxTokens
}
if dstVercel.Reasoning.Effort == nil {
dstVercel.Reasoning.Effort = defaultVercel.Reasoning.Effort
}
if dstVercel.Reasoning.Exclude == nil {
dstVercel.Reasoning.Exclude = defaultVercel.Reasoning.Exclude
}
}
if dstVercel.ProviderOptions == nil {
dstVercel.ProviderOptions = defaultVercel.ProviderOptions
} else if defaultVercel.ProviderOptions != nil {
if dstVercel.ProviderOptions.Order == nil {
dstVercel.ProviderOptions.Order = defaultVercel.ProviderOptions.Order
}
if dstVercel.ProviderOptions.Models == nil {
dstVercel.ProviderOptions.Models = defaultVercel.ProviderOptions.Models
}
}
if dstVercel.User == nil {
dstVercel.User = defaultVercel.User
}
if dstVercel.LogitBias == nil {
dstVercel.LogitBias = defaultVercel.LogitBias
}
if dstVercel.LogProbs == nil {
dstVercel.LogProbs = defaultVercel.LogProbs
}
if dstVercel.TopLogProbs == nil {
dstVercel.TopLogProbs = defaultVercel.TopLogProbs
}
if dstVercel.ParallelToolCalls == nil {
dstVercel.ParallelToolCalls = defaultVercel.ParallelToolCalls
}
if dstVercel.ExtraBody == nil {
dstVercel.ExtraBody = defaultVercel.ExtraBody
}
}
}
}
// ModelFromConfig resolves a provider/model pair and constructs a fantasy
// language model client using the provided provider credentials.
func ModelFromConfig(
providerHint string,
modelName string,
providerKeys ProviderAPIKeys,
) (fantasy.LanguageModel, error) {
provider, modelID, err := ResolveModelWithProviderHint(modelName, providerHint)
if err != nil {
return nil, err
}
apiKey := providerKeys.APIKey(provider)
if apiKey == "" {
return nil, missingProviderAPIKeyError(provider)
}
baseURL := providerKeys.BaseURL(provider)
var providerClient fantasy.Provider
switch provider {
case fantasyanthropic.Name:
options := []fantasyanthropic.Option{
fantasyanthropic.WithAPIKey(apiKey),
}
if baseURL != "" {
options = append(options, fantasyanthropic.WithBaseURL(baseURL))
}
providerClient, err = fantasyanthropic.New(options...)
case fantasyazure.Name:
if baseURL == "" {
return nil, xerrors.New("AZURE_OPENAI_BASE_URL is not set")
}
providerClient, err = fantasyazure.New(
fantasyazure.WithAPIKey(apiKey),
fantasyazure.WithBaseURL(baseURL),
fantasyazure.WithUseResponsesAPI(),
)
case fantasybedrock.Name:
providerClient, err = fantasybedrock.New(fantasybedrock.WithAPIKey(apiKey))
case fantasygoogle.Name:
options := []fantasygoogle.Option{
fantasygoogle.WithGeminiAPIKey(apiKey),
}
if baseURL != "" {
options = append(options, fantasygoogle.WithBaseURL(baseURL))
}
providerClient, err = fantasygoogle.New(options...)
case fantasyopenai.Name:
options := []fantasyopenai.Option{
fantasyopenai.WithAPIKey(apiKey),
fantasyopenai.WithUseResponsesAPI(),
}
if baseURL != "" {
options = append(options, fantasyopenai.WithBaseURL(baseURL))
}
providerClient, err = fantasyopenai.New(options...)
case fantasyopenaicompat.Name:
options := []fantasyopenaicompat.Option{
fantasyopenaicompat.WithAPIKey(apiKey),
}
if baseURL != "" {
options = append(options, fantasyopenaicompat.WithBaseURL(baseURL))
}
providerClient, err = fantasyopenaicompat.New(options...)
case fantasyopenrouter.Name:
providerClient, err = fantasyopenrouter.New(fantasyopenrouter.WithAPIKey(apiKey))
case fantasyvercel.Name:
options := []fantasyvercel.Option{
fantasyvercel.WithAPIKey(apiKey),
}
if baseURL != "" {
options = append(options, fantasyvercel.WithBaseURL(baseURL))
}
providerClient, err = fantasyvercel.New(options...)
default:
return nil, xerrors.Errorf("unsupported model provider %q", provider)
}
if err != nil {
return nil, xerrors.Errorf("create %s provider: %w", provider, err)
}
model, err := providerClient.LanguageModel(context.Background(), modelID)
if err != nil {
return nil, xerrors.Errorf("load %s model: %w", provider, err)
}
return model, nil
}
func missingProviderAPIKeyError(provider string) error {
switch provider {
case fantasyanthropic.Name:
return xerrors.New("ANTHROPIC_API_KEY is not set")
case fantasyazure.Name:
return xerrors.New("AZURE_OPENAI_API_KEY is not set")
case fantasybedrock.Name:
return xerrors.New("BEDROCK_API_KEY is not set")
case fantasygoogle.Name:
return xerrors.New("GOOGLE_API_KEY is not set")
case fantasyopenai.Name:
return xerrors.New("OPENAI_API_KEY is not set")
case fantasyopenaicompat.Name:
return xerrors.New("OPENAI_COMPAT_API_KEY is not set")
case fantasyopenrouter.Name:
return xerrors.New("OPENROUTER_API_KEY is not set")
case fantasyvercel.Name:
return xerrors.New("VERCEL_API_KEY is not set")
default:
return xerrors.Errorf("API key for provider %q is not set", provider)
}
}
// ProviderOptionsFromChatModelConfig converts chat model provider options to
// fantasy provider options used for inference calls.
func ProviderOptionsFromChatModelConfig(
model fantasy.LanguageModel,
options *codersdk.ChatModelProviderOptions,
) fantasy.ProviderOptions {
if options == nil {
return nil
}
result := fantasy.ProviderOptions{}
if options.OpenAI != nil {
result[fantasyopenai.Name] = openAIProviderOptionsFromChatConfig(
model,
options.OpenAI,
)
}
if options.Anthropic != nil {
result[fantasyanthropic.Name] = anthropicProviderOptionsFromChatConfig(
options.Anthropic,
)
}
if options.Google != nil {
result[fantasygoogle.Name] = googleProviderOptionsFromChatConfig(
options.Google,
)
}
if options.OpenAICompat != nil {
result[fantasyopenaicompat.Name] = openAICompatProviderOptionsFromChatConfig(
options.OpenAICompat,
)
}
if options.OpenRouter != nil {
result[fantasyopenrouter.Name] = openRouterProviderOptionsFromChatConfig(
options.OpenRouter,
)
}
if options.Vercel != nil {
result[fantasyvercel.Name] = vercelProviderOptionsFromChatConfig(
options.Vercel,
)
}
if len(result) == 0 {
return nil
}
return result
}
func openAIProviderOptionsFromChatConfig(
model fantasy.LanguageModel,
options *codersdk.ChatModelOpenAIProviderOptions,
) fantasy.ProviderOptionsData {
reasoningEffort := openAIReasoningEffortFromChat(options.ReasoningEffort)
if useOpenAIResponsesOptions(model) {
include := ensureOpenAIResponseIncludes(openAIIncludeFromChat(options.Include))
providerOptions := &fantasyopenai.ResponsesProviderOptions{
Include: include,
Instructions: normalizedStringPointer(options.Instructions),
Logprobs: openAIResponsesLogProbsFromChat(options),
MaxToolCalls: options.MaxToolCalls,
Metadata: options.Metadata,
ParallelToolCalls: options.ParallelToolCalls,
PromptCacheKey: normalizedStringPointer(options.PromptCacheKey),
ReasoningEffort: reasoningEffort,
ReasoningSummary: normalizedStringPointer(options.ReasoningSummary),
SafetyIdentifier: normalizedStringPointer(options.SafetyIdentifier),
ServiceTier: openAIServiceTierFromChat(options.ServiceTier),
StrictJSONSchema: options.StrictJSONSchema,
TextVerbosity: OpenAITextVerbosityFromChat(options.TextVerbosity),
User: normalizedStringPointer(options.User),
}
return providerOptions
}
return &fantasyopenai.ProviderOptions{
LogitBias: options.LogitBias,
LogProbs: options.LogProbs,
TopLogProbs: options.TopLogProbs,
ParallelToolCalls: options.ParallelToolCalls,
User: normalizedStringPointer(options.User),
ReasoningEffort: reasoningEffort,
MaxCompletionTokens: options.MaxCompletionTokens,
TextVerbosity: normalizedStringPointer(options.TextVerbosity),
Prediction: options.Prediction,
Store: options.Store,
Metadata: options.Metadata,
PromptCacheKey: normalizedStringPointer(options.PromptCacheKey),
SafetyIdentifier: normalizedStringPointer(options.SafetyIdentifier),
ServiceTier: normalizedStringPointer(options.ServiceTier),
StructuredOutputs: options.StructuredOutputs,
}
}
func anthropicProviderOptionsFromChatConfig(
options *codersdk.ChatModelAnthropicProviderOptions,
) *fantasyanthropic.ProviderOptions {
result := &fantasyanthropic.ProviderOptions{
SendReasoning: options.SendReasoning,
Effort: anthropicEffortFromChat(options.Effort),
DisableParallelToolUse: options.DisableParallelToolUse,
}
if options.Thinking != nil && options.Thinking.BudgetTokens != nil {
result.Thinking = &fantasyanthropic.ThinkingProviderOption{
BudgetTokens: *options.Thinking.BudgetTokens,
}
}
return result
}
func googleProviderOptionsFromChatConfig(
options *codersdk.ChatModelGoogleProviderOptions,
) *fantasygoogle.ProviderOptions {
result := &fantasygoogle.ProviderOptions{
CachedContent: strings.TrimSpace(options.CachedContent),
Threshold: strings.TrimSpace(options.Threshold),
}
if options.ThinkingConfig != nil {
result.ThinkingConfig = &fantasygoogle.ThinkingConfig{
ThinkingBudget: options.ThinkingConfig.ThinkingBudget,
IncludeThoughts: options.ThinkingConfig.IncludeThoughts,
}
}
if options.SafetySettings != nil {
result.SafetySettings = make(
[]fantasygoogle.SafetySetting,
0,
len(options.SafetySettings),
)
for _, setting := range options.SafetySettings {
result.SafetySettings = append(result.SafetySettings, fantasygoogle.SafetySetting{
Category: strings.TrimSpace(setting.Category),
Threshold: strings.TrimSpace(setting.Threshold),
})
}
}
return result
}
func openAICompatProviderOptionsFromChatConfig(
options *codersdk.ChatModelOpenAICompatProviderOptions,
) *fantasyopenaicompat.ProviderOptions {
return &fantasyopenaicompat.ProviderOptions{
User: normalizedStringPointer(options.User),
ReasoningEffort: openAIReasoningEffortFromChat(options.ReasoningEffort),
}
}
func openRouterProviderOptionsFromChatConfig(
options *codersdk.ChatModelOpenRouterProviderOptions,
) *fantasyopenrouter.ProviderOptions {
result := &fantasyopenrouter.ProviderOptions{
ExtraBody: options.ExtraBody,
IncludeUsage: options.IncludeUsage,
LogitBias: options.LogitBias,
LogProbs: options.LogProbs,
ParallelToolCalls: options.ParallelToolCalls,
User: normalizedStringPointer(options.User),
}
if options.Reasoning != nil {
result.Reasoning = &fantasyopenrouter.ReasoningOptions{
Enabled: options.Reasoning.Enabled,
Exclude: options.Reasoning.Exclude,
MaxTokens: options.Reasoning.MaxTokens,
Effort: openRouterReasoningEffortFromChat(options.Reasoning.Effort),
}
}
if options.Provider != nil {
result.Provider = &fantasyopenrouter.Provider{
Order: options.Provider.Order,
AllowFallbacks: options.Provider.AllowFallbacks,
RequireParameters: options.Provider.RequireParameters,
DataCollection: normalizedStringPointer(options.Provider.DataCollection),
Only: options.Provider.Only,
Ignore: options.Provider.Ignore,
Quantizations: options.Provider.Quantizations,
Sort: normalizedStringPointer(options.Provider.Sort),
}
}
return result
}
func vercelProviderOptionsFromChatConfig(
options *codersdk.ChatModelVercelProviderOptions,
) *fantasyvercel.ProviderOptions {
result := &fantasyvercel.ProviderOptions{
User: normalizedStringPointer(options.User),
LogitBias: options.LogitBias,
LogProbs: options.LogProbs,
TopLogProbs: options.TopLogProbs,
ParallelToolCalls: options.ParallelToolCalls,
ExtraBody: options.ExtraBody,
}
if options.Reasoning != nil {
result.Reasoning = &fantasyvercel.ReasoningOptions{
Enabled: options.Reasoning.Enabled,
MaxTokens: options.Reasoning.MaxTokens,
Effort: vercelReasoningEffortFromChat(options.Reasoning.Effort),
Exclude: options.Reasoning.Exclude,
}
}
if options.ProviderOptions != nil {
result.ProviderOptions = &fantasyvercel.GatewayProviderOptions{
Order: options.ProviderOptions.Order,
Models: options.ProviderOptions.Models,
}
}
return result
}
func openAIResponsesLogProbsFromChat(
options *codersdk.ChatModelOpenAIProviderOptions,
) any {
if options.TopLogProbs != nil {
return *options.TopLogProbs
}
if options.LogProbs != nil {
return *options.LogProbs
}
return nil
}
func openAIIncludeFromChat(values []string) []fantasyopenai.IncludeType {
if values == nil {
return nil
}
result := make([]fantasyopenai.IncludeType, 0, len(values))
for _, value := range values {
switch strings.TrimSpace(value) {
case string(fantasyopenai.IncludeReasoningEncryptedContent):
result = append(result, fantasyopenai.IncludeReasoningEncryptedContent)
case string(fantasyopenai.IncludeFileSearchCallResults):
result = append(result, fantasyopenai.IncludeFileSearchCallResults)
case string(fantasyopenai.IncludeMessageOutputTextLogprobs):
result = append(result, fantasyopenai.IncludeMessageOutputTextLogprobs)
}
}
return result
}
func ensureOpenAIResponseIncludes(
values []fantasyopenai.IncludeType,
) []fantasyopenai.IncludeType {
const required = fantasyopenai.IncludeReasoningEncryptedContent
for _, value := range values {
if value == required {
return values
}
}
return append(values, required)
}
func useOpenAIResponsesOptions(model fantasy.LanguageModel) bool {
if model == nil {
return false
}
switch model.Provider() {
case fantasyopenai.Name, fantasyazure.Name:
return fantasyopenai.IsResponsesModel(model.Model())
default:
return false
}
}
func normalizedStringPointer(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func openAIReasoningEffortFromChat(value *string) *fantasyopenai.ReasoningEffort {
effort := ReasoningEffortFromChat(fantasyopenai.Name, value)
if effort == nil {
return nil
}
valueCopy := fantasyopenai.ReasoningEffort(*effort)
return &valueCopy
}
func anthropicEffortFromChat(value *string) *fantasyanthropic.Effort {
effort := ReasoningEffortFromChat(fantasyanthropic.Name, value)
if effort == nil {
return nil
}
valueCopy := fantasyanthropic.Effort(*effort)
return &valueCopy
}
func openRouterReasoningEffortFromChat(value *string) *fantasyopenrouter.ReasoningEffort {
effort := ReasoningEffortFromChat(fantasyopenrouter.Name, value)
if effort == nil {
return nil
}
valueCopy := fantasyopenrouter.ReasoningEffort(*effort)
return &valueCopy
}
func vercelReasoningEffortFromChat(value *string) *fantasyvercel.ReasoningEffort {
effort := ReasoningEffortFromChat(fantasyvercel.Name, value)
if effort == nil {
return nil
}
valueCopy := fantasyvercel.ReasoningEffort(*effort)
return &valueCopy
}
func openAIServiceTierFromChat(value *string) *fantasyopenai.ServiceTier {
normalized := normalizedStringPointer(value)
if normalized == nil {
return nil
}
switch strings.ToLower(*normalized) {
case string(fantasyopenai.ServiceTierAuto):
serviceTier := fantasyopenai.ServiceTierAuto
return &serviceTier
case string(fantasyopenai.ServiceTierFlex):
serviceTier := fantasyopenai.ServiceTierFlex
return &serviceTier
case string(fantasyopenai.ServiceTierPriority):
serviceTier := fantasyopenai.ServiceTierPriority
return &serviceTier
default:
return nil
}
}