codeep 2.13.1 → 2.14.0
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/dist/acp/server.js +265 -247
- package/dist/config/providers.js +20 -12
- package/dist/renderer/agentExecution.d.ts +1 -1
- package/dist/renderer/agentExecution.js +9 -4
- package/dist/renderer/commands.js +4 -1
- package/dist/renderer/main.js +4 -0
- package/dist/utils/agentChat.js +5 -1
- package/dist/utils/codeepCloud.d.ts +6 -0
- package/dist/utils/tokenTracker.d.ts +40 -3
- package/dist/utils/tokenTracker.js +68 -12
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/acp/server.js
CHANGED
|
@@ -18,7 +18,7 @@ import { ApiError } from '../api/index.js';
|
|
|
18
18
|
import { PROVIDERS } from '../config/providers.js';
|
|
19
19
|
import { getCurrentVersion } from '../utils/update.js';
|
|
20
20
|
import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
|
|
21
|
-
import { getCostBreakdown,
|
|
21
|
+
import { getCostBreakdown, getRecordCount, createTokenScope, runWithTokenScope } from '../utils/tokenTracker.js';
|
|
22
22
|
import { isGitRepository } from '../utils/git.js';
|
|
23
23
|
import { getProjectContext } from '../utils/project.js';
|
|
24
24
|
// ─── Slash commands advertised to Zed ────────────────────────────────────────
|
|
@@ -141,10 +141,18 @@ export function formatToolInputForPermission(tool, params) {
|
|
|
141
141
|
? lines.slice(0, MAX_CONTENT_LINES).join('\n') + `\n… (${lines.length - MAX_CONTENT_LINES} more lines)`
|
|
142
142
|
: params.content;
|
|
143
143
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
// The built-in edit_file tool schema emits old_text / new_text (see
|
|
145
|
+
// utils/tools.ts); accept the old_string/new_string spelling too for
|
|
146
|
+
// robustness. Without this the permission dialog dropped the diff and
|
|
147
|
+
// showed only { file, path } — the user approved edits blind.
|
|
148
|
+
const oldText = typeof params.old_text === 'string' ? params.old_text
|
|
149
|
+
: (typeof params.old_string === 'string' ? params.old_string : undefined);
|
|
150
|
+
const newText = typeof params.new_text === 'string' ? params.new_text
|
|
151
|
+
: (typeof params.new_string === 'string' ? params.new_string : undefined);
|
|
152
|
+
if (oldText !== undefined && newText !== undefined) {
|
|
153
|
+
out.changes = `replace ${oldText.split('\n').length} line(s)`;
|
|
154
|
+
out.old_string = truncateDiff(oldText);
|
|
155
|
+
out.new_string = truncateDiff(newText);
|
|
148
156
|
}
|
|
149
157
|
return out;
|
|
150
158
|
}
|
|
@@ -860,7 +868,6 @@ export function startAcpServer() {
|
|
|
860
868
|
},
|
|
861
869
|
});
|
|
862
870
|
};
|
|
863
|
-
resetTokenTracking();
|
|
864
871
|
// Manual mode gates write/edit for THIS run via a per-call option passed to
|
|
865
872
|
// runAgentSession (extraDangerousTools, below) — NOT by mutating the global
|
|
866
873
|
// `agentConfirmWriteFile` config, which leaked the session's mode into the
|
|
@@ -876,255 +883,283 @@ export function startAcpServer() {
|
|
|
876
883
|
},
|
|
877
884
|
});
|
|
878
885
|
};
|
|
879
|
-
// Try slash commands first
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
// Update title with first real prompt if session had no history
|
|
896
|
-
if (!session.titleSent && !session.hadHistory) {
|
|
897
|
-
session.titleSent = true;
|
|
898
|
-
sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
|
|
899
|
-
}
|
|
900
|
-
transport.respond(msg.id, { stopReason: 'end_turn' });
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
// Not a command — run agent loop
|
|
904
|
-
let enrichedPrompt = prompt;
|
|
905
|
-
if (session.addedFiles.size > 0) {
|
|
906
|
-
const parts = ['[Attached files]'];
|
|
907
|
-
for (const [, f] of session.addedFiles) {
|
|
908
|
-
parts.push(`\nFile: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``);
|
|
909
|
-
}
|
|
910
|
-
enrichedPrompt = parts.join('\n') + '\n\n' + prompt;
|
|
911
|
-
}
|
|
912
|
-
runAgentSession({
|
|
913
|
-
prompt: enrichedPrompt,
|
|
914
|
-
workspaceRoot: session.workspaceRoot,
|
|
915
|
-
conversationId: params.sessionId,
|
|
916
|
-
abortSignal: abortController.signal,
|
|
917
|
-
onChunk: sendChunk,
|
|
918
|
-
onThought: (text) => {
|
|
919
|
-
transport.notify('session/update', {
|
|
920
|
-
sessionId: params.sessionId,
|
|
921
|
-
update: {
|
|
922
|
-
sessionUpdate: 'agent_thought_chunk',
|
|
923
|
-
content: { type: 'text', text },
|
|
924
|
-
},
|
|
925
|
-
});
|
|
926
|
-
},
|
|
927
|
-
onToolCall: (toolCallId, toolName, kind, title, status, locations, rawOutput) => {
|
|
928
|
-
if (status === 'running') {
|
|
929
|
-
// Initial tool_call notification: spec ToolCall shape
|
|
886
|
+
// Try slash commands first.
|
|
887
|
+
// Run the whole prompt lifecycle inside THIS ACP session's token scope so
|
|
888
|
+
// (a) concurrent sessions on one process can't mix usage totals, and
|
|
889
|
+
// (b) usage accumulates into the session's own buffer across prompts, so
|
|
890
|
+
// `/cost` stays session-cumulative. `tokenReportStart` marks the pre-prompt
|
|
891
|
+
// count so we report only this prompt's delta to cloud telemetry.
|
|
892
|
+
session.tokenRecords ??= createTokenScope();
|
|
893
|
+
runWithTokenScope(session.tokenRecords, () => {
|
|
894
|
+
const tokenReportStart = getRecordCount();
|
|
895
|
+
return handleCommand(prompt, session, sendChunk, abortController.signal)
|
|
896
|
+
.then((cmd) => {
|
|
897
|
+
if (cmd.handled) {
|
|
898
|
+
if (cmd.response)
|
|
899
|
+
sendChunk(cmd.response);
|
|
900
|
+
// If provider or model changed, push updated config options to Zed
|
|
901
|
+
if (cmd.configOptionsChanged) {
|
|
930
902
|
transport.notify('session/update', {
|
|
931
903
|
sessionId: params.sessionId,
|
|
932
904
|
update: {
|
|
933
|
-
sessionUpdate: '
|
|
934
|
-
|
|
935
|
-
title: title || toolName,
|
|
936
|
-
kind: kind || 'other',
|
|
937
|
-
status: 'in_progress',
|
|
938
|
-
...(locations && locations.length > 0
|
|
939
|
-
? { locations: locations.map(path => ({ path })) }
|
|
940
|
-
: {}),
|
|
905
|
+
sessionUpdate: 'config_option_update',
|
|
906
|
+
configOptions: buildConfigOptions(),
|
|
941
907
|
},
|
|
942
908
|
});
|
|
943
|
-
// Add to plan as in_progress — only meaningful actions (not reads)
|
|
944
|
-
if (kind === 'edit' || kind === 'execute' || kind === 'delete') {
|
|
945
|
-
planEntries.set(toolCallId, {
|
|
946
|
-
id: toolCallId,
|
|
947
|
-
content: title || toolName,
|
|
948
|
-
priority: kind === 'execute' ? 'high' : 'medium',
|
|
949
|
-
status: 'in_progress',
|
|
950
|
-
});
|
|
951
|
-
sendPlan();
|
|
952
|
-
}
|
|
953
909
|
}
|
|
954
|
-
|
|
955
|
-
|
|
910
|
+
// Update title with first real prompt if session had no history
|
|
911
|
+
if (!session.titleSent && !session.hadHistory) {
|
|
912
|
+
session.titleSent = true;
|
|
913
|
+
sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
|
|
914
|
+
}
|
|
915
|
+
transport.respond(msg.id, { stopReason: 'end_turn' });
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
// Not a command — run agent loop
|
|
919
|
+
let enrichedPrompt = prompt;
|
|
920
|
+
if (session.addedFiles.size > 0) {
|
|
921
|
+
const parts = ['[Attached files]'];
|
|
922
|
+
for (const [, f] of session.addedFiles) {
|
|
923
|
+
parts.push(`\nFile: ${f.relativePath}\n\`\`\`\n${f.content}\n\`\`\``);
|
|
924
|
+
}
|
|
925
|
+
enrichedPrompt = parts.join('\n') + '\n\n' + prompt;
|
|
926
|
+
}
|
|
927
|
+
runAgentSession({
|
|
928
|
+
prompt: enrichedPrompt,
|
|
929
|
+
workspaceRoot: session.workspaceRoot,
|
|
930
|
+
conversationId: params.sessionId,
|
|
931
|
+
abortSignal: abortController.signal,
|
|
932
|
+
onChunk: sendChunk,
|
|
933
|
+
onThought: (text) => {
|
|
956
934
|
transport.notify('session/update', {
|
|
957
935
|
sessionId: params.sessionId,
|
|
958
936
|
update: {
|
|
959
|
-
sessionUpdate: '
|
|
960
|
-
|
|
961
|
-
status: status === 'finished' ? 'completed' : 'failed',
|
|
962
|
-
...(rawOutput !== undefined ? { rawOutput } : {}),
|
|
937
|
+
sessionUpdate: 'agent_thought_chunk',
|
|
938
|
+
content: { type: 'text', text },
|
|
963
939
|
},
|
|
964
940
|
});
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
941
|
+
},
|
|
942
|
+
onToolCall: (toolCallId, toolName, kind, title, status, locations, rawOutput) => {
|
|
943
|
+
if (status === 'running') {
|
|
944
|
+
// Initial tool_call notification: spec ToolCall shape
|
|
945
|
+
transport.notify('session/update', {
|
|
946
|
+
sessionId: params.sessionId,
|
|
947
|
+
update: {
|
|
948
|
+
sessionUpdate: 'tool_call',
|
|
949
|
+
toolCallId,
|
|
950
|
+
title: title || toolName,
|
|
951
|
+
kind: kind || 'other',
|
|
952
|
+
status: 'in_progress',
|
|
953
|
+
...(locations && locations.length > 0
|
|
954
|
+
? { locations: locations.map(path => ({ path })) }
|
|
955
|
+
: {}),
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
// Add to plan as in_progress — only meaningful actions (not reads)
|
|
959
|
+
if (kind === 'edit' || kind === 'execute' || kind === 'delete') {
|
|
960
|
+
planEntries.set(toolCallId, {
|
|
961
|
+
id: toolCallId,
|
|
962
|
+
content: title || toolName,
|
|
963
|
+
priority: kind === 'execute' ? 'high' : 'medium',
|
|
964
|
+
status: 'in_progress',
|
|
965
|
+
});
|
|
966
|
+
sendPlan();
|
|
967
|
+
}
|
|
970
968
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
// no global config mutation).
|
|
975
|
-
extraDangerousTools: session.currentModeId === 'manual' ? ['write_file', 'edit_file'] : undefined,
|
|
976
|
-
// Only request permission in Manual mode
|
|
977
|
-
onRequestPermission: session.currentModeId === 'manual'
|
|
978
|
-
? async (toolCall) => {
|
|
979
|
-
const permToolCallId = `perm_${randomUUID()}`;
|
|
980
|
-
const result = await transport.request('session/request_permission', {
|
|
981
|
-
sessionId: params.sessionId,
|
|
982
|
-
toolCall: {
|
|
983
|
-
toolCallId: permToolCallId,
|
|
984
|
-
toolName: toolCall.tool,
|
|
985
|
-
toolInput: formatToolInputForPermission(toolCall.tool, toolCall.parameters),
|
|
986
|
-
status: 'pending',
|
|
987
|
-
content: [],
|
|
988
|
-
},
|
|
989
|
-
options: [
|
|
990
|
-
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
|
|
991
|
-
{ optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' },
|
|
992
|
-
{ optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
|
|
993
|
-
{ optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
|
|
994
|
-
],
|
|
995
|
-
});
|
|
996
|
-
// Map ACP outcome back to PermissionOutcome
|
|
997
|
-
if (!result || result.outcome.type === 'cancelled')
|
|
998
|
-
return 'reject_once';
|
|
999
|
-
return result.outcome.optionId;
|
|
1000
|
-
}
|
|
1001
|
-
: undefined,
|
|
1002
|
-
// Per ACP spec, `fs/read_text_file` and `fs/write_text_file` are
|
|
1003
|
-
// CLIENT methods — only safe to call when the client advertised
|
|
1004
|
-
// the capability in `initialize`. Routing through the client
|
|
1005
|
-
// means the editor's dirty buffers + undo history stay correct
|
|
1006
|
-
// (otherwise an in-editor unsaved change would be invisible to
|
|
1007
|
-
// the agent, or worse, silently overwritten).
|
|
1008
|
-
fs: {
|
|
1009
|
-
readTextFile: clientSupportsFsRead
|
|
1010
|
-
? async (absolutePath) => {
|
|
1011
|
-
const result = await transport.request('fs/read_text_file', {
|
|
969
|
+
else {
|
|
970
|
+
// tool_call_update: update status to completed/failed, with optional content
|
|
971
|
+
transport.notify('session/update', {
|
|
1012
972
|
sessionId: params.sessionId,
|
|
1013
|
-
|
|
973
|
+
update: {
|
|
974
|
+
sessionUpdate: 'tool_call_update',
|
|
975
|
+
toolCallId,
|
|
976
|
+
status: status === 'finished' ? 'completed' : 'failed',
|
|
977
|
+
...(rawOutput !== undefined ? { rawOutput } : {}),
|
|
978
|
+
},
|
|
1014
979
|
});
|
|
1015
|
-
|
|
1016
|
-
|
|
980
|
+
// Mark plan entry as completed
|
|
981
|
+
const entry = planEntries.get(toolCallId);
|
|
982
|
+
if (entry) {
|
|
983
|
+
entry.status = 'completed';
|
|
984
|
+
sendPlan();
|
|
1017
985
|
}
|
|
1018
|
-
return result.content;
|
|
1019
986
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
987
|
+
},
|
|
988
|
+
// Manual mode gates write_file/edit_file for this run only (per-call,
|
|
989
|
+
// no global config mutation).
|
|
990
|
+
extraDangerousTools: session.currentModeId === 'manual' ? ['write_file', 'edit_file'] : undefined,
|
|
991
|
+
// Only request permission in Manual mode
|
|
992
|
+
onRequestPermission: session.currentModeId === 'manual'
|
|
993
|
+
? async (toolCall) => {
|
|
994
|
+
const permToolCallId = `perm_${randomUUID()}`;
|
|
995
|
+
const result = await transport.request('session/request_permission', {
|
|
1024
996
|
sessionId: params.sessionId,
|
|
1025
|
-
|
|
1026
|
-
|
|
997
|
+
toolCall: {
|
|
998
|
+
toolCallId: permToolCallId,
|
|
999
|
+
toolName: toolCall.tool,
|
|
1000
|
+
toolInput: formatToolInputForPermission(toolCall.tool, toolCall.parameters),
|
|
1001
|
+
status: 'pending',
|
|
1002
|
+
content: [],
|
|
1003
|
+
},
|
|
1004
|
+
options: [
|
|
1005
|
+
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
|
|
1006
|
+
{ optionId: 'allow_always', name: 'Allow always', kind: 'allow_always' },
|
|
1007
|
+
{ optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' },
|
|
1008
|
+
{ optionId: 'reject_always', name: 'Reject always', kind: 'reject_always' },
|
|
1009
|
+
],
|
|
1027
1010
|
});
|
|
1011
|
+
// Map ACP outcome back to PermissionOutcome
|
|
1012
|
+
if (!result || result.outcome.type === 'cancelled')
|
|
1013
|
+
return 'reject_once';
|
|
1014
|
+
return result.outcome.optionId;
|
|
1028
1015
|
}
|
|
1029
1016
|
: undefined,
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
//
|
|
1033
|
-
//
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1017
|
+
// Per ACP spec, `fs/read_text_file` and `fs/write_text_file` are
|
|
1018
|
+
// CLIENT methods — only safe to call when the client advertised
|
|
1019
|
+
// the capability in `initialize`. Routing through the client
|
|
1020
|
+
// means the editor's dirty buffers + undo history stay correct
|
|
1021
|
+
// (otherwise an in-editor unsaved change would be invisible to
|
|
1022
|
+
// the agent, or worse, silently overwritten).
|
|
1023
|
+
fs: {
|
|
1024
|
+
readTextFile: clientSupportsFsRead
|
|
1025
|
+
? async (absolutePath) => {
|
|
1026
|
+
const result = await transport.request('fs/read_text_file', {
|
|
1027
|
+
sessionId: params.sessionId,
|
|
1028
|
+
path: absolutePath,
|
|
1029
|
+
});
|
|
1030
|
+
if (!result || typeof result.content !== 'string') {
|
|
1031
|
+
throw new Error('fs/read_text_file returned no content');
|
|
1032
|
+
}
|
|
1033
|
+
return result.content;
|
|
1034
|
+
}
|
|
1035
|
+
: undefined,
|
|
1036
|
+
writeTextFile: clientSupportsFsWrite
|
|
1037
|
+
? async (absolutePath, content) => {
|
|
1038
|
+
await transport.request('fs/write_text_file', {
|
|
1039
|
+
sessionId: params.sessionId,
|
|
1040
|
+
path: absolutePath,
|
|
1041
|
+
content,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
: undefined,
|
|
1045
|
+
},
|
|
1046
|
+
onExecuteCommand: async (command, args, cwd) => {
|
|
1047
|
+
// Per ACP spec, only call terminal/* if the client advertised the
|
|
1048
|
+
// capability in initialize. Otherwise execute locally.
|
|
1049
|
+
if (!clientSupportsTerminal) {
|
|
1050
|
+
const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: 120000 });
|
|
1051
|
+
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
|
|
1052
|
+
}
|
|
1053
|
+
try {
|
|
1054
|
+
const createResult = await transport.request('terminal/create', {
|
|
1055
|
+
sessionId: params.sessionId,
|
|
1056
|
+
command,
|
|
1057
|
+
args,
|
|
1058
|
+
cwd,
|
|
1059
|
+
outputByteLimit: 1_000_000,
|
|
1060
|
+
});
|
|
1061
|
+
const { terminalId } = createResult;
|
|
1062
|
+
// Spec method is snake_case `terminal/wait_for_exit` and takes
|
|
1063
|
+
// only { sessionId, terminalId } — no timeoutMs.
|
|
1064
|
+
const waitResult = await transport.request('terminal/wait_for_exit', {
|
|
1065
|
+
sessionId: params.sessionId,
|
|
1066
|
+
terminalId,
|
|
1067
|
+
});
|
|
1068
|
+
const outputResult = await transport.request('terminal/output', {
|
|
1069
|
+
sessionId: params.sessionId,
|
|
1070
|
+
terminalId,
|
|
1071
|
+
});
|
|
1072
|
+
await transport.request('terminal/release', {
|
|
1073
|
+
sessionId: params.sessionId,
|
|
1074
|
+
terminalId,
|
|
1075
|
+
});
|
|
1076
|
+
const exitCode = waitResult.exitStatus.type === 'exited' ? waitResult.exitStatus.code : 1;
|
|
1077
|
+
return { stdout: outputResult.output ?? '', stderr: '', exitCode };
|
|
1078
|
+
}
|
|
1079
|
+
catch (err) {
|
|
1080
|
+
// Client terminal failed — fall back to local execution
|
|
1081
|
+
const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: 120000 });
|
|
1082
|
+
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
|
|
1083
|
+
}
|
|
1084
|
+
},
|
|
1085
|
+
}).then(() => {
|
|
1086
|
+
session.history.push({ role: 'user', content: prompt });
|
|
1087
|
+
const agentResponse = agentResponseChunks.join('');
|
|
1088
|
+
if (agentResponse) {
|
|
1089
|
+
session.history.push({ role: 'assistant', content: agentResponse });
|
|
1037
1090
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1091
|
+
autoSaveSession(session.history, session.workspaceRoot);
|
|
1092
|
+
// Report token usage to dashboard
|
|
1093
|
+
const projectCtx = getProjectContext(session.workspaceRoot);
|
|
1094
|
+
const sharedFields = {
|
|
1095
|
+
sessionId: session.codeepSessionId,
|
|
1096
|
+
sessionName: session.codeepSessionId,
|
|
1097
|
+
messageCount: session.history.length,
|
|
1098
|
+
cliVersion: getCurrentVersion(),
|
|
1099
|
+
projectName: projectCtx?.name,
|
|
1100
|
+
projectId: generateProjectId(session.workspaceRoot),
|
|
1101
|
+
language: projectCtx?.type,
|
|
1102
|
+
isGit: isGitRepository(session.workspaceRoot),
|
|
1103
|
+
};
|
|
1104
|
+
const costBreakdown = getCostBreakdown(tokenReportStart);
|
|
1105
|
+
if (costBreakdown.length > 0) {
|
|
1106
|
+
for (const entry of costBreakdown) {
|
|
1107
|
+
reportStats({
|
|
1108
|
+
...sharedFields,
|
|
1109
|
+
model: entry.model,
|
|
1110
|
+
provider: entry.provider,
|
|
1111
|
+
inputTokens: entry.promptTokens || undefined,
|
|
1112
|
+
outputTokens: entry.completionTokens || undefined,
|
|
1113
|
+
cacheCreationTokens: entry.cacheCreationTokens || undefined,
|
|
1114
|
+
cacheReadTokens: entry.cacheReadTokens || undefined,
|
|
1115
|
+
estimatedCost: entry.estimatedCost || undefined,
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1063
1118
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
const r = await executeCommandAsync(command, args, { cwd, projectRoot: cwd, timeout: 120000 });
|
|
1067
|
-
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', exitCode: r.exitCode ?? 0 };
|
|
1119
|
+
else {
|
|
1120
|
+
reportStats({ ...sharedFields, model: config.get('model'), provider: config.get('provider') });
|
|
1068
1121
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
sessionId: session.codeepSessionId,
|
|
1081
|
-
sessionName: session.codeepSessionId,
|
|
1082
|
-
messageCount: session.history.length,
|
|
1083
|
-
cliVersion: getCurrentVersion(),
|
|
1084
|
-
projectName: projectCtx?.name,
|
|
1085
|
-
projectId: generateProjectId(session.workspaceRoot),
|
|
1086
|
-
language: projectCtx?.type,
|
|
1087
|
-
isGit: isGitRepository(session.workspaceRoot),
|
|
1088
|
-
};
|
|
1089
|
-
const costBreakdown = getCostBreakdown();
|
|
1090
|
-
if (costBreakdown.length > 0) {
|
|
1091
|
-
for (const entry of costBreakdown) {
|
|
1092
|
-
reportStats({
|
|
1093
|
-
...sharedFields,
|
|
1094
|
-
model: entry.model,
|
|
1095
|
-
provider: entry.provider,
|
|
1096
|
-
inputTokens: entry.promptTokens || undefined,
|
|
1097
|
-
outputTokens: entry.completionTokens || undefined,
|
|
1098
|
-
estimatedCost: entry.estimatedCost || undefined,
|
|
1099
|
-
});
|
|
1122
|
+
// Sync session history to dashboard
|
|
1123
|
+
syncSession({
|
|
1124
|
+
sessionId: session.codeepSessionId,
|
|
1125
|
+
projectName: projectCtx?.name,
|
|
1126
|
+
projectId: generateProjectId(session.workspaceRoot),
|
|
1127
|
+
messages: session.history,
|
|
1128
|
+
});
|
|
1129
|
+
// Update title with first real prompt if session had no history
|
|
1130
|
+
if (!session.titleSent && !session.hadHistory) {
|
|
1131
|
+
session.titleSent = true;
|
|
1132
|
+
sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
|
|
1100
1133
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
messages: session.history,
|
|
1111
|
-
});
|
|
1112
|
-
// Update title with first real prompt if session had no history
|
|
1113
|
-
if (!session.titleSent && !session.hadHistory) {
|
|
1114
|
-
session.titleSent = true;
|
|
1115
|
-
sendSessionTitle(params.sessionId, [{ role: 'user', content: prompt }]);
|
|
1116
|
-
}
|
|
1117
|
-
transport.respond(msg.id, { stopReason: 'end_turn' });
|
|
1118
|
-
}).catch((err) => {
|
|
1119
|
-
if (err.name === 'AbortError') {
|
|
1120
|
-
// Clear plan UI on the client side when session is cancelled
|
|
1121
|
-
if (planEntries.size > 0) {
|
|
1122
|
-
planEntries.clear();
|
|
1123
|
-
sendPlan();
|
|
1134
|
+
transport.respond(msg.id, { stopReason: 'end_turn' });
|
|
1135
|
+
}).catch((err) => {
|
|
1136
|
+
if (err.name === 'AbortError') {
|
|
1137
|
+
// Clear plan UI on the client side when session is cancelled
|
|
1138
|
+
if (planEntries.size > 0) {
|
|
1139
|
+
planEntries.clear();
|
|
1140
|
+
sendPlan();
|
|
1141
|
+
}
|
|
1142
|
+
transport.respond(msg.id, { stopReason: 'cancelled' });
|
|
1124
1143
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1144
|
+
else if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
|
|
1145
|
+
sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
|
|
1146
|
+
transport.respond(msg.id, { stopReason: 'end_turn' });
|
|
1147
|
+
}
|
|
1148
|
+
else if (err instanceof ApiError && err.status >= 500) {
|
|
1149
|
+
sendChunk(`⚠️ API server error (${err.status}). Please try again.`);
|
|
1150
|
+
transport.respond(msg.id, { stopReason: 'end_turn' });
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
transport.error(msg.id, -32000, err.message);
|
|
1154
|
+
}
|
|
1155
|
+
}).finally(() => {
|
|
1156
|
+
if (session)
|
|
1157
|
+
session.abortController = null;
|
|
1158
|
+
planEntries.clear();
|
|
1159
|
+
});
|
|
1160
|
+
})
|
|
1161
|
+
.catch((err) => {
|
|
1162
|
+
if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
|
|
1128
1163
|
sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
|
|
1129
1164
|
transport.respond(msg.id, { stopReason: 'end_turn' });
|
|
1130
1165
|
}
|
|
@@ -1135,26 +1170,9 @@ export function startAcpServer() {
|
|
|
1135
1170
|
else {
|
|
1136
1171
|
transport.error(msg.id, -32000, err.message);
|
|
1137
1172
|
}
|
|
1138
|
-
}).finally(() => {
|
|
1139
1173
|
if (session)
|
|
1140
1174
|
session.abortController = null;
|
|
1141
|
-
planEntries.clear();
|
|
1142
1175
|
});
|
|
1143
|
-
})
|
|
1144
|
-
.catch((err) => {
|
|
1145
|
-
if (err.message?.includes('API key not configured') || err.message?.includes('API key') || (err instanceof ApiError && err.status === 401)) {
|
|
1146
|
-
sendChunk(`❌ No API key configured. Use /login <provider> <key> or set the environment variable (e.g. ZAI_API_KEY, ANTHROPIC_API_KEY).`);
|
|
1147
|
-
transport.respond(msg.id, { stopReason: 'end_turn' });
|
|
1148
|
-
}
|
|
1149
|
-
else if (err instanceof ApiError && err.status >= 500) {
|
|
1150
|
-
sendChunk(`⚠️ API server error (${err.status}). Please try again.`);
|
|
1151
|
-
transport.respond(msg.id, { stopReason: 'end_turn' });
|
|
1152
|
-
}
|
|
1153
|
-
else {
|
|
1154
|
-
transport.error(msg.id, -32000, err.message);
|
|
1155
|
-
}
|
|
1156
|
-
if (session)
|
|
1157
|
-
session.abortController = null;
|
|
1158
1176
|
});
|
|
1159
1177
|
}
|
|
1160
1178
|
// Keep process alive until stdin closes (Zed terminates us)
|
package/dist/config/providers.js
CHANGED
|
@@ -428,8 +428,9 @@ export const PROVIDERS = {
|
|
|
428
428
|
},
|
|
429
429
|
},
|
|
430
430
|
models: [
|
|
431
|
+
{ id: 'claude-fable-5', name: 'Claude Fable 5', description: 'Most capable — hardest reasoning & long-horizon agentic work' },
|
|
431
432
|
{ id: 'claude-opus-4-8', name: 'Claude Opus 4.8', description: 'Most capable Opus model' },
|
|
432
|
-
{ id: 'claude-sonnet-
|
|
433
|
+
{ id: 'claude-sonnet-5', name: 'Claude Sonnet 5', description: 'Best balance of speed and intelligence' },
|
|
433
434
|
{ id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku', description: 'Fastest and most affordable' },
|
|
434
435
|
],
|
|
435
436
|
defaultModel: 'claude-opus-4-8',
|
|
@@ -475,8 +476,9 @@ export const PROVIDERS = {
|
|
|
475
476
|
// get a working dropdown.
|
|
476
477
|
models: [
|
|
477
478
|
{ id: 'openrouter/auto', name: 'Auto-route', description: 'OpenRouter picks the best model for the task' },
|
|
478
|
-
{ id: 'anthropic/claude-
|
|
479
|
-
{ id: 'anthropic/claude-
|
|
479
|
+
{ id: 'anthropic/claude-fable-5', name: 'Claude Fable 5', description: 'Anthropic — most capable' },
|
|
480
|
+
{ id: 'anthropic/claude-opus-4', name: 'Claude Opus 4', description: 'Anthropic — Opus tier' },
|
|
481
|
+
{ id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5', description: 'Anthropic — balanced' },
|
|
480
482
|
{ id: 'openai/gpt-5.5', name: 'GPT-5.5', description: 'OpenAI — flagship' },
|
|
481
483
|
{ id: 'openai/gpt-5.4-mini', name: 'GPT-5.4 Mini', description: 'OpenAI — fast/cheap' },
|
|
482
484
|
{ id: 'google/gemini-3.1-pro', name: 'Gemini 3.1 Pro', description: 'Google — multimodal' },
|
|
@@ -651,14 +653,15 @@ export function providerNoStreamWithTools(providerId) {
|
|
|
651
653
|
}
|
|
652
654
|
/**
|
|
653
655
|
* Models that reject sampling parameters (temperature/top_p/top_k) with a 400.
|
|
654
|
-
* Anthropic removed them on Fable 5 and Opus 4.7
|
|
655
|
-
* accept them, so this must be a
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
656
|
+
* Anthropic removed them on Fable 5 and Opus 4.7+, and Sonnet 5 rejects any
|
|
657
|
+
* non-default value; older Claude models still accept them, so this must be a
|
|
658
|
+
* MODEL-level check, not a provider-level one (requiresDefaultTemperature
|
|
659
|
+
* can't express it). Omitting the field is always safe — the API treats
|
|
660
|
+
* omission as default. Kimi K2.x code/thinking models fix temperature
|
|
661
|
+
* internally and 400 on any custom value, so they're here too.
|
|
659
662
|
*/
|
|
660
663
|
const SAMPLING_PARAMS_REJECTED = [
|
|
661
|
-
'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7',
|
|
664
|
+
'claude-fable-5', 'claude-opus-4-8', 'claude-opus-4-7', 'claude-sonnet-5',
|
|
662
665
|
'kimi-k2.7-code', 'kimi-for-coding',
|
|
663
666
|
];
|
|
664
667
|
export function modelRejectsSamplingParams(model) {
|
|
@@ -701,10 +704,10 @@ export function modelSupportsReasoningEffort(providerId, model) {
|
|
|
701
704
|
const id = canonicalModelId(model);
|
|
702
705
|
switch (providerId) {
|
|
703
706
|
case 'anthropic':
|
|
704
|
-
// Effort is GA on Opus 4.5+, Sonnet 4.6, Fable 5 — NOT Haiku or Sonnet 4.5.
|
|
707
|
+
// Effort is GA on Opus 4.5+, Sonnet 4.6/5, Fable 5 — NOT Haiku or Sonnet 4.5.
|
|
705
708
|
if (idMatches(id, 'claude-haiku-4-5') || idMatches(id, 'claude-sonnet-4-5'))
|
|
706
709
|
return false;
|
|
707
|
-
return /^claude-(opus-4-([5-9]|\d\d)|sonnet-4-6|fable-5)/.test(id);
|
|
710
|
+
return /^claude-(opus-4-([5-9]|\d\d)|sonnet-(4-6|5)|fable-5)/.test(id);
|
|
708
711
|
case 'openai':
|
|
709
712
|
// GPT-5.x are reasoning models — reasoning_effort across the family (incl. mini).
|
|
710
713
|
return id.startsWith('gpt-5');
|
|
@@ -722,7 +725,12 @@ export function modelSupportsReasoningEffort(providerId, model) {
|
|
|
722
725
|
return idMatches(id, 'glm-5-2');
|
|
723
726
|
case 'grok':
|
|
724
727
|
// Grok reasoning models accept reasoning_effort (none/low/medium/high).
|
|
725
|
-
//
|
|
728
|
+
// The coders (grok-code-fast, grok-build — the default) are NON-reasoning
|
|
729
|
+
// and 400 on reasoning_effort; a 400 here silently drops the whole turn
|
|
730
|
+
// into the weaker text-tool fallback (agentChat.ts), so exclude them
|
|
731
|
+
// alongside the explicit *-non-reasoning variants.
|
|
732
|
+
if (id.startsWith('grok-build') || id.startsWith('grok-code'))
|
|
733
|
+
return false;
|
|
726
734
|
return id.startsWith('grok') && !id.includes('non-reasoning');
|
|
727
735
|
// Kimi (thinking on/off, not graded) and Qwen coders (non-thinking) have
|
|
728
736
|
// no graded knob → fall through to default false.
|
|
@@ -23,7 +23,7 @@ export interface AppExecutionContext {
|
|
|
23
23
|
formatAddedFilesContext: () => string;
|
|
24
24
|
handleCommand: (command: string, args: string[]) => Promise<void>;
|
|
25
25
|
sessionDisplayName?: string;
|
|
26
|
-
setSessionDisplayName?: (name: string) => void;
|
|
26
|
+
setSessionDisplayName?: (name: string | null) => void;
|
|
27
27
|
}
|
|
28
28
|
export declare function isDangerousTool(toolName: string, parameters: Record<string, unknown>): boolean;
|
|
29
29
|
export declare function requestToolConfirmation(app: App, tool: string, parameters: Record<string, unknown>, onConfirm: () => void, onCancel: () => void): void;
|
|
@@ -10,7 +10,7 @@ import { runAgent } from '../utils/agent.js';
|
|
|
10
10
|
import { config, autoSaveSession, getCurrentSessionId } from '../config/index.js';
|
|
11
11
|
import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
|
|
12
12
|
import { getGitStatus, isGitRepository } from '../utils/git.js';
|
|
13
|
-
import { getCostBreakdown,
|
|
13
|
+
import { getCostBreakdown, getRecordCount } from '../utils/tokenTracker.js';
|
|
14
14
|
function getActionType(toolName) {
|
|
15
15
|
return toolName.includes('write') ? 'write' :
|
|
16
16
|
toolName.includes('edit') ? 'edit' :
|
|
@@ -140,7 +140,9 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
140
140
|
ctx.setAgentRunning(true);
|
|
141
141
|
const abortController = new AbortController();
|
|
142
142
|
ctx.setAbortController(abortController);
|
|
143
|
-
|
|
143
|
+
// Marker for cloud reporting: report only this run's tokens to the dashboard
|
|
144
|
+
// without wiping the session-cumulative store the status bar and `/cost` read.
|
|
145
|
+
const tokenReportStart = getRecordCount();
|
|
144
146
|
const prefix = dryRun ? '[DRY RUN] ' : '[AGENT] ';
|
|
145
147
|
app.addMessage({ role: 'user', content: prefix + task });
|
|
146
148
|
app.setAgentRunning(true);
|
|
@@ -378,8 +380,9 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
378
380
|
messages: app.getMessages(),
|
|
379
381
|
});
|
|
380
382
|
// Report per-model so tokens are attributed to the correct model/provider
|
|
381
|
-
// even if the user switched model mid-session.
|
|
382
|
-
|
|
383
|
+
// even if the user switched model mid-session. Only this run's delta
|
|
384
|
+
// (since tokenReportStart) is reported; the cumulative store is preserved.
|
|
385
|
+
const costBreakdown = getCostBreakdown(tokenReportStart);
|
|
383
386
|
const sharedFields = {
|
|
384
387
|
sessionId,
|
|
385
388
|
sessionName: displayName,
|
|
@@ -398,6 +401,8 @@ export async function executeAgentTask(task, dryRun, ctx) {
|
|
|
398
401
|
provider: entry.provider,
|
|
399
402
|
inputTokens: entry.promptTokens || undefined,
|
|
400
403
|
outputTokens: entry.completionTokens || undefined,
|
|
404
|
+
cacheCreationTokens: entry.cacheCreationTokens || undefined,
|
|
405
|
+
cacheReadTokens: entry.cacheReadTokens || undefined,
|
|
401
406
|
estimatedCost: entry.estimatedCost || undefined,
|
|
402
407
|
});
|
|
403
408
|
}
|
|
@@ -679,6 +679,9 @@ export async function handleCommand(command, args, ctx) {
|
|
|
679
679
|
case 'new': {
|
|
680
680
|
ctx.app.clearMessages();
|
|
681
681
|
ctx.setSessionId(startNewSession());
|
|
682
|
+
// Clear the derived display name so the next chat re-derives it — else
|
|
683
|
+
// the new session syncs/reports under the PREVIOUS session's name.
|
|
684
|
+
ctx.setSessionDisplayName?.(null);
|
|
682
685
|
ctx.app.notify('New session started');
|
|
683
686
|
break;
|
|
684
687
|
}
|
|
@@ -1071,7 +1074,7 @@ Format: use headers per category, only include categories where you found issues
|
|
|
1071
1074
|
return;
|
|
1072
1075
|
}
|
|
1073
1076
|
const index = blockNum === -1 ? codeBlocks.length - 1 : blockNum - 1;
|
|
1074
|
-
if (index < 0 || index >= codeBlocks.length) {
|
|
1077
|
+
if (Number.isNaN(index) || index < 0 || index >= codeBlocks.length) {
|
|
1075
1078
|
ctx.app.notify(`Invalid block number. Available: 1-${codeBlocks.length}`);
|
|
1076
1079
|
return;
|
|
1077
1080
|
}
|
package/dist/renderer/main.js
CHANGED
|
@@ -203,6 +203,8 @@ async function handleSubmit(message) {
|
|
|
203
203
|
provider: entry.provider,
|
|
204
204
|
inputTokens: entry.promptTokens || undefined,
|
|
205
205
|
outputTokens: entry.completionTokens || undefined,
|
|
206
|
+
cacheCreationTokens: entry.cacheCreationTokens || undefined,
|
|
207
|
+
cacheReadTokens: entry.cacheReadTokens || undefined,
|
|
206
208
|
estimatedCost: entry.estimatedCost || undefined,
|
|
207
209
|
});
|
|
208
210
|
}
|
|
@@ -888,6 +890,8 @@ async function gracefulShutdown() {
|
|
|
888
890
|
projectId,
|
|
889
891
|
inputTokens: tokenStats.totalPromptTokens || undefined,
|
|
890
892
|
outputTokens: tokenStats.totalCompletionTokens || undefined,
|
|
893
|
+
cacheCreationTokens: tokenStats.totalCacheCreationTokens || undefined,
|
|
894
|
+
cacheReadTokens: tokenStats.totalCacheReadTokens || undefined,
|
|
891
895
|
estimatedCost: tokenStats.estimatedCost || undefined,
|
|
892
896
|
}),
|
|
893
897
|
]);
|
package/dist/utils/agentChat.js
CHANGED
|
@@ -427,7 +427,11 @@ additionalTools) {
|
|
|
427
427
|
model, messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
|
428
428
|
tools: getOpenAITools(additionalTools), tool_choice: 'auto', stream: useStreaming,
|
|
429
429
|
...tempParam, ...tokParam, ...reasoningParam,
|
|
430
|
-
|
|
430
|
+
// Ask ALL OpenAI-compatible providers to emit a usage block in the
|
|
431
|
+
// stream — without this most (DeepSeek/Kimi/Grok/Qwen/GLM/…) send no
|
|
432
|
+
// usage on streamed responses and the whole turn records zero tokens.
|
|
433
|
+
// (OpenRouter also gets usage via openRouterExtras below; both is fine.)
|
|
434
|
+
...(useStreaming ? { stream_options: { include_usage: true } } : {}),
|
|
431
435
|
...openRouterExtras,
|
|
432
436
|
};
|
|
433
437
|
}
|
|
@@ -25,6 +25,12 @@ export interface StatsPayload {
|
|
|
25
25
|
isGit?: boolean;
|
|
26
26
|
inputTokens?: number;
|
|
27
27
|
outputTokens?: number;
|
|
28
|
+
/** Anthropic prompt caching: tokens written to cache (billed ~1.25× input).
|
|
29
|
+
* Undefined for providers that don't report caching. */
|
|
30
|
+
cacheCreationTokens?: number;
|
|
31
|
+
/** Anthropic prompt caching: tokens read from cache (billed ~0.1× input).
|
|
32
|
+
* Undefined for providers that don't report caching. */
|
|
33
|
+
cacheReadTokens?: number;
|
|
28
34
|
estimatedCost?: number;
|
|
29
35
|
}
|
|
30
36
|
/**
|
|
@@ -19,6 +19,10 @@ export interface SessionTokenStats {
|
|
|
19
19
|
totalTokens: number;
|
|
20
20
|
requestCount: number;
|
|
21
21
|
estimatedCost: number;
|
|
22
|
+
/** Anthropic prompt caching: total tokens written to cache this session. */
|
|
23
|
+
totalCacheCreationTokens: number;
|
|
24
|
+
/** Anthropic prompt caching: total tokens read from cache this session. */
|
|
25
|
+
totalCacheReadTokens: number;
|
|
22
26
|
}
|
|
23
27
|
interface TokenRecord {
|
|
24
28
|
timestamp: number;
|
|
@@ -42,6 +46,18 @@ export declare function getPricingTable(): {
|
|
|
42
46
|
inputPer1M: number;
|
|
43
47
|
outputPer1M: number;
|
|
44
48
|
}[];
|
|
49
|
+
/** An isolated token-record buffer for one scope (e.g. one ACP session). */
|
|
50
|
+
export type TokenScope = TokenRecord[];
|
|
51
|
+
/** Create a fresh, empty scope buffer (one per ACP session). */
|
|
52
|
+
export declare function createTokenScope(): TokenScope;
|
|
53
|
+
/**
|
|
54
|
+
* Run `fn` with `scope` as the active token-record buffer. Every
|
|
55
|
+
* recordTokenUsage() call made within `fn`'s async flow (including across
|
|
56
|
+
* awaits) accumulates into `scope`, and reads (getCostBreakdown/…) made in the
|
|
57
|
+
* same flow see only `scope`. Used by the ACP server to isolate per-session
|
|
58
|
+
* usage without threading a session id through the deep API layer.
|
|
59
|
+
*/
|
|
60
|
+
export declare function runWithTokenScope<T>(scope: TokenScope, fn: () => T): T;
|
|
45
61
|
/**
|
|
46
62
|
* Record token usage from an API response. The optional `actualCostUsd`
|
|
47
63
|
* argument lets aggregator providers (OpenRouter) pass through the
|
|
@@ -63,12 +79,23 @@ export interface ProviderCostBreakdown {
|
|
|
63
79
|
model: string;
|
|
64
80
|
promptTokens: number;
|
|
65
81
|
completionTokens: number;
|
|
82
|
+
/** Anthropic prompt caching: tokens written to cache (billed ~1.25× input).
|
|
83
|
+
* 0 for providers that don't report caching. */
|
|
84
|
+
cacheCreationTokens: number;
|
|
85
|
+
/** Anthropic prompt caching: tokens read from cache (billed ~0.1× input).
|
|
86
|
+
* 0 for providers that don't report caching. */
|
|
87
|
+
cacheReadTokens: number;
|
|
66
88
|
estimatedCost: number;
|
|
67
89
|
}
|
|
68
90
|
/**
|
|
69
|
-
* Get cost breakdown grouped by provider/model
|
|
91
|
+
* Get cost breakdown grouped by provider/model.
|
|
92
|
+
*
|
|
93
|
+
* `startIndex` lets callers price only the records appended since a marker (see
|
|
94
|
+
* getRecordCount) — used to report a single run/prompt's delta to cloud
|
|
95
|
+
* telemetry WITHOUT wiping the session-cumulative store the status bar and
|
|
96
|
+
* `/cost` read. Defaults to 0 (the whole current scope).
|
|
70
97
|
*/
|
|
71
|
-
export declare function getCostBreakdown(): ProviderCostBreakdown[];
|
|
98
|
+
export declare function getCostBreakdown(startIndex?: number): ProviderCostBreakdown[];
|
|
72
99
|
/**
|
|
73
100
|
* Aggregate Anthropic prompt-caching stats for the current session.
|
|
74
101
|
* Returns the breakdown plus an estimate of what the input billing would
|
|
@@ -95,7 +122,17 @@ export declare function getLastUsage(): TokenRecord | null;
|
|
|
95
122
|
*/
|
|
96
123
|
export declare function formatTokenCount(tokens: number): string;
|
|
97
124
|
/**
|
|
98
|
-
*
|
|
125
|
+
* Number of records in the current scope. Capture before a run/prompt and pass
|
|
126
|
+
* it to getCostBreakdown(startIndex) to price just that run's delta (for cloud
|
|
127
|
+
* telemetry) without wiping the cumulative store the status bar and `/cost`
|
|
128
|
+
* read.
|
|
129
|
+
*/
|
|
130
|
+
export declare function getRecordCount(): number;
|
|
131
|
+
/**
|
|
132
|
+
* Reset the current scope's tracking. Production run/prompt paths no longer
|
|
133
|
+
* call this (they use getRecordCount + getCostBreakdown(startIndex) so the
|
|
134
|
+
* session-cumulative totals survive); retained for the test suite, which uses
|
|
135
|
+
* it to isolate the process-wide default buffer between cases.
|
|
99
136
|
*/
|
|
100
137
|
export declare function resetTokenTracking(): void;
|
|
101
138
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token and cost tracking for API usage
|
|
3
3
|
*/
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
5
|
// Context window sizes per model (in tokens).
|
|
5
6
|
// Keep this table in lockstep with `providers.ts` — entries for models that
|
|
6
7
|
// aren't in the provider catalogue only show up if a user types an id by hand
|
|
@@ -14,8 +15,10 @@ const MODEL_CONTEXT_WINDOWS = {
|
|
|
14
15
|
'gpt-5.4': 1_050_000,
|
|
15
16
|
'gpt-5.4-mini': 400_000,
|
|
16
17
|
// Anthropic
|
|
18
|
+
'claude-fable-5': 1_000_000,
|
|
17
19
|
'claude-opus-4-8': 1_000_000,
|
|
18
20
|
'claude-sonnet-4-6': 1_000_000,
|
|
21
|
+
'claude-sonnet-5': 1_000_000,
|
|
19
22
|
'claude-haiku-4-5-20251001': 200_000,
|
|
20
23
|
// DeepSeek
|
|
21
24
|
'deepseek-v4-pro': 1_000_000,
|
|
@@ -67,8 +70,10 @@ const MODEL_PRICING = {
|
|
|
67
70
|
'gpt-5.4': { inputPer1M: 2.50, outputPer1M: 15.00 },
|
|
68
71
|
'gpt-5.4-mini': { inputPer1M: 0.75, outputPer1M: 4.50 },
|
|
69
72
|
// Anthropic
|
|
73
|
+
'claude-fable-5': { inputPer1M: 10.00, outputPer1M: 50.00 },
|
|
70
74
|
'claude-opus-4-8': { inputPer1M: 5.00, outputPer1M: 25.00 },
|
|
71
75
|
'claude-sonnet-4-6': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
76
|
+
'claude-sonnet-5': { inputPer1M: 3.00, outputPer1M: 15.00 },
|
|
72
77
|
'claude-haiku-4-5-20251001': { inputPer1M: 1.00, outputPer1M: 5.00 },
|
|
73
78
|
// DeepSeek (cache-miss input pricing)
|
|
74
79
|
'deepseek-v4-pro': { inputPer1M: 1.74, outputPer1M: 3.48 },
|
|
@@ -103,8 +108,27 @@ const MODEL_PRICING = {
|
|
|
103
108
|
export function getPricingTable() {
|
|
104
109
|
return Object.entries(MODEL_PRICING).map(([model, p]) => ({ model, ...p }));
|
|
105
110
|
}
|
|
106
|
-
|
|
107
|
-
const
|
|
111
|
+
const defaultRecords = [];
|
|
112
|
+
const recordsStore = new AsyncLocalStorage();
|
|
113
|
+
/** The record buffer for the current async flow (a scope's buffer inside
|
|
114
|
+
* runWithTokenScope, otherwise the process-wide default). */
|
|
115
|
+
function currentRecords() {
|
|
116
|
+
return recordsStore.getStore() ?? defaultRecords;
|
|
117
|
+
}
|
|
118
|
+
/** Create a fresh, empty scope buffer (one per ACP session). */
|
|
119
|
+
export function createTokenScope() {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Run `fn` with `scope` as the active token-record buffer. Every
|
|
124
|
+
* recordTokenUsage() call made within `fn`'s async flow (including across
|
|
125
|
+
* awaits) accumulates into `scope`, and reads (getCostBreakdown/…) made in the
|
|
126
|
+
* same flow see only `scope`. Used by the ACP server to isolate per-session
|
|
127
|
+
* usage without threading a session id through the deep API layer.
|
|
128
|
+
*/
|
|
129
|
+
export function runWithTokenScope(scope, fn) {
|
|
130
|
+
return recordsStore.run(scope, fn);
|
|
131
|
+
}
|
|
108
132
|
/**
|
|
109
133
|
* Record token usage from an API response. The optional `actualCostUsd`
|
|
110
134
|
* argument lets aggregator providers (OpenRouter) pass through the
|
|
@@ -113,7 +137,7 @@ const records = [];
|
|
|
113
137
|
* for every OpenRouter-listed model — there are 100+).
|
|
114
138
|
*/
|
|
115
139
|
export function recordTokenUsage(usage, model, provider, actualCostUsd) {
|
|
116
|
-
|
|
140
|
+
currentRecords().push({
|
|
117
141
|
timestamp: Date.now(),
|
|
118
142
|
promptTokens: usage.promptTokens,
|
|
119
143
|
completionTokens: usage.completionTokens,
|
|
@@ -130,10 +154,16 @@ export function recordTokenUsage(usage, model, provider, actualCostUsd) {
|
|
|
130
154
|
*/
|
|
131
155
|
export function extractOpenAIUsage(data) {
|
|
132
156
|
if (data?.usage) {
|
|
157
|
+
// OpenAI-protocol `prompt_tokens` is INCLUSIVE of cached prompt tokens
|
|
158
|
+
// (DeepSeek/OpenAI report cache hits in prompt_tokens_details.cached_tokens).
|
|
159
|
+
// Surface them so getCostBreakdown bills cache reads at the discounted
|
|
160
|
+
// rate instead of the full cache-miss input rate.
|
|
161
|
+
const cached = data.usage.prompt_tokens_details?.cached_tokens || 0;
|
|
133
162
|
return {
|
|
134
163
|
promptTokens: data.usage.prompt_tokens || 0,
|
|
135
164
|
completionTokens: data.usage.completion_tokens || 0,
|
|
136
165
|
totalTokens: data.usage.total_tokens || 0,
|
|
166
|
+
cacheReadTokens: cached || undefined,
|
|
137
167
|
};
|
|
138
168
|
}
|
|
139
169
|
return null;
|
|
@@ -161,15 +191,22 @@ export function extractAnthropicUsage(data) {
|
|
|
161
191
|
return null;
|
|
162
192
|
}
|
|
163
193
|
/**
|
|
164
|
-
* Get cost breakdown grouped by provider/model
|
|
194
|
+
* Get cost breakdown grouped by provider/model.
|
|
195
|
+
*
|
|
196
|
+
* `startIndex` lets callers price only the records appended since a marker (see
|
|
197
|
+
* getRecordCount) — used to report a single run/prompt's delta to cloud
|
|
198
|
+
* telemetry WITHOUT wiping the session-cumulative store the status bar and
|
|
199
|
+
* `/cost` read. Defaults to 0 (the whole current scope).
|
|
165
200
|
*/
|
|
166
|
-
export function getCostBreakdown() {
|
|
201
|
+
export function getCostBreakdown(startIndex = 0) {
|
|
167
202
|
const grouped = new Map();
|
|
168
|
-
for (const record of
|
|
203
|
+
for (const record of currentRecords().slice(startIndex)) {
|
|
169
204
|
const key = `${record.provider}/${record.model}`;
|
|
170
|
-
const existing = grouped.get(key) ?? { provider: record.provider, model: record.model, promptTokens: 0, completionTokens: 0, estimatedCost: 0 };
|
|
205
|
+
const existing = grouped.get(key) ?? { provider: record.provider, model: record.model, promptTokens: 0, completionTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, estimatedCost: 0 };
|
|
171
206
|
existing.promptTokens += record.promptTokens;
|
|
172
207
|
existing.completionTokens += record.completionTokens;
|
|
208
|
+
existing.cacheCreationTokens += record.cacheCreationTokens ?? 0;
|
|
209
|
+
existing.cacheReadTokens += record.cacheReadTokens ?? 0;
|
|
173
210
|
// Cost source priority:
|
|
174
211
|
// 1. Provider-reported USD (OpenRouter, MaxiCloud, etc.) — most accurate.
|
|
175
212
|
// 2. Our MODEL_PRICING table — for built-in providers we maintain rates for.
|
|
@@ -201,7 +238,7 @@ export function getCacheStats() {
|
|
|
201
238
|
let cacheCreate = 0;
|
|
202
239
|
let cacheRead = 0;
|
|
203
240
|
let savings = 0;
|
|
204
|
-
for (const record of
|
|
241
|
+
for (const record of currentRecords()) {
|
|
205
242
|
cacheCreate += record.cacheCreationTokens ?? 0;
|
|
206
243
|
cacheRead += record.cacheReadTokens ?? 0;
|
|
207
244
|
// Savings = what cache-read tokens would have cost at full input rate,
|
|
@@ -223,24 +260,31 @@ export function getSessionStats() {
|
|
|
223
260
|
let totalPromptTokens = 0;
|
|
224
261
|
let totalCompletionTokens = 0;
|
|
225
262
|
let totalTokens = 0;
|
|
226
|
-
|
|
263
|
+
let totalCacheCreationTokens = 0;
|
|
264
|
+
let totalCacheReadTokens = 0;
|
|
265
|
+
for (const record of currentRecords()) {
|
|
227
266
|
totalPromptTokens += record.promptTokens;
|
|
228
267
|
totalCompletionTokens += record.completionTokens;
|
|
229
268
|
totalTokens += record.totalTokens;
|
|
269
|
+
totalCacheCreationTokens += record.cacheCreationTokens ?? 0;
|
|
270
|
+
totalCacheReadTokens += record.cacheReadTokens ?? 0;
|
|
230
271
|
}
|
|
231
272
|
const estimatedCost = getCostBreakdown().reduce((s, b) => s + b.estimatedCost, 0);
|
|
232
273
|
return {
|
|
233
274
|
totalPromptTokens,
|
|
234
275
|
totalCompletionTokens,
|
|
235
276
|
totalTokens,
|
|
236
|
-
requestCount:
|
|
277
|
+
requestCount: currentRecords().length,
|
|
237
278
|
estimatedCost,
|
|
279
|
+
totalCacheCreationTokens,
|
|
280
|
+
totalCacheReadTokens,
|
|
238
281
|
};
|
|
239
282
|
}
|
|
240
283
|
/**
|
|
241
284
|
* Get last request usage
|
|
242
285
|
*/
|
|
243
286
|
export function getLastUsage() {
|
|
287
|
+
const records = currentRecords();
|
|
244
288
|
return records.length > 0 ? records[records.length - 1] : null;
|
|
245
289
|
}
|
|
246
290
|
/**
|
|
@@ -254,10 +298,22 @@ export function formatTokenCount(tokens) {
|
|
|
254
298
|
return (tokens / 1000000).toFixed(2) + 'M';
|
|
255
299
|
}
|
|
256
300
|
/**
|
|
257
|
-
*
|
|
301
|
+
* Number of records in the current scope. Capture before a run/prompt and pass
|
|
302
|
+
* it to getCostBreakdown(startIndex) to price just that run's delta (for cloud
|
|
303
|
+
* telemetry) without wiping the cumulative store the status bar and `/cost`
|
|
304
|
+
* read.
|
|
305
|
+
*/
|
|
306
|
+
export function getRecordCount() {
|
|
307
|
+
return currentRecords().length;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Reset the current scope's tracking. Production run/prompt paths no longer
|
|
311
|
+
* call this (they use getRecordCount + getCostBreakdown(startIndex) so the
|
|
312
|
+
* session-cumulative totals survive); retained for the test suite, which uses
|
|
313
|
+
* it to isolate the process-wide default buffer between cases.
|
|
258
314
|
*/
|
|
259
315
|
export function resetTokenTracking() {
|
|
260
|
-
|
|
316
|
+
currentRecords().length = 0;
|
|
261
317
|
}
|
|
262
318
|
/**
|
|
263
319
|
* Format a session cost report as a Markdown block. Used by `/cost` in both
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "2.
|
|
1
|
+
export declare const VERSION = "2.14.0";
|
package/dist/version.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
|
|
2
2
|
// Baked from package.json at build time so the bun-compiled binary reports
|
|
3
3
|
// the right version (it has no package.json on disk to read at runtime).
|
|
4
|
-
export const VERSION = '2.
|
|
4
|
+
export const VERSION = '2.14.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeep",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|