Files
Cyrene_v2/internal/runtime/model/openai.go
T
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明: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)。
2026-10-04 17:57:08 +08:00

247 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
})
}