@thincoder/core 0.10.12 → 0.10.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,14 @@
5
5
  */
6
6
 
7
7
  import { proxyFetch } from "../proxy.mjs"
8
+ import { withUserAgent } from "../client-identity.mjs"
8
9
  import { requestWithRetry } from "./retry.mjs"
9
10
  import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
10
11
  import { abortError, timeoutError } from "../abort-provenance.mjs"
11
12
  import { terminateBody } from "../stream-destroy.mjs"
12
13
  import { requestBudget } from "../config.mjs"
13
14
  import { DEFAULT_BODY_IDLE_MS } from "../config-defaults.mjs"
15
+ import { assertPromptFits } from "./errors.mjs"
14
16
 
15
17
  /** OpenAI 语义 tool_choice → Gemini FunctionCallingConfig(2026-08-31 能力层)。 */
16
18
  function mapFunctionCallingConfig(choice) {
@@ -77,7 +79,7 @@ export function convertMessages(messages) {
77
79
 
78
80
  /** Build and send a Gemini chat request. Returns the same shape as core.mjs chat.
79
81
  * 2026-08-31 会诊 #6:接入 rateGate/recordRate(原实现完全绕过 TPM/RPM 闸门)。 */
80
- export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice }) {
82
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, logCtx }) {
81
83
  const systemMessages = messages.filter((m) => m.role === "system")
82
84
  const contents = convertMessages(messages)
83
85
 
@@ -118,7 +120,10 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
118
120
 
119
121
  // 会诊 #6:TPM/RPM 闸门 + 记账
120
122
  const { rateGate, recordRate, estimateRequestTokens } = await import("./rate.mjs")
121
- const estimated = estimateRequestTokens({ messages })
123
+ // §6.17 发送面净判:估准面补 `tools`(原只投影 messages);body 用 `maxOutputTokens`、无 `max_tokens` 键 ⇒ 减项恒 0
124
+ const estimated = estimateRequestTokens({ messages, tools })
125
+ const estPrompt = estimated - (body.max_tokens ?? 0)
126
+ assertPromptFits(provider, estPrompt, logCtx)
122
127
  await rateGate(provider, estimated, onWait, signal)
123
128
 
124
129
  // 2026-08-31:5xx/网络与 OpenAI 格式统一退避重试链(原完全无重试——Gemini 高峰
@@ -126,7 +131,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
126
131
  const response = await requestWithRetry(
127
132
  () => proxyFetch(url, {
128
133
  method: "POST",
129
- headers: { ...(provider.headers ?? {}), "Content-Type": "application/json" }, // 定制头展开(PROVIDER.md §21)——定制头在前、内置头在后:内置头胜出
134
+ headers: { ...withUserAgent(provider.headers), "Content-Type": "application/json" }, // 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17);内置头在后故同名胜出
130
135
  body: JSON.stringify(body),
131
136
  // 2026-09-01:同 core.mjs——绝对墙钟废除;响应头阶段 fetchTimeoutMs(600s 默认),body 阶段读侧 idle 管
132
137
  signal,
@@ -145,6 +150,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
145
150
  content: result.content,
146
151
  reasoning: result.reasoning,
147
152
  finishReason: result.finishReason ?? null, // §6.31.7 F-DA5(原三 transport 恒 null——字段值收正)
153
+ estimated: estPrompt, // §6.17 自校准采样(den 与净判同口径)
148
154
  usage: {
149
155
  prompt_tokens: usage.prompt_tokens ?? 0,
150
156
  completion_tokens: usage.completion_tokens ?? 0,
@@ -154,7 +160,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
154
160
  }
155
161
  }
156
162
 
157
- return { content: result.content, reasoning: result.reasoning, finishReason: result.finishReason ?? null, toolCalls: result.toolCalls }
163
+ return { content: result.content, reasoning: result.reasoning, finishReason: result.finishReason ?? null, estimated: estPrompt, toolCalls: result.toolCalls }
158
164
  }
159
165
 
160
166
  /** Gemini `finishReason` ⇒ 核统一 `finishReason`(§6.31.7 F-DA5——归一只喂既有分类;
@@ -14,6 +14,7 @@
14
14
  * 解析保持防御性(字段缺失即跳过该项)。候选不过滤非对话模型(embedding 等——§16.6 #14)。
15
15
  */
16
16
  import { proxyFetch } from "../proxy.mjs"
17
+ import { withUserAgent } from "../client-identity.mjs"
17
18
  import { resolveProviderSecrets } from "../env-ref.mjs"
18
19
  import { positiveInt, recordListedContext } from "../model-context.mjs"
19
20
 
@@ -74,7 +75,7 @@ function listedWindowOf(row) {
74
75
 
75
76
  async function listOpenai(provider, signal) {
76
77
  const url = `${provider.baseURL}/models`
77
- const data = await fetchJson(provider, url, { ...(provider.headers ?? {}), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
78
+ const data = await fetchJson(provider, url, { ...withUserAgent(provider.headers), Authorization: `Bearer ${provider.apiKey ?? ""}` }, signal)
78
79
  // L3 写面的收集段(PROVIDER.md §6.15):解析 `data[]` 时顺带收 `id → 窗口`——三字段按序取
79
80
  // 第一个**合法**值(R25 ∥ R26),非法相当于该字段缺席;三字段都不可得 = 该模型列表未报(不进表)。
80
81
  const ids = []
@@ -92,7 +93,7 @@ async function listOpenai(provider, signal) {
92
93
  }
93
94
 
94
95
  async function listAnthropic(provider, signal) {
95
- const headers = { ...(provider.headers ?? {}), "x-api-key": provider.apiKey ?? "", "anthropic-version": ANTHROPIC_VERSION }
96
+ const headers = { ...withUserAgent(provider.headers), "x-api-key": provider.apiKey ?? "", "anthropic-version": ANTHROPIC_VERSION }
96
97
  const base = `${provider.baseURL}/models?limit=1000`
97
98
  const out = []
98
99
  let afterId = null
@@ -108,7 +109,7 @@ async function listAnthropic(provider, signal) {
108
109
  }
109
110
 
110
111
  async function listGoogle(provider, signal) {
111
- const headers = { ...(provider.headers ?? {}) }
112
+ const headers = { ...withUserAgent(provider.headers) }
112
113
  const base = `${provider.baseURL}/models?key=${encodeURIComponent(provider.apiKey ?? "")}&pageSize=1000`
113
114
  const out = []
114
115
  let pageToken = null
@@ -13,10 +13,11 @@
13
13
  */
14
14
  import { effectiveFetchTimeoutMs, effectiveBodyIdleMs } from "./core.mjs"
15
15
  import { proxyFetch } from "../proxy.mjs"
16
+ import { withUserAgent } from "../client-identity.mjs"
16
17
  import { requestWithRetry } from "./retry.mjs"
17
18
  import { rateGate, recordRate, estimateRequestTokens } from "./rate.mjs"
18
19
  import { buildBody, normalizeUsage } from "./responses-request.mjs"
19
- import { classifyResponsesErrorCode } from "./errors.mjs"
20
+ import { classifyResponsesErrorCode, assertPromptFits } from "./errors.mjs"
20
21
  // 同名 re-export(2026-09-29 structure-split-2 · 台账 #620——缝 = 同名再出口):请求构造面出档 `provider/responses-request.mjs`;
21
22
  // `buildBody` / `isStoreRequiredHost` / `builtinToolsFor` 三名既有导出经本档转口——消费者 import 面零改。
22
23
  export { buildBody, isStoreRequiredHost, builtinToolsFor } from "./responses-request.mjs"
@@ -223,15 +224,18 @@ function handleEvent(ev, h) {
223
224
  }
224
225
 
225
226
  /** 主入口:请求 + 链状态推进(与 core.mjs 的 chat 同形返回)。 */
226
- export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, stateful }) {
227
+ export async function chat(provider, { messages, tools, onToken, onReasoning, onWait, signal, toolChoice, stateful, logCtx }) {
227
228
  const { body: reqBody, previousResponseId, warnings, newChain } = buildBody(provider, messages, tools, { toolChoice, stateful })
228
229
  const body = { ...reqBody, ...(previousResponseId ? { previous_response_id: previousResponseId } : {}) }
229
230
 
230
231
  // rateGate/recordRate 对齐 core(responses body 无 messages 键——按本地全量 messages 估算)
231
- const estimated = estimateRequestTokens({ messages })
232
+ // §6.17 发送面净判:估准面补 `tools`(原只投影 messages);body 用 `max_output_tokens`、无 `max_tokens` 键 ⇒ 减项恒 0
233
+ const estimated = estimateRequestTokens({ messages, tools })
234
+ const estPrompt = estimated - (body.max_tokens ?? 0)
235
+ assertPromptFits(provider, estPrompt, logCtx)
232
236
  await rateGate(provider, estimated, onWait, signal)
233
- // 定制头展开(PROVIDER.md §21):provider.headers 在前、内置头在后——同名内置头胜出(三处 fetch 共用)
234
- const headers = { ...(provider.headers ?? {}), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }
237
+ // 定制头 + 产品标识头(UA 不可被渠道覆盖——PROVIDER.md §6.17):定制头在前、内置头在后——同名内置头胜出(三处 fetch 共用)
238
+ const headers = { ...withUserAgent(provider.headers), "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` }
235
239
 
236
240
  // round2 复验 #1(2026-08-31):retry 层对 4xx 非可重试是 throw 而非返回——
237
241
  // requestWithRetry 从不返回 400/404 响应 → 下方 isChainInvalidError 分支原来不可达(D6 死代码)。
@@ -287,11 +291,17 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
287
291
  { signal, onWait, buildMessage: (status, text) => `Responses API error ${status}: ${text}` },
288
292
  )
289
293
  }
290
- return finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated })
294
+ return finish(provider, response, {
295
+ onToken, onReasoning, signal, newChain, warnings, estimated,
296
+ // §6.17 自校准口径:链式(`previous_response_id`)请求的 body 只带增量(`responses-request.mjs`
297
+ // `body.input = newOutputs`),而分母是本地全量估算 ⇒ 两者口径不同,本轮不采样。
298
+ estPrompt: previousResponseId ? undefined : estPrompt,
299
+ })
291
300
  }
292
301
 
293
- async function finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated }) {
302
+ async function finish(provider, response, { onToken, onReasoning, signal, newChain, warnings, estimated, estPrompt }) {
294
303
  const result = await parseStream(response, { onToken, onReasoning, signal })
304
+ result.estimated = estPrompt // §6.17 自校准采样(den 与净判同口径)
295
305
  recordRate(provider, estimated, result.usage)
296
306
  // 链状态推进:completed 事件里 response.id 供同一 turn 后续增量;
297
307
  // 截断/失败/无 id(部分端点不回传)→ 链作废(后续全量,正确性优先)。
@@ -322,6 +322,9 @@ export function resetSessionState(agent) {
322
322
  agent._runStartHistoryLen = 0
323
323
  agent._lastPromptTokens = null
324
324
  agent._usageAtLen = null
325
+ // §6.17 估算自校准:新会话无样本(比 = 1)——跨会话不继承旧模型的估算偏差
326
+ agent._tok = null
327
+ agent._tokRatio = 1
325
328
  agent.planMode = false
326
329
  agent.goal = null
327
330
  agent._pendingReminders = []
package/token-window.mjs CHANGED
@@ -138,11 +138,13 @@ export function contextUsage(agent, extras = {}) {
138
138
  const overhead = system + tools
139
139
  const historyTokens = estimateTokens(history)
140
140
  const measured = agent?._lastPromptTokens != null
141
+ // §6.3 / §6.17 估算自校准:估算值乘 `_tokRatio`;`_lastPromptTokens` 是服务端真值,不乘。
142
+ const ratio = agent?._tokRatio ?? 1
141
143
  return {
142
144
  basis: measured ? "measured" : "estimated",
143
145
  total: measured
144
- ? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
145
- : historyTokens + overhead,
146
+ ? agent._lastPromptTokens + Math.round(estimateTokens(history.slice(agent._usageAtLen ?? history.length)) * ratio)
147
+ : Math.round((historyTokens + overhead) * ratio),
146
148
  history: historyTokens,
147
149
  system,
148
150
  tools,
@@ -153,6 +155,25 @@ export function contextUsage(agent, extras = {}) {
153
155
  }
154
156
  }
155
157
 
158
+ /**
159
+ * 估算自校准采样(§6.17「估算自校准」)——池化比:`num += usage.prompt_tokens`、`den += estPrompt`
160
+ * (分子分母同取输入侧口径;`estPrompt` 由发送面随 result 带回,与净判同一个值)。
161
+ * `key = format:model` 变 ⇒ 归零重采(切模型自清,不接四个刷新点);`_tok` / `_tokRatio` 只由本函数写,
162
+ * `/new` 与 run 装配复位。无样本 ⇒ 比 = 1(估算维持原口径)。
163
+ * 作用面 = `contextUsage` 两支承(§6.3);实测基线 `_lastPromptTokens` 不乘。
164
+ */
165
+ export function recordTokenSample(agent, provider, estPrompt, usage) {
166
+ const prompt = Number(usage?.prompt_tokens)
167
+ if (!agent || !(prompt > 0) || !(estPrompt > 0)) return
168
+ const key = `${provider?.format ?? "openai"}:${provider?.model ?? ""}`
169
+ if (agent._tok?.key !== key) agent._tok = { key, num: 0, den: 0 }
170
+ agent._tok.num += prompt
171
+ agent._tok.den += estPrompt
172
+ // 下界 = 1:已知偏差方向是估算偏低;比 < 1 会把全部估算支缩小、重开「该压没压」原始故障,
173
+ // 保守方向不取(仅一侧校准,零新魔数)。
174
+ agent._tokRatio = Math.max(1, agent._tok.num / agent._tok.den)
175
+ }
176
+
156
177
  /**
157
178
  * 状态行百分比(§6.16.4「与状态行 ctx% 的单源关系」)——与 CLI 状态行(`render-frame.mjs` 式)与 VSC `ctxPercentForHistory` **同式**;
158
179
  * stats 报出的就是用户看到的那个百分比(不新增第三口径);空历史 ⇒ 0(状态行的 `ctxPct > 0` 显示门在端侧)。