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 lastPath string lastRequest map[string]any } func (f *fakeUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.lastPath = r.URL.Path if r.Method == http.MethodGet && r.URL.Path == "/models" { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"object":"list","data":[{"id":"ds-chat"},{"id":"ds-haiku"}]}`) return } if r.Method == http.MethodPost && r.URL.Path == "/v1/responses" { 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") _, _ = io.WriteString(w, "event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n") return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "id": "resp_native", "object": "response", "status": "completed", "model": f.lastModel, "output": []any{}, "usage": map[string]any{"input_tokens": 9, "output_tokens": 2, "total_tokens": 11}, }) return } 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 upstreamURL 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", }, config.TargetClaude) 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, upstreamURL: up.URL} } 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 plus a dedicated Codex provider, activated. 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) } p, err := env.store.AddProvider("codex-up", "", env.upstreamURL, "up-key", config.Models{Default: "ds-chat"}, config.TargetCodex) if err != nil { t.Fatalf("AddProvider: %v", err) } if _, err := env.store.ActivateCodex(p.ID); err != nil { t.Fatalf("ActivateCodex: %v", err) } return env } func providerIDForTarget(env *testEnv, target string) string { for _, p := range env.store.Config().Providers { if p.Target == target { return p.ID } } return "" } 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 TestNativeResponsesProtocol(t *testing.T) { for _, stream := range []bool{false, true} { name := "non-streaming" if stream { name = "streaming" } t.Run(name, func(t *testing.T) { env := newCodexTestEnv(t) provider, err := env.store.AddProvider("native-responses", "", env.upstreamURL+"/v1", "up-key", config.Models{Default: "native-model"}, config.TargetCodex, config.ProtocolOpenAIResponses) if err != nil { t.Fatalf("AddProvider: %v", err) } if _, err := env.store.ActivateCodex(provider.ID); err != nil { t.Fatalf("ActivateCodex: %v", err) } body := map[string]any{ "model": "gpt-5-codex", "input": "What model are you?", "stream": stream, "reasoning": map[string]any{"effort": "high"}, "tools": []any{ map[string]any{"type": "web_search_preview"}, map[string]any{"type": "function", "name": "read_file", "parameters": map[string]any{"type": "object"}}, }, } resp := env.post(t, "/v1/responses", body, env.store.Config().RelayToken) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d, body = %s", resp.StatusCode, data) } data, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read response: %v", err) } if stream { if !strings.Contains(string(data), "event: response.completed") { t.Fatalf("stream response = %q", data) } } else if !strings.Contains(string(data), `"id":"resp_native"`) { t.Fatalf("native response = %s", data) } if env.upstream.lastPath != "/v1/responses" { t.Errorf("upstream path = %q, want /v1/responses", env.upstream.lastPath) } if env.upstream.lastAuth != "Bearer up-key" { t.Errorf("upstream auth = %q", env.upstream.lastAuth) } if env.upstream.lastModel != "native-model" { t.Errorf("upstream model = %q, want model mapping", env.upstream.lastModel) } if env.upstream.lastRequest["input"] != "What model are you?" || env.upstream.lastRequest["messages"] != nil { t.Errorf("request was converted instead of passed through: %v", env.upstream.lastRequest) } if env.upstream.lastRequest["reasoning"] == nil { t.Errorf("native reasoning field was dropped: %v", env.upstream.lastRequest) } tools := env.upstream.lastRequest["tools"].([]any) if len(tools) != 2 || tools[0].(map[string]any)["type"] != "web_search_preview" { t.Errorf("native tools were changed: %v", tools) } }) } } 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 := providerIDForTarget(env, config.TargetCodex) 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/v1" { 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) claudeID := providerIDForTarget(env, config.TargetClaude) codexID := providerIDForTarget(env, config.TargetCodex) resp := env.post(t, "/api/providers/"+claudeID+"/apply", map[string]any{"target": "claude"}, "") resp.Body.Close() resp = env.post(t, "/api/providers/"+codexID+"/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 TestResponsesRequestsAreLogged(t *testing.T) { env := newCodexTestEnv(t) resp := env.post(t, "/v1/responses", responsesBody("gpt-5-codex", false), env.store.Config().RelayToken) io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } status := env.get(t, "/api/status") stats := status["stats"].(map[string]any) if stats["requests_total"].(float64) != 1 { t.Errorf("requests_total = %v, want 1", stats["requests_total"]) } logs := env.get(t, "/api/logs")["items"].([]any) if len(logs) != 1 { t.Fatalf("request logs = %v, want exactly one Responses request", logs) } if logs[0].(map[string]any)["path"] != "/v1/responses" { t.Errorf("logged path = %v, want /v1/responses", logs[0].(map[string]any)["path"]) } if logs[0].(map[string]any)["app"] != "Codex" || logs[0].(map[string]any)["provider"] != "codex-up" { t.Errorf("log app/provider = %v/%v, want Codex/codex-up", logs[0].(map[string]any)["app"], logs[0].(map[string]any)["provider"]) } } func TestManagementRequestsAreExcludedFromLogsAndStats(t *testing.T) { env := newTestEnv(t) for _, path := range []string{"/api/status", "/api/logs", "/api/providers?target=claude", "/api/settings"} { resp, err := http.Get(env.gateway.URL + path) if err != nil { t.Fatalf("get %s: %v", path, err) } io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("GET %s status = %d, want 200", path, resp.StatusCode) } } resp := env.post(t, "/v1/messages/count_tokens", messagesBody("m", false), env.store.Config().RelayToken) resp.Body.Close() status := env.get(t, "/api/status") stats := status["stats"].(map[string]any) if stats["requests_total"].(float64) != 0 { t.Errorf("requests_total = %v, want 0 after management requests", stats["requests_total"]) } logs := env.get(t, "/api/logs")["items"].([]any) if len(logs) != 0 { t.Errorf("request logs = %v, want no management requests", logs) } } 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, want exactly the one model request", 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["app"] != "Claude Code" || messageLog["provider"] != "test" { t.Errorf("log app/provider = %v/%v, want Claude Code/test", messageLog["app"], messageLog["provider"]) } 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) } var created map[string]any _ = json.NewDecoder(resp.Body).Decode(&created) if created["target"] != "claude" { t.Errorf("created provider target = %v, want claude by default", created["target"]) } 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) } } func TestApplyRejectsWrongTarget(t *testing.T) { env := newTestEnv(t) id := providerIDForTarget(env, config.TargetClaude) resp := env.post(t, "/api/providers/"+id+"/apply", map[string]any{"target": "codex"}, "") defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { t.Errorf("apply status = %d, want 404 for a provider of the other target", resp.StatusCode) } } func TestFetchModels(t *testing.T) { env := newTestEnv(t) resp := env.post(t, "/api/fetch-models", map[string]any{ "base_url": env.upstreamURL, "api_key": "up-key", }, "") defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d", resp.StatusCode) } var out struct { Items []struct { ID string `json:"id"` } `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } if len(out.Items) != 2 || out.Items[0].ID != "ds-chat" { t.Errorf("items = %+v", out.Items) } } func TestFetchModelsRejectsBadInput(t *testing.T) { env := newTestEnv(t) for _, body := range []map[string]any{ {"base_url": env.upstreamURL}, {"api_key": "up-key"}, {"base_url": "ftp://x", "api_key": "k"}, } { resp := env.post(t, "/api/fetch-models", body, "") resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("body %v status = %d, want 400", body, resp.StatusCode) } } } func TestFetchModelsUpstreamFailure(t *testing.T) { env := newTestEnv(t) broken := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusUnauthorized) _, _ = io.WriteString(w, `{"error":{"message":"bad key"}}`) })) defer broken.Close() resp := env.post(t, "/api/fetch-models", map[string]any{ "base_url": broken.URL, "api_key": "sk-bad", }, "") defer resp.Body.Close() if resp.StatusCode != http.StatusBadGateway { t.Errorf("status = %d, want 502", resp.StatusCode) } } func TestListProvidersFiltersByTarget(t *testing.T) { env := newCodexTestEnv(t) codexItems := env.get(t, "/api/providers?target=codex")["items"].([]any) if len(codexItems) != 1 { t.Fatalf("codex providers = %d, want 1", len(codexItems)) } if codexItems[0].(map[string]any)["target"] != "codex" { t.Errorf("codex list leaked another target: %v", codexItems[0]) } claudeItems := env.get(t, "/api/providers?target=claude")["items"].([]any) if len(claudeItems) != 1 { t.Fatalf("claude providers = %d, want 1", len(claudeItems)) } allItems := env.get(t, "/api/providers")["items"].([]any) if len(allItems) != 2 { t.Errorf("unfiltered providers = %d, want 2", len(allItems)) } resp, err := http.Get(env.gateway.URL + "/api/providers?target=bogus") if err != nil { t.Fatalf("get: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Errorf("bogus target status = %d, want 400", resp.StatusCode) } }