@ottocode/server 0.1.173

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.
Files changed (111) hide show
  1. package/package.json +42 -0
  2. package/src/events/bus.ts +43 -0
  3. package/src/events/types.ts +32 -0
  4. package/src/index.ts +281 -0
  5. package/src/openapi/helpers.ts +64 -0
  6. package/src/openapi/paths/ask.ts +70 -0
  7. package/src/openapi/paths/config.ts +218 -0
  8. package/src/openapi/paths/files.ts +72 -0
  9. package/src/openapi/paths/git.ts +457 -0
  10. package/src/openapi/paths/messages.ts +92 -0
  11. package/src/openapi/paths/sessions.ts +90 -0
  12. package/src/openapi/paths/setu.ts +154 -0
  13. package/src/openapi/paths/stream.ts +26 -0
  14. package/src/openapi/paths/terminals.ts +226 -0
  15. package/src/openapi/schemas.ts +345 -0
  16. package/src/openapi/spec.ts +49 -0
  17. package/src/presets.ts +85 -0
  18. package/src/routes/ask.ts +113 -0
  19. package/src/routes/auth.ts +592 -0
  20. package/src/routes/branch.ts +106 -0
  21. package/src/routes/config/agents.ts +44 -0
  22. package/src/routes/config/cwd.ts +21 -0
  23. package/src/routes/config/defaults.ts +45 -0
  24. package/src/routes/config/index.ts +16 -0
  25. package/src/routes/config/main.ts +73 -0
  26. package/src/routes/config/models.ts +139 -0
  27. package/src/routes/config/providers.ts +46 -0
  28. package/src/routes/config/utils.ts +120 -0
  29. package/src/routes/files.ts +218 -0
  30. package/src/routes/git/branch.ts +75 -0
  31. package/src/routes/git/commit.ts +209 -0
  32. package/src/routes/git/diff.ts +137 -0
  33. package/src/routes/git/index.ts +18 -0
  34. package/src/routes/git/push.ts +160 -0
  35. package/src/routes/git/schemas.ts +48 -0
  36. package/src/routes/git/staging.ts +208 -0
  37. package/src/routes/git/status.ts +83 -0
  38. package/src/routes/git/types.ts +31 -0
  39. package/src/routes/git/utils.ts +249 -0
  40. package/src/routes/openapi.ts +6 -0
  41. package/src/routes/research.ts +392 -0
  42. package/src/routes/root.ts +5 -0
  43. package/src/routes/session-approval.ts +63 -0
  44. package/src/routes/session-files.ts +387 -0
  45. package/src/routes/session-messages.ts +170 -0
  46. package/src/routes/session-stream.ts +61 -0
  47. package/src/routes/sessions.ts +814 -0
  48. package/src/routes/setu.ts +346 -0
  49. package/src/routes/terminals.ts +227 -0
  50. package/src/runtime/agent/registry.ts +351 -0
  51. package/src/runtime/agent/runner-reasoning.ts +108 -0
  52. package/src/runtime/agent/runner-setup.ts +257 -0
  53. package/src/runtime/agent/runner.ts +375 -0
  54. package/src/runtime/agent-registry.ts +6 -0
  55. package/src/runtime/ask/service.ts +369 -0
  56. package/src/runtime/context/environment.ts +202 -0
  57. package/src/runtime/debug/index.ts +117 -0
  58. package/src/runtime/debug/state.ts +140 -0
  59. package/src/runtime/errors/api-error.ts +192 -0
  60. package/src/runtime/errors/handling.ts +199 -0
  61. package/src/runtime/message/compaction-auto.ts +154 -0
  62. package/src/runtime/message/compaction-context.ts +101 -0
  63. package/src/runtime/message/compaction-detect.ts +26 -0
  64. package/src/runtime/message/compaction-limits.ts +37 -0
  65. package/src/runtime/message/compaction-mark.ts +111 -0
  66. package/src/runtime/message/compaction-prune.ts +75 -0
  67. package/src/runtime/message/compaction.ts +21 -0
  68. package/src/runtime/message/history-builder.ts +266 -0
  69. package/src/runtime/message/service.ts +468 -0
  70. package/src/runtime/message/tool-history-tracker.ts +204 -0
  71. package/src/runtime/prompt/builder.ts +167 -0
  72. package/src/runtime/provider/anthropic.ts +50 -0
  73. package/src/runtime/provider/copilot.ts +12 -0
  74. package/src/runtime/provider/google.ts +8 -0
  75. package/src/runtime/provider/index.ts +60 -0
  76. package/src/runtime/provider/moonshot.ts +8 -0
  77. package/src/runtime/provider/oauth-adapter.ts +237 -0
  78. package/src/runtime/provider/openai.ts +18 -0
  79. package/src/runtime/provider/opencode.ts +7 -0
  80. package/src/runtime/provider/openrouter.ts +7 -0
  81. package/src/runtime/provider/selection.ts +118 -0
  82. package/src/runtime/provider/setu.ts +126 -0
  83. package/src/runtime/provider/zai.ts +16 -0
  84. package/src/runtime/session/branch.ts +280 -0
  85. package/src/runtime/session/db-operations.ts +285 -0
  86. package/src/runtime/session/manager.ts +99 -0
  87. package/src/runtime/session/queue.ts +243 -0
  88. package/src/runtime/stream/abort-handler.ts +65 -0
  89. package/src/runtime/stream/error-handler.ts +371 -0
  90. package/src/runtime/stream/finish-handler.ts +101 -0
  91. package/src/runtime/stream/handlers.ts +5 -0
  92. package/src/runtime/stream/step-finish.ts +93 -0
  93. package/src/runtime/stream/types.ts +25 -0
  94. package/src/runtime/tools/approval.ts +180 -0
  95. package/src/runtime/tools/context.ts +83 -0
  96. package/src/runtime/tools/mapping.ts +154 -0
  97. package/src/runtime/tools/setup.ts +44 -0
  98. package/src/runtime/topup/manager.ts +110 -0
  99. package/src/runtime/utils/cwd.ts +69 -0
  100. package/src/runtime/utils/token.ts +35 -0
  101. package/src/tools/adapter.ts +634 -0
  102. package/src/tools/database/get-parent-session.ts +183 -0
  103. package/src/tools/database/get-session-context.ts +161 -0
  104. package/src/tools/database/index.ts +42 -0
  105. package/src/tools/database/present-session-links.ts +47 -0
  106. package/src/tools/database/query-messages.ts +160 -0
  107. package/src/tools/database/query-sessions.ts +126 -0
  108. package/src/tools/database/search-history.ts +135 -0
  109. package/src/types/sql-imports.d.ts +5 -0
  110. package/sst-env.d.ts +8 -0
  111. package/tsconfig.json +7 -0
