Files
Cyrene_v2/internal/runtime/model/anthropic_stream.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

245 lines
7.6 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 (
"bufio"
"encoding/json"
"fmt"
"io"
"strings"
)
// anthropicStreamEvent 是 /v1/messages(stream: true)的一个 SSE 负载。
// Anthropic 既在 SSE 的 `event:` 字段里、也在 JSON 的 `type` 字段里
// 携带事件种类,所以解析器按 JSON 负载的 type 取值。
type anthropicStreamEvent struct {
Type string `json:"type"`
Message *anthropicStartBlock `json:"message,omitempty"`
Index *int `json:"index,omitempty"`
Content *anthropicBlock `json:"content_block,omitempty"`
Delta *anthropicDelta `json:"delta,omitempty"`
Usage *anthropicUsage `json:"usage,omitempty"`
Error *anthropicStreamError `json:"error,omitempty"`
}
// anthropicStartBlock 是 message_start 事件的负载。
type anthropicStartBlock struct {
Model string `json:"model"`
Usage *anthropicUsage `json:"usage,omitempty"`
}
// anthropicDelta 是 content_block_delta 事件里的增量负载。
// Anthropic 把 message_delta 事件的终止 stop_reason 复用了同一字段名
// (end_turn / max_tokens / tool_use),所以这里也一并解码。
type anthropicDelta struct {
Type string `json:"type"` // 取值:text_delta | input_json_delta | thinking_delta
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
Thinking string `json:"thinking,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
}
// anthropicStreamError 是 error 事件的负载。
type anthropicStreamError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// streamBlock 跟踪流式过程中单个内容块的部分状态。
type streamBlock struct {
index int
typ string
id string
name string
text strings.Builder
input strings.Builder
initialArgs map[string]any
}
// scanAnthropicSSE 增量解码 Anthropic 的内容块流。文本
// 增量以 text_delta 事件转发(片段由
// text_start/text_end 包起来);tool_use 的 input_json_delta 片段以
// tool_call_delta 事件转发(片段由 tool_call_start/tool_call_end 包起来),
// 并按块累积,以便块结束时重新拼装成结构化的 Args map。
// 流必须以 message_stop 事件终止,
// 否则返回 ErrStream。
func scanAnthropicSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes)
agg := Response{}
blocks := map[int]*streamBlock{}
var order []int
var text strings.Builder
sawMessageStop := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, sseDataPrefix) {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, sseDataPrefix))
if data == "" {
continue
}
var evt anthropicStreamEvent
if err := json.Unmarshal([]byte(data), &evt); err != nil {
return Response{}, fmt.Errorf("model: %w: decode stream chunk: %v", ErrUpstream, err)
}
if err := applyAnthropicEvent(&agg, blocks, &order, &text, evt, onEvent); err != nil {
return Response{}, err
}
if evt.Type == "message_stop" {
sawMessageStop = true
}
}
if err := scanner.Err(); err != nil {
return Response{}, fmt.Errorf("model: %w: read stream: %v", ErrUpstream, err)
}
if !sawMessageStop {
return Response{}, ErrStream
}
finalizeAnthropicStream(&agg, blocks, order, text.String())
return agg, nil
}
// applyAnthropicEvent 把单个流事件折叠进正在运行的聚合状态。
func applyAnthropicEvent(
agg *Response,
blocks map[int]*streamBlock,
order *[]int,
text *strings.Builder,
evt anthropicStreamEvent,
onEvent func(StreamEvent),
) error {
switch evt.Type {
case "message_start":
if evt.Message != nil {
agg.Model = evt.Message.Model
// 输入侧用量(含缓存读/写两档)在 message_start 就齐了,
// 因此在这里一次折进去 —— 它与 message_delta 的输出侧用量
// 合起来构成这次调用的完整成本面。
applyAnthropicUsage(agg, evt.Message.Usage)
}
case "content_block_start":
if evt.Index == nil || evt.Content == nil {
return nil
}
b := &streamBlock{
index: *evt.Index,
typ: evt.Content.Type,
id: evt.Content.ID,
name: evt.Content.Name,
initialArgs: evt.Content.Input,
}
switch evt.Content.Type {
case "text":
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindTextStart, Index: *evt.Index})
}
if evt.Content.Text != "" {
b.text.WriteString(evt.Content.Text)
text.WriteString(evt.Content.Text)
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindTextDelta, Delta: evt.Content.Text, Index: *evt.Index})
}
}
case "tool_use":
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindToolCallStart, Index: *evt.Index, ID: evt.Content.ID})
}
case "thinking":
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindThinkingStart, Index: *evt.Index})
}
if evt.Content.Text != "" && onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindThinkingDelta, Delta: evt.Content.Text, Index: *evt.Index})
}
}
if _, ok := blocks[*evt.Index]; !ok {
*order = append(*order, *evt.Index)
}
blocks[*evt.Index] = b
case "content_block_delta":
if evt.Index == nil || evt.Delta == nil {
return nil
}
b := blocks[*evt.Index]
if b == nil {
return nil
}
switch evt.Delta.Type {
case "text_delta":
b.text.WriteString(evt.Delta.Text)
text.WriteString(evt.Delta.Text)
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindTextDelta, Delta: evt.Delta.Text, Index: *evt.Index})
}
case "input_json_delta":
b.input.WriteString(evt.Delta.PartialJSON)
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindToolCallDelta, Delta: evt.Delta.PartialJSON, Index: *evt.Index, ID: b.id})
}
case "thinking_delta":
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindThinkingDelta, Delta: evt.Delta.Thinking, Index: *evt.Index})
}
}
case "content_block_stop":
if evt.Index == nil {
return nil
}
b := blocks[*evt.Index]
if b == nil {
return nil
}
if b.typ == "text" {
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindTextEnd, Index: *evt.Index})
}
} else if b.typ == "tool_use" {
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindToolCallEnd, Index: *evt.Index, ID: b.id})
}
} else if b.typ == "thinking" {
if onEvent != nil {
onEvent(StreamEvent{Kind: StreamKindThinkingEnd, Index: *evt.Index})
}
}
case "message_delta":
if evt.Delta != nil {
agg.StopReason = evt.Delta.StopReason
}
if evt.Usage != nil {
agg.CompletionTokens = evt.Usage.OutputTokens
}
case "error":
if evt.Error != nil {
return fmt.Errorf("model: %w: anthropic stream error: %s: %s", ErrUpstream, evt.Error.Type, evt.Error.Message)
}
}
return nil
}
// finalizeAnthropicStream 由累积的内容块拼装出聚合后的 Response:
// 拼接的文本,加上按块导出的 tool_use ToolCall。
func finalizeAnthropicStream(agg *Response, blocks map[int]*streamBlock, order []int, content string) {
agg.Content = content
agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: content}}}
agg.TotalTokens = agg.PromptTokens + agg.CompletionTokens
for _, idx := range order {
b := blocks[idx]
if b == nil || b.typ != "tool_use" {
continue
}
args := b.initialArgs
if b.input.Len() > 0 {
decoded := map[string]any{}
if err := json.Unmarshal([]byte(b.input.String()), &decoded); err == nil {
args = decoded
} else {
args = map[string]any{}
}
}
agg.ToolCalls = append(agg.ToolCalls, ToolCall{Name: b.name, ID: b.id, Args: args})
}
}