9470a127f7
build / build (push) Successful in 1m11s
统一AI分析入口及双任务安全确认,采用全量载入的可见行渲染,保留完整原文。 右侧非模态详情固定五项质量字段,清理冗余展示并对齐查询和同步按钮。 默认4并发由数据库准入与逐任务租约保护,保留共享限流冷却、截断归因和有界重试。 兼容真实引用标题字段,不伪造证据或准确率,不修改源生产问答与公司区域规则。 验证:完整make verify退出0,710项Go通过、124项已声明可选跳过且必需0跳过; 另全量mirror隔离PostgreSQL race退出0,AI单元race退出0。 真实本地浏览器通过六项交互、390px内滚动及显式AI部分失败/零缺口故障夹具。 治理原生备份已真实恢复,schema57、72表计数与29张QA内容哈希一致。 候选镜像、精确提交CI与正式上线验收待本次提交后执行,不宣称已部署。
117 lines
4.5 KiB
Go
117 lines
4.5 KiB
Go
// Package qaopsai owns independent governance-side model jobs, never production chat writes.
|
|
package qaopsai
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var ErrUnavailable = errors.New("qa_ai_unavailable")
|
|
var ErrInvalid = errors.New("qa_ai_invalid_request")
|
|
var ErrConflict = errors.New("qa_ai_version_conflict")
|
|
|
|
type RemoteError struct {
|
|
RetryAfter time.Duration
|
|
Code string
|
|
Retryable bool
|
|
}
|
|
|
|
func (e *RemoteError) Error() string { return "qa_ai_" + e.Code }
|
|
|
|
type Client struct{ AllowPrivate, DNSOverTLS bool }
|
|
type Completion struct{ Content, Model string }
|
|
|
|
func (c Client) Call(ctx context.Context, base, model, key, system string, input any, maxTokens int) (Completion, error) {
|
|
endpoint, err := url.Parse(base)
|
|
if err != nil || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" || (endpoint.Scheme != "https" && !(endpoint.Scheme == "http" && c.AllowPrivate)) || strings.TrimSpace(base) != base || len(base) > 500 || strings.TrimSpace(model) == "" || len(model) > 200 || strings.TrimSpace(key) == "" || len(key) > 4096 || strings.ContainsAny(key, "\r\n") {
|
|
return Completion{}, ErrInvalid
|
|
}
|
|
if err := c.validateHost(endpoint.Hostname()); err != nil {
|
|
return Completion{}, err
|
|
}
|
|
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/chat/completions"
|
|
body, err := json.Marshal(input)
|
|
if err != nil || len(body) > 8<<20 {
|
|
return Completion{}, ErrInvalid
|
|
}
|
|
wire, err := json.Marshal(map[string]any{"model": model, "messages": []map[string]string{{"role": "system", "content": system}, {"role": "user", "content": string(body)}}, "response_format": map[string]string{"type": "json_object"}, "max_tokens": maxTokens, "stream": false})
|
|
if err != nil {
|
|
return Completion{}, ErrInvalid
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(wire))
|
|
if err != nil {
|
|
return Completion{}, ErrInvalid
|
|
}
|
|
request.Header.Set("Authorization", "Bearer "+key)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
client := &http.Client{Transport: &http.Transport{DialContext: c.safeDial, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 110 * time.Second}, Timeout: 120 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
defer client.CloseIdleConnections()
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
if errors.Is(err, ErrInvalid) {
|
|
return Completion{}, ErrInvalid
|
|
}
|
|
return Completion{}, &RemoteError{Code: "network_or_timeout", Retryable: true}
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
code := "request_rejected"
|
|
retry := false
|
|
switch {
|
|
case response.StatusCode == 401 || response.StatusCode == 403:
|
|
code = "credentials_rejected"
|
|
case response.StatusCode == 429:
|
|
code = "rate_limited"
|
|
retry = true
|
|
case response.StatusCode >= 500:
|
|
code = "provider_unavailable"
|
|
retry = true
|
|
case response.StatusCode >= 300 && response.StatusCode < 400:
|
|
code = "redirect_rejected"
|
|
}
|
|
var delay time.Duration
|
|
if code == "rate_limited" {
|
|
if seconds, e := strconv.Atoi(response.Header.Get("Retry-After")); e == nil && seconds > 0 {
|
|
delay = time.Duration(seconds) * time.Second
|
|
} else if date, e := http.ParseTime(response.Header.Get("Retry-After")); e == nil {
|
|
delay = time.Until(date)
|
|
}
|
|
if delay < 15*time.Second {
|
|
delay = 15 * time.Second
|
|
}
|
|
if delay > 120*time.Second {
|
|
delay = 120 * time.Second
|
|
}
|
|
}
|
|
return Completion{}, &RemoteError{Code: code, Retryable: retry, RetryAfter: delay}
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(response.Body, (4<<20)+1))
|
|
if err != nil || len(raw) > 4<<20 {
|
|
return Completion{}, &RemoteError{Code: "invalid_response"}
|
|
}
|
|
var result struct {
|
|
Model string `json:"model"`
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
FinishReason string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
}
|
|
if json.Unmarshal(raw, &result) == nil && len(result.Choices) == 1 && result.Choices[0].FinishReason == "length" {
|
|
return Completion{}, &RemoteError{Code: "output_truncated", Retryable: true}
|
|
}
|
|
if json.Unmarshal(raw, &result) != nil || len(result.Choices) != 1 || result.Choices[0].FinishReason != "stop" || strings.TrimSpace(result.Choices[0].Message.Content) == "" {
|
|
return Completion{}, &RemoteError{Code: "incomplete_response", Retryable: true}
|
|
}
|
|
return Completion{Content: result.Choices[0].Message.Content, Model: result.Model}, nil
|
|
}
|