- 窗口声明: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)。
245 lines
7.6 KiB
Go
245 lines
7.6 KiB
Go
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})
|
||
}
|
||
}
|