- 窗口声明: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)。
118 lines
4.8 KiB
Go
118 lines
4.8 KiB
Go
package model
|
||
|
||
import (
|
||
"encoding/json"
|
||
)
|
||
|
||
// openAIChatResponse 是 /chat/completions 返回的 JSON 体,既用于
|
||
// 非流式负载,也用于每个 SSE 流块。
|
||
type openAIChatResponse struct {
|
||
ID string `json:"id"`
|
||
Model string `json:"model"`
|
||
Choices []openAIChoice `json:"choices"`
|
||
Usage *openAIUsage `json:"usage,omitempty"`
|
||
}
|
||
|
||
// openAIChoice 是一个候选补全。
|
||
type openAIChoice struct {
|
||
Index int `json:"index"`
|
||
Message openAIResponseMessage `json:"message"`
|
||
Delta *openAIDelta `json:"delta,omitempty"`
|
||
FinishReason string `json:"finish_reason,omitempty"`
|
||
}
|
||
|
||
// openAIResponseMessage 是非流式 choice 里的助手消息。
|
||
type openAIResponseMessage struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
|
||
}
|
||
|
||
// openAIDelta 是流块里的增量负载。推理内容
|
||
// (OpenAI o 系 / DeepSeek / DashScope)到达 ReasoningContent;流式
|
||
// 函数调用到达 ToolCalls,按片段索引累积。
|
||
type openAIDelta struct {
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
ReasoningContent string `json:"reasoning_content"`
|
||
ToolCalls []openAIToolCallDelta `json:"tool_calls"`
|
||
}
|
||
|
||
// openAIToolCallDelta 是一个流式函数调用片段。OpenAI 会发出一串
|
||
// 共享同一 Index 的此类片段:ID 与 Function.Name 通常
|
||
// 只在第一个片段里出现一次,而 Function.Arguments 跨
|
||
// 多个片段流式到达。
|
||
type openAIToolCallDelta struct {
|
||
Index int `json:"index"`
|
||
ID string `json:"id"`
|
||
Function openAIToolCallFn `json:"function"`
|
||
}
|
||
|
||
// openAIUsage 上报 token 消耗。缓存两档(prompt_cache_hit_tokens /
|
||
// prompt_cache_miss_tokens)由 DeepSeek 上报;OpenAI 兼容家族里另有
|
||
// prompt_tokens_details.cached_tokens 表达同一件事,两者都解析、缺一个就用
|
||
// 另一个(见 applyOpenAIUsage)。
|
||
type openAIUsage struct {
|
||
PromptTokens int `json:"prompt_tokens"`
|
||
CompletionTokens int `json:"completion_tokens"`
|
||
TotalTokens int `json:"total_tokens"`
|
||
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
|
||
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
|
||
PromptTokensDetails openAIPromptTokenDetails `json:"prompt_tokens_details"`
|
||
CompletionTokenDetails openAICompletionTokenDetails `json:"completion_tokens_details"`
|
||
}
|
||
|
||
// openAIPromptTokenDetails 是输入侧明细(OpenAI / DashScope 形状)。
|
||
type openAIPromptTokenDetails struct {
|
||
// CachedTokens 是命中上下文缓存的 token 数,与 prompt_cache_hit_tokens 同义。
|
||
CachedTokens int `json:"cached_tokens"`
|
||
}
|
||
|
||
// openAICompletionTokenDetails 是完成侧明细:推理模型把思维链 token 报在这里。
|
||
type openAICompletionTokenDetails struct {
|
||
// ReasoningTokens 是思维链 token 数(design/04 附录 A.3 第 4 条要观测它)。
|
||
ReasoningTokens int `json:"reasoning_tokens"`
|
||
}
|
||
|
||
// applyOpenAIUsage 把一个 usage 对象折进 Response。它容忍任何字段缺失:
|
||
// 缺失即零值,绝不 panic,也绝不因为用量字段的形态问题影响正文解析。
|
||
//
|
||
// 未命中的补齐用的是提供商自己的不变式「prompt_tokens = hit + miss」(见
|
||
// DeepSeek 文档)—— 只在提供商没给 miss 时才补,免得把命中率算成 100%。
|
||
func applyOpenAIUsage(out *Response, u *openAIUsage) {
|
||
if u == nil {
|
||
return
|
||
}
|
||
out.PromptTokens = u.PromptTokens
|
||
out.CompletionTokens = u.CompletionTokens
|
||
out.TotalTokens = u.TotalTokens
|
||
out.CacheHitTokens = u.PromptCacheHitTokens
|
||
if out.CacheHitTokens == 0 {
|
||
out.CacheHitTokens = u.PromptTokensDetails.CachedTokens
|
||
}
|
||
out.CacheMissTokens = u.PromptCacheMissTokens
|
||
if out.CacheMissTokens == 0 && out.PromptTokens > out.CacheHitTokens {
|
||
out.CacheMissTokens = out.PromptTokens - out.CacheHitTokens
|
||
}
|
||
out.ReasoningTokens = u.CompletionTokenDetails.ReasoningTokens
|
||
}
|
||
|
||
func responseFromWire(wire *openAIChatResponse) Response {
|
||
out := Response{Model: wire.Model}
|
||
if len(wire.Choices) > 0 {
|
||
text := wire.Choices[0].Message.Content
|
||
out.Content = text
|
||
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: text}}}
|
||
out.StopReason = wire.Choices[0].FinishReason
|
||
for _, tc := range wire.Choices[0].Message.ToolCalls {
|
||
args := map[string]any{}
|
||
if tc.Function.Arguments != "" {
|
||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||
}
|
||
out.ToolCalls = append(out.ToolCalls, ToolCall{Name: tc.Function.Name, ID: tc.ID, Args: args})
|
||
}
|
||
}
|
||
applyOpenAIUsage(&out, wire.Usage)
|
||
return out
|
||
}
|