Files
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

118 lines
4.8 KiB
Go
Raw Permalink 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 (
"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
}