- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。 - 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。 - usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。 - 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。 - 顺手修越界:config.go 505 行(超 500)拆出 llm.go。 - 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
247 lines
7.5 KiB
Go
247 lines
7.5 KiB
Go
package model
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
)
|
||
|
||
// OpenAI 兼容 /chat/completions 协议的线格式约束。
|
||
const (
|
||
// sseDataPrefix 标记携带 JSON 负载的 SSE 事件行。
|
||
sseDataPrefix = "data: "
|
||
// sseDone 是终止用的 SSE 哨兵。
|
||
sseDone = "[DONE]"
|
||
// sseBufInit 是 bufio.Scanner 的初始读缓冲大小。
|
||
sseBufInit = 64 * 1024
|
||
// maxSSELineBytes 限制单条 SSE 事件行的长度。
|
||
maxSSELineBytes = 1024 * 1024
|
||
// errBodyCap 限制为取出消息而读取的错误响应体大小。
|
||
errBodyCap = 512
|
||
// 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
|
||
// 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 }
|
||
}
|
||
|
||
// 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, 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(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, 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(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, err)
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
func (a *OpenAIChatAdapter) upstreamError(resp *http.Response) error {
|
||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap))
|
||
return fmt.Errorf("model: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
|
||
}
|
||
|
||
// record 上报一次成功调用的用量:先记一条结构化用量日志(含缓存命中率,
|
||
// 所有调用点都覆盖 —— 见 usage.go),再喂给可选的 CallRecord 计账出口。
|
||
func (a *OpenAIChatAdapter) record(req Request, start time.Time, resp Response) {
|
||
logUsage(a.log, 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(),
|
||
})
|
||
}
|