654 lines
21 KiB
Go
654 lines
21 KiB
Go
package gateway
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.rutua.cn/kate522/CrossRelay/internal/config"
|
|
)
|
|
|
|
// fakeUpstream mimics a Chat Completions provider. It records the last
|
|
// request so tests can assert on the converted model and messages.
|
|
type fakeUpstream struct {
|
|
lastModel string
|
|
lastAuth string
|
|
lastRequest map[string]any
|
|
}
|
|
|
|
func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/chat/completions" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
f.lastAuth = r.Header.Get("Authorization")
|
|
body, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(body, &f.lastRequest)
|
|
f.lastModel, _ = f.lastRequest["model"].(string)
|
|
stream, _ := f.lastRequest["stream"].(bool)
|
|
if stream {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{"content":"Hi"}}]}`)
|
|
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{"content":" there"}}]}`)
|
|
writeSSELine(w, `data: {"id":"1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":2}}`)
|
|
writeSSELine(w, "data: [DONE]")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": "chatcmpl-1",
|
|
"object": "chat.completion",
|
|
"model": f.lastModel,
|
|
"choices": []any{map[string]any{"index": 0, "message": map[string]any{"role": "assistant", "content": "Hi there"}, "finish_reason": "stop"}},
|
|
"usage": map[string]any{"prompt_tokens": 9, "completion_tokens": 2, "total_tokens": 11},
|
|
})
|
|
}
|
|
|
|
func writeSSELine(w http.ResponseWriter, line string) {
|
|
_, _ = io.WriteString(w, line+"\n\n")
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
}
|
|
|
|
type testEnv struct {
|
|
gateway *httptest.Server
|
|
upstream *fakeUpstream
|
|
store *config.Store
|
|
settings string
|
|
codex string
|
|
}
|
|
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
t.Setenv("CROSSRELAY_HOME", t.TempDir())
|
|
settingsPath := filepath.Join(t.TempDir(), "settings.json")
|
|
if err := os.WriteFile(settingsPath, []byte(`{"theme":"dark","env":{"DISABLE_AUTOUPDATER":"1"}}`), 0o600); err != nil {
|
|
t.Fatalf("write settings: %v", err)
|
|
}
|
|
t.Setenv("CROSSRELAY_CLAUDE_SETTINGS", settingsPath)
|
|
|
|
upstream := &fakeUpstream{}
|
|
up := httptest.NewServer(upstream)
|
|
t.Cleanup(up.Close)
|
|
|
|
store, err := config.Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
p, err := store.AddProvider("test", "", up.URL, "up-key", config.Models{
|
|
Default: "ds-chat",
|
|
Sonnet: "ds-chat",
|
|
Haiku: "ds-haiku",
|
|
Opus: "ds-opus",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddProvider: %v", err)
|
|
}
|
|
if _, err := store.ActivateClaude(p.ID); err != nil {
|
|
t.Fatalf("ActivateClaude: %v", err)
|
|
}
|
|
|
|
gw := httptest.NewServer(New(store, 18787).Handler())
|
|
t.Cleanup(gw.Close)
|
|
return &testEnv{gateway: gw, upstream: upstream, store: store, settings: settingsPath}
|
|
}
|
|
|
|
func (e *testEnv) post(t *testing.T, path string, body any, token string) *http.Response {
|
|
t.Helper()
|
|
var reader io.Reader
|
|
if body != nil {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
reader = strings.NewReader(string(data))
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, e.gateway.URL+path, reader)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("post %s: %v", path, err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func messagesBody(model string, stream bool) map[string]any {
|
|
return map[string]any{
|
|
"model": model,
|
|
"max_tokens": 100,
|
|
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
|
"stream": stream,
|
|
}
|
|
}
|
|
|
|
// newCodexTestEnv builds the standard environment with a realistic
|
|
// config.toml in place and the shared provider activated for Codex.
|
|
func newCodexTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
env := newTestEnv(t)
|
|
codexHome := t.TempDir()
|
|
t.Setenv("CODEX_HOME", codexHome)
|
|
env.codex = filepath.Join(codexHome, "config.toml")
|
|
if err := os.WriteFile(env.codex, []byte("model_provider = \"custom\"\n\n[model_providers.custom]\nname = \"custom\"\nwire_api = \"responses\"\nbase_url = \"https://old.example.com/v1\"\n"), 0o600); err != nil {
|
|
t.Fatalf("write config.toml: %v", err)
|
|
}
|
|
if _, err := env.store.ActivateCodex(env.store.Config().Providers[0].ID); err != nil {
|
|
t.Fatalf("ActivateCodex: %v", err)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func (e *testEnv) get(t *testing.T, path string) map[string]any {
|
|
t.Helper()
|
|
resp, err := http.Get(e.gateway.URL + path)
|
|
if err != nil {
|
|
t.Fatalf("get %s: %v", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var out map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
return out
|
|
}
|
|
|
|
func responsesBody(model string, stream bool) map[string]any {
|
|
return map[string]any{
|
|
"model": model,
|
|
"instructions": "be brief",
|
|
"input": []any{map[string]any{
|
|
"type": "message", "role": "user",
|
|
"content": []any{map[string]any{"type": "input_text", "text": "hello"}},
|
|
}},
|
|
"stream": stream,
|
|
}
|
|
}
|
|
|
|
func TestResponsesNonStream(t *testing.T) {
|
|
env := newCodexTestEnv(t)
|
|
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), env.store.Config().RelayToken)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var out map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if out["object"] != "response" || out["status"] != "completed" {
|
|
t.Errorf("envelope = %v", out)
|
|
}
|
|
if out["model"] != "gpt-5-codex" {
|
|
t.Errorf("model = %v, want the requested model echoed", out["model"])
|
|
}
|
|
if out["output_text"] != "Hi there" {
|
|
t.Errorf("output_text = %v", out["output_text"])
|
|
}
|
|
usage := out["usage"].(map[string]any)
|
|
if usage["input_tokens"].(float64) != 9 || usage["output_tokens"].(float64) != 2 {
|
|
t.Errorf("usage = %v", usage)
|
|
}
|
|
if env.upstream.lastModel != "ds-chat" {
|
|
t.Errorf("upstream model = %q, want the resolved model", env.upstream.lastModel)
|
|
}
|
|
if env.upstream.lastAuth != "Bearer up-key" {
|
|
t.Errorf("upstream auth = %q", env.upstream.lastAuth)
|
|
}
|
|
msgs := env.upstream.lastRequest["messages"].([]any)
|
|
system := msgs[0].(map[string]any)
|
|
if system["role"] != "system" || system["content"] != "be brief" {
|
|
t.Errorf("system message = %v", system)
|
|
}
|
|
user := msgs[1].(map[string]any)
|
|
if user["role"] != "user" || user["content"] != "hello" {
|
|
t.Errorf("user message = %v", user)
|
|
}
|
|
}
|
|
|
|
func TestResponsesStream(t *testing.T) {
|
|
env := newCodexTestEnv(t)
|
|
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", true), env.store.Config().RelayToken)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
|
|
t.Errorf("content type = %q", ct)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read stream: %v", err)
|
|
}
|
|
var names []string
|
|
var text strings.Builder
|
|
for _, block := range strings.Split(strings.TrimSpace(string(body)), "\n\n") {
|
|
if block == "" {
|
|
continue
|
|
}
|
|
for _, line := range strings.Split(block, "\n") {
|
|
switch {
|
|
case strings.HasPrefix(line, "event: "):
|
|
names = append(names, strings.TrimPrefix(line, "event: "))
|
|
case strings.HasPrefix(line, "data: "):
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err == nil {
|
|
if payload["type"] == "response.output_text.delta" {
|
|
text.WriteString(payload["delta"].(string))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
want := []string{
|
|
"response.created", "response.in_progress",
|
|
"response.output_item.added", "response.content_part.added",
|
|
"response.output_text.delta", "response.output_text.delta",
|
|
"response.output_text.done", "response.content_part.done",
|
|
"response.output_item.done", "response.completed",
|
|
}
|
|
if len(names) != len(want) {
|
|
t.Fatalf("events = %v, want %v", names, want)
|
|
}
|
|
for i := range want {
|
|
if names[i] != want[i] {
|
|
t.Fatalf("events = %v, want %v", names, want)
|
|
}
|
|
}
|
|
if text.String() != "Hi there" {
|
|
t.Errorf("streamed text = %q", text.String())
|
|
}
|
|
}
|
|
|
|
func TestResponsesRejectsBadToken(t *testing.T) {
|
|
env := newCodexTestEnv(t)
|
|
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), "crl_wrong")
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Errorf("status = %d, want 401", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestResponsesWithoutActiveProvider(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
t.Setenv("CODEX_HOME", t.TempDir())
|
|
resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), env.store.Config().RelayToken)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
var out map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
detail, ok := out["error"].(map[string]any)
|
|
if !ok || !strings.Contains(detail["message"].(string), "no active provider") {
|
|
t.Errorf("error = %v", out["error"])
|
|
}
|
|
}
|
|
|
|
func TestApplyAndRestoreCodex(t *testing.T) {
|
|
env := newCodexTestEnv(t)
|
|
id := env.store.Config().Providers[0].ID
|
|
|
|
resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("apply status = %d", resp.StatusCode)
|
|
}
|
|
var out map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
injection, ok := out["injection"].(map[string]any)
|
|
if !ok || injection["model_provider"] != "crossrelay" {
|
|
t.Fatalf("injection = %v", out["injection"])
|
|
}
|
|
table := injection["table"].(map[string]any)
|
|
if table["wire_api"] != "responses" || table["base_url"] != "http://127.0.0.1:18787" {
|
|
t.Errorf("injected table = %v", table)
|
|
}
|
|
if table["experimental_bearer_token"] != env.store.Config().RelayToken {
|
|
t.Errorf("injected token = %v", table["experimental_bearer_token"])
|
|
}
|
|
|
|
data, err := os.ReadFile(env.codex)
|
|
if err != nil {
|
|
t.Fatalf("read config.toml: %v", err)
|
|
}
|
|
got := string(data)
|
|
for _, want := range []string{
|
|
`model_provider = "crossrelay"`,
|
|
"[model_providers.crossrelay]",
|
|
"requires_openai_auth = false",
|
|
"[model_providers.custom]",
|
|
`base_url = "https://old.example.com/v1"`,
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("config.toml missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
if env.store.Config().CodexPriorProvider != "custom" {
|
|
t.Errorf("prior provider = %q, want custom", env.store.Config().CodexPriorProvider)
|
|
}
|
|
if status := env.get(t, "/api/status"); status["active_codex_provider"] == nil {
|
|
t.Errorf("status lacks active_codex_provider: %v", status)
|
|
}
|
|
|
|
resp = env.post(t, "/api/restore", map[string]any{"target": "codex"}, "")
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("restore status = %d", resp.StatusCode)
|
|
}
|
|
data, _ = os.ReadFile(env.codex)
|
|
got = string(data)
|
|
if !strings.Contains(got, `model_provider = "custom"`) {
|
|
t.Errorf("prior provider not restored:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "crossrelay") {
|
|
t.Errorf("managed keys survived restore:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, `base_url = "https://old.example.com/v1"`) {
|
|
t.Errorf("user's provider table damaged:\n%s", got)
|
|
}
|
|
if env.store.ActiveCodex() != nil {
|
|
t.Error("active codex provider survived restore")
|
|
}
|
|
if env.store.Config().CodexPriorProvider != "" {
|
|
t.Error("prior provider record survived restore")
|
|
}
|
|
}
|
|
|
|
func TestBackupsTargetDimension(t *testing.T) {
|
|
env := newCodexTestEnv(t)
|
|
id := env.store.Config().Providers[0].ID
|
|
|
|
resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "claude"}, "")
|
|
resp.Body.Close()
|
|
resp = env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "")
|
|
resp.Body.Close()
|
|
|
|
codexBackups := env.get(t, "/api/backups?target=codex")["items"].([]any)
|
|
if len(codexBackups) != 1 {
|
|
t.Fatalf("codex backups = %d, want 1", len(codexBackups))
|
|
}
|
|
if name := codexBackups[0].(map[string]any)["name"].(string); !strings.HasPrefix(name, "config-") {
|
|
t.Errorf("codex backup name = %q", name)
|
|
}
|
|
claudeBackups := env.get(t, "/api/backups")["items"].([]any)
|
|
if len(claudeBackups) != 1 {
|
|
t.Fatalf("claude backups = %d, want 1", len(claudeBackups))
|
|
}
|
|
if name := claudeBackups[0].(map[string]any)["name"].(string); !strings.HasPrefix(name, "settings-") {
|
|
t.Errorf("claude backup name = %q", name)
|
|
}
|
|
}
|
|
|
|
func TestMessagesNonStream(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var out map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if out["type"] != "message" || out["role"] != "assistant" {
|
|
t.Errorf("envelope = %v", out)
|
|
}
|
|
if out["model"] != "claude-sonnet-4-5" {
|
|
t.Errorf("model = %v, want requested model echoed", out["model"])
|
|
}
|
|
content := out["content"].([]any)
|
|
if len(content) != 1 {
|
|
t.Fatalf("content = %v", content)
|
|
}
|
|
block := content[0].(map[string]any)
|
|
if block["type"] != "text" || block["text"] != "Hi there" {
|
|
t.Errorf("text block = %v", block)
|
|
}
|
|
if out["stop_reason"] != "end_turn" {
|
|
t.Errorf("stop reason = %v", out["stop_reason"])
|
|
}
|
|
usage := out["usage"].(map[string]any)
|
|
if usage["input_tokens"].(float64) != 9 || usage["output_tokens"].(float64) != 2 {
|
|
t.Errorf("usage = %v", usage)
|
|
}
|
|
// The upstream must have seen the mapped model and the provider key.
|
|
if env.upstream.lastModel != "ds-chat" {
|
|
t.Errorf("upstream model = %q, want ds-chat", env.upstream.lastModel)
|
|
}
|
|
if env.upstream.lastAuth != "Bearer up-key" {
|
|
t.Errorf("upstream auth = %q", env.upstream.lastAuth)
|
|
}
|
|
}
|
|
|
|
func TestMessagesHaikuRoleMapping(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
resp := env.post(t, "/v1/messages", messagesBody("claude-3-5-haiku-20241022", false), env.store.Config().RelayToken)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
if env.upstream.lastModel != "ds-haiku" {
|
|
t.Errorf("upstream model = %q, want ds-haiku", env.upstream.lastModel)
|
|
}
|
|
}
|
|
|
|
func TestMessagesRequiresToken(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
resp := env.post(t, "/v1/messages", messagesBody("m", false), "")
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
|
}
|
|
var out map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
errObj := out["error"].(map[string]any)
|
|
if errObj["type"] != "authentication_error" {
|
|
t.Errorf("error = %v", errObj)
|
|
}
|
|
}
|
|
|
|
func TestMessagesWithoutActiveProvider(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
if err := env.store.ClearActiveClaude(); err != nil {
|
|
t.Fatalf("ClearActiveClaude: %v", err)
|
|
}
|
|
resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestMessagesStream(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
resp := env.post(t, "/v1/messages", messagesBody("claude-sonnet-4-5", true), env.store.Config().RelayToken)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
|
|
t.Fatalf("content type = %q", ct)
|
|
}
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
var events []string
|
|
var text strings.Builder
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "event: ") {
|
|
events = append(events, strings.TrimPrefix(line, "event: "))
|
|
}
|
|
if strings.HasPrefix(line, "data: ") && strings.Contains(line, "text_delta") {
|
|
var payload map[string]any
|
|
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &payload); err == nil {
|
|
delta := payload["delta"].(map[string]any)
|
|
text.WriteString(delta["text"].(string))
|
|
}
|
|
}
|
|
}
|
|
want := []string{"message_start", "content_block_start", "content_block_delta", "content_block_delta", "content_block_stop", "message_delta", "message_stop"}
|
|
if len(events) != len(want) {
|
|
t.Fatalf("events = %v, want %v", events, want)
|
|
}
|
|
for i := range want {
|
|
if events[i] != want[i] {
|
|
t.Fatalf("events = %v, want %v", events, want)
|
|
}
|
|
}
|
|
if text.String() != "Hi there" {
|
|
t.Errorf("streamed text = %q", text.String())
|
|
}
|
|
}
|
|
|
|
func TestCountTokens(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
resp := env.post(t, "/v1/messages/count_tokens", messagesBody("claude-sonnet-4-5", false), env.store.Config().RelayToken)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", resp.StatusCode)
|
|
}
|
|
var out map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
if out["input_tokens"].(float64) <= 0 {
|
|
t.Errorf("input tokens = %v", out["input_tokens"])
|
|
}
|
|
}
|
|
|
|
func TestApplyAndRestore(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
providers := env.store.Config().Providers
|
|
if len(providers) != 1 {
|
|
t.Fatalf("providers = %d", len(providers))
|
|
}
|
|
resp := env.post(t, "/api/providers/"+providers[0].ID+"/apply", nil, "")
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("apply status = %d", resp.StatusCode)
|
|
}
|
|
data, err := os.ReadFile(env.settings)
|
|
if err != nil {
|
|
t.Fatalf("read settings: %v", err)
|
|
}
|
|
var settings map[string]any
|
|
_ = json.Unmarshal(data, &settings)
|
|
if settings["theme"] != "dark" {
|
|
t.Errorf("theme lost: %v", settings)
|
|
}
|
|
envVars := settings["env"].(map[string]any)
|
|
if envVars["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:18787" {
|
|
t.Errorf("base url = %v", envVars["ANTHROPIC_BASE_URL"])
|
|
}
|
|
if envVars["ANTHROPIC_AUTH_TOKEN"] != env.store.Config().RelayToken {
|
|
t.Errorf("relay token = %v", envVars["ANTHROPIC_AUTH_TOKEN"])
|
|
}
|
|
if envVars["DISABLE_AUTOUPDATER"] != "1" {
|
|
t.Errorf("unrelated env key lost: %v", envVars)
|
|
}
|
|
|
|
resp = env.post(t, "/api/restore", nil, "")
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("restore status = %d", resp.StatusCode)
|
|
}
|
|
data, _ = os.ReadFile(env.settings)
|
|
_ = json.Unmarshal(data, &settings)
|
|
envVars = settings["env"].(map[string]any)
|
|
if _, ok := envVars["ANTHROPIC_BASE_URL"]; ok {
|
|
t.Error("base url survived restore")
|
|
}
|
|
if env.store.ActiveClaude() != nil {
|
|
t.Error("active provider survived restore")
|
|
}
|
|
}
|
|
|
|
func TestStatusAndLogs(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
resp := env.post(t, "/v1/messages", messagesBody("m", false), env.store.Config().RelayToken)
|
|
defer resp.Body.Close()
|
|
io.Copy(io.Discard, resp.Body)
|
|
|
|
get := func(path string) map[string]any {
|
|
resp, err := http.Get(env.gateway.URL + path)
|
|
if err != nil {
|
|
t.Fatalf("get %s: %v", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var out map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
|
return out
|
|
}
|
|
status := get("/api/status")
|
|
if status["running"] != true || status["port"].(float64) != 18787 {
|
|
t.Errorf("status = %v", status)
|
|
}
|
|
stats := status["stats"].(map[string]any)
|
|
if stats["requests_total"].(float64) < 1 {
|
|
t.Errorf("stats = %v", stats)
|
|
}
|
|
active := status["active_claude_provider"].(map[string]any)
|
|
if active["name"] != "test" {
|
|
t.Errorf("active provider = %v", active)
|
|
}
|
|
|
|
logs := get("/api/logs")
|
|
items := logs["items"].([]any)
|
|
var messageLog map[string]any
|
|
for _, item := range items {
|
|
entry := item.(map[string]any)
|
|
if entry["path"] == "/v1/messages" {
|
|
messageLog = entry
|
|
break
|
|
}
|
|
}
|
|
if messageLog == nil {
|
|
t.Fatalf("no /v1/messages entry in %v", items)
|
|
}
|
|
if messageLog["model"] != "ds-chat" {
|
|
t.Errorf("log model = %v, want the resolved model", messageLog["model"])
|
|
}
|
|
if messageLog["status"].(float64) != 200 {
|
|
t.Errorf("log status = %v", messageLog["status"])
|
|
}
|
|
}
|
|
|
|
func TestProviderCRUD(t *testing.T) {
|
|
env := newTestEnv(t)
|
|
post := func(path string, body any) *http.Response {
|
|
data, _ := json.Marshal(body)
|
|
req, _ := http.NewRequest(http.MethodPost, env.gateway.URL+path, strings.NewReader(string(data)))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("post: %v", err)
|
|
}
|
|
return resp
|
|
}
|
|
resp := post("/api/providers", map[string]any{
|
|
"name": "kimi",
|
|
"base_url": "https://api.moonshot.cn/v1",
|
|
"api_key": "sk-kimi",
|
|
})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create status = %d", resp.StatusCode)
|
|
}
|
|
if len(env.store.Config().Providers) != 2 {
|
|
t.Fatalf("providers = %d, want 2", len(env.store.Config().Providers))
|
|
}
|
|
resp = post("/api/providers", map[string]any{"name": "", "base_url": "x", "api_key": "y"})
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("invalid create status = %d, want 400", resp.StatusCode)
|
|
}
|
|
}
|