251 lines
7.8 KiB
Go
251 lines
7.8 KiB
Go
package agent
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/zalando/go-keyring"
|
|
)
|
|
|
|
type memorySecrets map[string]string
|
|
|
|
func (m memorySecrets) Get(id string) (string, error) {
|
|
v, ok := m[id]
|
|
if !ok {
|
|
return "", keyring.ErrNotFound
|
|
}
|
|
return v, nil
|
|
}
|
|
func (m memorySecrets) Set(id, value string) error { m[id] = value; return nil }
|
|
func (m memorySecrets) Delete(id string) error { delete(m, id); return nil }
|
|
|
|
func testService(t *testing.T) (*Service, memorySecrets) {
|
|
t.Helper()
|
|
secrets := memorySecrets{}
|
|
return newService(filepath.Join(t.TempDir(), "model.json"), secrets), secrets
|
|
}
|
|
func testConfig(base string) ModelConfig {
|
|
c := defaultModelConfig()
|
|
c.Enabled = true
|
|
c.BaseURL = base
|
|
c.Model = "test-model"
|
|
c.ContextTurns = 1
|
|
c.MaxTokens = 32
|
|
return c
|
|
}
|
|
func reply(w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"choices":[{"message":{"content":"测试回答"}}]}`)
|
|
}
|
|
|
|
func TestSaveConfigKeepsCredentialsOutOfFilesAndResponses(t *testing.T) {
|
|
s, secrets := testService(t)
|
|
c := testConfig("https://example.com/v1/")
|
|
view, err := s.SaveModelConfig(ModelConfigInput{Config: c, APIKey: "test-secret"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !view.HasAPIKey || view.Config.BaseURL != "https://example.com/v1" {
|
|
t.Fatalf("bad view: %+v", view)
|
|
}
|
|
raw, err := os.ReadFile(s.configPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
encoded, _ := json.Marshal(view)
|
|
if strings.Contains(string(raw), "test-secret") || strings.Contains(string(encoded), "test-secret") {
|
|
t.Fatal("credential escaped secret store")
|
|
}
|
|
reloaded := newService(s.configPath, secrets)
|
|
got, err := reloaded.GetModelConfig()
|
|
if err != nil || !got.HasAPIKey || got.Config.Model != c.Model {
|
|
t.Fatalf("reload: %+v %v", got, err)
|
|
}
|
|
changed := got.Config
|
|
changed.BaseURL = "https://different.example/v1"
|
|
if _, err := reloaded.SaveModelConfig(ModelConfigInput{Config: changed}); err == nil {
|
|
t.Fatal("credential was reused at another endpoint")
|
|
}
|
|
got.Config.Enabled = false
|
|
cleared, err := reloaded.SaveModelConfig(ModelConfigInput{Config: got.Config, ClearAPIKey: true})
|
|
if err != nil || cleared.HasAPIKey {
|
|
t.Fatalf("clear: %+v %v", cleared, err)
|
|
}
|
|
if _, err := secrets.Get(credentialID(got.Config)); !errors.Is(err, keyring.ErrNotFound) {
|
|
t.Fatal("credential was not deleted")
|
|
}
|
|
}
|
|
|
|
func TestSaveFailureRollsBackCredential(t *testing.T) {
|
|
s, secrets := testService(t)
|
|
c := testConfig("https://example.com/v1")
|
|
secrets[credentialID(c)] = "old-key"
|
|
blocked := filepath.Join(t.TempDir(), "not-a-directory")
|
|
os.WriteFile(blocked, []byte("x"), 0600)
|
|
s.configPath = filepath.Join(blocked, "model.json")
|
|
if _, err := s.SaveModelConfig(ModelConfigInput{Config: c, APIKey: "new-key"}); err == nil {
|
|
t.Fatal("expected failed config write")
|
|
}
|
|
if secrets[credentialID(c)] != "old-key" {
|
|
t.Fatal("credential changed after failed save")
|
|
}
|
|
}
|
|
|
|
func TestDraftModelTestAndListDoNotPersist(t *testing.T) {
|
|
requests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
if r.Header.Get("Authorization") != "Bearer draft-key" {
|
|
t.Error("missing request credential")
|
|
}
|
|
switch r.URL.Path {
|
|
case "/v1/models":
|
|
fmt.Fprint(w, `{"data":[{"id":"z"},{"id":"a"},{"id":"z"}]}`)
|
|
case "/v1/chat/completions":
|
|
reply(w)
|
|
default:
|
|
t.Errorf("unexpected path %s", r.URL.Path)
|
|
w.WriteHeader(404)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
s, secrets := testService(t)
|
|
draft := ModelConfigInput{Config: testConfig(server.URL + "/v1"), APIKey: "draft-key"}
|
|
if result := s.TestModel(draft); !result.OK {
|
|
t.Fatal(result.Message)
|
|
}
|
|
draft.Config.Model = ""
|
|
models, err := s.ListModels(draft)
|
|
if err != nil || strings.Join(models, ",") != "a,z" {
|
|
t.Fatalf("models: %v %v", models, err)
|
|
}
|
|
if requests != 2 || len(secrets) != 0 || len(s.history) != 0 {
|
|
t.Fatal("draft test changed persistent state")
|
|
}
|
|
if _, err := os.Stat(s.configPath); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatal("draft was written to disk")
|
|
}
|
|
}
|
|
|
|
func TestChatUsesSavedModelAndBoundedHistory(t *testing.T) {
|
|
var bodies []map[string]json.RawMessage
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]json.RawMessage
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if r.Header.Get("Authorization") != "Bearer saved-key" {
|
|
t.Error("saved key not used")
|
|
}
|
|
bodies = append(bodies, body)
|
|
reply(w)
|
|
}))
|
|
defer server.Close()
|
|
s, _ := testService(t)
|
|
c := testConfig(server.URL + "/v1")
|
|
if _, err := s.Chat("phone", "hello"); err == nil {
|
|
t.Fatal("unconfigured chat was accepted")
|
|
}
|
|
if _, err := s.SaveModelConfig(ModelConfigInput{Config: c, APIKey: "saved-key"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, msg := range []string{"first", "second", "third"} {
|
|
if _, err := s.Chat("phone", msg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for i, body := range bodies {
|
|
if _, ok := body["temperature"]; ok {
|
|
t.Fatal("temperature was sent without opting in")
|
|
}
|
|
if string(body["max_completion_tokens"]) != "32" {
|
|
t.Fatal("OpenAI token limit field missing")
|
|
}
|
|
var messages []modelMessage
|
|
json.Unmarshal(body["messages"], &messages)
|
|
want := 4
|
|
if i == 0 {
|
|
want = 2
|
|
}
|
|
if len(messages) != want {
|
|
t.Fatalf("turn %d: got %d messages want %d", i, len(messages), want)
|
|
}
|
|
if i == 2 && messages[1].Content != "second" {
|
|
t.Fatalf("old history was not trimmed: %+v", messages)
|
|
}
|
|
}
|
|
c.ContextTurns = 0
|
|
c.Provider = "custom"
|
|
c.UseTemperature = true
|
|
c.Temperature = 0.5
|
|
if _, err := s.SaveModelConfig(ModelConfigInput{Config: c}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Chat("phone", "fourth"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
last := bodies[len(bodies)-1]
|
|
if string(last["temperature"]) != "0.5" || string(last["max_tokens"]) != "32" {
|
|
t.Fatal("custom parameters not applied")
|
|
}
|
|
if len(s.history["phone"]) != 0 {
|
|
t.Fatal("zero context retained history")
|
|
}
|
|
}
|
|
|
|
func TestProviderErrorsAreUsefulAndRedacted(t *testing.T) {
|
|
for _, status := range []int{401, 404, 429, 500} {
|
|
t.Run(fmt.Sprint(status), func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(status)
|
|
fmt.Fprint(w, `{"error":{"message":"rejected secret-value"}}`)
|
|
}))
|
|
defer server.Close()
|
|
s, _ := testService(t)
|
|
result := s.TestModel(ModelConfigInput{Config: testConfig(server.URL), APIKey: "secret-value"})
|
|
if result.OK || !strings.Contains(result.Message, fmt.Sprint(status)) || strings.Contains(result.Message, "secret-value") {
|
|
t.Fatalf("unsafe/errorless result: %+v", result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestModelRequestsDoNotFollowRedirects(t *testing.T) {
|
|
hit := false
|
|
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hit = true; reply(w) }))
|
|
defer target.Close()
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect)
|
|
}))
|
|
defer server.Close()
|
|
s, _ := testService(t)
|
|
result := s.TestModel(ModelConfigInput{Config: testConfig(server.URL), APIKey: "secret"})
|
|
if result.OK || hit {
|
|
t.Fatal("request/credentials followed redirect")
|
|
}
|
|
}
|
|
|
|
func TestConfigValidation(t *testing.T) {
|
|
for _, base := range []string{"ftp://example.com", "https://key@example.com", "https://example.com?key=secret", "http://public.example.com"} {
|
|
if _, err := normalizeConfig(testConfig(base), true); err == nil {
|
|
t.Errorf("accepted unsafe/invalid base %s", base)
|
|
}
|
|
}
|
|
c := testConfig("http://127.0.0.1:1234/v1/chat/completions/")
|
|
got, err := normalizeConfig(c, true)
|
|
if err != nil || got.BaseURL != "http://127.0.0.1:1234/v1" {
|
|
t.Fatalf("endpoint normalization: %+v %v", got, err)
|
|
}
|
|
c.ContextTurns = 31
|
|
if _, err := normalizeConfig(c, true); err == nil {
|
|
t.Fatal("unbounded context accepted")
|
|
}
|
|
}
|