mirror of
https://github.com/coder/coder.git
synced 2026-11-01 21:13:14 +08:00
Two live `ai_providers` rows can share the same `base_url` hostname.
This is an intended configuration (AIGOV-97): the direct routing path
(`/api/v2/ai-gateway/{name}/...`) routes by provider name and fully
supports it. Only the MITM proxy path (`aibridgeproxyd`) cannot
disambiguate, because its `nameByHost` map is 1:1.
`classifyProviderRow` marked the second provider as
`ProviderStatusError`, which surfaced as a misleading log warning and a
`status=error` Prometheus label. The provider is not actually broken; it
is fully routable via the direct path. The error was only visible in
server logs and Prometheus, not in the API or UI.
This change:
- Extracts `BaseURLHostname` into `coderd/aibridged` and eliminates
three duplicate implementations (proxy classifier, chatprovider, and the
API layer now uses it for status checks).
- Downgrades the proxy classifier from `ProviderStatusError` to a new
`ProviderStatusProxyExcluded` status. The provider is not broken; it is
just not reachable via the MITM proxy path.
- Adds `Status *AIProviderStatus` to `codersdk.AIProvider` with
`Warnings []string`, matching the `WorkspaceProxy` precedent.
- Populates warnings on create, update, GET, and list when a provider's
hostname collides with another enabled provider.
- Adds an integration test proving that a duplicate provider is routable
via the direct path but not via the proxy path.
Refs AIGOV-596
Generated by Coder Agents
https://github.com/user-attachments/assets/bf323837-5121-4d1b-ab19-593f1d32374e
1829 lines
57 KiB
Go
1829 lines
57 KiB
Go
package chatprovider_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"charm.land/fantasy"
|
|
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
|
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"
|
|
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
|
|
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream/eventstreamapi"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatprovider"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chattest"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
func TestResolveUserProviderKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configuredProvider := func(id uuid.UUID, provider string, centralEnabled bool, centralKey string, allowUser bool, allowCentralFallback bool) chatprovider.ConfiguredProvider {
|
|
return chatprovider.ConfiguredProvider{
|
|
ProviderID: id,
|
|
Provider: provider,
|
|
APIKey: centralKey,
|
|
CentralAPIKeyEnabled: centralEnabled,
|
|
AllowUserAPIKey: allowUser,
|
|
AllowCentralAPIKeyFallback: allowCentralFallback,
|
|
}
|
|
}
|
|
|
|
userProviderKey := func(id uuid.UUID, apiKey string) chatprovider.UserProviderKey {
|
|
return chatprovider.UserProviderKey{
|
|
ChatProviderID: id,
|
|
APIKey: apiKey,
|
|
}
|
|
}
|
|
|
|
openAIProviderID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
|
|
anthropicProviderID := uuid.MustParse("00000000-0000-0000-0000-000000000002")
|
|
bedrockProviderID := uuid.MustParse("00000000-0000-0000-0000-000000000003")
|
|
|
|
tests := []struct {
|
|
name string
|
|
fallback chatprovider.ProviderAPIKeys
|
|
providers []chatprovider.ConfiguredProvider
|
|
userKeys []chatprovider.UserProviderKey
|
|
wantAvailability map[string]chatprovider.ProviderAvailability
|
|
wantKeys map[string]string
|
|
wantKeyPresence map[string]bool
|
|
}{
|
|
{
|
|
name: "CentralOnlyKeyPresent",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-central",
|
|
},
|
|
},
|
|
{
|
|
name: "CentralOnlyKeyMissing",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasyopenai.Name: false,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockCentralOnlyAmbientCredentialsEnabled",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: true,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockFallbackAmbientCredentialsEnabled",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "", true, true)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: true,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockUserKeyRequiredWithoutFallback",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "", true, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: false,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockCentralDisabledMissingAPIKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, false, "", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableMissingAPIKey},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: false,
|
|
},
|
|
},
|
|
{
|
|
name: "BedrockCentralStoredKeyPresent",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(bedrockProviderID, fantasybedrock.Name, true, "bedrock-token", false, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasybedrock.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasybedrock.Name: "bedrock-token",
|
|
},
|
|
wantKeyPresence: map[string]bool{
|
|
fantasybedrock.Name: true,
|
|
},
|
|
},
|
|
{
|
|
name: "UserOnlyUserHasKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, false, "sk-central", true, false)},
|
|
userKeys: []chatprovider.UserProviderKey{userProviderKey(openAIProviderID, "sk-user")},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-user",
|
|
},
|
|
},
|
|
{
|
|
name: "UserOnlyUserHasNoKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, false, "sk-central", true, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOffUserHasKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", true, false)},
|
|
userKeys: []chatprovider.UserProviderKey{userProviderKey(openAIProviderID, "sk-user")},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-user",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOffUserHasNoKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", true, false)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOnUserHasKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", true, true)},
|
|
userKeys: []chatprovider.UserProviderKey{userProviderKey(openAIProviderID, "sk-user")},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-user",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOnUserHasNoKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", true, true)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-central",
|
|
},
|
|
},
|
|
{
|
|
name: "BothEnabledFallbackOnCentralKeyEmptyUserHasNoKey",
|
|
providers: []chatprovider.ConfiguredProvider{configuredProvider(openAIProviderID, fantasyopenai.Name, true, "", true, true)},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "",
|
|
},
|
|
},
|
|
{
|
|
name: "MultipleProvidersDifferentPolicies",
|
|
providers: []chatprovider.ConfiguredProvider{
|
|
configuredProvider(openAIProviderID, fantasyopenai.Name, true, "sk-central", false, false),
|
|
configuredProvider(anthropicProviderID, fantasyanthropic.Name, false, "", true, false),
|
|
},
|
|
wantAvailability: map[string]chatprovider.ProviderAvailability{
|
|
fantasyopenai.Name: {Available: true},
|
|
fantasyanthropic.Name: {Available: false, UnavailableReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired},
|
|
},
|
|
wantKeys: map[string]string{
|
|
fantasyopenai.Name: "sk-central",
|
|
fantasyanthropic.Name: "",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys, availability := chatprovider.ResolveUserProviderKeys(tt.fallback, tt.providers, tt.userKeys)
|
|
|
|
require.Len(t, availability, len(tt.wantAvailability))
|
|
for provider, wantAvailability := range tt.wantAvailability {
|
|
gotAvailability, ok := availability[provider]
|
|
require.True(t, ok, "expected availability for provider %q", provider)
|
|
require.Equal(t, wantAvailability, gotAvailability)
|
|
require.Equal(t, tt.wantKeys[provider], keys.APIKey(provider))
|
|
}
|
|
for provider, wantPresent := range tt.wantKeyPresence {
|
|
gotKey, ok := keys.ByProvider[provider]
|
|
require.Equal(t, wantPresent, ok, "unexpected key presence for provider %q", provider)
|
|
require.Equal(t, wantPresent, keys.HasProvider(provider), "unexpected HasProvider result for provider %q", provider)
|
|
if wantPresent {
|
|
require.Equal(t, tt.wantKeys[provider], gotKey)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProviderAPIKeysEmpty_RegionCountsAsConfigured(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
require.True(t, (chatprovider.ProviderAPIKeys{}).Empty())
|
|
require.False(t, (chatprovider.ProviderAPIKeys{
|
|
RegionByProvider: map[string]string{
|
|
fantasybedrock.Name: "us-east-1",
|
|
},
|
|
}).Empty())
|
|
}
|
|
|
|
func TestMergeProviderAPIKeys_PreservesProviderRegions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
merged := chatprovider.MergeProviderAPIKeys(
|
|
chatprovider.ProviderAPIKeys{
|
|
RegionByProvider: map[string]string{
|
|
"BEDROCK": "us-east-1",
|
|
},
|
|
},
|
|
[]chatprovider.ConfiguredProvider{{
|
|
ProviderID: uuid.New(),
|
|
Provider: fantasybedrock.Name,
|
|
Region: "eu-central-1",
|
|
}},
|
|
)
|
|
|
|
require.Equal(t, "eu-central-1", merged.Region(fantasybedrock.Name))
|
|
require.False(t, merged.Empty())
|
|
}
|
|
|
|
func TestResolveUserProviderKeys_PreservesProviderRegions(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys, availability := chatprovider.ResolveUserProviderKeys(
|
|
chatprovider.ProviderAPIKeys{
|
|
RegionByProvider: map[string]string{
|
|
"BEDROCK": "us-east-1",
|
|
},
|
|
},
|
|
[]chatprovider.ConfiguredProvider{{
|
|
ProviderID: uuid.New(),
|
|
Provider: fantasybedrock.Name,
|
|
Region: "eu-central-1",
|
|
CentralAPIKeyEnabled: true,
|
|
}},
|
|
nil,
|
|
)
|
|
|
|
require.Equal(t, "eu-central-1", keys.Region(fantasybedrock.Name))
|
|
require.True(t, keys.HasProvider(fantasybedrock.Name))
|
|
require.Equal(t, chatprovider.ProviderAvailability{Available: true}, availability[fantasybedrock.Name])
|
|
}
|
|
|
|
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return fn(req)
|
|
}
|
|
|
|
func TestAnthropicThinkingDisplayFromChat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input *string
|
|
want *fantasyanthropic.ThinkingDisplay
|
|
}{
|
|
{
|
|
name: "Summarized",
|
|
input: ptr.Ref(" SUMMARIZED "),
|
|
want: ptr.Ref(fantasyanthropic.ThinkingDisplaySummarized),
|
|
},
|
|
{
|
|
name: "Omitted",
|
|
input: ptr.Ref("omitted"),
|
|
want: ptr.Ref(fantasyanthropic.ThinkingDisplayOmitted),
|
|
},
|
|
{
|
|
name: "InvalidReturnsNil",
|
|
input: ptr.Ref("summary"),
|
|
},
|
|
{
|
|
name: "NilInputReturnsNil",
|
|
input: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := chatprovider.AnthropicThinkingDisplayFromChat(tt.input)
|
|
require.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProviderOptionsForCall_AnthropicThinkingDisplay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
providerOptions := chatprovider.ProviderOptionsForCall(chatprovider.Model{}, codersdk.ChatModelCallConfig{
|
|
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
|
Anthropic: &codersdk.ChatModelAnthropicProviderOptions{
|
|
ThinkingDisplay: ptr.Ref(" SUMMARIZED "),
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
require.NotNil(t, providerOptions)
|
|
anthropicOptions, ok := providerOptions[fantasyanthropic.Name].(*fantasyanthropic.ProviderOptions)
|
|
require.True(t, ok)
|
|
require.NotNil(t, anthropicOptions.ThinkingDisplay)
|
|
require.Equal(t, fantasyanthropic.ThinkingDisplaySummarized, *anthropicOptions.ThinkingDisplay)
|
|
}
|
|
|
|
func TestGoogleThinkingLevelFromChat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input *string
|
|
want *fantasygoogle.ThinkingLevel
|
|
}{
|
|
{
|
|
name: "Minimal",
|
|
input: ptr.Ref(" MINIMAL "),
|
|
want: ptr.Ref(fantasygoogle.ThinkingLevelMinimal),
|
|
},
|
|
{
|
|
name: "Low",
|
|
input: ptr.Ref("low"),
|
|
want: ptr.Ref(fantasygoogle.ThinkingLevelLow),
|
|
},
|
|
{
|
|
name: "Medium",
|
|
input: ptr.Ref("Medium"),
|
|
want: ptr.Ref(fantasygoogle.ThinkingLevelMedium),
|
|
},
|
|
{
|
|
name: "High",
|
|
input: ptr.Ref("high"),
|
|
want: ptr.Ref(fantasygoogle.ThinkingLevelHigh),
|
|
},
|
|
{
|
|
name: "InvalidReturnsNil",
|
|
input: ptr.Ref("ultra"),
|
|
},
|
|
{
|
|
name: "EmptyReturnsNil",
|
|
input: ptr.Ref(" "),
|
|
},
|
|
{
|
|
name: "NilInputReturnsNil",
|
|
input: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := chatprovider.GoogleThinkingLevelFromChat(tt.input)
|
|
require.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProviderOptionsForCall_GoogleThinkingConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
googleModel := chatprovider.NewModel(&chattest.FakeModel{
|
|
ProviderName: fantasygoogle.Name,
|
|
ModelName: "gemini-3.7-flash",
|
|
}, nil)
|
|
|
|
t.Run("PinnedLevelWithoutEffortConfig", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
providerOptions := chatprovider.ProviderOptionsForCall(googleModel, codersdk.ChatModelCallConfig{
|
|
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
|
Google: &codersdk.ChatModelGoogleProviderOptions{
|
|
ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{
|
|
ThinkingLevel: ptr.Ref(" MEDIUM "),
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions)
|
|
require.True(t, ok)
|
|
require.NotNil(t, googleOptions.ThinkingConfig)
|
|
require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel)
|
|
require.Equal(t, fantasygoogle.ThinkingLevelMedium, *googleOptions.ThinkingConfig.ThinkingLevel)
|
|
})
|
|
|
|
t.Run("PinnedLevelDroppedForGemini25", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
gemini25 := chatprovider.NewModel(&chattest.FakeModel{
|
|
ProviderName: fantasygoogle.Name,
|
|
ModelName: "gemini-2.5-flash",
|
|
}, nil)
|
|
|
|
providerOptions := chatprovider.ProviderOptionsForCall(gemini25, codersdk.ChatModelCallConfig{
|
|
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
|
Google: &codersdk.ChatModelGoogleProviderOptions{
|
|
ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{
|
|
ThinkingLevel: ptr.Ref("medium"),
|
|
IncludeThoughts: ptr.Ref(true),
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions)
|
|
require.True(t, ok)
|
|
require.NotNil(t, googleOptions.ThinkingConfig)
|
|
require.Nil(t, googleOptions.ThinkingConfig.ThinkingLevel)
|
|
require.True(t, *googleOptions.ThinkingConfig.IncludeThoughts)
|
|
})
|
|
|
|
t.Run("PinnedLevelClampedForGemini3Pro", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
gemini3Pro := chatprovider.NewModel(&chattest.FakeModel{
|
|
ProviderName: fantasygoogle.Name,
|
|
ModelName: "gemini-3-pro-preview",
|
|
}, nil)
|
|
|
|
providerOptions := chatprovider.ProviderOptionsForCall(gemini3Pro, codersdk.ChatModelCallConfig{
|
|
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
|
Google: &codersdk.ChatModelGoogleProviderOptions{
|
|
ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{
|
|
ThinkingLevel: ptr.Ref("minimal"),
|
|
},
|
|
},
|
|
},
|
|
}, nil)
|
|
|
|
googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions)
|
|
require.True(t, ok)
|
|
require.NotNil(t, googleOptions.ThinkingConfig)
|
|
require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel)
|
|
require.Equal(t, fantasygoogle.ThinkingLevelLow, *googleOptions.ThinkingConfig.ThinkingLevel)
|
|
})
|
|
|
|
t.Run("EffortClampedForGemini3Pro", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
gemini3Pro := chatprovider.NewModel(&chattest.FakeModel{
|
|
ProviderName: fantasygoogle.Name,
|
|
ModelName: "gemini-3-pro-preview",
|
|
}, nil)
|
|
|
|
providerOptions := chatprovider.ProviderOptionsForCall(gemini3Pro, codersdk.ChatModelCallConfig{
|
|
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
|
|
Default: ptr.Ref(codersdk.ChatModelReasoningEffortMedium),
|
|
Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
|
|
},
|
|
}, ptr.Ref(codersdk.ChatModelReasoningEffortMedium))
|
|
|
|
googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions)
|
|
require.True(t, ok)
|
|
require.NotNil(t, googleOptions.ThinkingConfig)
|
|
require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel)
|
|
require.Equal(t, fantasygoogle.ThinkingLevelHigh, *googleOptions.ThinkingConfig.ThinkingLevel)
|
|
})
|
|
|
|
t.Run("EffortOverridesPinnedLevel", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
providerOptions := chatprovider.ProviderOptionsForCall(googleModel, codersdk.ChatModelCallConfig{
|
|
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
|
Google: &codersdk.ChatModelGoogleProviderOptions{
|
|
ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{
|
|
ThinkingLevel: ptr.Ref("medium"),
|
|
},
|
|
},
|
|
},
|
|
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
|
|
Default: ptr.Ref(codersdk.ChatModelReasoningEffortMedium),
|
|
Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
|
|
},
|
|
}, ptr.Ref(codersdk.ChatModelReasoningEffortHigh))
|
|
|
|
googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions)
|
|
require.True(t, ok)
|
|
require.NotNil(t, googleOptions.ThinkingConfig)
|
|
require.NotNil(t, googleOptions.ThinkingConfig.ThinkingLevel)
|
|
require.Equal(t, fantasygoogle.ThinkingLevelHigh, *googleOptions.ThinkingConfig.ThinkingLevel)
|
|
})
|
|
|
|
t.Run("ConfiguredBudgetSuppressesEffortLevel", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
providerOptions := chatprovider.ProviderOptionsForCall(googleModel, codersdk.ChatModelCallConfig{
|
|
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
|
Google: &codersdk.ChatModelGoogleProviderOptions{
|
|
ThinkingConfig: &codersdk.ChatModelGoogleThinkingConfig{
|
|
ThinkingBudget: ptr.Ref(int64(2048)),
|
|
},
|
|
},
|
|
},
|
|
ReasoningEffort: &codersdk.ChatModelReasoningEffortConfig{
|
|
Default: ptr.Ref(codersdk.ChatModelReasoningEffortMedium),
|
|
Max: ptr.Ref(codersdk.ChatModelReasoningEffortHigh),
|
|
},
|
|
}, nil)
|
|
|
|
googleOptions, ok := providerOptions[fantasygoogle.Name].(*fantasygoogle.ProviderOptions)
|
|
require.True(t, ok)
|
|
require.NotNil(t, googleOptions.ThinkingConfig)
|
|
require.Equal(t, int64(2048), *googleOptions.ThinkingConfig.ThinkingBudget)
|
|
require.Nil(t, googleOptions.ThinkingConfig.ThinkingLevel)
|
|
})
|
|
}
|
|
|
|
func TestResolveUserProviderKeys_UnavailableReason(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
provider chatprovider.ConfiguredProvider
|
|
wantReason codersdk.ChatModelProviderUnavailableReason
|
|
}{
|
|
{
|
|
name: "FallbackConfiguredWithoutCentralKeyReturnsUserAPIKeyRequired",
|
|
provider: chatprovider.ConfiguredProvider{
|
|
Provider: "anthropic",
|
|
CentralAPIKeyEnabled: true,
|
|
AllowUserAPIKey: true,
|
|
AllowCentralAPIKeyFallback: true,
|
|
},
|
|
wantReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired,
|
|
},
|
|
{
|
|
name: "UserKeyRequiredWithoutFallback",
|
|
provider: chatprovider.ConfiguredProvider{
|
|
Provider: "anthropic",
|
|
CentralAPIKeyEnabled: true,
|
|
AllowUserAPIKey: true,
|
|
},
|
|
wantReason: codersdk.ChatModelProviderUnavailableReasonUserAPIKeyRequired,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys, availability := chatprovider.ResolveUserProviderKeys(
|
|
chatprovider.ProviderAPIKeys{},
|
|
[]chatprovider.ConfiguredProvider{tt.provider},
|
|
nil,
|
|
)
|
|
|
|
require.Empty(t, keys.APIKey(tt.provider.Provider))
|
|
resolved, ok := availability[tt.provider.Provider]
|
|
require.True(t, ok)
|
|
require.False(t, resolved.Available)
|
|
require.Equal(t, tt.wantReason, resolved.UnavailableReason)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPruneDisabledProviderKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
enabledProviders := func(providers ...string) map[string]struct{} {
|
|
result := make(map[string]struct{}, len(providers))
|
|
for _, provider := range providers {
|
|
result[chatprovider.NormalizeProvider(provider)] = struct{}{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
keys chatprovider.ProviderAPIKeys
|
|
enabledProviders map[string]struct{}
|
|
want chatprovider.ProviderAPIKeys
|
|
}{
|
|
{
|
|
name: "DisabledProviderEntriesRemoved",
|
|
keys: chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
fantasyopenai.Name: "sk-openai",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name),
|
|
want: chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "DisabledProviderRegionsRemoved",
|
|
keys: chatprovider.ProviderAPIKeys{
|
|
RegionByProvider: map[string]string{
|
|
fantasybedrock.Name: "us-east-2",
|
|
fantasyopenai.Name: "us-east-1",
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasybedrock.Name),
|
|
want: chatprovider.ProviderAPIKeys{
|
|
RegionByProvider: map[string]string{
|
|
fantasybedrock.Name: "us-east-2",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "OpenAIDisabledClearsLegacyField",
|
|
keys: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyanthropic.Name),
|
|
want: chatprovider.ProviderAPIKeys{
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "AnthropicDisabledClearsLegacyField",
|
|
keys: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name),
|
|
want: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "AllEnabledLeavesKeysUnchanged",
|
|
keys: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
enabledProviders: enabledProviders(fantasyopenai.Name, fantasyanthropic.Name),
|
|
want: chatprovider.ProviderAPIKeys{
|
|
OpenAI: "sk-openai",
|
|
Anthropic: "sk-anthropic",
|
|
ByProvider: map[string]string{
|
|
fantasyopenai.Name: "sk-openai",
|
|
fantasyanthropic.Name: "sk-anthropic",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasyopenai.Name: "https://openai.example.com",
|
|
fantasyanthropic.Name: "https://anthropic.example.com",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
keys := tt.keys
|
|
chatprovider.PruneDisabledProviderKeys(&keys, tt.enabledProviders)
|
|
require.Equal(t, tt.want, keys)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCoderHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("RootChatNoWorkspace", func(t *testing.T) {
|
|
t.Parallel()
|
|
chatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
chat := database.Chat{
|
|
ID: chatID,
|
|
OwnerID: ownerID,
|
|
}
|
|
h := chatprovider.CoderHeaders(chat)
|
|
require.Equal(t, ownerID.String(), h[chatprovider.HeaderCoderOwnerID])
|
|
require.Equal(t, chatID.String(), h[chatprovider.HeaderCoderChatID])
|
|
require.NotContains(t, h, chatprovider.HeaderCoderSubchatID)
|
|
require.NotContains(t, h, chatprovider.HeaderCoderWorkspaceID)
|
|
})
|
|
|
|
t.Run("RootChatWithWorkspace", func(t *testing.T) {
|
|
t.Parallel()
|
|
chatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
chat := database.Chat{
|
|
ID: chatID,
|
|
OwnerID: ownerID,
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
h := chatprovider.CoderHeaders(chat)
|
|
require.Equal(t, ownerID.String(), h[chatprovider.HeaderCoderOwnerID])
|
|
require.Equal(t, chatID.String(), h[chatprovider.HeaderCoderChatID])
|
|
require.NotContains(t, h, chatprovider.HeaderCoderSubchatID)
|
|
require.Equal(t, workspaceID.String(), h[chatprovider.HeaderCoderWorkspaceID])
|
|
})
|
|
|
|
t.Run("SubchatWithWorkspace", func(t *testing.T) {
|
|
t.Parallel()
|
|
parentID := uuid.New()
|
|
subchatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
chat := database.Chat{
|
|
ID: subchatID,
|
|
OwnerID: ownerID,
|
|
ParentChatID: uuid.NullUUID{UUID: parentID, Valid: true},
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
h := chatprovider.CoderHeaders(chat)
|
|
require.Equal(t, ownerID.String(), h[chatprovider.HeaderCoderOwnerID])
|
|
require.Equal(t, parentID.String(), h[chatprovider.HeaderCoderChatID])
|
|
require.Equal(t, subchatID.String(), h[chatprovider.HeaderCoderSubchatID])
|
|
require.Equal(t, workspaceID.String(), h[chatprovider.HeaderCoderWorkspaceID])
|
|
})
|
|
|
|
t.Run("SubchatNoWorkspace", func(t *testing.T) {
|
|
t.Parallel()
|
|
parentID := uuid.New()
|
|
subchatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
chat := database.Chat{
|
|
ID: subchatID,
|
|
OwnerID: ownerID,
|
|
ParentChatID: uuid.NullUUID{UUID: parentID, Valid: true},
|
|
}
|
|
h := chatprovider.CoderHeaders(chat)
|
|
require.Equal(t, ownerID.String(), h[chatprovider.HeaderCoderOwnerID])
|
|
require.Equal(t, parentID.String(), h[chatprovider.HeaderCoderChatID])
|
|
require.Equal(t, subchatID.String(), h[chatprovider.HeaderCoderSubchatID])
|
|
require.NotContains(t, h, chatprovider.HeaderCoderWorkspaceID)
|
|
})
|
|
}
|
|
|
|
func TestModelFromConfig_Bedrock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const modelID = "us.anthropic.claude-sonnet-4-20250514-v1:0"
|
|
|
|
// This verifies the policy gate that permits an empty Bedrock key.
|
|
// End-to-end ambient credential auth would need a real AWS
|
|
// environment or a more complete mock, which is outside this scope.
|
|
t.Run("AllowsEmptyAPIKeyForAmbientCredentials", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
modelID,
|
|
chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, model.Valid())
|
|
require.Equal(t, fantasybedrock.Name, model.Provider())
|
|
})
|
|
|
|
t.Run("RequiresResolvedProviderForAmbientCredentials", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
modelID,
|
|
chatprovider.ProviderAPIKeys{},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
require.False(t, model.Valid())
|
|
require.EqualError(t, err, "API key for provider \"bedrock\" is not set")
|
|
})
|
|
|
|
t.Run("ForwardsBaseURLAndExplicitAPIKey", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
type requestCapture struct {
|
|
Path string
|
|
Authorization string
|
|
UserAgent string
|
|
}
|
|
|
|
requests := make(chan requestCapture, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests <- requestCapture{
|
|
Path: r.URL.Path,
|
|
Authorization: r.Header.Get("Authorization"),
|
|
UserAgent: r.Header.Get("User-Agent"),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(bedrockNonStreamingResponse())
|
|
}))
|
|
defer server.Close()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
modelID,
|
|
chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasybedrock.Name: "test-key",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasybedrock.Name: server.URL,
|
|
},
|
|
},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, model.Valid())
|
|
|
|
_, err = model.LanguageModel().Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "hello"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got := testutil.TryReceive(ctx, t, requests)
|
|
require.Equal(t, "/model/"+modelID+"/invoke", got.Path)
|
|
require.Equal(t, "Bearer test-key", got.Authorization)
|
|
require.Equal(t, chatprovider.UserAgent(), got.UserAgent)
|
|
})
|
|
|
|
t.Run("NonBedrockStillRequiresAPIKey", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
model string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "OpenAI",
|
|
provider: fantasyopenai.Name,
|
|
model: "gpt-4",
|
|
wantErr: "OPENAI_API_KEY is not set",
|
|
},
|
|
{
|
|
name: "Anthropic",
|
|
provider: fantasyanthropic.Name,
|
|
model: "claude-sonnet-4-20250514",
|
|
wantErr: "ANTHROPIC_API_KEY is not set",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
tt.provider,
|
|
tt.model,
|
|
chatprovider.ProviderAPIKeys{},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
require.False(t, model.Valid())
|
|
require.EqualError(t, err, tt.wantErr)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestModelFromConfig_BedrockStripsAnthropicHeaders is a regression test
|
|
// for a bug where the Anthropic SDK reads ANTHROPIC_API_KEY from the
|
|
// process environment and adds X-Api-Key and Anthropic-Version headers to
|
|
// every request. On Bedrock, these headers conflict with SigV4 signing and
|
|
// cause auth failures. The SDK's Bedrock middleware strips them before
|
|
// signing. This test verifies the outgoing request shape with both
|
|
// Anthropic and AWS credentials present.
|
|
func TestModelFromConfig_BedrockStripsAnthropicHeaders(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
t.Setenv("ANTHROPIC_API_KEY", "anthropic-env-key")
|
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
|
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
|
|
t.Setenv("AWS_SESSION_TOKEN", "test-session-token")
|
|
|
|
type requestCapture struct {
|
|
Path string
|
|
Authorization string
|
|
AnthropicVersion string
|
|
XAPIKey string
|
|
Body string
|
|
ReadError error
|
|
}
|
|
|
|
requests := make(chan requestCapture, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
|
|
requests <- requestCapture{
|
|
Path: r.URL.Path,
|
|
Authorization: r.Header.Get("Authorization"),
|
|
AnthropicVersion: r.Header.Get("Anthropic-Version"),
|
|
XAPIKey: r.Header.Get("X-Api-Key"),
|
|
Body: string(body),
|
|
ReadError: err,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(bedrockNonStreamingResponse())
|
|
}))
|
|
defer server.Close()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
"anthropic.claude-opus-4-6-v1",
|
|
chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasybedrock.Name: server.URL,
|
|
},
|
|
RegionByProvider: map[string]string{
|
|
fantasybedrock.Name: "us-east-2",
|
|
},
|
|
},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, model.Valid())
|
|
|
|
_, err = model.LanguageModel().Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "hello"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
got := testutil.TryReceive(ctx, t, requests)
|
|
require.NoError(t, got.ReadError)
|
|
require.Equal(t, "/model/us.anthropic.claude-opus-4-6-v1/invoke", got.Path)
|
|
require.Empty(t, got.AnthropicVersion)
|
|
require.Empty(t, got.XAPIKey)
|
|
require.Contains(t, got.Authorization, "AWS4-HMAC-SHA256")
|
|
require.NotContains(t, got.Authorization, "anthropic-version")
|
|
require.NotContains(t, got.Authorization, "x-api-key")
|
|
require.Contains(t, got.Body, `"anthropic_version":"bedrock-2023-05-31"`)
|
|
}
|
|
|
|
func TestModelFromConfig_BedrockStreamingHeaders(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
t.Setenv("ANTHROPIC_API_KEY", "anthropic-env-key")
|
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
|
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
|
|
t.Setenv("AWS_SESSION_TOKEN", "test-session-token")
|
|
|
|
type requestCapture struct {
|
|
Path string
|
|
Accept string
|
|
BedrockAccept string
|
|
Authorization string
|
|
Body string
|
|
ReadError error
|
|
}
|
|
|
|
requests := make(chan requestCapture, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
|
|
requests <- requestCapture{
|
|
Path: r.URL.Path,
|
|
Accept: r.Header.Get("Accept"),
|
|
BedrockAccept: r.Header.Get("X-Amzn-Bedrock-Accept"),
|
|
Authorization: r.Header.Get("Authorization"),
|
|
Body: string(body),
|
|
ReadError: err,
|
|
}
|
|
|
|
if err := writeBedrockAnthropicStream(w,
|
|
`{"type":"message_start","message":{}}`,
|
|
`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":1}}`,
|
|
`{"type":"message_stop"}`,
|
|
); err != nil {
|
|
t.Errorf("write bedrock stream: %v", err)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
"global.anthropic.claude-opus-4-6-v1",
|
|
chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{
|
|
fantasybedrock.Name: "",
|
|
},
|
|
BaseURLByProvider: map[string]string{
|
|
fantasybedrock.Name: server.URL,
|
|
},
|
|
RegionByProvider: map[string]string{
|
|
fantasybedrock.Name: "us-east-2",
|
|
},
|
|
},
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
require.True(t, model.Valid())
|
|
|
|
stream, err := model.LanguageModel().Stream(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "hello"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
for part := range stream {
|
|
require.NotEqual(t, fantasy.StreamPartTypeError, part.Type)
|
|
}
|
|
|
|
got := testutil.TryReceive(ctx, t, requests)
|
|
require.NoError(t, got.ReadError)
|
|
require.Equal(t, "/model/global.anthropic.claude-opus-4-6-v1/invoke-with-response-stream", got.Path)
|
|
require.Empty(t, got.Accept)
|
|
require.Equal(t, "application/json", got.BedrockAccept)
|
|
require.Contains(t, got.Authorization, "AWS4-HMAC-SHA256")
|
|
require.Contains(t, got.Authorization, "x-amzn-bedrock-accept")
|
|
require.Contains(t, got.Body, `"anthropic_version":"bedrock-2023-05-31"`)
|
|
}
|
|
|
|
func writeBedrockAnthropicStream(w http.ResponseWriter, events ...string) error {
|
|
w.Header().Set("Content-Type", "application/vnd.amazon.eventstream")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
encoder := eventstream.NewEncoder()
|
|
for _, event := range events {
|
|
payload, err := json.Marshal(map[string]string{
|
|
"bytes": base64.StdEncoding.EncodeToString([]byte(event)),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = encoder.Encode(w, eventstream.Message{
|
|
Headers: eventstream.Headers{
|
|
{
|
|
Name: eventstreamapi.MessageTypeHeader,
|
|
Value: eventstream.StringValue(eventstreamapi.EventMessageType),
|
|
},
|
|
{
|
|
Name: eventstreamapi.EventTypeHeader,
|
|
Value: eventstream.StringValue("chunk"),
|
|
},
|
|
{
|
|
Name: eventstreamapi.ContentTypeHeader,
|
|
Value: eventstream.StringValue("application/json"),
|
|
},
|
|
},
|
|
Payload: payload,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if flusher, ok := w.(http.Flusher); ok {
|
|
flusher.Flush()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func bedrockNonStreamingResponse() map[string]any {
|
|
return map[string]any{
|
|
"id": "msg_01Test",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": "claude-sonnet-4-20250514",
|
|
"content": []any{
|
|
map[string]any{
|
|
"type": "text",
|
|
"text": "Hi there",
|
|
},
|
|
},
|
|
"stop_reason": "end_turn",
|
|
"stop_sequence": "",
|
|
"usage": map[string]any{
|
|
"cache_creation": map[string]any{
|
|
"ephemeral_1h_input_tokens": 0,
|
|
"ephemeral_5m_input_tokens": 0,
|
|
},
|
|
"cache_creation_input_tokens": 0,
|
|
"cache_read_input_tokens": 0,
|
|
"input_tokens": 5,
|
|
"output_tokens": 2,
|
|
"server_tool_use": map[string]any{
|
|
"web_search_requests": 0,
|
|
},
|
|
"service_tier": "standard",
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestModelFromConfig_ExtraHeaders verifies that extra headers passed
|
|
// to ModelFromConfig are sent on outgoing LLM API requests. Only the
|
|
// OpenAI and Anthropic providers are tested end-to-end because the
|
|
// WithHeaders injection is the same mechanical pattern across all
|
|
// eight provider cases, and these are the only two providers with
|
|
// chattest test servers. CoderHeaders construction is tested
|
|
// separately in TestCoderHeaders.
|
|
func TestModelFromConfig_ExtraHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
parentID := uuid.New()
|
|
subchatID := uuid.New()
|
|
ownerID := uuid.New()
|
|
workspaceID := uuid.New()
|
|
|
|
chat := database.Chat{
|
|
ID: subchatID,
|
|
OwnerID: ownerID,
|
|
ParentChatID: uuid.NullUUID{UUID: parentID, Valid: true},
|
|
WorkspaceID: uuid.NullUUID{UUID: workspaceID, Valid: true},
|
|
}
|
|
headers := chatprovider.CoderHeaders(chat)
|
|
|
|
assertCoderHeaders := func(t *testing.T, got http.Header) {
|
|
t.Helper()
|
|
assert.Equal(t, ownerID.String(), got.Get(chatprovider.HeaderCoderOwnerID))
|
|
assert.Equal(t, parentID.String(), got.Get(chatprovider.HeaderCoderChatID))
|
|
assert.Equal(t, subchatID.String(), got.Get(chatprovider.HeaderCoderSubchatID))
|
|
assert.Equal(t, workspaceID.String(), got.Get(chatprovider.HeaderCoderWorkspaceID))
|
|
}
|
|
|
|
t.Run("OpenAI", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
assertCoderHeaders(t, req.Header)
|
|
close(called)
|
|
return chattest.OpenAINonStreamingResponse("hello")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{"openai": "test-key"},
|
|
BaseURLByProvider: map[string]string{"openai": serverURL},
|
|
}
|
|
|
|
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), headers, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = model.LanguageModel().Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
})
|
|
|
|
t.Run("Anthropic", func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
|
assertCoderHeaders(t, req.Header)
|
|
close(called)
|
|
return chattest.AnthropicNonStreamingResponse("hello")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{"anthropic": "test-key"},
|
|
BaseURLByProvider: map[string]string{"anthropic": serverURL},
|
|
}
|
|
|
|
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), headers, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = model.LanguageModel().Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
})
|
|
}
|
|
|
|
func TestBetaHeadersFromCallConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
configWith1M := func(enabled *bool) *codersdk.ChatModelCallConfig {
|
|
return &codersdk.ChatModelCallConfig{
|
|
ProviderOptions: &codersdk.ChatModelProviderOptions{
|
|
Anthropic: &codersdk.ChatModelAnthropicProviderOptions{
|
|
Context1MEnabled: enabled,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
beta := map[string]string{
|
|
chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M,
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
provider string
|
|
config *codersdk.ChatModelCallConfig
|
|
want map[string]string
|
|
}{
|
|
{name: "NilConfig", provider: fantasyanthropic.Name, config: nil, want: nil},
|
|
{name: "NilProviderOptions", provider: fantasyanthropic.Name, config: &codersdk.ChatModelCallConfig{}, want: nil},
|
|
{name: "NilAnthropicOptions", provider: fantasyanthropic.Name, config: &codersdk.ChatModelCallConfig{ProviderOptions: &codersdk.ChatModelProviderOptions{}}, want: nil},
|
|
{name: "Unset", provider: fantasyanthropic.Name, config: configWith1M(nil), want: nil},
|
|
{name: "Disabled", provider: fantasyanthropic.Name, config: configWith1M(ptr.Ref(false)), want: nil},
|
|
{name: "EnabledAnthropic", provider: fantasyanthropic.Name, config: configWith1M(ptr.Ref(true)), want: beta},
|
|
{name: "EnabledBedrock", provider: fantasybedrock.Name, config: configWith1M(ptr.Ref(true)), want: beta},
|
|
{name: "EnabledOpenAI", provider: fantasyopenai.Name, config: configWith1M(ptr.Ref(true)), want: nil},
|
|
{name: "EnabledUnknownProvider", provider: "does-not-exist", config: configWith1M(ptr.Ref(true)), want: nil},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
require.Equal(t, tt.want, chatprovider.BetaHeadersFromCallConfig(tt.provider, tt.config))
|
|
})
|
|
}
|
|
}
|
|
|
|
func generateHello(ctx context.Context, model chatprovider.Model) error {
|
|
_, err := model.LanguageModel().Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
},
|
|
},
|
|
})
|
|
return err
|
|
}
|
|
|
|
func TestModelFromConfig_AnthropicBetaExtraHeader(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
|
assert.Equal(t, chatprovider.AnthropicBetaContext1M, req.Header.Get(chatprovider.HeaderAnthropicBeta))
|
|
close(called)
|
|
return chattest.AnthropicNonStreamingResponse("hello")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{fantasyanthropic.Name: "test-key"},
|
|
BaseURLByProvider: map[string]string{fantasyanthropic.Name: serverURL},
|
|
}
|
|
betaHeaders := map[string]string{
|
|
chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M,
|
|
}
|
|
model, err := chatprovider.ModelFromConfig(fantasyanthropic.Name, "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), betaHeaders, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, generateHello(ctx, model))
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
}
|
|
|
|
// The Anthropic SDK's Bedrock middleware moves Anthropic-Beta into the
|
|
// request body before SigV4 signing.
|
|
func TestModelFromConfig_BedrockBetaExtraHeader(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
|
|
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
|
|
t.Setenv("AWS_SESSION_TOKEN", "test-session-token")
|
|
|
|
type requestCapture struct {
|
|
AnthropicBeta string
|
|
Authorization string
|
|
Body string
|
|
ReadError error
|
|
}
|
|
|
|
requests := make(chan requestCapture, 1)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, err := io.ReadAll(r.Body)
|
|
|
|
requests <- requestCapture{
|
|
AnthropicBeta: r.Header.Get(chatprovider.HeaderAnthropicBeta),
|
|
Authorization: r.Header.Get("Authorization"),
|
|
Body: string(body),
|
|
ReadError: err,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(bedrockNonStreamingResponse())
|
|
}))
|
|
defer server.Close()
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
fantasybedrock.Name,
|
|
"anthropic.claude-opus-4-6-v1",
|
|
chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{fantasybedrock.Name: ""},
|
|
BaseURLByProvider: map[string]string{fantasybedrock.Name: server.URL},
|
|
RegionByProvider: map[string]string{fantasybedrock.Name: "us-east-2"},
|
|
},
|
|
chatprovider.UserAgent(),
|
|
map[string]string{
|
|
chatprovider.HeaderAnthropicBeta: chatprovider.AnthropicBetaContext1M,
|
|
},
|
|
nil,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, generateHello(ctx, model))
|
|
|
|
got := testutil.TryReceive(ctx, t, requests)
|
|
require.NoError(t, got.ReadError)
|
|
require.Empty(t, got.AnthropicBeta)
|
|
require.Contains(t, got.Authorization, "AWS4-HMAC-SHA256")
|
|
require.Contains(t, got.Body, `"anthropic_beta":["context-1m-2025-08-07"]`)
|
|
}
|
|
|
|
// TestModelFromConfig_AnthropicPDFFilePartReachesProvider pins the end-to-end
|
|
// path that lets a user-uploaded PDF actually reach Claude/Bedrock: a
|
|
// fantasy.FilePart with MediaType "application/pdf" must be serialized as an
|
|
// Anthropic "document" content block with a base64 source carrying the PDF
|
|
// bytes and a sanitized filename as the document title. Older fantasy versions
|
|
// silently dropped PDF FileParts in the Anthropic provider, so the user
|
|
// message ended up empty and the model never saw the document. The underlying
|
|
// PDF block support came from coder/fantasy#37, a cherry-pick of upstream
|
|
// charmbracelet/fantasy#197. The Generate call would fail outright on the
|
|
// regressed code path because the dropped FilePart leaves the request with
|
|
// zero messages.
|
|
func TestModelFromConfig_AnthropicPDFFilePartReachesProvider(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
pdfData := []byte("%PDF-1.7\nfake pdf bytes for regression test")
|
|
wantData := base64.StdEncoding.EncodeToString(pdfData)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewAnthropic(t, func(req *chattest.AnthropicRequest) chattest.AnthropicResponse {
|
|
defer close(called)
|
|
|
|
require.Len(t, req.Messages, 1, "PDF FilePart should produce one Anthropic message, not be dropped as empty")
|
|
require.Equal(t, "user", req.Messages[0].Role)
|
|
|
|
var blocks []struct {
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Source struct {
|
|
Type string `json:"type"`
|
|
MediaType string `json:"media_type"`
|
|
Data string `json:"data"`
|
|
} `json:"source"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(req.Messages[0].Content, &blocks),
|
|
"user content should be a structured block array, got: %s", string(req.Messages[0].Content))
|
|
|
|
var found bool
|
|
for _, block := range blocks {
|
|
if block.Type != "document" {
|
|
continue
|
|
}
|
|
assert.Equal(t, "base64", block.Source.Type, "PDF document block must use a base64 source")
|
|
assert.Equal(t, wantData, block.Source.Data, "PDF bytes must round-trip base64 unchanged")
|
|
assert.Equal(t,
|
|
"quarterly report v1 pdf",
|
|
block.Title,
|
|
"PDF filename must reach Anthropic as a sanitized document title",
|
|
)
|
|
if block.Source.MediaType != "" {
|
|
assert.Equal(t, "application/pdf", block.Source.MediaType)
|
|
}
|
|
found = true
|
|
}
|
|
require.True(t, found, "expected an Anthropic document block carrying the PDF, got: %s", string(req.Messages[0].Content))
|
|
|
|
return chattest.AnthropicNonStreamingResponse("ok")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{"anthropic": "test-key"},
|
|
BaseURLByProvider: map[string]string{"anthropic": serverURL},
|
|
}
|
|
|
|
model, err := chatprovider.ModelFromConfig("anthropic", "claude-sonnet-4-20250514", keys, chatprovider.UserAgent(), nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = model.LanguageModel().Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.FilePart{
|
|
Filename: "quarterly_report.v1.pdf",
|
|
Data: pdfData,
|
|
MediaType: "application/pdf",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
}
|
|
|
|
func TestModelFromConfig_NilExtraHeaders(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
// Coder headers must be absent when nil is passed.
|
|
assert.Empty(t, req.Header.Get(chatprovider.HeaderCoderOwnerID))
|
|
assert.Empty(t, req.Header.Get(chatprovider.HeaderCoderChatID))
|
|
assert.Empty(t, req.Header.Get(chatprovider.HeaderCoderSubchatID))
|
|
assert.Empty(t, req.Header.Get(chatprovider.HeaderCoderWorkspaceID))
|
|
close(called)
|
|
return chattest.OpenAINonStreamingResponse("hello")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{"openai": "test-key"},
|
|
BaseURLByProvider: map[string]string{"openai": serverURL},
|
|
}
|
|
|
|
model, err := chatprovider.ModelFromConfig("openai", "gpt-4", keys, chatprovider.UserAgent(), nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
_, err = model.LanguageModel().Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
},
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
}
|
|
|
|
func TestModelFromConfig_HTTPClient(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitShort)
|
|
|
|
called := make(chan struct{})
|
|
serverURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse {
|
|
assert.Equal(t, "true", req.Header.Get("X-Test-Transport"))
|
|
close(called)
|
|
return chattest.OpenAINonStreamingResponse("hello")
|
|
})
|
|
|
|
keys := chatprovider.ProviderAPIKeys{
|
|
ByProvider: map[string]string{"openai": "test-key"},
|
|
BaseURLByProvider: map[string]string{"openai": serverURL},
|
|
}
|
|
client := &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
|
cloned := req.Clone(req.Context())
|
|
cloned.Header = req.Header.Clone()
|
|
cloned.Header.Set("X-Test-Transport", "true")
|
|
return http.DefaultTransport.RoundTrip(cloned)
|
|
})}
|
|
|
|
model, err := chatprovider.ModelFromConfig(
|
|
"openai",
|
|
"gpt-4",
|
|
keys,
|
|
chatprovider.UserAgent(),
|
|
nil,
|
|
client,
|
|
nil,
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
_, err = model.LanguageModel().Generate(ctx, fantasy.Call{
|
|
Prompt: []fantasy.Message{{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{fantasy.TextPart{Text: "hello"}},
|
|
}},
|
|
})
|
|
require.NoError(t, err)
|
|
_ = testutil.TryReceive(ctx, t, called)
|
|
}
|
|
|
|
func TestResolveModelWithProviderHint(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
modelName string
|
|
providerHint string
|
|
wantProvider string
|
|
wantModel string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "VercelHintPreservesPrefixedModelID",
|
|
modelName: "anthropic/claude-4-5-sonnet",
|
|
providerHint: fantasyvercel.Name,
|
|
wantProvider: fantasyvercel.Name,
|
|
wantModel: "anthropic/claude-4-5-sonnet",
|
|
},
|
|
{
|
|
name: "OpenRouterHintPreservesPrefixedModelID",
|
|
modelName: "anthropic/claude-3.5-haiku",
|
|
providerHint: fantasyopenrouter.Name,
|
|
wantProvider: fantasyopenrouter.Name,
|
|
wantModel: "anthropic/claude-3.5-haiku",
|
|
},
|
|
{
|
|
name: "OpenAICompatHintPreservesPrefixedModelID",
|
|
modelName: "anthropic/claude-4-5-sonnet",
|
|
providerHint: fantasyopenaicompat.Name,
|
|
wantProvider: fantasyopenaicompat.Name,
|
|
wantModel: "anthropic/claude-4-5-sonnet",
|
|
},
|
|
{
|
|
name: "OpenRouterHintPreservesOpenRouterModelID",
|
|
modelName: "anthropic/claude-opus-4.6",
|
|
providerHint: fantasyopenrouter.Name,
|
|
wantProvider: fantasyopenrouter.Name,
|
|
wantModel: "anthropic/claude-opus-4.6",
|
|
},
|
|
{
|
|
name: "OpenAICompatHintPreservesOpenRouterModelID",
|
|
modelName: "anthropic/claude-opus-4.6",
|
|
providerHint: fantasyopenaicompat.Name,
|
|
wantProvider: fantasyopenaicompat.Name,
|
|
wantModel: "anthropic/claude-opus-4.6",
|
|
},
|
|
{
|
|
name: "OpenAIHintPreservesCanonicalPrefix",
|
|
modelName: "anthropic/claude-opus-4.6",
|
|
providerHint: fantasyopenai.Name,
|
|
wantProvider: fantasyopenai.Name,
|
|
wantModel: "anthropic/claude-opus-4.6",
|
|
},
|
|
{
|
|
name: "OpenAIHintPreservesUnknownSlashNamespace",
|
|
modelName: "meta-llama/llama-3-70b",
|
|
providerHint: fantasyopenai.Name,
|
|
wantProvider: fantasyopenai.Name,
|
|
wantModel: "meta-llama/llama-3-70b",
|
|
},
|
|
{
|
|
name: "AnthropicHintPreservesCanonicalPrefix",
|
|
modelName: "anthropic/claude-4-5-sonnet",
|
|
providerHint: fantasyanthropic.Name,
|
|
wantProvider: fantasyanthropic.Name,
|
|
wantModel: "anthropic/claude-4-5-sonnet",
|
|
},
|
|
{
|
|
name: "AnthropicHintPreservesBedrockNamespace",
|
|
modelName: "bedrock/claude-opus-4-8",
|
|
providerHint: fantasyanthropic.Name,
|
|
wantProvider: fantasyanthropic.Name,
|
|
wantModel: "bedrock/claude-opus-4-8",
|
|
},
|
|
{
|
|
name: "AnthropicHintPreservesDoubleBedrockNamespace",
|
|
modelName: "bedrock/bedrock/claude-opus-4-8",
|
|
providerHint: fantasyanthropic.Name,
|
|
wantProvider: fantasyanthropic.Name,
|
|
wantModel: "bedrock/bedrock/claude-opus-4-8",
|
|
},
|
|
{
|
|
name: "AnthropicHintPreservesColonCanonicalRef",
|
|
modelName: "openai:gpt-5.2",
|
|
providerHint: fantasyanthropic.Name,
|
|
wantProvider: fantasyanthropic.Name,
|
|
wantModel: "openai:gpt-5.2",
|
|
},
|
|
{
|
|
name: "BedrockHintWithBareModel",
|
|
modelName: "claude-opus-4-6",
|
|
providerHint: fantasybedrock.Name,
|
|
wantProvider: fantasybedrock.Name,
|
|
wantModel: "claude-opus-4-6",
|
|
},
|
|
{
|
|
name: "NoHintUsesCanonicalRef",
|
|
modelName: "anthropic/claude-4-5-sonnet",
|
|
providerHint: "",
|
|
wantProvider: fantasyanthropic.Name,
|
|
wantModel: "claude-4-5-sonnet",
|
|
},
|
|
{
|
|
name: "VercelHintWithoutSlashPasses",
|
|
modelName: "claude-4-5-sonnet",
|
|
providerHint: fantasyvercel.Name,
|
|
wantProvider: fantasyvercel.Name,
|
|
wantModel: "claude-4-5-sonnet",
|
|
},
|
|
{
|
|
name: "BareGeminiModelResolvesToGoogle",
|
|
modelName: "gemini-3.5-flash",
|
|
providerHint: "",
|
|
wantProvider: fantasygoogle.Name,
|
|
wantModel: "gemini-3.5-flash",
|
|
},
|
|
{
|
|
name: "BareGemmaModelResolvesToGoogle",
|
|
modelName: "gemma-3-27b",
|
|
providerHint: "",
|
|
wantProvider: fantasygoogle.Name,
|
|
wantModel: "gemma-3-27b",
|
|
},
|
|
{
|
|
name: "GoogleHintWithGeminiModel",
|
|
modelName: "gemini-2.5-pro",
|
|
providerHint: fantasygoogle.Name,
|
|
wantProvider: fantasygoogle.Name,
|
|
wantModel: "gemini-2.5-pro",
|
|
},
|
|
{
|
|
name: "CanonicalGoogleRefResolvesToGoogle",
|
|
modelName: "google/gemini-3.5-flash",
|
|
providerHint: "",
|
|
wantProvider: fantasygoogle.Name,
|
|
wantModel: "gemini-3.5-flash",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
provider, model, err := chatprovider.ResolveModelWithProviderHint(tt.modelName, tt.providerHint)
|
|
if tt.wantErr {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
require.Equal(t, tt.wantProvider, provider)
|
|
require.Equal(t, tt.wantModel, model)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnsupportedProviders(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("copilot only", func(t *testing.T) {
|
|
t.Parallel()
|
|
got := chatprovider.UnsupportedProviders([]chatprovider.ConfiguredProvider{
|
|
{Provider: string(codersdk.AIProviderTypeCopilot)},
|
|
})
|
|
require.Equal(t, []codersdk.ChatUnsupportedProvider{
|
|
{
|
|
Provider: "copilot",
|
|
DisplayName: "GitHub Copilot",
|
|
},
|
|
}, got)
|
|
})
|
|
|
|
t.Run("supported provider omitted", func(t *testing.T) {
|
|
t.Parallel()
|
|
got := chatprovider.UnsupportedProviders([]chatprovider.ConfiguredProvider{
|
|
{Provider: fantasyanthropic.Name},
|
|
{Provider: fantasyopenai.Name},
|
|
})
|
|
require.Empty(t, got)
|
|
})
|
|
|
|
t.Run("dedup by type and skip supported", func(t *testing.T) {
|
|
t.Parallel()
|
|
got := chatprovider.UnsupportedProviders([]chatprovider.ConfiguredProvider{
|
|
{Provider: fantasyanthropic.Name},
|
|
{Provider: string(codersdk.AIProviderTypeCopilot)},
|
|
{Provider: "Copilot"},
|
|
})
|
|
require.Len(t, got, 1)
|
|
require.Equal(t, "copilot", got[0].Provider)
|
|
})
|
|
}
|