@@ -0,0 +1,126 @@
1
+ import {
2
+ createSetuModel,
3
+ catalog,
4
+ type SetuPaymentCallbacks,
5
+ getAuth,
6
+ loadConfig,
7
+ } from '@ottocode/sdk';
8
+ import { publish } from '../../events/bus.ts';
9
+ import {
10
+ waitForTopupMethodSelection,
11
+ type TopupMethod,
12
+ } from '../topup/manager.ts';
13
+
14
+ const MIN_TOPUP_USD = 5;
15
+
16
+ function getProviderNpm(model: string): string | undefined {
17
+ const entry = catalog.setu?.models?.find((m) => m.id === model);
18
+ return entry?.provider?.npm;
19
+ }
20
+
21
+ export interface ResolveSetuModelOptions {
22
+ messageId?: string;
23
+ topupApprovalMode?: 'auto' | 'approval';
24
+ }
25
+
26
+ async function getSetuPrivateKey(): Promise<string> {
27
+ if (process.env.SETU_PRIVATE_KEY) {
28
+ return process.env.SETU_PRIVATE_KEY;
29
+ }
30
+ try {
31
+ const cfg = await loadConfig(process.cwd());
32
+ const auth = await getAuth('setu', cfg.projectRoot);
33
+ if (auth?.type === 'wallet' && auth.secret) {
34
+ return auth.secret;
35
+ }
36
+ } catch {}
37
+ return '';
38
+ }
39
+
40
+ export async function resolveSetuModel(
41
+ model: string,
42
+ sessionId?: string,
43
+ options: ResolveSetuModelOptions = {},
44
+ ) {
45
+ const privateKey = await getSetuPrivateKey();
46
+ if (!privateKey) {
47
+ throw new Error(
48
+ 'Setu provider requires SETU_PRIVATE_KEY (base58 Solana secret).',
49
+ );
50
+ }
51
+ const baseURL = process.env.SETU_BASE_URL;
52
+ const rpcURL = process.env.SETU_SOLANA_RPC_URL;
53
+ const { messageId, topupApprovalMode = 'approval' } = options;
54
+
55
+ const callbacks: SetuPaymentCallbacks = sessionId
56
+ ? {
57
+ onPaymentRequired: (amountUsd, currentBalance) => {
58
+ publish({
59
+ type: 'setu.payment.required',
60
+ sessionId,
61
+ payload: { amountUsd, currentBalance },
62
+ });
63
+ },
64
+ onPaymentSigning: () => {
65
+ publish({
66
+ type: 'setu.payment.signing',
67
+ sessionId,
68
+ payload: {},
69
+ });
70
+ },
71
+ onPaymentComplete: (data) => {
72
+ publish({
73
+ type: 'setu.payment.complete',
74
+ sessionId,
75
+ payload: data,
76
+ });
77
+ },
78
+ onPaymentError: (error) => {
79
+ publish({
80
+ type: 'setu.payment.error',
81
+ sessionId,
82
+ payload: { error },
83
+ });
84
+ },
85
+ onPaymentApproval: async (info): Promise<TopupMethod | 'cancel'> => {
86
+ const suggestedTopupUsd = Math.max(
87
+ MIN_TOPUP_USD,
88
+ Math.ceil(info.amountUsd * 2),
89
+ );
90
+
91
+ publish({
92
+ type: 'setu.topup.required',
93
+ sessionId,
94
+ payload: {
95
+ messageId,
96
+ amountUsd: info.amountUsd,
97
+ currentBalance: info.currentBalance,
98
+ minTopupUsd: MIN_TOPUP_USD,
99
+ suggestedTopupUsd,
100
+ },
101
+ });
102
+
103
+ return waitForTopupMethodSelection(
104
+ sessionId,
105
+ messageId ?? '',
106
+ info.amountUsd,
107
+ info.currentBalance,
108
+ );
109
+ },
110
+ }
111
+ : {};
112
+
113
+ const providerNpm = getProviderNpm(model);
114
+
115
+ return createSetuModel(
116
+ model,
117
+ { privateKey },
118
+ {
119
+ baseURL,
120
+ rpcURL,
121
+ callbacks,
122
+ providerNpm,
123
+ topupApprovalMode,
124
+ },
125
+ );
126
+ }
@@ -0,0 +1,16 @@
1
+ import type { OttoConfig } from '@ottocode/sdk';
2
+ import { getAuth, createZaiModel, createZaiCodingModel } from '@ottocode/sdk';
3
+
4
+ export async function getZaiInstance(cfg: OttoConfig, model: string) {
5
+ const auth = await getAuth('zai', cfg.projectRoot);
6
+ const apiKey = auth?.type === 'api' ? auth.key : undefined;
7
+ return createZaiModel(model, { apiKey });
8
+ }
9
+
10
+ export async function getZaiCodingInstance(cfg: OttoConfig, model: string) {
11
+ const auth =
12
+ (await getAuth('zai', cfg.projectRoot)) ||
13
+ (await getAuth('zai-coding', cfg.projectRoot));
14
+ const apiKey = auth?.type === 'api' ? auth.key : undefined;
15
+ return createZaiCodingModel(model, { apiKey });
16
+ }
@@ -0,0 +1,280 @@
1
+ import { eq, asc } from 'drizzle-orm';
2
+ import type { DB } from '@ottocode/database';
3
+ import { sessions, messages, messageParts } from '@ottocode/database/schema';
4
+ import { publish } from '../../events/bus.ts';
5
+ import type { ProviderId } from '@ottocode/sdk';
6
+
7
+ type SessionRow = typeof sessions.$inferSelect;
8
+
9
+ export type CreateBranchInput = {
10
+ db: DB;
11
+ parentSessionId: string;
12
+ fromMessageId: string;
13
+ provider?: ProviderId;
14
+ model?: string;
15
+ agent?: string;
16
+ title?: string;
17
+ projectPath: string;
18
+ };
19
+
20
+ export type BranchResult = {
21
+ session: SessionRow;
22
+ parentSessionId: string;
23
+ branchPointMessageId: string;
24
+ copiedMessages: number;
25
+ copiedParts: number;
26
+ };
27
+
28
+ export async function createBranch({
29
+ db,
30
+ parentSessionId,
31
+ fromMessageId,
32
+ provider,
33
+ model,
34
+ agent,
35
+ title,
36
+ projectPath,
37
+ }: CreateBranchInput): Promise<BranchResult> {
38
+ const parentRows = await db
39
+ .select()
40
+ .from(sessions)
41
+ .where(eq(sessions.id, parentSessionId));
42
+
43
+ if (!parentRows.length) {
44
+ throw new Error('Parent session not found');
45
+ }
46
+
47
+ const parent = parentRows[0];
48
+
49
+ if (parent.projectPath !== projectPath) {
50
+ throw new Error('Parent session not found in this project');
51
+ }
52
+
53
+ const branchPointRows = await db
54
+ .select()
55
+ .from(messages)
56
+ .where(eq(messages.id, fromMessageId));
57
+
58
+ if (!branchPointRows.length) {
59
+ throw new Error('Branch point message not found');
60
+ }
61
+
62
+ const branchPoint = branchPointRows[0];
63
+
64
+ if (branchPoint.sessionId !== parentSessionId) {
65
+ throw new Error('Branch point message does not belong to parent session');
66
+ }
67
+
68
+ const allMessages = await db
69
+ .select()
70
+ .from(messages)
71
+ .where(eq(messages.sessionId, parentSessionId))
72
+ .orderBy(asc(messages.createdAt));
73
+
74
+ const branchPointIndex = allMessages.findIndex((m) => m.id === fromMessageId);
75
+ if (branchPointIndex === -1) {
76
+ throw new Error('Branch point message not found in session');
77
+ }
78
+
79
+ const messagesToCopy = allMessages.slice(0, branchPointIndex + 1);
80
+
81
+ const newSessionId = crypto.randomUUID();
82
+ const now = Date.now();
83
+
84
+ const newSession: typeof sessions.$inferInsert = {
85
+ id: newSessionId,
86
+ title: title || `Branch of ${parent.title || 'Untitled'}`,
87
+ agent: agent || parent.agent,
88
+ provider: provider || parent.provider,
89
+ model: model || parent.model,
90
+ projectPath: parent.projectPath,
91
+ createdAt: now,
92
+ lastActiveAt: now,
93
+ parentSessionId,
94
+ branchPointMessageId: fromMessageId,
95
+ sessionType: 'branch',
96
+ };
97
+
98
+ await db.insert(sessions).values(newSession);
99
+
100
+ const messageIdMap = new Map<string, string>();
101
+ let copiedParts = 0;
102
+
103
+ for (const msg of messagesToCopy) {
104
+ const newMessageId = crypto.randomUUID();
105
+ messageIdMap.set(msg.id, newMessageId);
106
+
107
+ const newMessage: typeof messages.$inferInsert = {
108
+ id: newMessageId,
109
+ sessionId: newSessionId,
110
+ role: msg.role,
111
+ status: msg.status,
112
+ agent: msg.agent,
113
+ provider: msg.provider,
114
+ model: msg.model,
115
+ createdAt: msg.createdAt,
116
+ completedAt: msg.completedAt,
117
+ latencyMs: msg.latencyMs,
118
+ inputTokens: msg.inputTokens,
119
+ outputTokens: msg.outputTokens,
120
+ totalTokens: msg.totalTokens,
121
+ cachedInputTokens: msg.cachedInputTokens,
122
+ cacheCreationInputTokens: msg.cacheCreationInputTokens,
123
+ reasoningTokens: msg.reasoningTokens,
124
+ error: msg.error,
125
+ errorType: msg.errorType,
126
+ errorDetails: msg.errorDetails,
127
+ isAborted: msg.isAborted,
128
+ };
129
+
130
+ await db.insert(messages).values(newMessage);
131
+
132
+ const parts = await db
133
+ .select()
134
+ .from(messageParts)
135
+ .where(eq(messageParts.messageId, msg.id))
136
+ .orderBy(asc(messageParts.index));
137
+
138
+ for (const part of parts) {
139
+ const newPart: typeof messageParts.$inferInsert = {
140
+ id: crypto.randomUUID(),
141
+ messageId: newMessageId,
142
+ index: part.index,
143
+ stepIndex: part.stepIndex,
144
+ type: part.type,
145
+ content: part.content,
146
+ agent: part.agent,
147
+ provider: part.provider,
148
+ model: part.model,
149
+ startedAt: part.startedAt,
150
+ completedAt: part.completedAt,
151
+ compactedAt: part.compactedAt,
152
+ toolName: part.toolName,
153
+ toolCallId: part.toolCallId,
154
+ toolDurationMs: part.toolDurationMs,
155
+ };
156
+
157
+ await db.insert(messageParts).values(newPart);
158
+ copiedParts++;
159
+ }
160
+ }
161
+
162
+ const result: SessionRow = {
163
+ ...newSession,
164
+ totalInputTokens: null,
165
+ totalOutputTokens: null,
166
+ totalCachedTokens: null,
167
+ totalCacheCreationTokens: null,
168
+ totalReasoningTokens: null,
169
+ totalToolTimeMs: null,
170
+ toolCountsJson: null,
171
+ currentContextTokens: null,
172
+ contextSummary: null,
173
+ lastCompactedAt: null,
174
+ };
175
+
176
+ publish({
177
+ type: 'session.created',
178
+ sessionId: newSessionId,
179
+ payload: result,
180
+ });
181
+
182
+ return {
183
+ session: result,
184
+ parentSessionId,
185
+ branchPointMessageId: fromMessageId,
186
+ copiedMessages: messagesToCopy.length,
187
+ copiedParts,
188
+ };
189
+ }
190
+
191
+ export type ListBranchesResult = Array<{
192
+ session: SessionRow;
193
+ branchPointMessageId: string | null;
194
+ branchPointPreview: string | null;
195
+ createdAt: number;
196
+ }>;
197
+
198
+ export async function listBranches(
199
+ db: DB,
200
+ sessionId: string,
201
+ projectPath: string,
202
+ ): Promise<ListBranchesResult> {
203
+ const branches = await db
204
+ .select()
205
+ .from(sessions)
206
+ .where(eq(sessions.parentSessionId, sessionId))
207
+ .orderBy(asc(sessions.createdAt));
208
+
209
+ const results: ListBranchesResult = [];
210
+
211
+ for (const branch of branches) {
212
+ if (branch.projectPath !== projectPath) continue;
213
+
214
+ let preview: string | null = null;
215
+
216
+ if (branch.branchPointMessageId) {
217
+ const msgRows = await db
218
+ .select()
219
+ .from(messages)
220
+ .where(eq(messages.id, branch.branchPointMessageId));
221
+
222
+ if (msgRows.length > 0) {
223
+ const parts = await db
224
+ .select()
225
+ .from(messageParts)
226
+ .where(eq(messageParts.messageId, branch.branchPointMessageId))
227
+ .orderBy(asc(messageParts.index));
228
+
229
+ for (const part of parts) {
230
+ if (part.type === 'text') {
231
+ try {
232
+ const content = JSON.parse(part.content || '{}');
233
+ if (content.text) {
234
+ preview = content.text.slice(0, 100);
235
+ break;
236
+ }
237
+ } catch {}
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ results.push({
244
+ session: branch,
245
+ branchPointMessageId: branch.branchPointMessageId,
246
+ branchPointPreview: preview,
247
+ createdAt: branch.createdAt,
248
+ });
249
+ }
250
+
251
+ return results;
252
+ }
253
+
254
+ export async function getParentSession(
255
+ db: DB,
256
+ sessionId: string,
257
+ projectPath: string,
258
+ ): Promise<SessionRow | null> {
259
+ const sessionRows = await db
260
+ .select()
261
+ .from(sessions)
262
+ .where(eq(sessions.id, sessionId));
263
+
264
+ if (!sessionRows.length) return null;
265
+
266
+ const session = sessionRows[0];
267
+ if (!session.parentSessionId) return null;
268
+
269
+ const parentRows = await db
270
+ .select()
271
+ .from(sessions)
272
+ .where(eq(sessions.id, session.parentSessionId));
273
+
274
+ if (!parentRows.length) return null;
275
+
276
+ const parent = parentRows[0];
277
+ if (parent.projectPath !== projectPath) return null;
278
+
279
+ return parent;
280
+ }
@@ -0,0 +1,285 @@
1
+ import type { getDb } from '@ottocode/database';
2
+ import { messages, messageParts, sessions } from '@ottocode/database/schema';
3
+ import { eq } from 'drizzle-orm';
4
+ import { catalog, type ProviderId } from '@ottocode/sdk';
5
+ import type { RunOpts } from './queue.ts';
6
+
7
+ export type UsageData = {
8
+ inputTokens?: number;
9
+ outputTokens?: number;
10
+ totalTokens?: number;
11
+ cachedInputTokens?: number;
12
+ cacheCreationInputTokens?: number;
13
+ reasoningTokens?: number;
14
+ };
15
+
16
+ export type ProviderMetadata = Record<string, unknown> & {
17
+ openai?: {
18
+ cachedPromptTokens?: number;
19
+ [key: string]: unknown;
20
+ };
21
+ anthropic?: {
22
+ cacheCreationInputTokens?: number;
23
+ cacheReadInputTokens?: number;
24
+ [key: string]: unknown;
25
+ };
26
+ };
27
+
28
+ export function normalizeUsage(
29
+ usage: UsageData,
30
+ providerOptions: ProviderMetadata | undefined,
31
+ provider: ProviderId,
32
+ ): UsageData {
33
+ const rawInputTokens = Number(usage.inputTokens ?? 0);
34
+ const outputTokens = Number(usage.outputTokens ?? 0);
35
+ const reasoningTokens = Number(usage.reasoningTokens ?? 0);
36
+
37
+ const cachedInputTokens =
38
+ usage.cachedInputTokens != null
39
+ ? Number(usage.cachedInputTokens)
40
+ : providerOptions?.openai?.cachedPromptTokens != null
41
+ ? Number(providerOptions.openai.cachedPromptTokens)
42
+ : providerOptions?.anthropic?.cacheReadInputTokens != null
43
+ ? Number(providerOptions.anthropic.cacheReadInputTokens)
44
+ : undefined;
45
+
46
+ const cacheCreationInputTokens =
47
+ usage.cacheCreationInputTokens != null
48
+ ? Number(usage.cacheCreationInputTokens)
49
+ : providerOptions?.anthropic?.cacheCreationInputTokens != null
50
+ ? Number(providerOptions.anthropic.cacheCreationInputTokens)
51
+ : undefined;
52
+
53
+ const cachedValue = cachedInputTokens ?? 0;
54
+
55
+ let inputTokens = rawInputTokens;
56
+ if (provider === 'openai') {
57
+ inputTokens = Math.max(0, rawInputTokens - cachedValue);
58
+ }
59
+
60
+ return {
61
+ inputTokens,
62
+ outputTokens,
63
+ cachedInputTokens,
64
+ cacheCreationInputTokens,
65
+ reasoningTokens,
66
+ };
67
+ }
68
+
69
+ export function resolveUsageProvider(
70
+ provider: ProviderId,
71
+ model: string,
72
+ ): ProviderId {
73
+ if (
74
+ provider !== 'setu' &&
75
+ provider !== 'openrouter' &&
76
+ provider !== 'opencode'
77
+ ) {
78
+ return provider;
79
+ }
80
+ const entry = catalog[provider];
81
+ const normalizedModel = model.includes('/') ? model.split('/').at(-1) : model;
82
+ const modelEntry = entry?.models.find(
83
+ (m) => m.id?.toLowerCase() === normalizedModel?.toLowerCase(),
84
+ );
85
+ const npm = modelEntry?.provider?.npm ?? '';
86
+ if (npm.includes('openai')) return 'openai';
87
+ if (npm.includes('anthropic')) return 'anthropic';
88
+ if (npm.includes('google')) return 'google';
89
+ if (npm.includes('zai')) return 'zai';
90
+ return provider;
91
+ }
92
+
93
+ /**
94
+ * Updates session token counts after each step.
95
+ * AI SDK v6: onStepFinish.usage is PER-STEP (each step = one API call).
96
+ * We ADD each step's tokens directly to session totals.
97
+ * We also track currentContextTokens = the latest step's full input context.
98
+ */
99
+ export async function updateSessionTokensIncremental(
100
+ usage: UsageData,
101
+ providerOptions: ProviderMetadata | undefined,
102
+ opts: RunOpts,
103
+ db: Awaited<ReturnType<typeof getDb>>,
104
+ ) {
105
+ if (!usage || !db) return;
106
+
107
+ const currentContextTokens = Number(usage.inputTokens ?? 0);
108
+
109
+ const usageProvider = resolveUsageProvider(opts.provider, opts.model);
110
+ const normalizedUsage = normalizeUsage(usage, providerOptions, usageProvider);
111
+
112
+ const stepInput = Number(normalizedUsage.inputTokens ?? 0);
113
+ const stepOutput = Number(normalizedUsage.outputTokens ?? 0);
114
+ const stepCached = Number(normalizedUsage.cachedInputTokens ?? 0);
115
+ const stepCacheCreation = Number(
116
+ normalizedUsage.cacheCreationInputTokens ?? 0,
117
+ );
118
+ const stepReasoning = Number(normalizedUsage.reasoningTokens ?? 0);
119
+
120
+ const sessRows = await db
121
+ .select()
122
+ .from(sessions)
123
+ .where(eq(sessions.id, opts.sessionId));
124
+
125
+ if (sessRows.length === 0 || !sessRows[0]) return;
126
+
127
+ const sess = sessRows[0];
128
+
129
+ await db
130
+ .update(sessions)
131
+ .set({
132
+ totalInputTokens: Number(sess.totalInputTokens ?? 0) + stepInput,
133
+ totalOutputTokens: Number(sess.totalOutputTokens ?? 0) + stepOutput,
134
+ totalCachedTokens: Number(sess.totalCachedTokens ?? 0) + stepCached,
135
+ totalCacheCreationTokens:
136
+ Number(sess.totalCacheCreationTokens ?? 0) + stepCacheCreation,
137
+ totalReasoningTokens:
138
+ Number(sess.totalReasoningTokens ?? 0) + stepReasoning,
139
+ currentContextTokens,
140
+ })
141
+ .where(eq(sessions.id, opts.sessionId));
142
+ }
143
+
144
+ /**
145
+ * Updates session token counts after a run completes.
146
+ * @deprecated Use updateSessionTokensIncremental for per-step tracking
147
+ */
148
+ export async function updateSessionTokens(
149
+ fin: { usage?: { inputTokens?: number; outputTokens?: number } },
150
+ opts: RunOpts,
151
+ db: Awaited<ReturnType<typeof getDb>>,
152
+ ) {
153
+ if (!fin.usage || !db) return;
154
+
155
+ const sessRows = await db
156
+ .select()
157
+ .from(sessions)
158
+ .where(eq(sessions.id, opts.sessionId));
159
+
160
+ if (sessRows.length > 0 && sessRows[0]) {
161
+ const row = sessRows[0];
162
+ const priorInput = Number(row.totalInputTokens ?? 0);
163
+ const priorOutput = Number(row.totalOutputTokens ?? 0);
164
+ const nextInput = priorInput + Number(fin.usage.inputTokens ?? 0);
165
+ const nextOutput = priorOutput + Number(fin.usage.outputTokens ?? 0);
166
+
167
+ await db
168
+ .update(sessions)
169
+ .set({
170
+ totalInputTokens: nextInput,
171
+ totalOutputTokens: nextOutput,
172
+ })
173
+ .where(eq(sessions.id, opts.sessionId));
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Updates message token counts after each step.
179
+ * AI SDK v6: onStepFinish.usage is PER-STEP. We ADD each step's tokens to message totals.
180
+ */
181
+ export async function updateMessageTokensIncremental(
182
+ usage: UsageData,
183
+ providerOptions: ProviderMetadata | undefined,
184
+ opts: RunOpts,
185
+ db: Awaited<ReturnType<typeof getDb>>,
186
+ ) {
187
+ if (!usage || !db) return;
188
+
189
+ const usageProvider = resolveUsageProvider(opts.provider, opts.model);
190
+ const normalizedUsage = normalizeUsage(usage, providerOptions, usageProvider);
191
+
192
+ const stepInput = Number(normalizedUsage.inputTokens ?? 0);
193
+ const stepOutput = Number(normalizedUsage.outputTokens ?? 0);
194
+ const stepCached = Number(normalizedUsage.cachedInputTokens ?? 0);
195
+ const stepCacheCreation = Number(
196
+ normalizedUsage.cacheCreationInputTokens ?? 0,
197
+ );
198
+ const stepReasoning = Number(normalizedUsage.reasoningTokens ?? 0);
199
+
200
+ const msgRows = await db
201
+ .select()
202
+ .from(messages)
203
+ .where(eq(messages.id, opts.assistantMessageId));
204
+
205
+ if (msgRows.length > 0 && msgRows[0]) {
206
+ const msg = msgRows[0];
207
+ const nextInput = Number(msg.inputTokens ?? 0) + stepInput;
208
+ const nextOutput = Number(msg.outputTokens ?? 0) + stepOutput;
209
+ const nextCached = Number(msg.cachedInputTokens ?? 0) + stepCached;
210
+ const nextCacheCreation =
211
+ Number(msg.cacheCreationInputTokens ?? 0) + stepCacheCreation;
212
+ const nextReasoning = Number(msg.reasoningTokens ?? 0) + stepReasoning;
213
+
214
+ await db
215
+ .update(messages)
216
+ .set({
217
+ inputTokens: nextInput,
218
+ outputTokens: nextOutput,
219
+ totalTokens:
220
+ nextInput +
221
+ nextOutput +
222
+ nextCached +
223
+ nextCacheCreation +
224
+ nextReasoning,
225
+ cachedInputTokens: nextCached,
226
+ cacheCreationInputTokens: nextCacheCreation,
227
+ reasoningTokens: nextReasoning,
228
+ })
229
+ .where(eq(messages.id, opts.assistantMessageId));
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Marks an assistant message as complete.
235
+ * Token usage is tracked incrementally via updateMessageTokensIncremental().
236
+ */
237
+ export async function completeAssistantMessage(
238
+ _fin: {
239
+ usage?: {
240
+ inputTokens?: number;
241
+ outputTokens?: number;
242
+ totalTokens?: number;
243
+ };
244
+ },
245
+ opts: RunOpts,
246
+ db: Awaited<ReturnType<typeof getDb>>,
247
+ ) {
248
+ if (!db) return;
249
+
250
+ // Only mark as complete - tokens are already tracked incrementally
251
+ await db
252
+ .update(messages)
253
+ .set({
254
+ status: 'complete',
255
+ completedAt: Date.now(),
256
+ })
257
+ .where(eq(messages.id, opts.assistantMessageId));
258
+ }
259
+
260
+ /**
261
+ * Removes empty text parts from an assistant message.
262
+ */
263
+ export async function cleanupEmptyTextParts(
264
+ opts: RunOpts,
265
+ db: Awaited<ReturnType<typeof getDb>>,
266
+ ) {
267
+ if (!db) return;
268
+
269
+ const parts = await db
270
+ .select()
271
+ .from(messageParts)
272
+ .where(eq(messageParts.messageId, opts.assistantMessageId));
273
+
274
+ for (const p of parts) {
275
+ if (p.type === 'text') {
276
+ let t = '';
277
+ try {
278
+ t = JSON.parse(p.content || '{}')?.text || '';
279
+ } catch {}
280
+ if (!t || !t.trim()) {
281
+ await db.delete(messageParts).where(eq(messageParts.id, p.id));
282
+ }
283
+ }
284
+ }
285
+ }