| |
| package testutil |
|
|
| import ( |
| "crypto/rand" |
| "encoding/hex" |
| "fmt" |
| "net/http" |
| "strings" |
|
|
| "ccLoad/internal/model" |
| "ccLoad/internal/util" |
| "ccLoad/internal/version" |
|
|
| "github.com/bytedance/sonic" |
| ) |
|
|
| |
| type ChannelTester interface { |
| |
| |
| Build(cfg *model.Config, apiKey string, req *TestChannelRequest) (fullURL string, headers http.Header, body []byte, err error) |
| |
| Parse(statusCode int, respBody []byte) map[string]any |
| } |
|
|
| |
|
|
| |
| func getTypedValue[T any](m map[string]any, key string) (T, bool) { |
| var zero T |
| v, ok := m[key] |
| if !ok { |
| return zero, false |
| } |
| typed, ok := v.(T) |
| return typed, ok |
| } |
|
|
| |
| func getSliceItem[T any](slice []any, index int) (T, bool) { |
| var zero T |
| if index < 0 || index >= len(slice) { |
| return zero, false |
| } |
| typed, ok := slice[index].(T) |
| return typed, ok |
| } |
|
|
| func extractStructuredAPIError(apiResp map[string]any) (string, bool) { |
| if errInfo, ok := getTypedValue[map[string]any](apiResp, "error"); ok { |
| if msg, ok := getTypedValue[string](errInfo, "message"); ok && strings.TrimSpace(msg) != "" { |
| return msg, true |
| } |
| if typeStr, ok := getTypedValue[string](errInfo, "type"); ok && strings.TrimSpace(typeStr) != "" { |
| return typeStr, true |
| } |
| if code, ok := getTypedValue[string](errInfo, "code"); ok && strings.TrimSpace(code) != "" { |
| return code, true |
| } |
| return "上游返回结构化错误", true |
| } |
|
|
| objectType, hasObjectType := getTypedValue[string](apiResp, "object") |
| status, hasStatus := getTypedValue[string](apiResp, "status") |
| if hasObjectType && objectType == "response" && hasStatus { |
| normalizedStatus := strings.ToLower(strings.TrimSpace(status)) |
| if normalizedStatus != "" && normalizedStatus != "completed" { |
| if details, ok := getTypedValue[map[string]any](apiResp, "incomplete_details"); ok { |
| if reason, ok := getTypedValue[string](details, "reason"); ok && strings.TrimSpace(reason) != "" { |
| return "响应未完成: " + reason, true |
| } |
| } |
| return "响应状态为 " + status, true |
| } |
| } |
|
|
| return "", false |
| } |
|
|
| func finalizeParsedAPIResponse(out map[string]any, apiResp map[string]any) map[string]any { |
| out["api_response"] = apiResp |
| if errorMsg, ok := extractStructuredAPIError(apiResp); ok { |
| out["success"] = false |
| out["error"] = errorMsg |
| out["api_error"] = apiResp |
| } |
| return out |
| } |
|
|
| func parseAPIResponse(respBody []byte, extractText func(map[string]any) (string, bool), usageKey string) map[string]any { |
| out := map[string]any{} |
| var apiResp map[string]any |
| if err := sonic.Unmarshal(respBody, &apiResp); err == nil { |
| if extractText != nil { |
| if text, ok := extractText(apiResp); ok { |
| out["response_text"] = text |
| } |
| } |
| if usageKey != "" { |
| if usage, ok := getTypedValue[map[string]any](apiResp, usageKey); ok { |
| out["usage"] = usage |
| } |
| } |
| return finalizeParsedAPIResponse(out, apiResp) |
| } |
| out["raw_response"] = string(respBody) |
| return out |
| } |
|
|
| func buildTesterURL(baseURL, endpointSuffix string) string { |
| if model.HasExactUpstreamURLMarker(baseURL) { |
| return model.StripExactUpstreamURLMarker(baseURL) |
| } |
| return strings.TrimRight(strings.TrimSpace(baseURL), "/") + endpointSuffix |
| } |
|
|
| |
| type CodexTester struct{} |
|
|
| |
| func (t *CodexTester) Build(cfg *model.Config, apiKey string, req *TestChannelRequest) (string, http.Header, []byte, error) { |
| testContent := req.Content |
| if strings.TrimSpace(testContent) == "" { |
| testContent = "test" |
| } |
|
|
| body, err := buildRequestFromTemplate("codex", map[string]any{ |
| "MODEL": req.Model, |
| "STREAM": req.Stream, |
| "CONTENT": testContent, |
| }) |
| if err != nil { |
| return "", nil, nil, err |
| } |
|
|
| fullURL := buildTesterURL(cfg.GetURLs()[0], "/v1/responses") |
|
|
| h := make(http.Header) |
| h.Set("Content-Type", "application/json") |
| h.Set("Authorization", "Bearer "+apiKey) |
| h.Set("User-Agent", "codex_cli_rs/0.41.0 (Mac OS 26.0.0; arm64) iTerm.app/3.6.1") |
| h.Set("Openai-Beta", "responses=experimental") |
| h.Set("Originator", "codex_cli_rs") |
| if req.Stream { |
| h.Set("Accept", "text/event-stream") |
| } |
|
|
| return fullURL, h, body, nil |
| } |
|
|
| |
| func extractCodexResponseText(apiResp map[string]any) (string, bool) { |
| output, ok := getTypedValue[[]any](apiResp, "output") |
| if !ok { |
| return "", false |
| } |
|
|
| for _, item := range output { |
| outputItem, ok := item.(map[string]any) |
| if !ok { |
| continue |
| } |
|
|
| outputType, ok := getTypedValue[string](outputItem, "type") |
| if !ok || outputType != "message" { |
| continue |
| } |
|
|
| content, ok := getTypedValue[[]any](outputItem, "content") |
| if !ok || len(content) == 0 { |
| continue |
| } |
|
|
| textBlock, ok := getSliceItem[map[string]any](content, 0) |
| if !ok { |
| continue |
| } |
|
|
| text, ok := getTypedValue[string](textBlock, "text") |
| if ok { |
| return text, true |
| } |
| } |
| return "", false |
| } |
|
|
| |
| func (t *CodexTester) Parse(_ int, respBody []byte) map[string]any { |
| return parseAPIResponse(respBody, extractCodexResponseText, "usage") |
| } |
|
|
| |
| type OpenAITester struct{} |
|
|
| |
| func (t *OpenAITester) Build(cfg *model.Config, apiKey string, req *TestChannelRequest) (string, http.Header, []byte, error) { |
| testContent := req.Content |
| if strings.TrimSpace(testContent) == "" { |
| testContent = "test" |
| } |
| sessionID := newTestSessionID() |
|
|
| body, err := buildRequestFromTemplate("openai", map[string]any{ |
| "MODEL": req.Model, |
| "STREAM": req.Stream, |
| "CONTENT": testContent, |
| "SESSION_ID": sessionID, |
| }) |
| if err != nil { |
| return "", nil, nil, err |
| } |
|
|
| fullURL := buildTesterURL(cfg.GetURLs()[0], "/v1/chat/completions") |
|
|
| h := make(http.Header) |
| h.Set("Content-Type", "application/json") |
| h.Set("Authorization", "Bearer "+apiKey) |
| h.Set("Session_id", sessionID) |
| h.Set("User-Agent", version.OutboundUserAgent()) |
| if req.Stream { |
| h.Set("Accept", "text/event-stream") |
| } |
|
|
| return fullURL, h, body, nil |
| } |
|
|
| |
| func (t *OpenAITester) Parse(_ int, respBody []byte) map[string]any { |
| out := map[string]any{} |
| var apiResp map[string]any |
| if err := sonic.Unmarshal(respBody, &apiResp); err == nil { |
| |
| if choices, ok := getTypedValue[[]any](apiResp, "choices"); ok && len(choices) > 0 { |
| if choice, ok := getSliceItem[map[string]any](choices, 0); ok { |
| if message, ok := getTypedValue[map[string]any](choice, "message"); ok { |
| if content, ok := getTypedValue[string](message, "content"); ok { |
| out["response_text"] = content |
| } |
| } |
| } |
| } |
|
|
| |
| if usage, ok := getTypedValue[map[string]any](apiResp, "usage"); ok { |
| out["usage"] = usage |
| } |
|
|
| return finalizeParsedAPIResponse(out, apiResp) |
| } |
| out["raw_response"] = string(respBody) |
| return out |
| } |
|
|
| |
| type GeminiTester struct{} |
|
|
| |
| func (t *GeminiTester) Build(cfg *model.Config, apiKey string, req *TestChannelRequest) (string, http.Header, []byte, error) { |
| testContent := req.Content |
| if strings.TrimSpace(testContent) == "" { |
| testContent = "test" |
| } |
|
|
| body, err := buildRequestFromTemplate("gemini", map[string]any{ |
| "CONTENT": testContent, |
| }) |
| if err != nil { |
| return "", nil, nil, err |
| } |
|
|
| |
| action := ":generateContent" |
| if req.Stream { |
| action = ":streamGenerateContent?alt=sse" |
| } |
| fullURL := buildTesterURL(cfg.GetURLs()[0], "/v1beta/models/"+req.Model+action) |
|
|
| h := make(http.Header) |
| h.Set("Content-Type", "application/json") |
| h.Set("x-goog-api-key", apiKey) |
| h.Set("User-Agent", version.OutboundUserAgent()) |
|
|
| return fullURL, h, body, nil |
| } |
|
|
| |
| func extractGeminiResponseText(apiResp map[string]any) (string, bool) { |
| candidates, ok := getTypedValue[[]any](apiResp, "candidates") |
| if !ok || len(candidates) == 0 { |
| return "", false |
| } |
|
|
| candidate, ok := getSliceItem[map[string]any](candidates, 0) |
| if !ok { |
| return "", false |
| } |
|
|
| content, ok := getTypedValue[map[string]any](candidate, "content") |
| if !ok { |
| return "", false |
| } |
|
|
| parts, ok := getTypedValue[[]any](content, "parts") |
| if !ok || len(parts) == 0 { |
| return "", false |
| } |
|
|
| part, ok := getSliceItem[map[string]any](parts, 0) |
| if !ok { |
| return "", false |
| } |
|
|
| text, ok := getTypedValue[string](part, "text") |
| return text, ok |
| } |
|
|
| |
| func (t *GeminiTester) Parse(_ int, respBody []byte) map[string]any { |
| return parseAPIResponse(respBody, extractGeminiResponseText, "usageMetadata") |
| } |
|
|
| func newTestSessionID() string { |
| return util.NewUUIDv4() |
| } |
|
|
| |
| type AnthropicTester struct{} |
|
|
| |
| func newClaudeCLIUserID() string { |
| |
| |
| deviceID := make([]byte, 32) |
| if _, err := rand.Read(deviceID); err != nil { |
| return `{"device_id":"0000000000000000000000000000000000000000000000000000000000000000","account_uuid":"","session_id":"00000000-0000-0000-0000-000000000000"}` |
| } |
|
|
| return fmt.Sprintf(`{"device_id":"%s","account_uuid":"","session_id":"%s"}`, hex.EncodeToString(deviceID), newTestSessionID()) |
| } |
|
|
| |
| func (t *AnthropicTester) Build(cfg *model.Config, apiKey string, req *TestChannelRequest) (string, http.Header, []byte, error) { |
| maxTokens := req.MaxTokens |
| if maxTokens == 0 { |
| maxTokens = 32000 |
| } |
| testContent := req.Content |
|
|
| body, err := buildRequestFromTemplate("anthropic", map[string]any{ |
| "MODEL": req.Model, |
| "STREAM": req.Stream, |
| "CONTENT": testContent, |
| "MAX_TOKENS": maxTokens, |
| "USER_ID": newClaudeCLIUserID(), |
| }) |
| if err != nil { |
| return "", nil, nil, err |
| } |
|
|
| fullURL := buildTesterURL(cfg.GetURLs()[0], "/v1/messages?beta=true") |
|
|
| h := make(http.Header) |
| h.Set("Accept", "application/json") |
| h.Set("Content-Type", "application/json") |
| h.Set("Authorization", "Bearer "+apiKey) |
| |
| h.Set("User-Agent", "claude-cli/2.1.97 (external, cli)") |
| h.Set("x-app", "cli") |
| h.Set("anthropic-version", "2023-06-01") |
| h.Set("anthropic-beta", "claude-code-20250219,context-1m-2025-08-07,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24") |
| h.Set("anthropic-dangerous-direct-browser-access", "true") |
| |
| h.Set("x-stainless-arch", "arm64") |
| h.Set("x-stainless-lang", "js") |
| h.Set("x-stainless-os", "MacOS") |
| h.Set("x-stainless-package-version", "0.81.0") |
| h.Set("x-stainless-retry-count", "0") |
| h.Set("x-stainless-runtime", "node") |
| h.Set("x-stainless-runtime-version", "v24.3.0") |
| h.Set("x-stainless-timeout", "300") |
| h.Set("X-Claude-Code-Session-Id", newTestSessionID()) |
| if req.Stream { |
| h.Set("x-stainless-helper-method", "stream") |
| } |
|
|
| return fullURL, h, body, nil |
| } |
|
|
| |
| |
| func extractAnthropicResponseText(apiResp map[string]any) (string, bool) { |
| content, ok := getTypedValue[[]any](apiResp, "content") |
| if !ok || len(content) == 0 { |
| return "", false |
| } |
|
|
| for i := range content { |
| block, ok := getSliceItem[map[string]any](content, i) |
| if !ok { |
| continue |
| } |
| |
| if blockType, ok := getTypedValue[string](block, "type"); ok && blockType != "text" { |
| continue |
| } |
| if text, ok := getTypedValue[string](block, "text"); ok { |
| return text, true |
| } |
| } |
| return "", false |
| } |
|
|
| |
| func (t *AnthropicTester) Parse(_ int, respBody []byte) map[string]any { |
| out := map[string]any{} |
| var apiResp map[string]any |
| if err := sonic.Unmarshal(respBody, &apiResp); err == nil { |
| |
| if text, ok := extractAnthropicResponseText(apiResp); ok { |
| out["response_text"] = text |
| } |
|
|
| |
| if usage, ok := getTypedValue[map[string]any](apiResp, "usage"); ok { |
| out["usage"] = usage |
| } |
|
|
| return finalizeParsedAPIResponse(out, apiResp) |
| } |
| out["raw_response"] = string(respBody) |
| return out |
| } |
|
|