mirror of
https://github.com/eolinker/apinto
synced 2025-12-24 13:28:15 +08:00
Merge pull request #184 from eolinker/feature/dashen/moonshot
add: enhance Moonshot API response handling and testing
This commit is contained in:
1
drivers/.gitignore
vendored
1
drivers/.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
*.DS_Store
|
*.DS_Store
|
||||||
|
**/.env
|
||||||
@@ -17,6 +17,7 @@ type Response struct {
|
|||||||
SystemFingerprint string `json:"system_fingerprint"`
|
SystemFingerprint string `json:"system_fingerprint"`
|
||||||
Choices []ResponseChoice `json:"choices"`
|
Choices []ResponseChoice `json:"choices"`
|
||||||
Usage Usage `json:"usage"`
|
Usage Usage `json:"usage"`
|
||||||
|
Error Error `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResponseChoice struct {
|
type ResponseChoice struct {
|
||||||
@@ -26,6 +27,8 @@ type ResponseChoice struct {
|
|||||||
FinishReason string `json:"finish_reason"`
|
FinishReason string `json:"finish_reason"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Usage provides information about the token counts for the request and response.
|
||||||
|
// {"prompt_tokens":19,"completion_tokens":21,"total_tokens":40}
|
||||||
type Usage struct {
|
type Usage struct {
|
||||||
PromptTokens int `json:"prompt_tokens"`
|
PromptTokens int `json:"prompt_tokens"`
|
||||||
CompletionTokens int `json:"completion_tokens"`
|
CompletionTokens int `json:"completion_tokens"`
|
||||||
@@ -36,3 +39,10 @@ type Usage struct {
|
|||||||
type CompletionTokensDetails struct {
|
type CompletionTokensDetails struct {
|
||||||
ReasoningTokens int `json:"reasoning_tokens"`
|
ReasoningTokens int `json:"reasoning_tokens"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error represents an error response from the API.
|
||||||
|
// {"error":{"type":"content_filter","message":"The request was rejected because it was considered high risk"}}
|
||||||
|
type Error struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package moonshot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/eolinker/apinto/convert"
|
"github.com/eolinker/apinto/convert"
|
||||||
ai_provider "github.com/eolinker/apinto/drivers/ai-provider"
|
ai_provider "github.com/eolinker/apinto/drivers/ai-provider"
|
||||||
"github.com/eolinker/eosc"
|
"github.com/eolinker/eosc"
|
||||||
@@ -74,15 +75,51 @@ func (c *Chat) ResponseConvert(ctx eocontext.EoContext) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if httpContext.Response().StatusCode() != 200 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
body := httpContext.Response().GetBody()
|
body := httpContext.Response().GetBody()
|
||||||
data := eosc.NewBase[Response]()
|
data := eosc.NewBase[Response]()
|
||||||
err = json.Unmarshal(body, data)
|
err = json.Unmarshal(body, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTTP Status Codes for Moonshot API
|
||||||
|
// Status Code | Type | Error Message
|
||||||
|
// ------------|---------------------|-------------------------------------
|
||||||
|
// 200 | Success | Request was successful.
|
||||||
|
// 400 | Client Error | Invalid request parameters (invalid_request_error).
|
||||||
|
// 401 | Authentication Error | Invalid API key (invalid_key).
|
||||||
|
// 403 | Forbidden | Access denied (forbidden_error).
|
||||||
|
// 404 | Not Found | Resource not found (not_found_error).
|
||||||
|
// 429 | Rate Limit Exceeded | Too many requests (rate_limit_error).
|
||||||
|
// 500 | Server Error | Internal server error (server_error).
|
||||||
|
// 503 | Service Unavailable | Service is temporarily unavailable (service_unavailable).
|
||||||
|
switch httpContext.Response().StatusCode() {
|
||||||
|
case 200:
|
||||||
|
// Calculate the token consumption for a successful request.
|
||||||
|
usage := data.Config.Usage
|
||||||
|
ai_provider.SetAIStatusNormal(ctx)
|
||||||
|
ai_provider.SetAIModelInputToken(ctx, usage.PromptTokens)
|
||||||
|
ai_provider.SetAIModelOutputToken(ctx, usage.CompletionTokens)
|
||||||
|
ai_provider.SetAIModelTotalToken(ctx, usage.TotalTokens)
|
||||||
|
case 400:
|
||||||
|
// Handle the bad request error.
|
||||||
|
ai_provider.SetAIStatusInvalidRequest(ctx)
|
||||||
|
case 401:
|
||||||
|
// 过期和无效的API密钥
|
||||||
|
ai_provider.SetAIStatusInvalid(ctx)
|
||||||
|
case 429:
|
||||||
|
switch data.Config.Error.Type {
|
||||||
|
case "exceeded_current_quota_error":
|
||||||
|
// Handle the insufficient quota error.
|
||||||
|
ai_provider.SetAIStatusQuotaExhausted(ctx)
|
||||||
|
case "engine_overloaded_error", "rate_limit_reached_error":
|
||||||
|
// Handle the rate limit error.
|
||||||
|
ai_provider.SetAIStatusExceeded(ctx)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
ai_provider.SetAIStatusInvalidRequest(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
responseBody := &ai_provider.ClientResponse{}
|
responseBody := &ai_provider.ClientResponse{}
|
||||||
if len(data.Config.Choices) > 0 {
|
if len(data.Config.Choices) > 0 {
|
||||||
msg := data.Config.Choices[0]
|
msg := data.Config.Choices[0]
|
||||||
@@ -93,7 +130,7 @@ func (c *Chat) ResponseConvert(ctx eocontext.EoContext) error {
|
|||||||
responseBody.FinishReason = msg.FinishReason
|
responseBody.FinishReason = msg.FinishReason
|
||||||
} else {
|
} else {
|
||||||
responseBody.Code = -1
|
responseBody.Code = -1
|
||||||
responseBody.Error = "no response"
|
responseBody.Error = data.Config.Error.Message
|
||||||
}
|
}
|
||||||
body, err = json.Marshal(responseBody)
|
body, err = json.Marshal(responseBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
232
drivers/ai-provider/moonshot/moonshot_test.go
Normal file
232
drivers/ai-provider/moonshot/moonshot_test.go
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
package moonshot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/eolinker/eosc/eocontext"
|
||||||
|
|
||||||
|
"github.com/eolinker/apinto/convert"
|
||||||
|
ai_provider "github.com/eolinker/apinto/drivers/ai-provider"
|
||||||
|
http_context "github.com/eolinker/apinto/node/http-context"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/valyala/fasthttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultConfig = `{
|
||||||
|
"frequency_penalty": "",
|
||||||
|
"max_tokens": 512,
|
||||||
|
"presence_penalty": "",
|
||||||
|
"response_format": "",
|
||||||
|
"temperature": "",
|
||||||
|
"top_p": ""
|
||||||
|
}`
|
||||||
|
successBody = []byte(`{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"content": "Hello, how can I help you?",
|
||||||
|
"role": "assistant"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
failBody = []byte(`{
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"content": "Hello, how can I help you?",
|
||||||
|
"role": "yyy"
|
||||||
|
}
|
||||||
|
],"variables":{}
|
||||||
|
}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func validNormalFunc(ctx eocontext.EoContext) bool {
|
||||||
|
fmt.Printf("input token: %d\n", ai_provider.GetAIModelInputToken(ctx))
|
||||||
|
fmt.Printf("output token: %d\n", ai_provider.GetAIModelOutputToken(ctx))
|
||||||
|
fmt.Printf("total token: %d\n", ai_provider.GetAIModelTotalToken(ctx))
|
||||||
|
if ai_provider.GetAIModelInputToken(ctx) <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if ai_provider.GetAIModelOutputToken(ctx) <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ai_provider.GetAIModelTotalToken(ctx) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSentTo tests the end-to-end execution of the moonshot integration.
|
||||||
|
func TestSentTo(t *testing.T) {
|
||||||
|
// Load .env file
|
||||||
|
err := godotenv.Load(".env")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Error loading .env file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test data for different scenarios
|
||||||
|
testData := []struct {
|
||||||
|
name string
|
||||||
|
apiKey string
|
||||||
|
wantStatus string
|
||||||
|
body []byte
|
||||||
|
validFunc func(ctx eocontext.EoContext) bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
apiKey: os.Getenv("ValidKey"),
|
||||||
|
wantStatus: ai_provider.StatusNormal,
|
||||||
|
body: successBody,
|
||||||
|
validFunc: validNormalFunc,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid request",
|
||||||
|
apiKey: os.Getenv("ValidKey"),
|
||||||
|
wantStatus: ai_provider.StatusInvalidRequest,
|
||||||
|
body: failBody,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid key",
|
||||||
|
apiKey: os.Getenv("InvalidKey"),
|
||||||
|
wantStatus: ai_provider.StatusInvalid,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired key",
|
||||||
|
apiKey: os.Getenv("ExpiredKey"),
|
||||||
|
wantStatus: ai_provider.StatusInvalid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests for each scenario
|
||||||
|
for _, data := range testData {
|
||||||
|
t.Run(data.name, func(t *testing.T) {
|
||||||
|
if err := runTest(data.apiKey, data.body, data.wantStatus, data.validFunc); err != nil {
|
||||||
|
t.Fatalf("Test failed: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTest handles a single test case
|
||||||
|
func runTest(apiKey string, requestBody []byte, wantStatus string, validFunc func(ctx eocontext.EoContext) bool) error {
|
||||||
|
cfg := &Config{
|
||||||
|
APIKey: apiKey,
|
||||||
|
Organization: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the worker
|
||||||
|
worker, err := Create("moonshot", "moonshot", cfg, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create worker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the handler
|
||||||
|
handler, ok := worker.(convert.IConverterDriver)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("worker does not implement IConverterDriver")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to success body if no body is provided
|
||||||
|
if len(requestBody) == 0 {
|
||||||
|
requestBody = successBody
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock HTTP context
|
||||||
|
ctx := createMockHttpContext("/xxx/xxx", nil, nil, requestBody)
|
||||||
|
|
||||||
|
// Execute the conversion process
|
||||||
|
err = executeConverter(ctx, handler, "moonshot-v1-8k", "https://api.moonshot.cn")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to execute conversion process: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the status
|
||||||
|
if ai_provider.GetAIStatus(ctx) != wantStatus {
|
||||||
|
return fmt.Errorf("unexpected status: got %s, expected %s", ai_provider.GetAIStatus(ctx), wantStatus)
|
||||||
|
}
|
||||||
|
if validFunc != nil {
|
||||||
|
if validFunc(ctx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("execute validFunc failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeConverter handles the full flow of a conversion process.
|
||||||
|
func executeConverter(ctx *http_context.HttpContext, handler convert.IConverterDriver, model string, baseUrl string) error {
|
||||||
|
// Balance handler setup
|
||||||
|
balanceHandler, err := ai_provider.NewBalanceHandler("test", baseUrl, 30*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create balance handler: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get model function
|
||||||
|
fn, has := handler.GetModel(model)
|
||||||
|
if !has {
|
||||||
|
return fmt.Errorf("model %s not found", model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate config
|
||||||
|
extender, err := fn(defaultConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get converter
|
||||||
|
converter, has := handler.GetConverter(model)
|
||||||
|
if !has {
|
||||||
|
return fmt.Errorf("converter for model %s not found", model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert request
|
||||||
|
if err := converter.RequestConvert(ctx, extender); err != nil {
|
||||||
|
return fmt.Errorf("request conversion failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select node via balance handler
|
||||||
|
node, _, err := balanceHandler.Select(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("node selection failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send request to the node
|
||||||
|
if err := ctx.SendTo(balanceHandler.Scheme(), node, balanceHandler.TimeOut()); err != nil {
|
||||||
|
return fmt.Errorf("failed to send request to node: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert response
|
||||||
|
if err := converter.ResponseConvert(ctx); err != nil {
|
||||||
|
return fmt.Errorf("response conversion failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createMockHttpContext creates a mock fasthttp.RequestCtx and wraps it with HttpContext.
|
||||||
|
func createMockHttpContext(rawURL string, headers map[string]string, query url.Values, body []byte) *http_context.HttpContext {
|
||||||
|
req := fasthttp.AcquireRequest()
|
||||||
|
u := fasthttp.AcquireURI()
|
||||||
|
|
||||||
|
// Set request URI and path
|
||||||
|
uri, _ := url.Parse(rawURL)
|
||||||
|
u.SetPath(uri.Path)
|
||||||
|
u.SetScheme(uri.Scheme)
|
||||||
|
u.SetHost(uri.Host)
|
||||||
|
u.SetQueryString(uri.RawQuery)
|
||||||
|
req.SetURI(u)
|
||||||
|
req.Header.SetMethod("POST")
|
||||||
|
|
||||||
|
// Set headers
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
req.SetBody(body)
|
||||||
|
|
||||||
|
// Create HttpContext
|
||||||
|
return http_context.NewContext(&fasthttp.RequestCtx{
|
||||||
|
Request: *req,
|
||||||
|
Response: fasthttp.Response{},
|
||||||
|
}, 8099)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user