model / multimodal / tts / embedding 里所有「把 net/http 的 *url.Error 直接打进 错误串」的地方(M51 清单的 B1 组 17 处 + 清单外的同根因 2 处)统一改走 trace.URLCause:错误串只少一段地址,底层原因、哨兵分类、状态码、重试/降级判据 一字不动。 清单内(17): - model/openai.go: Chat / ChatStream 的 NewRequest、doRequest - model/anthropic.go: chatOnce / streamOnce 的 NewRequest、doRequest - multimodal/vision/adapter.go: ask 的 NewRequest、client.Do - multimodal/voice/cloud.go: postJSON 的 NewRequest、client.Do - multimodal/voice/dashscope.go: download(预签名 OSS 地址)与 dashscopePost 各两处 - voice/tts/client.go: unreachable(返回值与同一行日志的 err 字段都只留原因, 地址只由 redactURL(endpoint) 出现一次) - embedding/api.go: embedChunk 的 client.Do、newEmbeddingRequest 的 NewRequest 清单外同根因(2,均为 %w 形态,B1 清单按 %v 搜时漏掉): - voice/tts/client.go: speak 的 NewRequest 失败 - embedding/endpoint.go: normalizeEndpointURL 的 url.Parse 失败 测试:5 个新文件 18 个用例,逐处可变异打红(实测:把任一处的 URLCause 去掉 ⇒ 对应用例红);dashscope 预签名地址单独一条用例,并另有一条钉住 403 状态码仍在。
256 lines
8.2 KiB
Go
256 lines
8.2 KiB
Go
package model
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
|
||
)
|
||
|
||
// OpenAI 兼容 /chat/completions 协议的线格式约束。
|
||
const (
|
||
// sseDataPrefix 标记携带 JSON 负载的 SSE 事件行。
|
||
sseDataPrefix = "data: "
|
||
// sseDone 是终止用的 SSE 哨兵。
|
||
sseDone = "[DONE]"
|
||
// sseBufInit 是 bufio.Scanner 的初始读缓冲大小。
|
||
sseBufInit = 64 * 1024
|
||
// maxSSELineBytes 限制单条 SSE 事件行的长度。
|
||
maxSSELineBytes = 1024 * 1024
|
||
// defaultHTTPTimeout 在 Options.Timeout 为 0 时约束单次提供商调用。
|
||
defaultHTTPTimeout = 180 * time.Second
|
||
)
|
||
|
||
// OpenAIChatAdapter 调用 OpenAI 兼容的 /chat/completions 端点。它由
|
||
// config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的
|
||
// Adapter。
|
||
type OpenAIChatAdapter struct {
|
||
name string
|
||
baseURL string
|
||
apiKey string
|
||
model string
|
||
client *http.Client
|
||
recorder Recorder
|
||
// usageSink 是可选的用量落库出口(切片 U1)。为 nil 时只记结构化日志,
|
||
// 行为逐字等于引入落库之前 —— 这也是所有既有测试与未接线调用方的形态。
|
||
usageSink UsageSink
|
||
// reasoningKind 是本端点已配置的推理开关形状
|
||
// (models.json 组的 reasoning.kind / LLM_REASONING_KIND,裁决 03.4.2/08.4.1)。
|
||
// 为空表示 "auto":按模型名/base URL 推断厂商,
|
||
// 形状含糊时什么都不发。
|
||
reasoningKind string
|
||
// log 上报 reasoning_effort 校验与被忽略的思考指令。它
|
||
// 永不为 nil:调用方未提供时,会装入一个带有本适配器来源
|
||
// 标记的丢弃式日志器,因此每个决定都可追溯。
|
||
log *slog.Logger
|
||
}
|
||
|
||
// OpenAIOption 用于配置 OpenAIChatAdapter。
|
||
type OpenAIOption func(*OpenAIChatAdapter)
|
||
|
||
// WithHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产中
|
||
// 注入 mock 或调优过的 transport)。
|
||
func WithHTTPClient(c *http.Client) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) { a.client = c }
|
||
}
|
||
|
||
// WithRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。
|
||
func WithRecorder(r Recorder) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) { a.recorder = r }
|
||
}
|
||
|
||
// WithUsageSink 装入用量落库出口(切片 U1)。nil 会被忽略,从而保留
|
||
// 「只记日志」的默认行为;实现方必须非阻塞(见 UsageSink)。
|
||
func WithUsageSink(s UsageSink) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) { a.usageSink = s }
|
||
}
|
||
|
||
// WithModel 覆盖适配器的默认模型名。
|
||
func WithModel(m string) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) { a.model = m }
|
||
}
|
||
|
||
// WithOpenAILogger 挂上用于上报 reasoning_effort 校验与
|
||
// 被忽略思考指令的日志器。nil 日志器会被忽略,
|
||
// 从而保留丢弃式的默认值。
|
||
func WithOpenAILogger(l *slog.Logger) OpenAIOption {
|
||
return func(a *OpenAIChatAdapter) {
|
||
if l != nil {
|
||
a.log = l
|
||
}
|
||
}
|
||
}
|
||
|
||
// NewOpenAIChat 由供应商配置构建适配器。baseURL 会先去掉
|
||
// 末尾斜杠,再拼接 /chat/completions 路径。
|
||
// 端点已配置的推理形状(cfg.ReasoningKind)随
|
||
// 适配器一起走,因此由同一份配置构建的每条链——组链、
|
||
// 它的按角色链以及本地级——都以
|
||
// 相同方式解出自己的推理开关。
|
||
func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapter {
|
||
a := &OpenAIChatAdapter{
|
||
name: "openai",
|
||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||
apiKey: cfg.APIKey,
|
||
model: cfg.Model,
|
||
reasoningKind: strings.TrimSpace(cfg.ReasoningKind),
|
||
client: &http.Client{
|
||
Transport: http.DefaultTransport,
|
||
Timeout: defaultHTTPTimeout,
|
||
},
|
||
log: discardLogger("runtime/model/openai"),
|
||
}
|
||
for _, o := range opts {
|
||
o(a)
|
||
}
|
||
return a
|
||
}
|
||
|
||
// Name 返回适配器标识。
|
||
func (a *OpenAIChatAdapter) Name() string { return a.name }
|
||
|
||
// Chat 执行一次非流式补全。
|
||
func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
|
||
start := time.Now()
|
||
if a.baseURL == "" {
|
||
return Response{}, ErrUpstream
|
||
}
|
||
ctx, cancel := withCallTimeout(ctx, opts)
|
||
defer cancel()
|
||
model, err := a.resolveModel(req)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
req.Model = model
|
||
body, err := a.buildRequest(req, false)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||
if err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
|
||
}
|
||
resp, err := a.doRequest(httpReq, false)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return Response{}, a.upstreamError(resp)
|
||
}
|
||
var wire openAIChatResponse
|
||
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: decode response: %v", ErrUpstream, err)
|
||
}
|
||
out := responseFromWire(&wire)
|
||
a.record(ctx, req, start, out)
|
||
return out, nil
|
||
}
|
||
|
||
// ChatStream 通过 onEvent 流出结构化事件,并在提供商于
|
||
// 末块上报用量时返回带用量的聚合响应。
|
||
func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
|
||
start := time.Now()
|
||
if a.baseURL == "" {
|
||
return Response{}, ErrUpstream
|
||
}
|
||
model, err := a.resolveModel(req)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
req.Model = model
|
||
body, err := a.buildRequest(req, true)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
|
||
if err != nil {
|
||
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, trace.URLCause(err))
|
||
}
|
||
resp, err := a.doRequest(httpReq, true)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return Response{}, a.upstreamError(resp)
|
||
}
|
||
out, err := scanSSE(resp.Body, onEvent)
|
||
if err != nil {
|
||
return Response{}, err
|
||
}
|
||
out.Model = model
|
||
a.record(ctx, req, start, out)
|
||
return out, nil
|
||
}
|
||
|
||
// withCallTimeout 在 opts.Timeout 为正时用该超时包裹 ctx。
|
||
func withCallTimeout(parent context.Context, opts Options) (context.Context, context.CancelFunc) {
|
||
if opts.Timeout > 0 {
|
||
return context.WithTimeout(parent, opts.Timeout)
|
||
}
|
||
return parent, func() {}
|
||
}
|
||
|
||
// resolveModel 返回实际生效的模型名。
|
||
func (a *OpenAIChatAdapter) resolveModel(req Request) (string, error) {
|
||
if req.Model != "" {
|
||
return req.Model, nil
|
||
}
|
||
if a.model != "" {
|
||
return a.model, nil
|
||
}
|
||
return "", ErrNoModel
|
||
}
|
||
|
||
// endpoint 返回完整的 /chat/completions URL。
|
||
func (a *OpenAIChatAdapter) endpoint() string {
|
||
return a.baseURL + "/chat/completions"
|
||
}
|
||
|
||
func (a *OpenAIChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http.Response, error) {
|
||
if a.apiKey != "" {
|
||
httpReq.Header.Set("Authorization", "Bearer "+a.apiKey)
|
||
}
|
||
httpReq.Header.Set("Content-Type", "application/json")
|
||
if stream {
|
||
httpReq.Header.Set("Accept", "text/event-stream")
|
||
} else {
|
||
httpReq.Header.Set("Accept", "application/json")
|
||
}
|
||
resp, err := a.client.Do(httpReq)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, trace.URLCause(err))
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
// upstreamError 把一次非 2xx 响应交给包内唯一的上游错误收口(见 upstream.go):
|
||
// 只留 status / code / type 与体积摘要,响应体正文与 error.message 不出镜。
|
||
func (a *OpenAIChatAdapter) upstreamError(resp *http.Response) error {
|
||
return providerUpstreamError(a.name, a.baseURL, resp)
|
||
}
|
||
|
||
// record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率,
|
||
// 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。
|
||
// ctx 用来取本次调用所属的会话(loop 播种,见 session_ctx.go)。
|
||
func (a *OpenAIChatAdapter) record(ctx context.Context, req Request, start time.Time, resp Response) {
|
||
logUsage(ctx, a.log, a.usageSink, a.name, req, resp, time.Since(start))
|
||
if a.recorder == nil {
|
||
return
|
||
}
|
||
a.recorder(CallRecord{
|
||
Model: req.Model,
|
||
Purpose: req.Purpose,
|
||
Tokens: resp.TotalTokens,
|
||
TookMs: time.Since(start).Milliseconds(),
|
||
})
|
||
}
|