mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3d6fe3a86 | ||
|
|
bfc4cd38ee | ||
|
|
0ccc419d0d | ||
|
|
7542aca687 |
No files matched your search
@@ -3,7 +3,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Cache-first agent loop for DeepSeek V3 & R1 — Ink TUI, MCP first-class, no LangChain.</em>
|
||||
<em>Cache-first agent loop for DeepSeek V4 (flash + pro) — Ink TUI, MCP first-class, no LangChain.</em>
|
||||
</p>
|
||||
|
||||
# Reasonix
|
||||
@@ -133,6 +133,34 @@ shell call will execute. Use for high-stakes changes you want to
|
||||
audit before the model touches disk. `/plan off` or picker
|
||||
Approve/Cancel exits.
|
||||
|
||||
### Prompt prefixes — `!cmd` and `@path`
|
||||
|
||||
Two inline shortcuts that don't need a slash:
|
||||
|
||||
**`!<cmd>` — run a shell command in the sandbox and feed it to the
|
||||
model.** Typed at the prompt, like bash. Output lands in the visible
|
||||
log AND in the session so the model's next turn reasons about it:
|
||||
|
||||
```
|
||||
reasonix code › !git status --short
|
||||
▸ M src/users.ts
|
||||
▸ M src/users.test.ts
|
||||
|
||||
reasonix code › 把这两个文件的改动说明一下
|
||||
assistant
|
||||
▸ tool<read_file> → src/users.ts, src/users.test.ts
|
||||
▸ …
|
||||
```
|
||||
|
||||
No allowlist gate — user-typed shell = explicit consent. 60s timeout,
|
||||
32k char cap, survives session resume since 0.5.14.
|
||||
|
||||
**`@path/to/file` — inline a file under "Referenced files."** Start
|
||||
typing `@` and a picker appears (↑/↓ navigate, Tab/Enter to insert).
|
||||
Good for "what does @src/users.ts do?" without making the model
|
||||
`read_file` it first. Sandboxed: relative paths only, no `..` escape,
|
||||
64KB per-file cap. Recent files rank higher.
|
||||
|
||||
### `/commit` — stage + commit in one step
|
||||
|
||||
```
|
||||
@@ -145,12 +173,14 @@ reasonix code › /commit "fix: findByEmail case-insensitive"
|
||||
|
||||
- `/tool 1` — dump the last tool call's full output (when the 400-char
|
||||
inline clip isn't enough).
|
||||
- `/think` — see the model's full R1 reasoning for the last turn
|
||||
(reasoner preset only).
|
||||
- `/think` — see the model's full reasoning for the last turn
|
||||
(thinking-mode models: v4-flash / v4-pro / reasoner alias).
|
||||
- `/undo` — roll back the last applied edit batch.
|
||||
- `/new` — start fresh in the same directory without losing the
|
||||
session file.
|
||||
- `npx reasonix code --preset max` — R1 + 3-way self-consistency
|
||||
- `/effort high` — step down from the default `max` agent-class
|
||||
reasoning_effort for cheaper/faster turns on simple tasks.
|
||||
- `npx reasonix code --preset max` — v4-pro + 3-way self-consistency
|
||||
branching for gnarly refactors.
|
||||
- `npx reasonix code src/` — narrower sandbox (only `src/` is
|
||||
writable).
|
||||
@@ -182,7 +212,9 @@ in the file. No prompts, no completions, no tool arguments.
|
||||
### Staying current
|
||||
|
||||
The panel header shows the running version next to `Reasonix` (e.g.
|
||||
`Reasonix v0.4.22 · model …`). A quiet 24-hour background check against
|
||||
`Reasonix v0.5.21 · deepseek-v4-pro · harvest · max …`, the trailing
|
||||
`max` is the reasoning-effort badge — `/effort high` to step down).
|
||||
A quiet 24-hour background check against
|
||||
the npm registry surfaces a yellow `update: X.Y.Z` on the right side
|
||||
of the same row when a newer version has been published. No blocking,
|
||||
no nagging — the check runs once per day max and is silent on failure
|
||||
@@ -403,18 +435,25 @@ rendering, retries.
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `/preset <fast\|smart\|max>` | one-tap bundle (model + harvest + branch) |
|
||||
| `/model <id>` | switch DeepSeek model (`deepseek-chat`, `deepseek-reasoner`) |
|
||||
| `/model <id>` | switch DeepSeek model (`deepseek-v4-flash`, `deepseek-v4-pro`, plus `deepseek-chat` / `deepseek-reasoner` compat aliases) |
|
||||
| `/models` | list live models from DeepSeek `/models` endpoint |
|
||||
| `/harvest [on\|off]` | toggle R1 plan-state extraction |
|
||||
| `/branch <N\|off>` | run N parallel samples per turn, pick best (N ≥ 2) |
|
||||
| `/think` | dump the last turn's full R1 reasoning |
|
||||
| `/effort <high\|max>` | reasoning_effort cap — `max` is the agent default, `high` is cheaper/faster |
|
||||
| `/think` | dump the last turn's full thinking-mode reasoning |
|
||||
|
||||
**Context & tools**
|
||||
|
||||
| command | what it does |
|
||||
|---|---|
|
||||
| `/mcp` | list attached MCP servers and their tools |
|
||||
| `/mcp` | list attached MCP servers and their tools / resources / prompts |
|
||||
| `/resource [uri]` | browse + read MCP resources (no arg → list URIs; `<uri>` → fetch) |
|
||||
| `/prompt [name]` | browse + fetch MCP prompts |
|
||||
| `/tool [N]` | dump the Nth tool call's full output (1 = latest) |
|
||||
| `/compact [cap]` | shrink oversized tool results in the log |
|
||||
| `/compact [tokens]` | shrink oversized tool results in the log (default 4000 tokens/result) |
|
||||
| `/context` | break down where context tokens are going (system / tools / log) |
|
||||
| `/stats` | cross-session cost dashboard (today / week / month / all-time) |
|
||||
| `/keys` | keyboard shortcuts + prompt prefixes (`!` / `@` / `/`) cheatsheet |
|
||||
|
||||
**Memory & skills**
|
||||
|
||||
@@ -468,8 +507,8 @@ rendering, retries.
|
||||
- Malformed `assistant.tool_calls` / `tool` pairing is validated on
|
||||
every outgoing API call so a corrupted session can't keep 400ing.
|
||||
- Context gauge turns yellow at 50%, red at 80% with a `/compact`
|
||||
nudge. Approaching the 131k window triggers an automatic
|
||||
compaction attempt before falling back to a forced summary.
|
||||
nudge. Approaching the 1M-token window (V4 flash + pro) triggers an
|
||||
automatic compaction attempt before falling back to a forced summary.
|
||||
- The `reasonix code` sandbox refuses any path that resolves outside
|
||||
the launch directory, including symlink escape and `..` traversal.
|
||||
|
||||
@@ -728,7 +767,7 @@ cd reasonix
|
||||
npm install
|
||||
npm run dev code # run CLI from source via tsx
|
||||
npm run build # tsup to dist/
|
||||
npm test # vitest (648 tests)
|
||||
npm test # vitest (1007 tests)
|
||||
npm run lint # biome
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.5.18",
|
||||
"version": "0.5.22",
|
||||
"description": "DeepSeek-native agent framework: cache-first loop, R1 thought harvesting, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -95,7 +95,7 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
);
|
||||
|
||||
await chatCommand({
|
||||
model: opts.model ?? "deepseek-reasoner",
|
||||
model: opts.model ?? "deepseek-v4-pro",
|
||||
harvest: opts.harvest ?? false,
|
||||
system: codeSystemPrompt(rootDir),
|
||||
transcript: opts.transcript,
|
||||
|
||||
+11
-1
@@ -1180,7 +1180,14 @@ export function App({
|
||||
const contentBuf = { current: "" };
|
||||
const reasoningBuf = { current: "" };
|
||||
// Coalesces tool_call_delta events into one re-render per flush tick.
|
||||
const toolCallBuildBuf: { current: { name: string; chars: number } | null } = {
|
||||
const toolCallBuildBuf: {
|
||||
current: {
|
||||
name: string;
|
||||
chars: number;
|
||||
index?: number;
|
||||
readyCount?: number;
|
||||
} | null;
|
||||
} = {
|
||||
current: null,
|
||||
};
|
||||
|
||||
@@ -1259,6 +1266,8 @@ export function App({
|
||||
toolCallBuildBuf.current = {
|
||||
name: ev.toolName,
|
||||
chars: ev.toolCallArgsChars ?? 0,
|
||||
index: ev.toolCallIndex,
|
||||
readyCount: ev.toolCallReadyCount,
|
||||
};
|
||||
}
|
||||
} else if (ev.role === "branch_start") {
|
||||
@@ -1752,6 +1761,7 @@ export function App({
|
||||
prefixHash={prefixHash}
|
||||
harvestOn={loop.harvestEnabled}
|
||||
branchBudget={loop.branchOptions.budget}
|
||||
reasoningEffort={loop.reasoningEffort}
|
||||
planMode={planMode}
|
||||
balance={balance}
|
||||
busy={busy}
|
||||
|
||||
+28
-3
@@ -21,7 +21,7 @@ export interface DisplayEvent {
|
||||
stats?: TurnStats;
|
||||
repair?: string;
|
||||
streaming?: boolean;
|
||||
toolCallBuild?: { name: string; chars: number };
|
||||
toolCallBuild?: { name: string; chars: number; index?: number; readyCount?: number };
|
||||
/**
|
||||
* Render a thin horizontal rule above this event. Used to mark
|
||||
* "start of a new user turn" so long scrollbacks get visual
|
||||
@@ -405,13 +405,15 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
label = `R1 reasoning · ${event.reasoning?.length ?? 0} chars of thought`;
|
||||
labelColor = "cyan";
|
||||
} else if (toolCallOnly) {
|
||||
label = `assembling tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments`;
|
||||
label = `assembling tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments${formatReadyTail(toolCallBuild)}`;
|
||||
labelColor = "magenta";
|
||||
} else {
|
||||
const parts: string[] = [`writing response · ${event.text.length} chars`];
|
||||
if (event.reasoning) parts.push(`after ${event.reasoning.length} chars of reasoning`);
|
||||
if (toolCallBuild) {
|
||||
parts.push(`building tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars`);
|
||||
parts.push(
|
||||
`building tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars${formatReadyTail(toolCallBuild)}`,
|
||||
);
|
||||
}
|
||||
label = parts.join(" · ");
|
||||
labelColor = "green";
|
||||
@@ -469,6 +471,29 @@ function Pulse() {
|
||||
return <Text color="cyan">{frames[Math.floor(tick / 4) % frames.length]}</Text>;
|
||||
}
|
||||
|
||||
/**
|
||||
* "(call 3)" suffix for the tool-call label when we have the index.
|
||||
* Hides when the turn only has one call so the common case stays tidy.
|
||||
*/
|
||||
function formatToolCallIndex(tb: { index?: number; readyCount?: number } | undefined): string {
|
||||
if (!tb || tb.index === undefined) return "";
|
||||
// Only show the index when this isn't obviously the first + only call.
|
||||
if (tb.index === 0 && (tb.readyCount ?? 0) === 0) return "";
|
||||
return ` (call ${tb.index + 1})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "· 2 ready" tail that answers "how many tool calls have finished
|
||||
* streaming so far this turn?" — user feedback for multi-file turns
|
||||
* where the response takes 10–30s to stream. Hidden until readyCount
|
||||
* > 0 so single-call turns aren't cluttered.
|
||||
*/
|
||||
function formatReadyTail(tb: { readyCount?: number } | undefined): string {
|
||||
const n = tb?.readyCount ?? 0;
|
||||
if (n <= 0) return "";
|
||||
return ` · ${n} ready`;
|
||||
}
|
||||
|
||||
function lastLine(s: string, maxChars: number): string {
|
||||
const flat = s.replace(/\s+/g, " ").trim();
|
||||
if (!flat) return "";
|
||||
|
||||
@@ -56,6 +56,13 @@ export interface StatsPanelProps {
|
||||
prefixHash: string;
|
||||
harvestOn?: boolean;
|
||||
branchBudget?: number;
|
||||
/**
|
||||
* Current `reasoning_effort` cap. Shown as a green "· max" / yellow
|
||||
* "· high" tag in the header so the user always sees which tier the
|
||||
* next turn will use. Absent/undefined hides the tag (e.g. before
|
||||
* the loop is constructed).
|
||||
*/
|
||||
reasoningEffort?: "high" | "max";
|
||||
/**
|
||||
* True when `reasonix code` is currently running in read-only Plan
|
||||
* Mode. Surfaced as a red "PLAN" tag in the panel header so the user
|
||||
@@ -110,6 +117,7 @@ export function StatsPanel({
|
||||
prefixHash,
|
||||
harvestOn,
|
||||
branchBudget,
|
||||
reasoningEffort,
|
||||
planMode,
|
||||
balance,
|
||||
updateAvailable,
|
||||
@@ -135,6 +143,7 @@ export function StatsPanel({
|
||||
harvestOn={harvestOn}
|
||||
branchOn={branchOn}
|
||||
branchBudget={branchBudget ?? 1}
|
||||
reasoningEffort={reasoningEffort}
|
||||
planMode={planMode}
|
||||
turns={summary.turns}
|
||||
updateAvailable={updateAvailable}
|
||||
@@ -168,6 +177,7 @@ function Header({
|
||||
harvestOn,
|
||||
branchOn,
|
||||
branchBudget,
|
||||
reasoningEffort,
|
||||
planMode,
|
||||
turns,
|
||||
updateAvailable,
|
||||
@@ -179,6 +189,7 @@ function Header({
|
||||
harvestOn?: boolean;
|
||||
branchOn: boolean;
|
||||
branchBudget: number;
|
||||
reasoningEffort?: "high" | "max";
|
||||
planMode?: boolean;
|
||||
turns: number;
|
||||
updateAvailable?: string | null;
|
||||
@@ -200,6 +211,8 @@ function Header({
|
||||
)}
|
||||
{harvestOn ? <Text color="magenta"> · harvest</Text> : null}
|
||||
{branchOn ? <Text color="blue"> · branch{branchBudget}</Text> : null}
|
||||
{reasoningEffort === "max" ? <Text color="green"> · max</Text> : null}
|
||||
{reasoningEffort === "high" ? <Text color="yellow"> · high</Text> : null}
|
||||
{planMode ? (
|
||||
<Text color="red" bold>
|
||||
{" · PLAN"}
|
||||
|
||||
+10
-5
@@ -17,22 +17,27 @@ export interface PresetSettings {
|
||||
}
|
||||
|
||||
export const PRESETS: Record<PresetName, PresetSettings> = {
|
||||
// `deepseek-chat` / `deepseek-reasoner` are retained as the fast /
|
||||
// smart models because they're deprecated-but-working compat aliases
|
||||
// for v4-flash's non-thinking and thinking modes respectively. Same
|
||||
// billing, smaller config churn for existing users. `max` promotes
|
||||
// to v4-pro — 12× flash on input/output, reserved for hard tasks.
|
||||
fast: { model: "deepseek-chat", harvest: false, branch: 1 },
|
||||
smart: { model: "deepseek-reasoner", harvest: true, branch: 1 },
|
||||
max: { model: "deepseek-reasoner", harvest: true, branch: 3 },
|
||||
max: { model: "deepseek-v4-pro", harvest: true, branch: 3 },
|
||||
};
|
||||
|
||||
export const PRESET_DESCRIPTIONS: Record<PresetName, { headline: string; cost: string }> = {
|
||||
fast: {
|
||||
headline: "deepseek-chat, no reasoning harvest, no branching",
|
||||
headline: "deepseek-chat (= v4-flash non-thinking), no harvest, no branching",
|
||||
cost: "~1¢ per 100 turns · default",
|
||||
},
|
||||
smart: {
|
||||
headline: "deepseek-reasoner + Pillar 2 harvest",
|
||||
cost: "~10× cost vs fast · slower · better on multi-step tasks",
|
||||
headline: "deepseek-reasoner (= v4-flash thinking) + Pillar 2 harvest",
|
||||
cost: "same price as fast · slower · better on multi-step tasks",
|
||||
},
|
||||
max: {
|
||||
headline: "reasoner + harvest + self-consistency (3 branches)",
|
||||
headline: "deepseek-v4-pro + harvest + self-consistency (3 branches)",
|
||||
cost: "~30× cost vs fast · slowest · for hard single-shots",
|
||||
},
|
||||
};
|
||||
|
||||
+22
-1
@@ -242,6 +242,12 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
summary: "run N parallel samples per turn (N>=2)",
|
||||
argCompleter: ["off", "2", "3", "4", "5"],
|
||||
},
|
||||
{
|
||||
cmd: "effort",
|
||||
argsHint: "<high|max>",
|
||||
summary: "reasoning_effort cap — max is default (agent-class), high is cheaper/faster",
|
||||
argCompleter: ["max", "high"],
|
||||
},
|
||||
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
|
||||
{
|
||||
cmd: "resource",
|
||||
@@ -485,6 +491,7 @@ export function handleSlash(
|
||||
" /model <id> deepseek-chat or deepseek-reasoner",
|
||||
" /harvest [on|off] Pillar 2: structured plan-state extraction",
|
||||
" /branch <N|off> run N parallel samples (N>=2), pick most confident",
|
||||
" /effort <high|max> reasoning_effort cap (max=agent default, high=cheaper)",
|
||||
" /mcp list MCP servers + tools attached to this session",
|
||||
" /resource [uri] browse + read MCP resources (no arg → list URIs; <uri> → fetch)",
|
||||
" /prompt [name] browse + fetch MCP prompts (no arg → list names; <name> → render)",
|
||||
@@ -917,7 +924,7 @@ export function handleSlash(
|
||||
const planLine = ctx.planMode ? " plan ON — writes gated (submit_plan + approval)" : "";
|
||||
const lines = [
|
||||
` model ${loop.model}`,
|
||||
` flags harvest=${loop.harvestEnabled ? "on" : "off"} · branch=${branchBudget > 1 ? branchBudget : "off"} · stream=${loop.stream ? "on" : "off"}`,
|
||||
` flags harvest=${loop.harvestEnabled ? "on" : "off"} · branch=${branchBudget > 1 ? branchBudget : "off"} · stream=${loop.stream ? "on" : "off"} · effort=${loop.reasoningEffort}`,
|
||||
ctxLine,
|
||||
mcpLine,
|
||||
sessionLine,
|
||||
@@ -1019,6 +1026,20 @@ export function handleSlash(
|
||||
return { info: `branch → ${n} (harvest auto-enabled; streaming disabled)` };
|
||||
}
|
||||
|
||||
case "effort": {
|
||||
const raw = (args[0] ?? "").toLowerCase();
|
||||
if (raw === "") {
|
||||
return {
|
||||
info: `reasoning_effort → ${loop.reasoningEffort} (use /effort high for cheaper/faster, /effort max for the agent-class default)`,
|
||||
};
|
||||
}
|
||||
if (raw !== "high" && raw !== "max") {
|
||||
return { info: "usage: /effort <high|max>" };
|
||||
}
|
||||
loop.configure({ reasoningEffort: raw });
|
||||
return { info: `reasoning_effort → ${raw}` };
|
||||
}
|
||||
|
||||
default:
|
||||
return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
|
||||
}
|
||||
|
||||
+23
-1
@@ -110,7 +110,17 @@ export class DeepSeekClient {
|
||||
process.env.DEEPSEEK_BASE_URL ??
|
||||
"https://api.deepseek.com"
|
||||
).replace(/\/+$/, "");
|
||||
this.timeoutMs = opts.timeoutMs ?? 120_000;
|
||||
// 11 min. DeepSeek's load-balancer may keep a connection open for
|
||||
// up to 10 minutes while the request waits in queue (non-streaming
|
||||
// sends empty lines, streaming sends `:` SSE keep-alive comments —
|
||||
// both are invisible to our parsers, so neither surfaces until the
|
||||
// real response starts). Timing out at the legacy 2-min default
|
||||
// killed queued requests prematurely, burned the queue slot on
|
||||
// retry, and could loop through the whole queue repeatedly.
|
||||
// Setting 11 min lets the server's own 10-min cap close the
|
||||
// connection first (clean EOF → natural retry), and our timer
|
||||
// is a safety net for genuinely hung sockets.
|
||||
this.timeoutMs = opts.timeoutMs ?? 660_000;
|
||||
this._fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
this.retry = opts.retry ?? {};
|
||||
}
|
||||
@@ -125,6 +135,18 @@ export class DeepSeekClient {
|
||||
if (opts.temperature !== undefined) payload.temperature = opts.temperature;
|
||||
if (opts.maxTokens !== undefined) payload.max_tokens = opts.maxTokens;
|
||||
if (opts.responseFormat) payload.response_format = opts.responseFormat;
|
||||
// V4 thinking-mode toggle: lives under `extra_body.thinking.type` per
|
||||
// DeepSeek's docs. Docs also note that in thinking mode `temperature`,
|
||||
// `top_p`, `presence_penalty`, `frequency_penalty` are silently
|
||||
// ignored — we don't strip them here because the server's explicit
|
||||
// "setting won't report an error" contract means leaving them in is
|
||||
// safe and keeps the request payload diffable against OpenAI tooling.
|
||||
if (opts.thinking) {
|
||||
payload.extra_body = { thinking: { type: opts.thinking } };
|
||||
}
|
||||
if (opts.reasoningEffort) {
|
||||
payload.reasoning_effort = opts.reasoningEffort;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,12 @@ export async function harvest(
|
||||
const trimmed = reasoningContent.trim();
|
||||
if (trimmed.length < minLen) return emptyPlanState();
|
||||
|
||||
// Harvest is schema-constrained JSON extraction, not agent reasoning.
|
||||
// Default to `deepseek-chat` (= v4-flash non-thinking mode) — a few
|
||||
// hundred output tokens fit easily in the non-thinking budget, the
|
||||
// reply comes back ~10× faster than thinking mode, and the
|
||||
// per-turn cost stays an asterisk next to the main loop's spend
|
||||
// rather than a visible slice of it.
|
||||
const model = options.model ?? "deepseek-chat";
|
||||
const maxItems = options.maxItems ?? 5;
|
||||
const maxItemLen = options.maxItemLen ?? 80;
|
||||
@@ -96,6 +102,13 @@ export async function harvest(
|
||||
responseFormat: { type: "json_object" },
|
||||
temperature: 0,
|
||||
maxTokens: 600,
|
||||
// Pin mode + effort so a future default-model swap (e.g. someone
|
||||
// sets `options.model = "deepseek-v4-pro"`) can't accidentally
|
||||
// turn this micro-extraction into a multi-thousand-reasoning-
|
||||
// token call. DeepSeek ignores these on non-thinking models, so
|
||||
// the request stays valid regardless of the chosen model.
|
||||
thinking: "disabled",
|
||||
reasoningEffort: "high",
|
||||
signal,
|
||||
});
|
||||
return parsePlanState(resp.content, maxItems, maxItemLen);
|
||||
|
||||
+119
-2
@@ -96,6 +96,19 @@ export interface LoopEvent {
|
||||
toolArgs?: string;
|
||||
/** Cumulative arguments-string length for `role === "tool_call_delta"`. */
|
||||
toolCallArgsChars?: number;
|
||||
/**
|
||||
* Zero-based index of the tool call this delta belongs to. Surfaces
|
||||
* multi-tool turns: on a response emitting 4 write_file calls the UI
|
||||
* can show "building call 3/?" instead of a context-free spinner.
|
||||
*/
|
||||
toolCallIndex?: number;
|
||||
/**
|
||||
* Count of prior tool calls (this turn) whose arguments have finished
|
||||
* streaming into valid JSON. Not all ready calls have been dispatched
|
||||
* yet — dispatch still happens post-stream — but the user gets "2
|
||||
* ready" progress feedback while later calls keep streaming.
|
||||
*/
|
||||
toolCallReadyCount?: number;
|
||||
stats?: TurnStats;
|
||||
planState?: TypedPlanState;
|
||||
repair?: RepairReport;
|
||||
@@ -134,6 +147,11 @@ export interface CacheFirstLoopOptions {
|
||||
* since the default selector scores samples by plan-state uncertainty.
|
||||
*/
|
||||
branch?: number | BranchOptions;
|
||||
/**
|
||||
* Reasoning-effort cap. See {@link ReconfigurableOptions} — default
|
||||
* `max` for Reasonix (agent-class use per DeepSeek V4 docs).
|
||||
*/
|
||||
reasoningEffort?: "high" | "max";
|
||||
/**
|
||||
* Session name. When set, the loop pre-loads the session's prior messages
|
||||
* into its log on construction, and appends every new log entry to
|
||||
@@ -171,6 +189,14 @@ export interface ReconfigurableOptions {
|
||||
harvest?: boolean | HarvestOptions;
|
||||
branch?: number | BranchOptions;
|
||||
stream?: boolean;
|
||||
/**
|
||||
* Reasoning-effort cap sent per turn (V4 thinking mode only;
|
||||
* deepseek-chat ignores it). Reasonix pins `max` by default because
|
||||
* DeepSeek's V4 docs flag Claude-Code-style agent loops as the
|
||||
* canonical `max` use case. `/effort high` lets a user step down
|
||||
* mid-session for cheaper, faster turns on simple tasks.
|
||||
*/
|
||||
reasoningEffort?: "high" | "max";
|
||||
}
|
||||
|
||||
export class CacheFirstLoop {
|
||||
@@ -191,6 +217,8 @@ export class CacheFirstLoop {
|
||||
harvestOptions: HarvestOptions;
|
||||
branchEnabled: boolean;
|
||||
branchOptions: BranchOptions;
|
||||
/** See ReconfigurableOptions — mutable so `/effort` can flip mid-session. */
|
||||
reasoningEffort: "high" | "max";
|
||||
sessionName: string | null;
|
||||
|
||||
/**
|
||||
@@ -220,7 +248,8 @@ export class CacheFirstLoop {
|
||||
this.client = opts.client;
|
||||
this.prefix = opts.prefix;
|
||||
this.tools = opts.tools ?? new ToolRegistry();
|
||||
this.model = opts.model ?? "deepseek-chat";
|
||||
this.model = opts.model ?? "deepseek-v4-pro";
|
||||
this.reasoningEffort = opts.reasoningEffort ?? "max";
|
||||
// Iter cap is a safety net, not the primary stop condition. The
|
||||
// primary stop is the token-context guard inside step(): after
|
||||
// every model response we check whether the prompt is already past
|
||||
@@ -389,6 +418,7 @@ export class CacheFirstLoop {
|
||||
configure(opts: ReconfigurableOptions): void {
|
||||
if (opts.model !== undefined) this.model = opts.model;
|
||||
if (opts.stream !== undefined) this._streamPreference = opts.stream;
|
||||
if (opts.reasoningEffort !== undefined) this.reasoningEffort = opts.reasoningEffort;
|
||||
|
||||
if (opts.branch !== undefined) {
|
||||
if (typeof opts.branch === "number") {
|
||||
@@ -651,6 +681,8 @@ export class CacheFirstLoop {
|
||||
messages,
|
||||
tools: toolSpecs.length ? toolSpecs : undefined,
|
||||
signal,
|
||||
thinking: thinkingModeForModel(this.model),
|
||||
reasoningEffort: this.reasoningEffort,
|
||||
},
|
||||
{
|
||||
...this.branchOptions,
|
||||
@@ -705,11 +737,20 @@ export class CacheFirstLoop {
|
||||
};
|
||||
} else if (this.stream) {
|
||||
const callBuf: Map<number, ToolCall> = new Map();
|
||||
// Indices whose accumulated args have parsed as valid JSON at
|
||||
// least once. Purely informational — we don't dispatch until
|
||||
// the stream ends (that's the eager-dispatch feature we
|
||||
// intentionally punted) but the UI shows "N ready" so the
|
||||
// user sees progress on long multi-tool turns instead of a
|
||||
// stagnant "building tool call" spinner.
|
||||
const readyIndices = new Set<number>();
|
||||
for await (const chunk of this.client.stream({
|
||||
model: this.model,
|
||||
messages,
|
||||
tools: toolSpecs.length ? toolSpecs : undefined,
|
||||
signal,
|
||||
thinking: thinkingModeForModel(this.model),
|
||||
reasoningEffort: this.reasoningEffort,
|
||||
})) {
|
||||
if (chunk.contentDelta) {
|
||||
assistantContent += chunk.contentDelta;
|
||||
@@ -740,6 +781,18 @@ export class CacheFirstLoop {
|
||||
if (d.argumentsDelta)
|
||||
cur.function.arguments = (cur.function.arguments ?? "") + d.argumentsDelta;
|
||||
callBuf.set(d.index, cur);
|
||||
|
||||
// Mark this index "ready" once its args first parse as
|
||||
// valid JSON. JSON.parse is sub-millisecond on typical
|
||||
// tool-call payloads; skip the check once already ready.
|
||||
if (
|
||||
!readyIndices.has(d.index) &&
|
||||
cur.function.name &&
|
||||
looksLikeCompleteJson(cur.function.arguments ?? "")
|
||||
) {
|
||||
readyIndices.add(d.index);
|
||||
}
|
||||
|
||||
// Skip the id-only opener: name is empty until the next chunk.
|
||||
if (cur.function.name) {
|
||||
yield {
|
||||
@@ -748,6 +801,8 @@ export class CacheFirstLoop {
|
||||
content: "",
|
||||
toolName: cur.function.name,
|
||||
toolCallArgsChars: (cur.function.arguments ?? "").length,
|
||||
toolCallIndex: d.index,
|
||||
toolCallReadyCount: readyIndices.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -760,6 +815,8 @@ export class CacheFirstLoop {
|
||||
messages,
|
||||
tools: toolSpecs.length ? toolSpecs : undefined,
|
||||
signal,
|
||||
thinking: thinkingModeForModel(this.model),
|
||||
reasoningEffort: this.reasoningEffort,
|
||||
});
|
||||
assistantContent = resp.content;
|
||||
reasoningContent = resp.reasoningContent ?? "";
|
||||
@@ -1071,6 +1128,8 @@ export class CacheFirstLoop {
|
||||
messages,
|
||||
// no tools → model is forced to answer in text
|
||||
signal: this._turnAbort.signal,
|
||||
thinking: thinkingModeForModel(this.model),
|
||||
reasoningEffort: this.reasoningEffort,
|
||||
});
|
||||
const rawContent = resp.content?.trim() ?? "";
|
||||
const cleaned = stripHallucinatedToolMarkup(rawContent);
|
||||
@@ -1143,13 +1202,52 @@ export class CacheFirstLoop {
|
||||
*/
|
||||
private syntheticAssistantMessage(content: string): ChatMessage {
|
||||
const msg: ChatMessage = { role: "assistant", content };
|
||||
if (this.model.includes("reasoner")) {
|
||||
if (isThinkingModeModel(this.model)) {
|
||||
msg.reasoning_content = "";
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the model emits `reasoning_content` and therefore requires
|
||||
* it round-tripped on follow-up requests.
|
||||
* - `deepseek-reasoner`: legacy R1 alias (= v4-flash thinking mode)
|
||||
* - `deepseek-v4-flash` / `deepseek-v4-pro`: default to thinking mode
|
||||
* per the V4 docs (2026-04)
|
||||
* - `deepseek-chat`: non-thinking compat alias → false
|
||||
*
|
||||
* Exported so tests can lock the behavior down as DeepSeek adds more
|
||||
* model variants.
|
||||
*/
|
||||
export function isThinkingModeModel(model: string): boolean {
|
||||
if (model.includes("reasoner")) return true;
|
||||
if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `extra_body.thinking.type` value to send for a given model. Pins
|
||||
* the mode explicitly rather than relying on the server default, which
|
||||
* removes one source of ambiguity when DeepSeek validates the request's
|
||||
* `reasoning_content` round-trip.
|
||||
*
|
||||
* - `deepseek-chat` → "disabled" (non-thinking alias)
|
||||
* - `deepseek-reasoner` → "enabled" (thinking alias)
|
||||
* - `deepseek-v4-flash` / `-v4-pro` → "enabled" (V4 docs default)
|
||||
* - anything else → undefined (let server decide)
|
||||
*
|
||||
* Returning `undefined` makes the client skip the field entirely so
|
||||
* third-party models routed through a DeepSeek-compatible endpoint
|
||||
* don't get a parameter they don't recognize.
|
||||
*/
|
||||
export function thinkingModeForModel(model: string): "enabled" | "disabled" | undefined {
|
||||
if (model === "deepseek-chat") return "disabled";
|
||||
if (model.includes("reasoner")) return "enabled";
|
||||
if (model === "deepseek-v4-flash" || model === "deepseek-v4-pro") return "enabled";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* R1 occasionally hallucinates tool-call markup as plain text when the
|
||||
* real tool channel has been closed — typically our forced-summary
|
||||
@@ -1189,6 +1287,25 @@ function safeParseToolArgs(raw: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap "is this accumulated tool-call arguments blob now a complete
|
||||
* JSON value?" check. Used during streaming to mark a tool call as
|
||||
* ready for UI progress feedback — not for dispatch gating. Empty /
|
||||
* whitespace-only is not complete; anything that parses is.
|
||||
*
|
||||
* Exported so tests can lock down the precise shapes we consider
|
||||
* "ready" vs "still streaming."
|
||||
*/
|
||||
export function looksLikeCompleteJson(s: string): boolean {
|
||||
if (!s || !s.trim()) return false;
|
||||
try {
|
||||
JSON.parse(s);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format non-pass hook outcomes as `LoopEvent`s of role `warning`. */
|
||||
function* hookWarnings(outcomes: HookOutcome[], turn: number): Generator<LoopEvent> {
|
||||
for (const o of outcomes) {
|
||||
|
||||
+33
-16
@@ -1,24 +1,33 @@
|
||||
import type { Usage } from "./client.js";
|
||||
|
||||
/**
|
||||
* USD per 1M tokens. Source of truth is DeepSeek's CNY price sheet at
|
||||
* https://api-docs.deepseek.com/zh-cn/quick_start/pricing (as of
|
||||
* 2026-04-23): chat and reasoner are unified at ¥0.2 / ¥2 / ¥3 per 1M
|
||||
* tokens (cache-hit input / cache-miss input / output). Converted at
|
||||
* a fixed 7.2 CNY/USD rate so stats stay stable across the daily FX
|
||||
* USD per 1M tokens. Source: DeepSeek's CNY price sheet
|
||||
* (https://api-docs.deepseek.com/zh-cn/quick_start/pricing) converted
|
||||
* at a fixed 7.2 CNY/USD rate so billing stays stable across daily FX
|
||||
* drift; revisit if the rate moves more than ±5%.
|
||||
*
|
||||
* Historical note: the pre-unification prices were chat $0.07/$0.27/$1.10
|
||||
* and reasoner $0.14/$0.55/$2.19. Sessions logged under those values
|
||||
* in `~/.reasonix/usage.jsonl` remain as-is (USD frozen at record time)
|
||||
* — we never retroactively rewrite billing history.
|
||||
* 2026-04 V4 launch:
|
||||
* - deepseek-v4-flash ¥0.2 / ¥1 / ¥2 (hit / miss / out per 1M)
|
||||
* - deepseek-v4-pro ¥1 / ¥12 / ¥24
|
||||
*
|
||||
* deepseek-chat and deepseek-reasoner are now thin compat aliases for
|
||||
* v4-flash's non-thinking and thinking modes respectively — same
|
||||
* underlying model, same bill. We keep them in the table so existing
|
||||
* sessions (and configs that hard-code these names) keep pricing.
|
||||
*
|
||||
* Historical note: sessions logged before this file was updated remain
|
||||
* as-is in `~/.reasonix/usage.jsonl` — USD is frozen at record time,
|
||||
* we never retroactively rewrite billing history.
|
||||
*/
|
||||
export const DEEPSEEK_PRICING: Record<
|
||||
string,
|
||||
{ inputCacheHit: number; inputCacheMiss: number; output: number }
|
||||
> = {
|
||||
"deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
|
||||
"deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
|
||||
"deepseek-v4-flash": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
|
||||
"deepseek-v4-pro": { inputCacheHit: 0.139, inputCacheMiss: 1.667, output: 3.333 },
|
||||
// Compat aliases — priced as v4-flash per the deprecation notice.
|
||||
"deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
|
||||
"deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.139, output: 0.278 },
|
||||
};
|
||||
|
||||
/** Reference Claude Sonnet 4.6 pricing (USD per 1M tokens). */
|
||||
@@ -26,13 +35,21 @@ export const CLAUDE_SONNET_PRICING = { input: 3.0, output: 15.0 };
|
||||
|
||||
/**
|
||||
* Maximum prompt-side context window per DeepSeek model, in tokens.
|
||||
* Both V3 (`deepseek-chat`) and R1 (`deepseek-reasoner`) currently expose
|
||||
* a 131,072-token prompt limit per the OpenAPI spec; completion caps
|
||||
* differ but don't affect the prompt budget the StatsPanel shows.
|
||||
* V4 (flash + pro) jumps to 1,000,000 tokens. The compat aliases
|
||||
* (deepseek-chat / deepseek-reasoner) inherit that through the
|
||||
* v4-flash route — we bump them so the StatsPanel gauge reflects
|
||||
* what the API actually accepts.
|
||||
*
|
||||
* Completion caps (e.g. 384K for V4) are enforced by the server, not
|
||||
* tracked here — they don't affect the prompt-side budget the panel
|
||||
* shows. If a future feature surfaces output-cap warnings we'll add
|
||||
* a sibling table.
|
||||
*/
|
||||
export const DEEPSEEK_CONTEXT_TOKENS: Record<string, number> = {
|
||||
"deepseek-chat": 131_072,
|
||||
"deepseek-reasoner": 131_072,
|
||||
"deepseek-v4-flash": 1_000_000,
|
||||
"deepseek-v4-pro": 1_000_000,
|
||||
"deepseek-chat": 1_000_000,
|
||||
"deepseek-reasoner": 1_000_000,
|
||||
};
|
||||
|
||||
/** Fallback when the caller's model id isn't in the table — safe lower bound. */
|
||||
|
||||
@@ -109,7 +109,7 @@ export interface SpawnSubagentOptions {
|
||||
system: string;
|
||||
/** The task / question / instruction the subagent must address. */
|
||||
task: string;
|
||||
/** Model id for the child loop. Defaults to `deepseek-chat`. */
|
||||
/** Model id for the child loop. Defaults to `deepseek-v4-pro`. */
|
||||
model?: string;
|
||||
/** Iteration ceiling for the child loop. Defaults to 16. */
|
||||
maxToolIters?: number;
|
||||
@@ -173,7 +173,7 @@ export interface SubagentToolOptions {
|
||||
defaultSystem?: string;
|
||||
/** Project root for `applyProjectMemory` lookup. Omit in chat mode. */
|
||||
projectRoot?: string;
|
||||
/** Default model. `deepseek-chat` (V3) by default. */
|
||||
/** Default model. `deepseek-v4-pro` by default. */
|
||||
defaultModel?: string;
|
||||
/** Iteration ceiling. Lower than the parent (16 by default). */
|
||||
maxToolIters?: number;
|
||||
@@ -200,7 +200,7 @@ Formatting rules (the parent renders your reply in a TUI with a real markdown re
|
||||
|
||||
const DEFAULT_MAX_RESULT_CHARS = 8000;
|
||||
const DEFAULT_MAX_ITERS = 16;
|
||||
const DEFAULT_SUBAGENT_MODEL = "deepseek-chat";
|
||||
const DEFAULT_SUBAGENT_MODEL = "deepseek-v4-pro";
|
||||
|
||||
const SUBAGENT_TOOL_NAME = "spawn_subagent";
|
||||
/**
|
||||
@@ -423,9 +423,9 @@ export function registerSubagentTool(
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
enum: ["deepseek-chat", "deepseek-reasoner"],
|
||||
enum: ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"],
|
||||
description:
|
||||
"Which DeepSeek model the subagent runs on. 'deepseek-chat' (V3) is the default — fast and cheap. Use 'deepseek-reasoner' (R1) only when the subtask genuinely needs planning or multi-step reasoning; it is roughly 5-10x more expensive.",
|
||||
"Which DeepSeek model the subagent runs on. Default is 'deepseek-v4-pro' — the strongest model, best for complex subtasks. Override to 'deepseek-v4-flash' (or the legacy 'deepseek-chat' / 'deepseek-reasoner' aliases, which route to flash non-thinking / thinking modes) when the subtask is simple enough that flash's quality suffices — flash is roughly 12× cheaper.",
|
||||
},
|
||||
},
|
||||
required: ["task"],
|
||||
|
||||
@@ -64,4 +64,19 @@ export interface ChatRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
/** DeepSeek response_format — use { type: "json_object" } to force valid JSON. */
|
||||
responseFormat?: { type: "json_object" | "text" };
|
||||
/**
|
||||
* Explicitly toggle V4 thinking mode. Serialized as
|
||||
* `extra_body.thinking.type = enabled|disabled`. Omit to let the
|
||||
* server default apply (thinking enabled). Mainly used so the loop
|
||||
* can pin the mode per model: `deepseek-chat` → disabled (legacy
|
||||
* non-thinking compat), everything else → enabled.
|
||||
*/
|
||||
thinking?: "enabled" | "disabled";
|
||||
/**
|
||||
* Per-request reasoning-effort cap. Serialized as the top-level
|
||||
* `reasoning_effort` field. DeepSeek accepts `high` (standard) or
|
||||
* `max` (Agent-class, auto-applied to Claude-Code-style flows per
|
||||
* the V4 docs). Reasonix pins `max` because every turn is agent-like.
|
||||
*/
|
||||
reasoningEffort?: "high" | "max";
|
||||
}
|
||||
@@ -9,11 +9,45 @@
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { CacheFirstLoop, isThinkingModeModel, thinkingModeForModel } from "../src/loop.js";
|
||||
import { ImmutablePrefix } from "../src/memory.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
import type { ChatMessage } from "../src/types.js";
|
||||
|
||||
describe("isThinkingModeModel", () => {
|
||||
it("deepseek-reasoner → true (legacy R1 alias)", () => {
|
||||
expect(isThinkingModeModel("deepseek-reasoner")).toBe(true);
|
||||
});
|
||||
it("deepseek-v4-flash → true (default thinking)", () => {
|
||||
expect(isThinkingModeModel("deepseek-v4-flash")).toBe(true);
|
||||
});
|
||||
it("deepseek-v4-pro → true (default thinking)", () => {
|
||||
expect(isThinkingModeModel("deepseek-v4-pro")).toBe(true);
|
||||
});
|
||||
it("deepseek-chat → false (non-thinking compat alias)", () => {
|
||||
expect(isThinkingModeModel("deepseek-chat")).toBe(false);
|
||||
});
|
||||
it("unknown models → false (safe default)", () => {
|
||||
expect(isThinkingModeModel("gpt-4")).toBe(false);
|
||||
expect(isThinkingModeModel("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("thinkingModeForModel", () => {
|
||||
it("chat → disabled, reasoner → enabled (compat aliases pin the mode)", () => {
|
||||
expect(thinkingModeForModel("deepseek-chat")).toBe("disabled");
|
||||
expect(thinkingModeForModel("deepseek-reasoner")).toBe("enabled");
|
||||
});
|
||||
it("v4 models → enabled (docs default)", () => {
|
||||
expect(thinkingModeForModel("deepseek-v4-flash")).toBe("enabled");
|
||||
expect(thinkingModeForModel("deepseek-v4-pro")).toBe("enabled");
|
||||
});
|
||||
it("unknown models → undefined (let server decide)", () => {
|
||||
expect(thinkingModeForModel("gpt-4")).toBeUndefined();
|
||||
expect(thinkingModeForModel("anthropic-claude")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
interface FakeResponseShape {
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
@@ -23,13 +57,25 @@ interface FakeResponseShape {
|
||||
|
||||
function capturingFetch(responses: FakeResponseShape[]): {
|
||||
fetch: typeof fetch;
|
||||
bodies: Array<{ messages: ChatMessage[] }>;
|
||||
bodies: Array<{
|
||||
messages: ChatMessage[];
|
||||
extra_body?: { thinking?: { type?: string } };
|
||||
reasoning_effort?: string;
|
||||
}>;
|
||||
} {
|
||||
const bodies: Array<{ messages: ChatMessage[] }> = [];
|
||||
const bodies: Array<{
|
||||
messages: ChatMessage[];
|
||||
extra_body?: { thinking?: { type?: string } };
|
||||
reasoning_effort?: string;
|
||||
}> = [];
|
||||
let i = 0;
|
||||
const fn = vi.fn(async (_url: any, init: any) => {
|
||||
const body = init?.body ? JSON.parse(init.body) : {};
|
||||
bodies.push({ messages: body.messages });
|
||||
bodies.push({
|
||||
messages: body.messages,
|
||||
extra_body: body.extra_body,
|
||||
reasoning_effort: body.reasoning_effort,
|
||||
});
|
||||
const resp = responses[i++] ?? responses[responses.length - 1]!;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -143,4 +189,54 @@ describe("R1 reasoning_content round-trip", () => {
|
||||
expect(assistant).toBeDefined();
|
||||
expect(assistant?.reasoning_content).toBe("reasoning attached to a plain-text turn");
|
||||
});
|
||||
|
||||
it("pins thinking=enabled + reasoning_effort=max for v4-pro (agent-class default)", async () => {
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([{ content: "done" }]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s" }),
|
||||
model: "deepseek-v4-pro",
|
||||
stream: false,
|
||||
});
|
||||
for await (const _ev of loop.step("hello")) {
|
||||
/* drain */
|
||||
}
|
||||
expect(bodies[0]!.extra_body?.thinking?.type).toBe("enabled");
|
||||
expect(bodies[0]!.reasoning_effort).toBe("max");
|
||||
});
|
||||
|
||||
it("pins thinking=disabled for deepseek-chat (non-thinking compat alias)", async () => {
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([{ content: "done" }]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s" }),
|
||||
model: "deepseek-chat",
|
||||
stream: false,
|
||||
});
|
||||
for await (const _ev of loop.step("hello")) {
|
||||
/* drain */
|
||||
}
|
||||
expect(bodies[0]!.extra_body?.thinking?.type).toBe("disabled");
|
||||
expect(bodies[0]!.reasoning_effort).toBe("max");
|
||||
});
|
||||
|
||||
it("omits thinking entirely for unknown models (let the server decide)", async () => {
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([{ content: "done" }]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s" }),
|
||||
model: "some-third-party-model",
|
||||
stream: false,
|
||||
});
|
||||
for await (const _ev of loop.step("hello")) {
|
||||
/* drain */
|
||||
}
|
||||
expect(bodies[0]!.extra_body).toBeUndefined();
|
||||
// reasoning_effort is always set — it's a benign field for models
|
||||
// that don't know it (OpenAI just ignores unknown top-level fields).
|
||||
expect(bodies[0]!.reasoning_effort).toBe("max");
|
||||
});
|
||||
});
|
||||
+22
-17
@@ -102,7 +102,10 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
await loop.run("q");
|
||||
expect(loop.stats.aggregateCacheHitRatio).toBeCloseTo(0.8);
|
||||
expect(loop.stats.totalCost).toBeGreaterThan(0);
|
||||
expect(loop.stats.savingsVsClaude).toBeGreaterThan(0.9);
|
||||
// Savings vs Claude depends on which DeepSeek model is the loop's
|
||||
// default. v4-pro lands around 0.85; v4-flash around 0.97. Test the
|
||||
// lower bound so a future default swap doesn't churn this assertion.
|
||||
expect(loop.stats.savingsVsClaude).toBeGreaterThan(0.8);
|
||||
});
|
||||
|
||||
it("dispatches a tool call and loops until the model stops", async () => {
|
||||
@@ -386,18 +389,18 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
fn: async () => "ok",
|
||||
});
|
||||
// First response: chaining tool call with a prompt-token count
|
||||
// deliberately over 80% of DeepSeek's 131k window (131k * 0.8 = 104_857).
|
||||
// 120k trips the guard.
|
||||
// deliberately over 80% of DeepSeek V4's 1M window (1M * 0.8 =
|
||||
// 800k). 900k trips the guard.
|
||||
const responses: FakeResponseShape[] = [
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [{ id: "c", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
usage: {
|
||||
prompt_tokens: 120_000,
|
||||
prompt_tokens: 900_000,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 120_050,
|
||||
prompt_cache_hit_tokens: 90_000,
|
||||
prompt_cache_miss_tokens: 30_000,
|
||||
total_tokens: 900_050,
|
||||
prompt_cache_hit_tokens: 700_000,
|
||||
prompt_cache_miss_tokens: 200_000,
|
||||
},
|
||||
},
|
||||
// Forced-summary response (no tools)
|
||||
@@ -446,16 +449,17 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
fn: async () => "ok",
|
||||
});
|
||||
const responses: FakeResponseShape[] = [
|
||||
// Iter 0: chains a tool call, usage trips the guard.
|
||||
// Iter 0: chains a tool call, usage trips the 80% guard against
|
||||
// V4's 1M window.
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
usage: {
|
||||
prompt_tokens: 120_000,
|
||||
prompt_tokens: 900_000,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 120_010,
|
||||
prompt_cache_hit_tokens: 100_000,
|
||||
prompt_cache_miss_tokens: 20_000,
|
||||
total_tokens: 900_010,
|
||||
prompt_cache_hit_tokens: 750_000,
|
||||
prompt_cache_miss_tokens: 150_000,
|
||||
},
|
||||
},
|
||||
// Iter 1: model wraps up normally after the tool result.
|
||||
@@ -505,16 +509,17 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
fn: async () => "ok",
|
||||
});
|
||||
const responses: FakeResponseShape[] = [
|
||||
// 131k * 0.72 ≈ 94,320 tokens — squarely in the proactive band.
|
||||
// V4's 1M window × 0.72 ≈ 720k tokens — squarely in the 60–80%
|
||||
// proactive band without tripping the reactive 80% guard.
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
usage: {
|
||||
prompt_tokens: 95_000,
|
||||
prompt_tokens: 720_000,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 95_010,
|
||||
prompt_cache_hit_tokens: 70_000,
|
||||
prompt_cache_miss_tokens: 25_000,
|
||||
total_tokens: 720_010,
|
||||
prompt_cache_hit_tokens: 550_000,
|
||||
prompt_cache_miss_tokens: 170_000,
|
||||
},
|
||||
},
|
||||
{ content: "done." },
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("resolveDefaults", () => {
|
||||
it("--preset max overrides config.preset=fast", () => {
|
||||
writeConfig({ preset: "fast" }, join(home, ".reasonix", "config.json"));
|
||||
const r = resolveDefaults({ preset: "max" });
|
||||
expect(r.model).toBe("deepseek-reasoner");
|
||||
expect(r.model).toBe("deepseek-v4-pro");
|
||||
expect(r.branch).toBe(3);
|
||||
});
|
||||
|
||||
|
||||
@@ -184,6 +184,29 @@ describe("handleSlash", () => {
|
||||
expect(loop.branchEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("/effort <high|max> flips the reasoningEffort cap", () => {
|
||||
const loop = makeLoop();
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
handleSlash("effort", ["high"], loop);
|
||||
expect(loop.reasoningEffort).toBe("high");
|
||||
handleSlash("effort", ["max"], loop);
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
});
|
||||
|
||||
it("/effort with no arg reports the current value, doesn't change it", () => {
|
||||
const loop = makeLoop();
|
||||
const r = handleSlash("effort", [], loop);
|
||||
expect(r.info).toMatch(/reasoning_effort → max/);
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
});
|
||||
|
||||
it("/effort rejects unknown values", () => {
|
||||
const loop = makeLoop();
|
||||
const r = handleSlash("effort", ["low"], loop);
|
||||
expect(r.info).toMatch(/usage: \/effort <high\|max>/);
|
||||
expect(loop.reasoningEffort).toBe("max");
|
||||
});
|
||||
|
||||
it("/branch caps at 8", () => {
|
||||
const loop = makeLoop();
|
||||
const r = handleSlash("branch", ["99"], loop);
|
||||
|
||||
@@ -288,7 +288,7 @@ describe("registerSubagentTool", () => {
|
||||
registerSubagentTool(parent, { client });
|
||||
// "gpt-4" is not a deepseek-* model — should be ignored.
|
||||
await parent.dispatch("spawn_subagent", JSON.stringify({ task: "go", model: "gpt-4" }));
|
||||
expect(seenModels[0]).toBe("deepseek-chat");
|
||||
expect(seenModels[0]).toBe("deepseek-v4-pro");
|
||||
});
|
||||
|
||||
it("aborts the child when the parent's tool ctx signal fires", async () => {
|
||||
|
||||
+24
-4
@@ -97,13 +97,33 @@ describe("inputCostUsd / outputCostUsd", () => {
|
||||
});
|
||||
|
||||
it("chat and reasoner are unified at the same price", () => {
|
||||
// Post-unification (2026-04): DeepSeek charges identically for both
|
||||
// models. If this test starts failing, either DeepSeek reintroduced
|
||||
// tiered pricing (update the constants) or someone accidentally
|
||||
// edited only one entry — catch that before shipping.
|
||||
// 2026-04 V4 launch: `deepseek-chat` and `deepseek-reasoner` are
|
||||
// compat aliases for v4-flash's non-thinking and thinking modes
|
||||
// respectively, so billing is identical. If this diverges, either
|
||||
// DeepSeek split them again (update the constants) or one alias
|
||||
// got out of sync during an update — catch before shipping.
|
||||
const chat = DEEPSEEK_PRICING["deepseek-chat"]!;
|
||||
const reasoner = DEEPSEEK_PRICING["deepseek-reasoner"]!;
|
||||
const flash = DEEPSEEK_PRICING["deepseek-v4-flash"]!;
|
||||
expect(reasoner).toEqual(chat);
|
||||
expect(chat).toEqual(flash);
|
||||
});
|
||||
|
||||
it("v4-pro pricing is present and strictly above v4-flash", () => {
|
||||
const flash = DEEPSEEK_PRICING["deepseek-v4-flash"]!;
|
||||
const pro = DEEPSEEK_PRICING["deepseek-v4-pro"]!;
|
||||
expect(pro.inputCacheHit).toBeGreaterThan(flash.inputCacheHit);
|
||||
expect(pro.inputCacheMiss).toBeGreaterThan(flash.inputCacheMiss);
|
||||
expect(pro.output).toBeGreaterThan(flash.output);
|
||||
});
|
||||
|
||||
it("v4-pro cost is computed with its own tier, not flash's", () => {
|
||||
// Sanity: passing the pro model to costUsd doesn't silently fall
|
||||
// back to flash rates, otherwise billing on pro would under-count.
|
||||
const u = new Usage(0, 100, 0, 0, 1000);
|
||||
const flashCost = costUsd("deepseek-v4-flash", u);
|
||||
const proCost = costUsd("deepseek-v4-pro", u);
|
||||
expect(proCost).toBeGreaterThan(flashCost * 5); // ~12x on output+miss
|
||||
});
|
||||
|
||||
it("both return 0 for an unknown model", () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Tool-call "ready" progress signal during streaming.
|
||||
*
|
||||
* Matches the 0.5.19 UX fix: as each tool_call's arguments stream into
|
||||
* valid JSON we emit `tool_call_delta` with an incrementing
|
||||
* `toolCallReadyCount`, so the UI can render "N ready · building call
|
||||
* M" instead of a context-free spinner. Dispatch still happens after
|
||||
* the stream ends — this is purely a visibility improvement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeCompleteJson } from "../src/loop.js";
|
||||
|
||||
describe("looksLikeCompleteJson", () => {
|
||||
it("empty / whitespace → false", () => {
|
||||
expect(looksLikeCompleteJson("")).toBe(false);
|
||||
expect(looksLikeCompleteJson(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("partial JSON → false (the common streaming case)", () => {
|
||||
expect(looksLikeCompleteJson("{")).toBe(false);
|
||||
expect(looksLikeCompleteJson('{"path"')).toBe(false);
|
||||
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hel')).toBe(false);
|
||||
});
|
||||
|
||||
it("complete JSON object → true", () => {
|
||||
expect(looksLikeCompleteJson("{}")).toBe(true);
|
||||
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hello"}')).toBe(true);
|
||||
});
|
||||
|
||||
it("complete JSON array → true", () => {
|
||||
expect(looksLikeCompleteJson("[]")).toBe(true);
|
||||
expect(looksLikeCompleteJson('[{"a": 1}]')).toBe(true);
|
||||
});
|
||||
|
||||
it("primitive JSON values → true", () => {
|
||||
expect(looksLikeCompleteJson("true")).toBe(true);
|
||||
expect(looksLikeCompleteJson("42")).toBe(true);
|
||||
expect(looksLikeCompleteJson('"text"')).toBe(true);
|
||||
});
|
||||
|
||||
it("valid JSON with trailing whitespace → true", () => {
|
||||
expect(looksLikeCompleteJson('{"a":1}\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user