thincoder 0.12.3 → 0.12.4
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.
- package/README.md +11 -1
- package/package.json +3 -3
- package/src/agent/completion.mjs +22 -7
- package/src/agent/setup.mjs +2 -1
- package/src/agent.mjs +44 -13
- package/src/config.mjs +13 -1
- package/src/context.mjs +47 -13
- package/src/generate-title.mjs +44 -0
- package/src/prompts/system.md +1 -1
- package/src/provider/core.mjs +58 -2
- package/src/session.mjs +90 -74
- package/src/tools/checklist.mjs +4 -3
- package/src/tools/codemode.mjs +13 -7
- package/src/tui/agent-turn.mjs +15 -0
- package/src/tui/ansi.mjs +4 -0
- package/src/tui/clipboard.mjs +9 -0
- package/src/tui/cmd-config.mjs +2 -26
- package/src/tui/cmd-session.mjs +2 -2
- package/src/tui/index.mjs +13 -3
- package/src/tui/key-handler.mjs +54 -14
- package/src/tui/markdown.mjs +52 -0
- package/src/tui/pickers.mjs +6 -1
- package/src/tui/render-conversation.mjs +5 -2
- package/src/tui/render-frame.mjs +2 -1
- package/src/tui/render.mjs +13 -7
package/README.md
CHANGED
|
@@ -89,6 +89,8 @@ Slash commands in the TUI: `/help`, `/model` (two-level picker: first select pro
|
|
|
89
89
|
|
|
90
90
|
Environment variables: `THINCODER_API_KEY` (or `DEEPSEEK_API_KEY` / `OPENAI_API_KEY`), `THINCODER_BASE_URL`, `THINCODER_MODEL`, `SILICONFLOW_API_KEY`.
|
|
91
91
|
|
|
92
|
+
> **Kimi note**: Kimi has **two separate platforms with non-interchangeable API keys** — Moonshot (`https://api.moonshot.cn/v1`, keys `sk-...`, platform.moonshot.cn) and **Kimi For Coding** (`https://api.kimi.com/coding/v1`, keys `sk-kimi-...`, platform.kimi.com, model ID `k3`). Use the `kimi` preset for Moonshot and `kimi-code` for Kimi For Coding — putting one platform's key on the other's endpoint fails with 401 (a hint is appended when the key/baseURL look mismatched).
|
|
93
|
+
|
|
92
94
|
## Configuration
|
|
93
95
|
|
|
94
96
|
`~/.thincoder/config.json`:
|
|
@@ -171,7 +173,8 @@ src/
|
|
|
171
173
|
distill.mjs session knowledge extraction (candidates + human confirmation)
|
|
172
174
|
config.mjs config loading
|
|
173
175
|
tui/ bare-ANSI terminal UI — index.mjs (startTUI), render.mjs (drawing primitives),
|
|
174
|
-
render-frame.mjs (frame layout),
|
|
176
|
+
render-frame.mjs (frame layout), render-conversation.mjs (conversation panel),
|
|
177
|
+
markdown.mjs (lightweight inline markdown → ANSI), ansi.mjs
|
|
175
178
|
tui.mjs re-export shim → src/tui/index.mjs
|
|
176
179
|
tui-render.mjs re-export shim → src/tui/render.mjs
|
|
177
180
|
prompts/ prompt texts — system.md (core), discipline.md (coding/testing rules),
|
|
@@ -205,6 +208,13 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
|
|
|
205
208
|
|
|
206
209
|
## Changelog
|
|
207
210
|
|
|
211
|
+
### 0.12.4 (2026-08)
|
|
212
|
+
- **Context compaction unified spec** — CLI/VS Code now share one compaction semantics (`docs/design/CONTEXT-COMPACTION.md`): window-adaptive tail size (`max(10, ctx/100K×30)`, ≤40% of history), measured prompt-token baseline preferred, pure-estimation path includes system+tools overhead, head/tail tool-call pairing protection on both sides, 3-tier fallback (LLM summary → deterministic truncation after 3 failures → per-message shrink), compaction summaries are silent to the frontend (no streaming into the conversation), task re-injection deduplicated. Unknown model names now warn once instead of silently degrading to the 128K default
|
|
213
|
+
- **Kimi For Coding support** — new `kimi-code` preset (`api.kimi.com/coding/v1`, model `k3`, platform.kimi.com, `sk-kimi-` keys — NOT interchangeable with Moonshot); `MODEL_SPECS` gains the `k3` alias (1M context / multimodal / partialMode / reasoningEcho); 401 errors hint at the two-platform key mismatch; README documents the split
|
|
214
|
+
- **Ctrl+C double-confirm** — idle-state first Ctrl+C only warns (3s window), second press exits; picker-cancel and in-flight abort semantics unchanged
|
|
215
|
+
- **Empty-response auto-retry** — a transient empty LLM response (reasoning exhausted / truncated output) injects a retry reminder instead of aborting the whole turn; after 2 consecutive empties the original error surfaces (with the `/think` lowering hint)
|
|
216
|
+
- **Lightweight inline markdown display** — model replies render `**bold**`, `` `code` `` (reverse video), `~~strike~~`, and `# headings` (marker-stripped + bold) via ANSI instead of showing literal markers; code spans are not re-interpreted; streaming-safe on unclosed markers; copied text comes out clean
|
|
217
|
+
|
|
208
218
|
### 0.12.3 (2026-08)
|
|
209
219
|
- **Fix: user-level skills loading** — skills in `~/.thincoder/skills/` are now properly discovered and loaded alongside project-level skills. Project-level skills with the same name take priority. Both `skill list` and `skill load` support both directories.
|
|
210
220
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thincoder",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.4",
|
|
4
4
|
"description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"url": "https://gitee.com/shanghai-xinbo/thincoder.git"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
|
-
"test": "node --test",
|
|
34
|
-
"prepublishOnly": "node --test"
|
|
33
|
+
"test": "node --test \"test/*.mjs\"",
|
|
34
|
+
"prepublishOnly": "node --test \"test/*.mjs\""
|
|
35
35
|
},
|
|
36
36
|
"author": "liwei <liwei@51marine.com> (上海新舶)",
|
|
37
37
|
"license": "MIT"
|
package/src/agent/completion.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Returns { action: 'continue' | 'done', content?, guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
6
6
|
*/
|
|
7
7
|
import { isDocFile } from "../advisor/repos.mjs"
|
|
8
|
+
import { pushReal } from "../context.mjs"
|
|
8
9
|
|
|
9
10
|
/** True when this run mutated at least one CODE file. Mirrors agent.mjs:hasCodeMutations. */
|
|
10
11
|
function hasCodeMutations(agent) {
|
|
@@ -16,6 +17,7 @@ function hasCodeMutations(agent) {
|
|
|
16
17
|
const MAX_VERIFY_PUSHBACKS = 2
|
|
17
18
|
const MAX_VERIFY_RETRIES = 3
|
|
18
19
|
const MAX_ADVISOR_PUSHBACKS = 3
|
|
20
|
+
const MAX_EMPTY_RETRIES = 2
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Handle a model turn with zero tool calls. May push back (verify/advisor/pending tasks)
|
|
@@ -32,6 +34,19 @@ const MAX_ADVISOR_PUSHBACKS = 3
|
|
|
32
34
|
*/
|
|
33
35
|
export function handleCompletion(agent, response, depth, turn, guardPushbacks, honestReminderInjected, advisorPushbacks, callbacks) {
|
|
34
36
|
if (!response.content) {
|
|
37
|
+
// Transient empty response (reasoning exhausted / output truncated): instead of
|
|
38
|
+
// aborting the whole turn, inject a reminder and let the model respond again.
|
|
39
|
+
// Bounded — after MAX_EMPTY_RETRIES consecutive empties, surface the original error.
|
|
40
|
+
const retries = agent._emptyRetries ?? 0
|
|
41
|
+
if (retries < MAX_EMPTY_RETRIES) {
|
|
42
|
+
agent._emptyRetries = retries + 1
|
|
43
|
+
agent.history.push({
|
|
44
|
+
role: "user",
|
|
45
|
+
content: "[System reminder: your last response was empty — the provider returned no content (likely reasoning was exhausted or output was truncated). Respond again, continuing your work from where you left off.]",
|
|
46
|
+
})
|
|
47
|
+
callbacks.onTurnEnd?.(agent, turn)
|
|
48
|
+
return { action: "continue", guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
49
|
+
}
|
|
35
50
|
throw new Error(
|
|
36
51
|
"LLM returned empty response (likely reasoning exhausted or output truncated). " +
|
|
37
52
|
"Try lowering reasoning effort if this persists (/think in TUI). " +
|
|
@@ -42,7 +57,7 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
|
|
|
42
57
|
// Pending tasks: remind the model before it declares itself done
|
|
43
58
|
if (depth === 0 && agent.tasks.some((t) => t.status === "pending")) {
|
|
44
59
|
const pending = agent.tasks.filter((t) => t.status === "pending").map((t) => t.title).join(", ")
|
|
45
|
-
agent
|
|
60
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
46
61
|
agent.history.push({
|
|
47
62
|
role: "user",
|
|
48
63
|
content: `[System reminder: you still have pending tasks: ${pending}. Update their status with the task tool before finishing — if they're done, mark them done; if they're not applicable, remove them.]`,
|
|
@@ -60,7 +75,7 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
|
|
|
60
75
|
// Not verified yet → pushback to run verify
|
|
61
76
|
if (agent._mutatedThisRun && !agent._verifiedThisRun && hasCodeMutations(agent) && guardPushbacks < MAX_VERIFY_PUSHBACKS) {
|
|
62
77
|
guardPushbacks++
|
|
63
|
-
agent
|
|
78
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
64
79
|
agent.history.push({
|
|
65
80
|
role: "user",
|
|
66
81
|
content: "[System reminder: you modified files in this run but have not verified the changes. Before finishing: call the verify tool to run syntax checks and tests. If verify reports failures, fix them and run verify again. If verification is genuinely impossible here, say so explicitly in your reply.]",
|
|
@@ -71,7 +86,7 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
|
|
|
71
86
|
// Verified but still failing → pushback to fix (up to MAX_VERIFY_RETRIES)
|
|
72
87
|
if (agent._verifiedThisRun && agent._verifyPassed === false && agent._verifyRetries < MAX_VERIFY_RETRIES) {
|
|
73
88
|
agent._verifyRetries++
|
|
74
|
-
agent
|
|
89
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
75
90
|
agent.history.push({
|
|
76
91
|
role: "user",
|
|
77
92
|
content: `[System reminder: verify reported test failures (retry ${agent._verifyRetries}/${MAX_VERIFY_RETRIES}). Review the failures, fix the issues, then run verify again. If you cannot fix after ${MAX_VERIFY_RETRIES} attempts, explain honestly what's blocking you.]`,
|
|
@@ -82,11 +97,11 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
|
|
|
82
97
|
// Exhausted retries — inject honesty reminder once
|
|
83
98
|
if (agent._verifiedThisRun && agent._verifyPassed === false && agent._verifyRetries >= MAX_VERIFY_RETRIES) {
|
|
84
99
|
if (honestReminderInjected) {
|
|
85
|
-
agent
|
|
100
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
86
101
|
return { action: "done", content: response.content, guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
87
102
|
}
|
|
88
103
|
honestReminderInjected = true
|
|
89
|
-
agent
|
|
104
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
90
105
|
agent.history.push({
|
|
91
106
|
role: "user",
|
|
92
107
|
content: `[System reminder: ${MAX_VERIFY_RETRIES} verify attempts exhausted and tests are still failing. In your response to the user, you MUST state explicitly: (1) what tests are still failing, (2) what you tried, (3) what you believe the root cause is. Do not present this as complete — the user needs to know the work is unfinished.]`,
|
|
@@ -104,7 +119,7 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
|
|
|
104
119
|
if (agent._mutatedThisRun && !agent._calledAdvisorThisRun && hasCodeMutations(agent)
|
|
105
120
|
&& advisorPushbacks < MAX_ADVISOR_PUSHBACKS) {
|
|
106
121
|
advisorPushbacks++
|
|
107
|
-
agent
|
|
122
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
108
123
|
agent.history.push({
|
|
109
124
|
role: "user",
|
|
110
125
|
content: `[System reminder: you changed code in this run and MUST get an advisor review before finishing (round ${agent._advisorRound + 1}). Call the \`advisor\` tool now. This is required, not optional — do not skip it even if you believe the changes are trivial — the review will be quick either way. After the review, produce a response table for every issue found (see discipline rules for format).]`,
|
|
@@ -114,6 +129,6 @@ export function handleCompletion(agent, response, depth, turn, guardPushbacks, h
|
|
|
114
129
|
}
|
|
115
130
|
}
|
|
116
131
|
|
|
117
|
-
agent
|
|
132
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
118
133
|
return { action: "done", content: response.content, guardPushbacks, honestReminderInjected, advisorPushbacks }
|
|
119
134
|
}
|
package/src/agent/setup.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* agent/setup.mjs — runAgent pre-flight setup: context injection, system prompt construction, tool injection
|
|
3
3
|
*/
|
|
4
4
|
import { search as memorySearch, docSearch } from "../memory.mjs"
|
|
5
|
+
import { pushReal } from "../context.mjs"
|
|
5
6
|
import { toOpenAISchema } from "../tools/index.mjs"
|
|
6
7
|
import { loadSkills, formatSkillListing } from "../skills.mjs"
|
|
7
8
|
import {
|
|
@@ -137,7 +138,7 @@ export async function prepareRun(agent, input, callbacks, {
|
|
|
137
138
|
}
|
|
138
139
|
} catch { /* checklist not available — suppress error */ }
|
|
139
140
|
}
|
|
140
|
-
agent
|
|
141
|
+
pushReal(agent, { role: "user", content: input })
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
if (agent._pendingReminders.length > 0) {
|
package/src/agent.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* LLM ↔ tool-call loop, until the task is done.
|
|
4
4
|
*/
|
|
5
5
|
import { chat } from "./provider/index.mjs"
|
|
6
|
-
import {
|
|
6
|
+
import { estimateText } from "./provider/rate.mjs"
|
|
7
|
+
import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT, pushReal } from "./context.mjs"
|
|
7
8
|
import { specForModel } from "./config.mjs"
|
|
8
9
|
import { readFileSync } from "node:fs"
|
|
9
10
|
import { join, dirname } from "node:path"
|
|
@@ -131,6 +132,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
131
132
|
agent._advisorRound = 0
|
|
132
133
|
agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
|
|
133
134
|
agent._advisorLastSnapshotHash = null // dedup baseline is per-run too — stale snapshot could wrongly suppress a diff refresh
|
|
135
|
+
agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
|
|
136
|
+
agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
|
|
134
137
|
}
|
|
135
138
|
// eng-coder authorization is set by subagent.mjs AFTER token validation but BEFORE runAgent —
|
|
136
139
|
// only reset for the top-level agent (depth 0); child runs must keep their granted authorization
|
|
@@ -145,6 +148,16 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
145
148
|
// this set survives across chat() calls (rule abort-retry, tool loop) within the turn.
|
|
146
149
|
const streamRuleFired = new Set()
|
|
147
150
|
|
|
151
|
+
// Compaction overhead for the pure-estimation path: system prompt + tools schema are
|
|
152
|
+
// part of every request but not in history — without them the first-turn/restored/just-
|
|
153
|
+
// compacted estimate under-counts and may never trigger compaction. Measured baseline
|
|
154
|
+
// path already includes both (prompt_tokens is the full context), so this only applies
|
|
155
|
+
// when _lastPromptTokens is null.
|
|
156
|
+
const compactionOverhead = {
|
|
157
|
+
systemPrompt,
|
|
158
|
+
tools: toolSchemas,
|
|
159
|
+
}
|
|
160
|
+
|
|
148
161
|
for (let turn = 0; turn < maxTurns; turn++) {
|
|
149
162
|
// Update turn counter for status bar display
|
|
150
163
|
agent._currentTurn = turn + 1
|
|
@@ -153,7 +166,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
153
166
|
const lastRole = agent.history.at(-1)?.role
|
|
154
167
|
if (lastRole === "user" || lastRole === "tool") {
|
|
155
168
|
try {
|
|
156
|
-
if (await compressIfNeeded(agent, threshold, callbacks)) {
|
|
169
|
+
if (await compressIfNeeded(agent, threshold, callbacks, compactionOverhead)) {
|
|
157
170
|
agent._compressFailures = 0
|
|
158
171
|
agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
|
|
159
172
|
recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
|
|
@@ -236,7 +249,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
236
249
|
// inject rule's message as a reminder, and retry from the same context.
|
|
237
250
|
if (response.ruleTriggered) {
|
|
238
251
|
if (response.content) {
|
|
239
|
-
agent
|
|
252
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
240
253
|
}
|
|
241
254
|
const label = response.ruleName ? ` — stream rule "${response.ruleName}"` : ""
|
|
242
255
|
agent.history.push({
|
|
@@ -262,7 +275,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
262
275
|
// the outer loop to recreate the controller and resume.
|
|
263
276
|
if (response.interrupted) {
|
|
264
277
|
if (response.content) {
|
|
265
|
-
agent
|
|
278
|
+
pushReal(agent, { role: "assistant", content: response.content })
|
|
266
279
|
}
|
|
267
280
|
agent.history.push({
|
|
268
281
|
role: "user",
|
|
@@ -306,7 +319,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
306
319
|
// abort after chat completes, before committing history: don't commit a half-finished turn
|
|
307
320
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError")
|
|
308
321
|
|
|
309
|
-
agent
|
|
322
|
+
pushReal(agent, {
|
|
310
323
|
role: "assistant",
|
|
311
324
|
content: response.content || null,
|
|
312
325
|
tool_calls: response.toolCalls.map((tc) => ({
|
|
@@ -335,6 +348,12 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
335
348
|
guardPushbacks = 0
|
|
336
349
|
advisorPushbacks = 0
|
|
337
350
|
|
|
351
|
+
// Multimodal user messages (injected images / not-injected reminders) must NOT be pushed
|
|
352
|
+
// between tool results of parallel calls — strict providers (DeepSeek) 400 when a tool
|
|
353
|
+
// message does not immediately follow its assistant tool_calls. Defer to after the loop.
|
|
354
|
+
// real: image injections are real messages (pushReal → _fullHistory); reminders stay machine-only.
|
|
355
|
+
const deferredUserMsgs = []
|
|
356
|
+
|
|
338
357
|
for (const { toolCall, result, ok } of results) {
|
|
339
358
|
const tool = toolByName.get(toolCall.name)
|
|
340
359
|
// Multimodal tools return JSON { text, images } — inject as multimodal user message
|
|
@@ -343,26 +362,32 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
343
362
|
const parsed = JSON.parse(result)
|
|
344
363
|
if (parsed.images?.length) {
|
|
345
364
|
// tool message first — closes the tool_call pairing (OpenAI API requires tool result immediately after assistant with tool_calls)
|
|
346
|
-
agent
|
|
365
|
+
pushReal(agent, { role: "tool", tool_call_id: toolCall.id, content: parsed.text })
|
|
347
366
|
if (specForModel(agent.provider.model).multimodal) {
|
|
348
367
|
// then inject multimodal user message with base64 images for the model to actually "see" them on the next turn
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
368
|
+
deferredUserMsgs.push({
|
|
369
|
+
real: true,
|
|
370
|
+
msg: {
|
|
371
|
+
role: "user",
|
|
372
|
+
content: [{ type: "text", text: parsed.text }, ...parsed.images],
|
|
373
|
+
},
|
|
352
374
|
})
|
|
353
375
|
} else {
|
|
354
376
|
// Non-vision model: image parts must never enter history — text-only APIs 400 on them on EVERY
|
|
355
377
|
// subsequent request, poisoning the conversation. (read_image itself already refuses; this is defense-in-depth.)
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
378
|
+
deferredUserMsgs.push({
|
|
379
|
+
real: false,
|
|
380
|
+
msg: {
|
|
381
|
+
role: "user",
|
|
382
|
+
content: `[System reminder: the image returned by ${toolCall.name} was NOT injected — model ${agent.provider.model} does not support image input. Do not call ${toolCall.name} again under this provider; verify visual output programmatically instead.]`,
|
|
383
|
+
},
|
|
359
384
|
})
|
|
360
385
|
}
|
|
361
386
|
continue
|
|
362
387
|
}
|
|
363
388
|
} catch { /* Parse failure doesn't affect normal tool messages */ }
|
|
364
389
|
}
|
|
365
|
-
agent
|
|
390
|
+
pushReal(agent, { role: "tool", tool_call_id: toolCall.id, content: result })
|
|
366
391
|
if (tool && ok) {
|
|
367
392
|
if (FILE_MUTATORS.has(toolCall.name)) {
|
|
368
393
|
// Direct file edit — code was changed.
|
|
@@ -415,6 +440,12 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
|
|
|
415
440
|
}
|
|
416
441
|
}
|
|
417
442
|
|
|
443
|
+
// All tool results committed — now safe to inject deferred multimodal user messages
|
|
444
|
+
for (const { real, msg } of deferredUserMsgs) {
|
|
445
|
+
if (real) pushReal(agent, msg)
|
|
446
|
+
else agent.history.push(msg)
|
|
447
|
+
}
|
|
448
|
+
|
|
418
449
|
injectPostTurn(agent, results, recentCallSigs, callbacks, turn)
|
|
419
450
|
}
|
|
420
451
|
|
package/src/config.mjs
CHANGED
|
@@ -16,8 +16,10 @@ export const configPath = join(configDir, "config.json")
|
|
|
16
16
|
export const PROVIDER_PRESETS = {
|
|
17
17
|
deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
|
|
18
18
|
kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
|
|
19
|
+
"kimi-code": { baseURL: "https://api.kimi.com/coding/v1", model: "k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi For Coding (platform.kimi.com — sk-kimi- keys; NOT interchangeable with Moonshot)" },
|
|
19
20
|
glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 128000, desc: "Zhipu GLM" },
|
|
20
21
|
qwen: { baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen3.7-max", reasoningEffort: "high", maxTokens: 131072, desc: "Qwen / Alibaba" },
|
|
22
|
+
qwenplan: { baseURL: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", model: "qwen3.7-max", reasoningEffort: "high", maxTokens: 131072, desc: "Qwen Token Plan (百炼套餐)" },
|
|
21
23
|
minimax: { baseURL: "https://api.minimaxi.com/v1", model: "MiniMax-M3", thinking: { type: "adaptive" }, maxTokens: 128000, chatPath: "/text/chatcompletion_v2", desc: "MiniMax" },
|
|
22
24
|
openai: { baseURL: "https://api.openai.com/v1", model: "gpt-4o", desc: "OpenAI" },
|
|
23
25
|
claude: { baseURL: "https://api.anthropic.com/v1", model: "claude-sonnet-4", format: "anthropic", maxTokens: 8192, desc: "Claude (Anthropic)" },
|
|
@@ -88,6 +90,8 @@ const MODEL_SPECS = [
|
|
|
88
90
|
["deepseek-chat", { context: 256_000, maxOutput: 384_000, thinking: false, prefixMode: true, cacheMode: "prompt", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["high", "max"], tempRange: [0, 2] }],
|
|
89
91
|
// Kimi series
|
|
90
92
|
["kimi-k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
|
|
93
|
+
// Kimi For Coding endpoint uses the short model ID "k3" (same specs as kimi-k3) — IK5VGJ
|
|
94
|
+
["k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
|
|
91
95
|
["kimi-k2", { context: 256_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none" }],
|
|
92
96
|
["moonshot", { context: 128_000, maxOutput: 32_000, thinking: false, cacheMode: "none" }],
|
|
93
97
|
// GLM series
|
|
@@ -99,7 +103,8 @@ const MODEL_SPECS = [
|
|
|
99
103
|
["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
|
|
100
104
|
// Qwen series
|
|
101
105
|
["qwen3.8-max-preview", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
|
|
102
|
-
|
|
106
|
+
// qwen3.7-max rejects image parts outright (DashScope 400 "Unexpected item type in content") — text-only
|
|
107
|
+
["qwen3.7-max", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
|
|
103
108
|
["qwen3.8-max", { context: 1_000_000, maxOutput: 128_000, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
|
|
104
109
|
["qwen-max", { context: 1_000_000, maxOutput: 128_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
105
110
|
["qwen-plus", { context: 1_000_000, maxOutput: 32_000, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
|
|
@@ -130,11 +135,18 @@ const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
|
|
|
130
135
|
const COMPACT_RATIO = 0.6
|
|
131
136
|
|
|
132
137
|
/** Look up spec by model name prefix (case-insensitive), conservative default for unknown models */
|
|
138
|
+
const warnedModels = new Set() // warn once per model name — specForModel is a hot path (every request)
|
|
133
139
|
export function specForModel(model) {
|
|
134
140
|
const m = (model ?? "").toLowerCase()
|
|
135
141
|
for (const [prefix, spec] of [...MODEL_SPECS].sort((a,b) => b[0].length - a[0].length)) {
|
|
136
142
|
if (m.startsWith(prefix.toLowerCase())) return spec
|
|
137
143
|
}
|
|
144
|
+
// Unknown model: warn ONCE (not per request) so a typo'd ID or a missing alias surfaces
|
|
145
|
+
// instead of silently degrading to the 128K default (IK5VGJ).
|
|
146
|
+
if (m && !warnedModels.has(m)) {
|
|
147
|
+
warnedModels.add(m)
|
|
148
|
+
console.warn(`[config] model "${model}" not found in MODEL_SPECS — using default spec (128K context, 32K output). Check the model ID or add an alias.`)
|
|
149
|
+
}
|
|
138
150
|
return DEFAULT_SPEC
|
|
139
151
|
}
|
|
140
152
|
|
package/src/context.mjs
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
import { chat } from "./provider/index.mjs"
|
|
9
9
|
import { estimateText } from "./provider/rate.mjs"
|
|
10
|
+
import { specForModel } from "./config.mjs"
|
|
10
11
|
|
|
11
|
-
const IMAGE_TOKEN_ESTIMATE =
|
|
12
|
+
const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
|
|
12
13
|
|
|
13
14
|
/** Rough token count for a list of messages (body + reasoning + tool_calls params) */
|
|
14
15
|
export function estimateTokens(messages) {
|
|
@@ -30,7 +31,13 @@ export function estimateTokens(messages) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
const KEEP_HEAD = 2 // Keep the earliest user intent — must not lose it
|
|
33
|
-
|
|
34
|
+
// Tail size scales with the model context window (~30 messages per 100K tokens),
|
|
35
|
+
// capped at 40% of history so small histories don't over-reserve. Window-adaptive
|
|
36
|
+
// replaces the old fixed 10: on a 1M window, 10 messages is too thin for recent work.
|
|
37
|
+
function keepTailSize(provider, historyLen) {
|
|
38
|
+
const ctxWindow = specForModel(provider?.model ?? "").context
|
|
39
|
+
return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
|
|
40
|
+
}
|
|
34
41
|
|
|
35
42
|
const SUMMARIZE_PROMPT = `You are a conversation compressor. Summarize the following agent work log into a compact summary for use as context in the ongoing conversation.
|
|
36
43
|
Requirements:
|
|
@@ -69,15 +76,15 @@ const FALLBACK_NOTE =
|
|
|
69
76
|
* The tail boundary must include any assistant whose tool results are in the tail — if the assistant is in the middle,
|
|
70
77
|
* the summary swallows it, leaving orphan tool results → protocol 400.
|
|
71
78
|
*/
|
|
72
|
-
function splitHistory(history) {
|
|
73
|
-
if (history.length <= KEEP_HEAD +
|
|
79
|
+
function splitHistory(history, keepTail) {
|
|
80
|
+
if (history.length <= KEEP_HEAD + keepTail + 1) return null
|
|
74
81
|
let headEnd = KEEP_HEAD
|
|
75
82
|
// head must not end with dangling tool_calls: when assistant declares tool_calls, all its tool results must stay in head.
|
|
76
83
|
// Parallel calls: one assistant followed by multiple tool messages — accepting only one still causes 400, must collect all
|
|
77
84
|
if (history[headEnd - 1]?.role === "assistant" && history[headEnd - 1].tool_calls?.length) {
|
|
78
85
|
while (headEnd < history.length && history[headEnd].role === "tool") headEnd++
|
|
79
86
|
}
|
|
80
|
-
let tailStart = history.length -
|
|
87
|
+
let tailStart = history.length - keepTail
|
|
81
88
|
|
|
82
89
|
// Tool messages in the tail region whose assistant tool_calls are in the middle: the summary would swallow the assistant,
|
|
83
90
|
// leaving orphan tool results → protocol 400. Collect tool_call_ids from the tail, find their owner assistants and pull them into tail
|
|
@@ -101,8 +108,25 @@ function splitHistory(history) {
|
|
|
101
108
|
return { headEnd, tailStart }
|
|
102
109
|
}
|
|
103
110
|
|
|
111
|
+
/**
|
|
112
|
+
* pushReal — the single entry point for REAL conversation messages.
|
|
113
|
+
* A real message (user input, assistant reply, tool result, multimodal image) is appended to BOTH:
|
|
114
|
+
* agent.history — the machine context (compaction shrinks this)
|
|
115
|
+
* agent._fullHistory — the NEVER-COMPACTED human-readable record (persistence source)
|
|
116
|
+
* Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
|
|
117
|
+
* are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
|
|
118
|
+
* The two lines are written independently at the source — no after-the-fact delta sync.
|
|
119
|
+
*/
|
|
120
|
+
export function pushReal(agent, msg) {
|
|
121
|
+
if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
|
|
122
|
+
agent._fullHistory.push(msg)
|
|
123
|
+
agent.history.push(msg)
|
|
124
|
+
}
|
|
125
|
+
|
|
104
126
|
/** Replace middle with a note, then re-inject task/plan state (shared by LLM summary and truncation fallback) */
|
|
105
127
|
function applyCompression(agent, headEnd, tailStart, note) {
|
|
128
|
+
// _fullHistory already holds every real message (written at the source via pushReal),
|
|
129
|
+
// so compaction only shrinks the machine line — nothing to preserve here.
|
|
106
130
|
const head = agent.history.slice(0, headEnd)
|
|
107
131
|
const tail = agent.history.slice(tailStart)
|
|
108
132
|
agent.history = [
|
|
@@ -142,20 +166,30 @@ function applyCompression(agent, headEnd, tailStart, note) {
|
|
|
142
166
|
* If history exceeds threshold, compact it. Returns whether compaction happened.
|
|
143
167
|
* Only called at safe points in the loop (history ends with user or tool message — a complete exchange boundary).
|
|
144
168
|
* Automatically re-injects task list state after compaction.
|
|
169
|
+
* @param {object} agent
|
|
170
|
+
* @param {number} threshold - compaction threshold in tokens
|
|
171
|
+
* @param {object} callbacks - { onToken, onReasoning, onCompress } — summary generation is SILENT
|
|
172
|
+
* (never forwards onToken/onReasoning: the compaction process is an internal mechanism, not a model reply)
|
|
173
|
+
* @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
|
|
174
|
+
* path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
|
|
145
175
|
*/
|
|
146
|
-
export async function compressIfNeeded(agent, threshold, callbacks) {
|
|
176
|
+
export async function compressIfNeeded(agent, threshold, callbacks, extras = {}) {
|
|
147
177
|
const history = agent.history
|
|
148
178
|
// Prefer the real baseline: the last response's prompt_tokens is the measured value for the full context (system+tools+history).
|
|
149
179
|
// Subsequent appended messages use estimation as increment; when no measured value exists (first turn / after restore / right after compaction), fall back to pure estimation
|
|
180
|
+
const overhead =
|
|
181
|
+
(extras.systemPrompt ? estimateText(extras.systemPrompt) : 0) +
|
|
182
|
+
(extras.tools ? estimateText(JSON.stringify(extras.tools)) : 0)
|
|
150
183
|
const tokens =
|
|
151
184
|
agent._lastPromptTokens != null
|
|
152
185
|
? agent._lastPromptTokens + estimateTokens(history.slice(agent._usageAtLen ?? history.length))
|
|
153
|
-
: estimateTokens(history)
|
|
186
|
+
: estimateTokens(history) + overhead
|
|
154
187
|
if (tokens <= threshold) return false
|
|
155
188
|
|
|
156
|
-
const
|
|
189
|
+
const keepTail = keepTailSize(agent.provider, history.length)
|
|
190
|
+
const split = splitHistory(history, keepTail)
|
|
157
191
|
if (!split) {
|
|
158
|
-
// History is too short (≤
|
|
192
|
+
// History is too short (≤KEEP_HEAD+keepTail+1 messages) to find a middle section, but tokens exceed threshold — typically a single giant message
|
|
159
193
|
// (large paste / huge injection). When summarization has no room, degrade to deterministic shrinking to ensure context always reduces
|
|
160
194
|
return shrinkOversized(agent)
|
|
161
195
|
}
|
|
@@ -171,11 +205,10 @@ export async function compressIfNeeded(agent, threshold, callbacks) {
|
|
|
171
205
|
})
|
|
172
206
|
.join("\n")
|
|
173
207
|
|
|
174
|
-
// The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens
|
|
208
|
+
// The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens.
|
|
209
|
+
// Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
|
|
175
210
|
const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
|
|
176
211
|
messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
|
|
177
|
-
onToken: callbacks?.onToken,
|
|
178
|
-
onReasoning: callbacks?.onReasoning,
|
|
179
212
|
})
|
|
180
213
|
|
|
181
214
|
// Auto-checkpoint before compaction: snapshot current state so the model can
|
|
@@ -205,7 +238,8 @@ export async function compressIfNeeded(agent, threshold, callbacks) {
|
|
|
205
238
|
* Drops the middle so the task can continue. Returns whether truncation happened.
|
|
206
239
|
*/
|
|
207
240
|
export function compressFallback(agent) {
|
|
208
|
-
const
|
|
241
|
+
const keepTail = keepTailSize(agent.provider, agent.history.length)
|
|
242
|
+
const split = splitHistory(agent.history, keepTail)
|
|
209
243
|
if (!split) return false
|
|
210
244
|
applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
|
|
211
245
|
return true
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* generate-title.mjs — LLM-generated session titles (CLI side)
|
|
3
|
+
* Called after the first user message to auto-title the session.
|
|
4
|
+
* Mirrors thincoder-vscode/src/extension/generate-title.mjs but uses the CLI provider shape.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Generate a session title from the first user message using an LLM. Returns title string or null. */
|
|
8
|
+
export async function generateTitle(userContent, provider) {
|
|
9
|
+
// Extract text even from multimodal content (array of parts)
|
|
10
|
+
const userText = Array.isArray(userContent)
|
|
11
|
+
? userContent.find((p) => p.type === "text")?.text || ""
|
|
12
|
+
: userContent
|
|
13
|
+
if (typeof userText !== "string" || userText.length < 10) return null
|
|
14
|
+
if (!provider?.apiKey || !provider?.baseURL || !provider?.model) return null
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const body = JSON.stringify({
|
|
18
|
+
model: provider.model,
|
|
19
|
+
messages: [
|
|
20
|
+
{ role: "system", content: "Generate a concise title (max 40 chars, no quotes) for this conversation. Reply ONLY with the title." },
|
|
21
|
+
{ role: "user", content: userText.slice(0, 200) },
|
|
22
|
+
],
|
|
23
|
+
max_tokens: 30,
|
|
24
|
+
stream: false,
|
|
25
|
+
})
|
|
26
|
+
const chatPath = provider.chatPath ?? "/chat/completions"
|
|
27
|
+
const url = `${provider.baseURL.replace(/\/+$/, "")}${chatPath}`
|
|
28
|
+
const opts = {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${provider.apiKey}` },
|
|
31
|
+
body,
|
|
32
|
+
signal: AbortSignal.timeout(10000),
|
|
33
|
+
}
|
|
34
|
+
const res = provider.proxyUri
|
|
35
|
+
? await (await import("../proxy.mjs")).proxyFetch(url, opts, provider.proxyUri)
|
|
36
|
+
: await fetch(url, opts)
|
|
37
|
+
if (!res.ok) return null
|
|
38
|
+
const data = await res.json()
|
|
39
|
+
const title = data.choices?.[0]?.message?.content?.trim().slice(0, 40)
|
|
40
|
+
return title || null
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/prompts/system.md
CHANGED
|
@@ -7,7 +7,7 @@ Reply, reason, and ask in the user's language. If they switch languages mid-sess
|
|
|
7
7
|
Programming is collaborative labor between you and the human. The human decides direction and makes the final call. You own the code — the entire project is your code. What you confirm is your contract.
|
|
8
8
|
|
|
9
9
|
**How you work — before you write any code:**
|
|
10
|
-
- **Read design docs first.** Use `doc_search` to find relevant design docs, AGENTS.md, and architecture decisions. Code without design context is guesswork. If docs conflict with code, docs are right.
|
|
10
|
+
- **Read design docs first.** Use `doc_search` to find relevant design docs, AGENTS.md, and architecture decisions. Code without design context is guesswork. If docs conflict with code, docs are right. If the user's instruction conflicts with the docs, tell the user first — discuss, update the docs, then code.
|
|
11
11
|
- **Check existing code.** Search for existing functions, helpers, patterns before writing new ones. Duplicates are technical debt.
|
|
12
12
|
- **Understand intent.** Ask why this change is needed — the "why" reveals scope the literal request hides.
|
|
13
13
|
- **Confirm understanding.** State what you believe the user asked for and what you plan to deliver. Wait for confirmation. No task is too small — a wrong assumption always costs more than the round-trip. Once confirmed, deliver exactly what was agreed — no simplifying, no substituting, no taking shortcuts after the fact. Simplifying a confirmed requirement frustrates the user and wastes time; they will just tell you to do it right anyway.
|
package/src/provider/core.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export async function chat(provider, { messages, tools, onToken, onReasoning, on
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
const spec = specForModel(provider.model)
|
|
66
|
-
messages = stripImagesForTextModel(messages, spec)
|
|
66
|
+
messages = normalizeToolPairing(stripImagesForTextModel(messages, spec))
|
|
67
67
|
// Compile string-pattern rules to RegExp at call time
|
|
68
68
|
const rules = compileStreamRules(streamRules)
|
|
69
69
|
const body = {
|
|
@@ -199,6 +199,50 @@ export function stripImagesForTextModel(messages, spec) {
|
|
|
199
199
|
return changed ? out : messages
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Enforce the OpenAI tool-message protocol on the outgoing payload: every tool message must
|
|
204
|
+
* immediately follow the assistant message declaring its tool_call_id, and every declared
|
|
205
|
+
* tool_call must have a result. Strict providers (DeepSeek) reject the whole request with 400
|
|
206
|
+
* ("Messages with role 'tool' must be a response to a preceding message with 'tool_calls'").
|
|
207
|
+
* History can legitimately violate this — parallel read_image injects a user message between
|
|
208
|
+
* tool results, compaction splits, interrupted sessions leave dangling tool_calls — so sanitize
|
|
209
|
+
* at send time. History itself is left untouched.
|
|
210
|
+
*/
|
|
211
|
+
export function normalizeToolPairing(messages) {
|
|
212
|
+
// Detach all tool messages; reinsert each right after its owner assistant.
|
|
213
|
+
const toolById = new Map()
|
|
214
|
+
const rest = []
|
|
215
|
+
for (const m of messages) {
|
|
216
|
+
if (m.role === "tool") {
|
|
217
|
+
if (!toolById.has(m.tool_call_id)) toolById.set(m.tool_call_id, m)
|
|
218
|
+
} else {
|
|
219
|
+
rest.push(m)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (toolById.size === 0) return messages // no tool messages — nothing to enforce
|
|
223
|
+
const out = []
|
|
224
|
+
for (const m of rest) {
|
|
225
|
+
out.push(m)
|
|
226
|
+
if (m.role !== "assistant" || !m.tool_calls?.length) continue
|
|
227
|
+
for (const tc of m.tool_calls) {
|
|
228
|
+
const t = toolById.get(tc.id)
|
|
229
|
+
if (t) {
|
|
230
|
+
toolById.delete(tc.id)
|
|
231
|
+
out.push(t)
|
|
232
|
+
} else {
|
|
233
|
+
// Declared tool_call with no recorded result (interrupted session / compaction split)
|
|
234
|
+
out.push({
|
|
235
|
+
role: "tool",
|
|
236
|
+
tool_call_id: tc.id,
|
|
237
|
+
content: "[Tool result missing: the call was interrupted or its result was dropped by context compaction]",
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Leftovers in toolById are orphans (owner assistant compacted away or never recorded) — dropped
|
|
243
|
+
return out
|
|
244
|
+
}
|
|
245
|
+
|
|
202
246
|
/** List available model IDs from the provider's /models endpoint */
|
|
203
247
|
export async function listModels(provider, { signal } = {}) {
|
|
204
248
|
const response = await fetch(`${provider.baseURL}/models`, {
|
|
@@ -247,7 +291,19 @@ async function requestWithRetry(provider, body, signal, onWait) {
|
|
|
247
291
|
if (response.ok) return response
|
|
248
292
|
|
|
249
293
|
const text = await response.text().catch(() => "")
|
|
250
|
-
|
|
294
|
+
let message = `LLM API error ${response.status}: ${text}`
|
|
295
|
+
// Kimi has TWO separate platforms with non-interchangeable keys (IK5VGJ):
|
|
296
|
+
// Moonshot (api.moonshot.cn, sk-...) vs Kimi For Coding (api.kimi.com/coding/v1, sk-kimi-...).
|
|
297
|
+
// A 401 on either endpoint is usually a wrong-platform key — say so instead of a bare 401.
|
|
298
|
+
if (response.status === 401) {
|
|
299
|
+
const key = String(provider.apiKey ?? "").trim()
|
|
300
|
+
const base = String(provider.baseURL ?? "").toLowerCase()
|
|
301
|
+
const kimiCodeKey = /^sk-kimi-/i.test(key)
|
|
302
|
+
const kimiCodeUrl = base.includes("api.kimi.com")
|
|
303
|
+
if (kimiCodeKey || kimiCodeUrl) {
|
|
304
|
+
message += " — tip: Kimi has two separate platforms with NON-interchangeable API keys: Moonshot (api.moonshot.cn/v1, sk-...) and Kimi For Coding (api.kimi.com/coding/v1, sk-kimi-...). Your key or baseURL looks mismatched — check which platform issued it."
|
|
305
|
+
}
|
|
306
|
+
}
|
|
251
307
|
lastStatus = response.status
|
|
252
308
|
if (isNonRetryableError(response.status, text)) throw new Error(message)
|
|
253
309
|
if (response.status === 429) {
|