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