M4a:神识全量网关能力——OpenAI 兼容适配器(元炁共用)、候选路由+凭证/绑定级冷却+首字超时切道+粘性会话、用量计量与 /me/usage,故障注入测试四场景

This commit is contained in:
chenxin committed 2026-08-27 09:32:37 +08:00
1 parent df5caa69e7
commit 7279904043
8 files changed
+717 -35

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
// OpenAI 兼容出站适配器:IR → OpenAI chat/completions(元炁亦走此协议,type=local 复用)。
package transform
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"hunjing/shenshi/internal/ir"
)
// UpstreamError 上游错误:携带 HTTP 状态,供路由层分类冷却(凭证级/绑定级)。
type UpstreamError struct {
StatusCode int
Message string
}
func (e *UpstreamError) Error() string {
return fmt.Sprintf("上游 %d: %s", e.StatusCode, e.Message)
}
// OpenAICompatAdaptor OpenAI 兼容端点适配器(云厂商兼容面 + 元炁本地端点)。
type OpenAICompatAdaptor struct {
EndpointName string // 端点标识(usage 日志与冷却键)
BaseURL string
APIKey string
// UpstreamModel 该端点承载的上游模型名(逻辑模型 → 端点绑定的组成部分)
UpstreamModel string
Client *http.Client
}
// Name 实现 Adaptor。
func (a *OpenAICompatAdaptor) Name() string { return a.EndpointName }
type oaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type oaRequest struct {
Model string `json:"model"`
Messages []oaMessage `json:"messages"`
Stream bool `json:"stream"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
}
type oaChunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
// Stream 实现 Adaptor:SSE 逐帧转 IR Delta。首字节超时由调用方 context 控制。
func (a *OpenAICompatAdaptor) Stream(ctx context.Context, req *ir.ChatRequest, out chan<- ir.Delta) error {
body := oaRequest{
Model: a.UpstreamModel,
Messages: make([]oaMessage, 0, len(req.Messages)),
Stream: true,
}
for _, m := range req.Messages {
body.Messages = append(body.Messages, oaMessage{Role: m.Role, Content: m.Content})
}
body.Temperature = req.Temperature
body.MaxTokens = req.MaxTokens
payload, err := json.Marshal(body)
if err != nil {
return err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(a.BaseURL, "/")+"/v1/chat/completions", bytes.NewReader(payload))
if err != nil {
return err
}
httpReq.Header.Set("content-type", "application/json")
if a.APIKey != "" {
httpReq.Header.Set("authorization", "Bearer "+a.APIKey)
}
client := a.Client
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(httpReq)
if err != nil {
return &UpstreamError{StatusCode: 0, Message: err.Error()} // 0 = 网络/超时(绑定级冷却)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode >= 400 {
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
return &UpstreamError{StatusCode: resp.StatusCode, Message: buf.String()}
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var chunk oaChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 {
c := chunk.Choices[0]
if c.Delta.Content != "" {
out <- ir.Delta{Delta: c.Delta.Content}
}
if c.FinishReason != nil {
finish := *c.FinishReason
usagePrompt, usageCompletion := 0, 0
if chunk.Usage != nil {
usagePrompt, usageCompletion = chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens
}
out <- ir.Delta{FinishReason: finish, UsagePromptTokens: usagePrompt, UsageCompletionTokens: usageCompletion}
}
}
}
return scanner.Err()
}