You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

298 lines
7.7 KiB

// Package openaichat 调用 SparkChain 海外 SDK 里 domain=openai 的文本模型。
//
// 会议总结走这条,不走星火 websocket。SDK 二进制里的形状是:
//
// POST https://cn-huadong-1.xf-yun.com/v1/private/s56f331b2
// 鉴权挂在 URL 上(host date request-line,没有 digest)
// 正文是讯飞信封,payload.input.text = base64(OpenAI chat JSON)
// 响应 payload.output.text 再 base64 解出来,里面是 chat.completion 或 responses
package openaichat
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
defaultHost = "cn-huadong-1.xf-yun.com"
defaultPath = "/v1/private/s56f331b2"
defaultModel = "gpt-5.3-chat"
)
// Message 一轮对话。Role 取 system / user / assistant。
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Client 讯飞 OpenAI 文本客户端。
type Client struct {
AppID string
APIKey string
Secret string
Host string
Model string
HTTP *http.Client
}
// New 用 SparkChain 的 appId / apiKey / apiSecret 建客户端。
// model 空则用文档里的对话模型 gpt-5.3-chat。host 空则用 SDK 预置地址。
func New(appID, apiKey, secret, host, model string) (*Client, error) {
if strings.TrimSpace(appID) == "" || strings.TrimSpace(apiKey) == "" || strings.TrimSpace(secret) == "" {
return nil, fmt.Errorf("iflytek openai: app_id/api_key/api_secret 都不能空")
}
host = strings.TrimSpace(host)
host = strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://")
host = strings.Trim(host, "/")
if host == "" || strings.Contains(host, "spark-api") {
host = defaultHost
}
model = EffectiveModel(model)
return &Client{
AppID: appID,
APIKey: apiKey,
Secret: secret,
Host: host,
Model: model,
HTTP: &http.Client{Timeout: 120 * time.Second, CheckRedirect: refuseRedirect},
}, nil
}
// EffectiveModel 空模型或星火的 general* 都改成文档里的对话模型。
func EffectiveModel(model string) string {
model = strings.TrimSpace(model)
if model == "" || strings.HasPrefix(model, "general") {
return defaultModel
}
return model
}
func refuseRedirect(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
// Chat 同步要一段回复。
func (c *Client) Chat(messages []Message) (string, error) {
if len(messages) == 0 {
return "", fmt.Errorf("iflytek openai: 没有输入")
}
body, err := c.envelope(messages)
if err != nil {
return "", err
}
endpoint, err := c.authURL()
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Host = c.Host
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return "", fmt.Errorf("iflytek openai: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return "", err
}
text, err := parseOuter(raw)
if err != nil {
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("iflytek openai: http %d: %w", resp.StatusCode, err)
}
return "", err
}
return text, nil
}
// envelope 讯飞外层。内层是 OpenAI chat JSON,text 字段 base64。
func (c *Client) envelope(messages []Message) ([]byte, error) {
inner := map[string]any{
"model": c.Model,
"max_completion_tokens": 4096,
"stream": false,
"messages": messages,
}
raw, err := json.Marshal(inner)
if err != nil {
return nil, err
}
outer := map[string]any{
"header": map[string]any{
"app_id": c.AppID,
"status": 3,
},
"parameter": map[string]any{
"chat": map[string]any{
"output": map[string]any{
"encoding": "utf8",
"compress": "raw",
"format": "json",
},
},
},
"payload": map[string]any{
"input": map[string]any{
"encoding": "utf8",
"compress": "raw",
"format": "json",
"status": 3,
"text": base64.StdEncoding.EncodeToString(raw),
},
},
}
return json.Marshal(outer)
}
func (c *Client) authURL() (string, error) {
date := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT")
origin := "host: " + c.Host + "\n" + "date: " + date + "\n" + "POST " + defaultPath + " HTTP/1.1"
mac := hmac.New(sha256.New, []byte(c.Secret))
_, _ = mac.Write([]byte(origin))
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
authOrigin := fmt.Sprintf(`api_key="%s", algorithm="hmac-sha256", headers="host date request-line", signature="%s"`,
c.APIKey, signature)
q := url.Values{}
q.Set("authorization", base64.StdEncoding.EncodeToString([]byte(authOrigin)))
q.Set("date", date)
q.Set("host", c.Host)
return "https://" + c.Host + defaultPath + "?" + q.Encode(), nil
}
func parseOuter(raw []byte) (string, error) {
var outer struct {
Header struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"header"`
Payload struct {
Output struct {
Text string `json:"text"`
} `json:"output"`
} `json:"payload"`
}
if err := json.Unmarshal(raw, &outer); err != nil {
return "", fmt.Errorf("iflytek openai: 响应不是 JSON")
}
if outer.Header.Code != 0 {
msg := strings.TrimSpace(outer.Header.Message)
if msg == "" {
msg = "gateway request failed"
}
return "", fmt.Errorf("iflytek openai: code=%d %s", outer.Header.Code, msg)
}
decoded, err := base64.StdEncoding.DecodeString(outer.Payload.Output.Text)
if err != nil {
decoded, err = base64.RawStdEncoding.DecodeString(outer.Payload.Output.Text)
}
if err != nil {
return "", fmt.Errorf("iflytek openai: 输出不是 base64")
}
text := extractContent(decoded)
text = strings.TrimSpace(text)
if text == "" {
return "", fmt.Errorf("iflytek openai: 空回复")
}
return text, nil
}
func extractContent(decoded []byte) string {
s := strings.TrimSpace(string(decoded))
if strings.Contains(s, "data:") && !json.Valid(decoded) {
return sseContent(s)
}
var body map[string]any
if json.Unmarshal(decoded, &body) != nil {
return s
}
if fmt.Sprint(body["object"]) == "response" {
if t := responseText(body); t != "" {
return t
}
}
if t := chatCompletionText(body); t != "" {
return t
}
if t := responseText(body); t != "" {
return t
}
return ""
}
func chatCompletionText(body map[string]any) string {
choices, _ := body["choices"].([]any)
var b strings.Builder
for _, item := range choices {
m, _ := item.(map[string]any)
if msg, ok := m["message"].(map[string]any); ok {
if c, ok := msg["content"].(string); ok {
b.WriteString(c)
}
}
if delta, ok := m["delta"].(map[string]any); ok {
if c, ok := delta["content"].(string); ok {
b.WriteString(c)
}
}
if t, ok := m["text"].(string); ok {
b.WriteString(t)
}
}
return b.String()
}
func responseText(body map[string]any) string {
var b strings.Builder
switch out := body["output"].(type) {
case string:
return out
case []any:
for _, item := range out {
m, _ := item.(map[string]any)
if t, ok := m["text"].(string); ok {
b.WriteString(t)
}
content, _ := m["content"].([]any)
for _, part := range content {
pm, _ := part.(map[string]any)
if t, ok := pm["text"].(string); ok {
b.WriteString(t)
}
}
}
}
return b.String()
}
func sseContent(s string) string {
var b strings.Builder
for _, line := range strings.Split(s, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" || payload == "[DONE]" {
continue
}
var body map[string]any
if json.Unmarshal([]byte(payload), &body) != nil {
continue
}
b.WriteString(chatCompletionText(body))
}
return b.String()
}