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