ccpoad / internal /testutil /api_tester.go
anyalerob's picture
Upload folder using huggingface_hub
2986042 verified
Raw
History Blame Contribute Delete
13.1 kB
// Package testutil 提供测试工具和辅助函数
package testutil
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"strings"
"ccLoad/internal/model"
"ccLoad/internal/util"
"ccLoad/internal/version"
"github.com/bytedance/sonic"
)
// ChannelTester 定义不同渠道类型的测试协议(OCP:新增类型无需修改调用方)
type ChannelTester interface {
// Build 构造完整请求:URL、基础请求头、请求体
// apiKey: 实际使用的API Key字符串(由调用方从数据库查询)
Build(cfg *model.Config, apiKey string, req *TestChannelRequest) (fullURL string, headers http.Header, body []byte, err error)
// Parse 解析响应体,返回通用结果字段(如 response_text、usage、api_response/api_error/raw_response)
Parse(statusCode int, respBody []byte) map[string]any
}
// === 泛型类型安全工具函数 ===
// getTypedValue 从map中安全获取指定类型的值(消除类型断言嵌套)
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
}
// getSliceItem 从切片中安全获取指定索引的指定类型元素
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
}
// CodexTester 兼容 Codex 风格(渠道类型: codex)
type CodexTester struct{}
// Build 构建 Codex 格式的 API 请求
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
}
// extractCodexResponseText 从Codex响应中提取文本(消除6层嵌套)
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
}
// Parse 解析 Codex 格式的 API 响应
func (t *CodexTester) Parse(_ int, respBody []byte) map[string]any {
return parseAPIResponse(respBody, extractCodexResponseText, "usage")
}
// OpenAITester 标准OpenAI API格式(渠道类型: openai)
type OpenAITester struct{}
// Build 构建 OpenAI 格式的 API 请求
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
}
// Parse 解析 OpenAI 格式的 API 响应
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 {
// 提取choices[0].message.content
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
}
}
}
}
// 提取usage
if usage, ok := getTypedValue[map[string]any](apiResp, "usage"); ok {
out["usage"] = usage
}
return finalizeParsedAPIResponse(out, apiResp)
}
out["raw_response"] = string(respBody)
return out
}
// GeminiTester 实现 Google Gemini 测试协议
type GeminiTester struct{}
// Build 构建 Gemini 格式的 API 请求
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
}
// Gemini API: 流式用 :streamGenerateContent?alt=sse,非流式用 :generateContent
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
}
// extractGeminiResponseText 从Gemini响应中提取文本(消除5层嵌套)
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
}
// Parse 解析 Gemini 格式的 API 响应
func (t *GeminiTester) Parse(_ int, respBody []byte) map[string]any {
return parseAPIResponse(respBody, extractGeminiResponseText, "usageMetadata")
}
func newTestSessionID() string {
return util.NewUUIDv4()
}
// AnthropicTester 实现 Anthropic 测试协议
type AnthropicTester struct{}
// newClaudeCLIUserID 生成 Claude CLI 用户ID
func newClaudeCLIUserID() string {
// Claude Code 真实格式:metadata.user_id 是一个 JSON 字符串
// 例如:{"device_id":"76efe6...","account_uuid":"","session_id":"ce6c5d34-..."}
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())
}
// Build 构建 Anthropic 格式的 API 请求
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)
// Claude Code CLI headers
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")
// x-stainless-* headers
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
}
// extractAnthropicResponseText 从Anthropic响应中提取文本
// 遍历content数组,跳过thinking block,取第一个type=text的block
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
}
// 优先匹配 type=text 的 block
if blockType, ok := getTypedValue[string](block, "type"); ok && blockType != "text" {
continue
}
if text, ok := getTypedValue[string](block, "text"); ok {
return text, true
}
}
return "", false
}
// Parse 解析 Anthropic 格式的 API 响应
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
}
// 提取usage(与其他Tester保持一致,便于上层统一处理)
if usage, ok := getTypedValue[map[string]any](apiResp, "usage"); ok {
out["usage"] = usage
}
return finalizeParsedAPIResponse(out, apiResp)
}
out["raw_response"] = string(respBody)
return out
}