Files
gochat/backend/internal/llm/provider_manager_test.go
T
Rogeeandrogee 2b182f9956 H-300: wire Captain Skills into Web runtime (#48)
* H-300: wire Captain Skills into Web runtime

* H-300: enforce effective model and conservative skill budget

* H-300: fix CI gosec step

* ci: extend golangci-lint timeout

* fix lint findings across backend

* fix(push): resolve delivery protocol blockers

* test(repository): close SQLite test databases

* test(repository): reuse SQLite schema per package

* H-307: restore backend Go cache in CI

* H-307: prefetch modules before cold lint

* H-307: resolve govulncheck security gate

* H-307: build lint with patched Go toolchain

* H-307: clear remaining security scan findings

---------

Co-authored-by: Rogee <rogee@ipao.vip>
2026-08-19 07:08:14 +08:00

329 lines
12 KiB
Go

package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestProviderManagerRequiresPageConfiguration(t *testing.T) {
manager := NewProviderManager()
_, err := manager.ChatCompletion(context.Background(), ChatRequest{})
require.ErrorIs(t, err, ErrProviderNotConfigured)
}
func TestProviderManagerUsesAccountFeatureModelAndGenerationSettings(t *testing.T) {
var request ChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
}))
defer server.Close()
manager := NewProviderManager()
manager.SetAccountModelResolver(func(_ context.Context, accountID uint, feature string) (string, error) {
assert.Equal(t, uint(42), accountID)
assert.Equal(t, "editor", feature)
return "account-editor-model", nil
})
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai",
ChatBaseURL: server.URL,
ChatAPIKey: "test-key",
ChatModel: "platform-model",
EmbeddingMode: EmbeddingModeReuseChat,
Temperature: 0.25,
MaxTokens: 777,
}))
ctx := WithAccountFeature(context.Background(), 42, "editor")
_, err := manager.ChatCompletion(ctx, ChatRequest{Model: "hard-coded-model"})
require.NoError(t, err)
assert.Equal(t, "account-editor-model", request.Model)
assert.Equal(t, 0.25, request.Temperature)
assert.Equal(t, 777, request.MaxTokens)
}
func TestProviderManagerResolvesAccountFeatureModel(t *testing.T) {
manager := NewProviderManager()
manager.SetAccountModelResolver(func(_ context.Context, accountID uint, feature string) (string, error) {
assert.Equal(t, uint(42), accountID)
assert.Equal(t, "assistant", feature)
return "account-assistant-model", nil
})
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai",
ChatAPIKey: "test-key",
ChatModel: "platform-model",
EmbeddingMode: EmbeddingModeReuseChat,
}))
model, err := manager.ResolveChatModel(WithAccountFeature(context.Background(), 42, "assistant"))
require.NoError(t, err)
assert.Equal(t, "account-assistant-model", model)
}
func TestProviderManagerCompatibleKeepsConfiguredModelAndAPIError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request ChatRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
assert.Equal(t, "provider-model", request.Model)
w.Header().Set("Content-Type", "application/json")
if request.Messages[0].Content == "fail" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":{"message":"rejected","type":"invalid_request_error","code":"model_not_found"}}`))
return
}
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"ok"}}]}`))
}))
defer server.Close()
manager := NewProviderManager()
manager.SetAccountModelResolver(func(context.Context, uint, string) (string, error) {
return "stale-openai-model", nil
})
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: server.URL,
ChatAPIKey: "test-key",
ChatModel: "provider-model",
EmbeddingMode: EmbeddingModeReuseChat,
}))
ctx := WithAccountFeature(context.Background(), 42, "assistant")
_, err := manager.ChatCompletion(ctx, ChatRequest{Messages: []ChatMessage{{Role: "user", Content: "ok"}}})
require.NoError(t, err)
_, err = manager.ChatCompletion(ctx, ChatRequest{Messages: []ChatMessage{{Role: "user", Content: "fail"}}})
var apiErr *APIError
require.ErrorAs(t, err, &apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
assert.Equal(t, "invalid_request_error", apiErr.Type)
assert.Equal(t, "model_not_found", apiErr.Code)
}
func TestProviderManagerPreservesExplicitAssistantTemperature(t *testing.T) {
var request ChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"chat-1","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
}))
defer server.Close()
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: server.URL,
ChatAPIKey: "test-key",
ChatModel: "platform-model",
EmbeddingMode: EmbeddingModeReuseChat,
Temperature: 0.9,
MaxTokens: 777,
}))
ctx := WithTemperatureOverride(context.Background(), 0.2)
_, err := manager.ChatCompletion(ctx, ChatRequest{})
require.NoError(t, err)
assert.Equal(t, 0.2, request.Temperature)
assert.Equal(t, 777, request.MaxTokens)
}
func TestProviderManagerUsesSeparateEmbeddingProvider(t *testing.T) {
var chatCalls atomic.Int32
chatServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
chatCalls.Add(1)
http.NotFound(w, r)
}))
defer chatServer.Close()
var embeddingRequest EmbeddingRequest
var authorization string
embeddingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authorization = r.Header.Get("Authorization")
require.NoError(t, json.NewDecoder(r.Body).Decode(&embeddingRequest))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"embed-model"}`))
}))
defer embeddingServer.Close()
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: chatServer.URL,
ChatAPIKey: "chat-key",
ChatModel: "chat-model",
EmbeddingMode: EmbeddingModeSeparate,
EmbeddingProvider: "openai_compatible",
EmbeddingBaseURL: embeddingServer.URL,
EmbeddingAPIKey: "embedding-key",
EmbeddingModel: "embed-model",
EmbeddingDimensions: 3,
}))
_, err := manager.CreateEmbedding(context.Background(), EmbeddingRequest{Model: "ignored", Input: []string{"hello"}})
require.NoError(t, err)
assert.Equal(t, int32(0), chatCalls.Load())
assert.Equal(t, "Bearer embedding-key", authorization)
assert.Equal(t, "embed-model", embeddingRequest.Model)
assert.Equal(t, 3, embeddingRequest.Dimensions)
}
func TestProviderManagerUsesAnthropicChatWithSeparateCompatibleEmbedding(t *testing.T) {
var (
anthropicRequest anthropicRequest
embeddingRequest EmbeddingRequest
anthropicKey string
embeddingAuth string
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/v1/messages":
anthropicKey = r.Header.Get("x-api-key")
require.NoError(t, json.NewDecoder(r.Body).Decode(&anthropicRequest))
_, _ = w.Write([]byte(`{"id":"msg-1","type":"message","role":"assistant","content":[{"type":"text","text":"anthropic ok"}],"model":"claude-test","stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":3}}`))
case "/embeddings":
embeddingAuth = r.Header.Get("Authorization")
require.NoError(t, json.NewDecoder(r.Body).Decode(&embeddingRequest))
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[0.1,0.2,0.3]}],"model":"embed-test"}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "anthropic",
ChatBaseURL: server.URL,
ChatAPIKey: "anthropic-key",
ChatModel: "claude-test",
EmbeddingMode: EmbeddingModeSeparate,
EmbeddingProvider: "openai_compatible",
EmbeddingBaseURL: server.URL,
EmbeddingAPIKey: "embedding-key",
EmbeddingModel: "embed-test",
EmbeddingDimensions: 3,
Temperature: 0.4,
MaxTokens: 321,
}))
chatResponse, err := manager.ChatCompletion(context.Background(), ChatRequest{
Messages: []ChatMessage{
{Role: "system", Content: "system prompt"},
{Role: "user", Content: "hello"},
},
})
require.NoError(t, err)
require.Len(t, chatResponse.Choices, 1)
assert.Equal(t, "anthropic ok", chatResponse.Choices[0].Message.Content)
assert.Equal(t, "anthropic-key", anthropicKey)
assert.Equal(t, "claude-test", anthropicRequest.Model)
assert.Equal(t, "system prompt", anthropicRequest.System)
assert.Equal(t, 0.4, anthropicRequest.Temperature)
assert.Equal(t, 321, anthropicRequest.MaxTokens)
embeddingResponse, err := manager.CreateEmbedding(context.Background(), EmbeddingRequest{Input: []string{"hello"}})
require.NoError(t, err)
require.Len(t, embeddingResponse.Data, 1)
assert.Equal(t, "Bearer embedding-key", embeddingAuth)
assert.Equal(t, "embed-test", embeddingRequest.Model)
assert.Equal(t, 3, embeddingRequest.Dimensions)
}
func TestProviderManagerExplicitZeroRetriesDoesNotRetry(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
http.Error(w, "temporary failure", http.StatusInternalServerError)
}))
defer server.Close()
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: server.URL,
ChatAPIKey: "test-key",
ChatModel: "test-model",
EmbeddingMode: EmbeddingModeReuseChat,
MaxRetries: 0,
}))
_, err := manager.ChatCompletion(context.Background(), ChatRequest{})
require.Error(t, err)
assert.Equal(t, int32(1), calls.Load())
}
func TestProviderManagerFailedConfigurationKeepsWorkingSnapshot(t *testing.T) {
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai",
ChatAPIKey: "working-key",
ChatModel: "working-model",
EmbeddingMode: EmbeddingModeReuseChat,
}))
err := manager.Configure(RuntimeProviderConfig{
ChatProvider: "anthropic",
ChatAPIKey: "bad-key",
ChatModel: "bad-model",
EmbeddingMode: EmbeddingModeReuseChat,
})
require.Error(t, err)
snapshot, configured := manager.Snapshot()
require.True(t, configured)
assert.Equal(t, "working-model", snapshot.ChatModel)
}
func TestProviderManagerClearRemovesActiveProvider(t *testing.T) {
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai",
ChatAPIKey: "test-key",
ChatModel: "test-model",
EmbeddingMode: EmbeddingModeReuseChat,
}))
manager.Clear()
_, configured := manager.Snapshot()
assert.False(t, configured)
_, err := manager.ChatCompletion(context.Background(), ChatRequest{})
require.ErrorIs(t, err, ErrProviderNotConfigured)
}
func TestProviderManagerUsesConfiguredDefaultModel(t *testing.T) {
var requestedModel string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload ChatRequest
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
requestedModel = payload.Model
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"chat-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
}))
defer server.Close()
manager := NewProviderManager()
require.NoError(t, manager.Configure(RuntimeProviderConfig{
ChatProvider: "openai_compatible",
ChatBaseURL: server.URL,
ChatAPIKey: "test-key",
ChatModel: "page-model",
EmbeddingMode: EmbeddingModeReuseChat,
}))
resp, err := manager.ChatCompletion(context.Background(), ChatRequest{
Messages: []ChatMessage{{Role: "user", Content: "hello"}},
})
require.NoError(t, err)
assert.Equal(t, "page-model", requestedModel)
require.Len(t, resp.Choices, 1)
assert.Equal(t, "ok", resp.Choices[0].Message.Content)
}