// 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() }