Files

139 lines
3.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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()
}