@agi-cli/server 0.1.145 → 0.1.147

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agi-cli/server",
3
- "version": "0.1.145",
3
+ "version": "0.1.147",
4
4
  "description": "HTTP API server for AGI CLI",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "dependencies": {
32
- "@agi-cli/sdk": "0.1.145",
33
- "@agi-cli/database": "0.1.145",
32
+ "@agi-cli/sdk": "0.1.147",
33
+ "@agi-cli/database": "0.1.147",
34
34
  "drizzle-orm": "^0.44.5",
35
35
  "hono": "^4.9.9",
36
36
  "zod": "^4.1.8"
@@ -5,6 +5,10 @@ export type AGIEventType =
5
5
  | 'setu.payment.signing'
6
6
  | 'setu.payment.complete'
7
7
  | 'setu.payment.error'
8
+ | 'setu.topup.required'
9
+ | 'setu.topup.method_selected'
10
+ | 'setu.topup.cancelled'
11
+ | 'setu.fiat.checkout_created'
8
12
  | 'session.created'
9
13
  | 'session.updated'
10
14
  | 'message.created'
@@ -696,4 +696,108 @@ export function registerSessionsRoutes(app: Hono) {
696
696
  newMessages: newMessages.length,
697
697
  });
698
698
  });
699
+
700
+ // Retry a failed assistant message
701
+ app.post('/v1/sessions/:sessionId/messages/:messageId/retry', async (c) => {
702
+ try {
703
+ const sessionId = c.req.param('sessionId');
704
+ const messageId = c.req.param('messageId');
705
+ const projectRoot = c.req.query('project') || process.cwd();
706
+ const cfg = await loadConfig(projectRoot);
707
+ const db = await getDb(cfg.projectRoot);
708
+
709
+ // Get the assistant message
710
+ const [assistantMsg] = await db
711
+ .select()
712
+ .from(messages)
713
+ .where(
714
+ and(
715
+ eq(messages.id, messageId),
716
+ eq(messages.sessionId, sessionId),
717
+ eq(messages.role, 'assistant'),
718
+ ),
719
+ )
720
+ .limit(1);
721
+
722
+ if (!assistantMsg) {
723
+ return c.json({ error: 'Message not found' }, 404);
724
+ }
725
+
726
+ // Only allow retry on error or complete messages
727
+ if (
728
+ assistantMsg.status !== 'error' &&
729
+ assistantMsg.status !== 'complete'
730
+ ) {
731
+ return c.json(
732
+ { error: 'Can only retry error or complete messages' },
733
+ 400,
734
+ );
735
+ }
736
+
737
+ // Get session for context
738
+ const [session] = await db
739
+ .select()
740
+ .from(sessions)
741
+ .where(eq(sessions.id, sessionId))
742
+ .limit(1);
743
+
744
+ if (!session) {
745
+ return c.json({ error: 'Session not found' }, 404);
746
+ }
747
+
748
+ // Delete existing message parts (the error content)
749
+ await db
750
+ .delete(messageParts)
751
+ .where(eq(messageParts.messageId, messageId));
752
+
753
+ // Reset message status to pending
754
+ await db
755
+ .update(messages)
756
+ .set({
757
+ status: 'pending',
758
+ error: null,
759
+ errorType: null,
760
+ errorDetails: null,
761
+ completedAt: null,
762
+ })
763
+ .where(eq(messages.id, messageId));
764
+
765
+ // Emit event so UI updates
766
+ const { publish } = await import('../events/bus.ts');
767
+ publish({
768
+ type: 'message.updated',
769
+ sessionId,
770
+ payload: { id: messageId, status: 'pending' },
771
+ });
772
+
773
+ // Re-enqueue the assistant run
774
+ const { enqueueAssistantRun } = await import(
775
+ '../runtime/session/queue.ts'
776
+ );
777
+ const { runSessionLoop } = await import('../runtime/agent/runner.ts');
778
+
779
+ const toolApprovalMode = cfg.defaults.toolApproval ?? 'auto';
780
+
781
+ enqueueAssistantRun(
782
+ {
783
+ sessionId,
784
+ assistantMessageId: messageId,
785
+ agent: assistantMsg.agent ?? 'build',
786
+ provider: (assistantMsg.provider ??
787
+ cfg.defaults.provider) as ProviderId,
788
+ model: assistantMsg.model ?? cfg.defaults.model,
789
+ projectRoot: cfg.projectRoot,
790
+ oneShot: false,
791
+ toolApprovalMode,
792
+ },
793
+ runSessionLoop,
794
+ );
795
+
796
+ return c.json({ success: true, messageId });
797
+ } catch (err) {
798
+ logger.error('Failed to retry message', err);
799
+ const errorResponse = serializeError(err);
800
+ return c.json(errorResponse, errorResponse.error.status || 500);
801
+ }
802
+ });
699
803
  }
@@ -8,6 +8,24 @@ import {
8
8
  } from '@agi-cli/sdk';
9
9
  import { logger } from '@agi-cli/sdk';
10
10
  import { serializeError } from '../runtime/errors/api-error.ts';
11
+ import { Keypair } from '@solana/web3.js';
12
+ import bs58 from 'bs58';
13
+ import nacl from 'tweetnacl';
14
+ import { publish } from '../events/bus.ts';
15
+ import {
16
+ resolveTopupMethodSelection,
17
+ rejectTopupSelection,
18
+ getPendingTopup,
19
+ type TopupMethod,
20
+ } from '../runtime/topup/manager.ts';
21
+
22
+ const SETU_BASE_URL = process.env.SETU_BASE_URL || 'https://setu.agi.nitish.sh';
23
+
24
+ function getSetuBaseUrl(): string {
25
+ return SETU_BASE_URL.endsWith('/')
26
+ ? SETU_BASE_URL.slice(0, -1)
27
+ : SETU_BASE_URL;
28
+ }
11
29
 
12
30
  async function getSetuPrivateKey(): Promise<string | null> {
13
31
  if (process.env.SETU_PRIVATE_KEY) {
@@ -25,6 +43,25 @@ async function getSetuPrivateKey(): Promise<string | null> {
25
43
  return null;
26
44
  }
27
45
 
46
+ function signNonce(nonce: string, privateKeyBytes: Uint8Array): string {
47
+ const data = new TextEncoder().encode(nonce);
48
+ const signature = nacl.sign.detached(data, privateKeyBytes);
49
+ return bs58.encode(signature);
50
+ }
51
+
52
+ function buildWalletHeaders(privateKey: string): Record<string, string> {
53
+ const privateKeyBytes = bs58.decode(privateKey);
54
+ const keypair = Keypair.fromSecretKey(privateKeyBytes);
55
+ const walletAddress = keypair.publicKey.toBase58();
56
+ const nonce = Date.now().toString();
57
+ const signature = signNonce(nonce, privateKeyBytes);
58
+ return {
59
+ 'x-wallet-address': walletAddress,
60
+ 'x-wallet-nonce': nonce,
61
+ 'x-wallet-signature': signature,
62
+ };
63
+ }
64
+
28
65
  export function registerSetuRoutes(app: Hono) {
29
66
  app.get('/v1/setu/balance', async (c) => {
30
67
  try {
@@ -97,4 +134,213 @@ export function registerSetuRoutes(app: Hono) {
97
134
  return c.json(errorResponse, errorResponse.error.status || 500);
98
135
  }
99
136
  });
137
+
138
+ app.get('/v1/setu/topup/polar/estimate', async (c) => {
139
+ try {
140
+ const amount = c.req.query('amount');
141
+ if (!amount) {
142
+ return c.json({ error: 'Missing amount parameter' }, 400);
143
+ }
144
+
145
+ const baseUrl = getSetuBaseUrl();
146
+ const response = await fetch(
147
+ `${baseUrl}/v1/topup/polar/estimate?amount=${amount}`,
148
+ {
149
+ method: 'GET',
150
+ headers: { 'Content-Type': 'application/json' },
151
+ },
152
+ );
153
+
154
+ const data = await response.json();
155
+ if (!response.ok) {
156
+ return c.json(data, response.status as 400 | 500);
157
+ }
158
+
159
+ return c.json(data);
160
+ } catch (error) {
161
+ logger.error('Failed to get Polar estimate', error);
162
+ const errorResponse = serializeError(error);
163
+ return c.json(errorResponse, errorResponse.error.status || 500);
164
+ }
165
+ });
166
+
167
+ app.post('/v1/setu/topup/polar', async (c) => {
168
+ try {
169
+ const privateKey = await getSetuPrivateKey();
170
+ if (!privateKey) {
171
+ return c.json({ error: 'Setu wallet not configured' }, 401);
172
+ }
173
+
174
+ const body = await c.req.json();
175
+ const { amount, successUrl } = body as {
176
+ amount: number;
177
+ successUrl: string;
178
+ };
179
+
180
+ if (!amount || typeof amount !== 'number') {
181
+ return c.json({ error: 'Invalid amount' }, 400);
182
+ }
183
+
184
+ if (!successUrl || typeof successUrl !== 'string') {
185
+ return c.json({ error: 'Missing successUrl' }, 400);
186
+ }
187
+
188
+ const walletHeaders = buildWalletHeaders(privateKey);
189
+ const baseUrl = getSetuBaseUrl();
190
+
191
+ const response = await fetch(`${baseUrl}/v1/topup/polar`, {
192
+ method: 'POST',
193
+ headers: {
194
+ 'Content-Type': 'application/json',
195
+ ...walletHeaders,
196
+ },
197
+ body: JSON.stringify({ amount, successUrl }),
198
+ });
199
+
200
+ const data = await response.json();
201
+ if (!response.ok) {
202
+ return c.json(data, response.status as 400 | 500);
203
+ }
204
+
205
+ return c.json(data);
206
+ } catch (error) {
207
+ logger.error('Failed to create Polar checkout', error);
208
+ const errorResponse = serializeError(error);
209
+ return c.json(errorResponse, errorResponse.error.status || 500);
210
+ }
211
+ });
212
+
213
+ app.post('/v1/setu/topup/select', async (c) => {
214
+ try {
215
+ const body = await c.req.json();
216
+ const { sessionId, method } = body as {
217
+ sessionId: string;
218
+ method: TopupMethod;
219
+ };
220
+
221
+ if (!sessionId || typeof sessionId !== 'string') {
222
+ return c.json({ error: 'Missing sessionId' }, 400);
223
+ }
224
+
225
+ if (!method || !['crypto', 'fiat'].includes(method)) {
226
+ return c.json(
227
+ { error: 'Invalid method, must be "crypto" or "fiat"' },
228
+ 400,
229
+ );
230
+ }
231
+
232
+ const resolved = resolveTopupMethodSelection(sessionId, method);
233
+ if (!resolved) {
234
+ return c.json(
235
+ { error: 'No pending topup request found for this session' },
236
+ 404,
237
+ );
238
+ }
239
+
240
+ publish({
241
+ type: 'setu.topup.method_selected',
242
+ sessionId,
243
+ payload: { method },
244
+ });
245
+
246
+ return c.json({ success: true, method });
247
+ } catch (error) {
248
+ logger.error('Failed to select topup method', error);
249
+ const errorResponse = serializeError(error);
250
+ return c.json(errorResponse, errorResponse.error.status || 500);
251
+ }
252
+ });
253
+
254
+ app.post('/v1/setu/topup/cancel', async (c) => {
255
+ try {
256
+ const body = await c.req.json();
257
+ const { sessionId, reason } = body as {
258
+ sessionId: string;
259
+ reason?: string;
260
+ };
261
+
262
+ if (!sessionId || typeof sessionId !== 'string') {
263
+ return c.json({ error: 'Missing sessionId' }, 400);
264
+ }
265
+
266
+ const rejected = rejectTopupSelection(
267
+ sessionId,
268
+ reason ?? 'User cancelled',
269
+ );
270
+ if (!rejected) {
271
+ return c.json(
272
+ { error: 'No pending topup request found for this session' },
273
+ 404,
274
+ );
275
+ }
276
+
277
+ publish({
278
+ type: 'setu.topup.cancelled',
279
+ sessionId,
280
+ payload: { reason: reason ?? 'User cancelled' },
281
+ });
282
+
283
+ return c.json({ success: true });
284
+ } catch (error) {
285
+ logger.error('Failed to cancel topup', error);
286
+ const errorResponse = serializeError(error);
287
+ return c.json(errorResponse, errorResponse.error.status || 500);
288
+ }
289
+ });
290
+
291
+ app.get('/v1/setu/topup/pending', async (c) => {
292
+ try {
293
+ const sessionId = c.req.query('sessionId');
294
+ if (!sessionId) {
295
+ return c.json({ error: 'Missing sessionId parameter' }, 400);
296
+ }
297
+
298
+ const pending = getPendingTopup(sessionId);
299
+ if (!pending) {
300
+ return c.json({ hasPending: false });
301
+ }
302
+
303
+ return c.json({
304
+ hasPending: true,
305
+ sessionId: pending.sessionId,
306
+ messageId: pending.messageId,
307
+ amountUsd: pending.amountUsd,
308
+ currentBalance: pending.currentBalance,
309
+ createdAt: pending.createdAt,
310
+ });
311
+ } catch (error) {
312
+ logger.error('Failed to get pending topup', error);
313
+ const errorResponse = serializeError(error);
314
+ return c.json(errorResponse, errorResponse.error.status || 500);
315
+ }
316
+ });
317
+
318
+ app.get('/v1/setu/topup/polar/status', async (c) => {
319
+ try {
320
+ const checkoutId = c.req.query('checkoutId');
321
+ if (!checkoutId) {
322
+ return c.json({ error: 'Missing checkoutId parameter' }, 400);
323
+ }
324
+
325
+ const baseUrl = getSetuBaseUrl();
326
+ const response = await fetch(
327
+ `${baseUrl}/v1/topup/polar/status?checkoutId=${checkoutId}`,
328
+ {
329
+ method: 'GET',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ },
332
+ );
333
+
334
+ const data = await response.json();
335
+ if (!response.ok) {
336
+ return c.json(data, response.status as 400 | 500);
337
+ }
338
+
339
+ return c.json(data);
340
+ } catch (error) {
341
+ logger.error('Failed to check Polar status', error);
342
+ const errorResponse = serializeError(error);
343
+ return c.json(errorResponse, errorResponse.error.status || 500);
344
+ }
345
+ });
100
346
  }
@@ -201,6 +201,7 @@ export async function setupRunner(opts: RunOpts): Promise<SetupResult> {
201
201
  const model = await resolveModel(opts.provider, opts.model, cfg, {
202
202
  systemPrompt: oauthSystemPrompt,
203
203
  sessionId: opts.sessionId,
204
+ messageId: opts.assistantMessageId,
204
205
  });
205
206
  debugLog(
206
207
  `[RUNNER] Model created: ${JSON.stringify({ id: model.modelId, provider: model.provider })}`,
@@ -3,7 +3,7 @@ import { getAnthropicInstance } from './anthropic.ts';
3
3
  import { resolveOpenAIModel } from './openai.ts';
4
4
  import { resolveGoogleModel } from './google.ts';
5
5
  import { resolveOpenRouterModel } from './openrouter.ts';
6
- import { resolveSetuModel } from './setu.ts';
6
+ import { resolveSetuModel, type ResolveSetuModelOptions } from './setu.ts';
7
7
  import { getZaiInstance, getZaiCodingInstance } from './zai.ts';
8
8
  import { resolveOpencodeModel } from './opencode.ts';
9
9
  import { getMoonshotInstance } from './moonshot.ts';
@@ -14,7 +14,12 @@ export async function resolveModel(
14
14
  provider: ProviderName,
15
15
  model: string,
16
16
  cfg: AGIConfig,
17
- options?: { systemPrompt?: string; sessionId?: string },
17
+ options?: {
18
+ systemPrompt?: string;
19
+ sessionId?: string;
20
+ messageId?: string;
21
+ topupApprovalMode?: ResolveSetuModelOptions['topupApprovalMode'];
22
+ },
18
23
  ) {
19
24
  if (provider === 'openai') {
20
25
  return resolveOpenAIModel(model, cfg, {
@@ -35,7 +40,10 @@ export async function resolveModel(
35
40
  return resolveOpencodeModel(model, cfg);
36
41
  }
37
42
  if (provider === 'setu') {
38
- return resolveSetuModel(model, options?.sessionId);
43
+ return resolveSetuModel(model, options?.sessionId, {
44
+ messageId: options?.messageId,
45
+ topupApprovalMode: options?.topupApprovalMode,
46
+ });
39
47
  }
40
48
  if (provider === 'zai') {
41
49
  return getZaiInstance(cfg, model);
@@ -4,13 +4,28 @@ import {
4
4
  type SetuPaymentCallbacks,
5
5
  } from '@agi-cli/sdk';
6
6
  import { publish } from '../../events/bus.ts';
7
+ import {
8
+ waitForTopupMethodSelection,
9
+ type TopupMethod,
10
+ } from '../topup/manager.ts';
11
+
12
+ const MIN_TOPUP_USD = 5;
7
13
 
8
14
  function getProviderNpm(model: string): string | undefined {
9
15
  const entry = catalog.setu?.models?.find((m) => m.id === model);
10
16
  return entry?.provider?.npm;
11
17
  }
12
18
 
13
- export function resolveSetuModel(model: string, sessionId?: string) {
19
+ export interface ResolveSetuModelOptions {
20
+ messageId?: string;
21
+ topupApprovalMode?: 'auto' | 'approval';
22
+ }
23
+
24
+ export function resolveSetuModel(
25
+ model: string,
26
+ sessionId?: string,
27
+ options: ResolveSetuModelOptions = {},
28
+ ) {
14
29
  const privateKey = process.env.SETU_PRIVATE_KEY ?? '';
15
30
  if (!privateKey) {
16
31
  throw new Error(
@@ -19,14 +34,15 @@ export function resolveSetuModel(model: string, sessionId?: string) {
19
34
  }
20
35
  const baseURL = process.env.SETU_BASE_URL;
21
36
  const rpcURL = process.env.SETU_SOLANA_RPC_URL;
37
+ const { messageId, topupApprovalMode = 'approval' } = options;
22
38
 
23
39
  const callbacks: SetuPaymentCallbacks = sessionId
24
40
  ? {
25
- onPaymentRequired: (amountUsd) => {
41
+ onPaymentRequired: (amountUsd, currentBalance) => {
26
42
  publish({
27
43
  type: 'setu.payment.required',
28
44
  sessionId,
29
- payload: { amountUsd },
45
+ payload: { amountUsd, currentBalance },
30
46
  });
31
47
  },
32
48
  onPaymentSigning: () => {
@@ -50,6 +66,31 @@ export function resolveSetuModel(model: string, sessionId?: string) {
50
66
  payload: { error },
51
67
  });
52
68
  },
69
+ onPaymentApproval: async (info): Promise<TopupMethod | 'cancel'> => {
70
+ const suggestedTopupUsd = Math.max(
71
+ MIN_TOPUP_USD,
72
+ Math.ceil(info.amountUsd * 2),
73
+ );
74
+
75
+ publish({
76
+ type: 'setu.topup.required',
77
+ sessionId,
78
+ payload: {
79
+ messageId,
80
+ amountUsd: info.amountUsd,
81
+ currentBalance: info.currentBalance,
82
+ minTopupUsd: MIN_TOPUP_USD,
83
+ suggestedTopupUsd,
84
+ },
85
+ });
86
+
87
+ return waitForTopupMethodSelection(
88
+ sessionId,
89
+ messageId ?? '',
90
+ info.amountUsd,
91
+ info.currentBalance,
92
+ );
93
+ },
53
94
  }
54
95
  : {};
55
96
 
@@ -63,6 +104,7 @@ export function resolveSetuModel(model: string, sessionId?: string) {
63
104
  rpcURL,
64
105
  callbacks,
65
106
  providerNpm,
107
+ topupApprovalMode,
66
108
  },
67
109
  );
68
110
  }
@@ -9,6 +9,7 @@ import type { ToolAdapterContext } from '../../tools/adapter.ts';
9
9
  import { pruneSession, performAutoCompaction } from '../message/compaction.ts';
10
10
  import { debugLog } from '../debug/index.ts';
11
11
  import { enqueueAssistantRun } from '../session/queue.ts';
12
+ import { clearPendingTopup } from '../topup/manager.ts';
12
13
 
13
14
  export function createErrorHandler(
14
15
  opts: RunOpts,
@@ -26,21 +27,155 @@ export function createErrorHandler(
26
27
  const nestedError = (errObj?.error as Record<string, unknown>)?.error as
27
28
  | Record<string, unknown>
28
29
  | undefined;
30
+ const causeError = errObj?.cause as Record<string, unknown> | undefined;
31
+
32
+ // Check for SETU_FIAT_SELECTED code specifically (not string matching)
29
33
  const errorCode =
30
- (errObj?.code as string) ?? (nestedError?.code as string) ?? '';
34
+ (errObj?.code as string) ??
35
+ ((errObj?.error as Record<string, unknown>)?.code as string) ??
36
+ ((
37
+ (errObj?.error as Record<string, unknown>)?.error as Record<
38
+ string,
39
+ unknown
40
+ >
41
+ )?.code as string) ??
42
+ ((errObj?.data as Record<string, unknown>)?.code as string) ??
43
+ ((errObj?.cause as Record<string, unknown>)?.code as string) ??
44
+ ((
45
+ (errObj?.cause as Record<string, unknown>)?.error as Record<
46
+ string,
47
+ unknown
48
+ >
49
+ )?.code as string) ??
50
+ (nestedError?.code as string) ??
51
+ (causeError?.code as string) ??
52
+ '';
53
+
54
+ // Also check error message for the exact fiat selection message
55
+ const errorMessage =
56
+ (errObj?.message as string) ??
57
+ ((errObj?.error as Record<string, unknown>)?.message as string) ??
58
+ ((
59
+ (errObj?.error as Record<string, unknown>)?.error as Record<
60
+ string,
61
+ unknown
62
+ >
63
+ )?.message as string) ??
64
+ ((errObj?.data as Record<string, unknown>)?.message as string) ??
65
+ ((errObj?.cause as Record<string, unknown>)?.message as string) ??
66
+ ((
67
+ (errObj?.cause as Record<string, unknown>)?.error as Record<
68
+ string,
69
+ unknown
70
+ >
71
+ )?.message as string) ??
72
+ (nestedError?.message as string) ??
73
+ (causeError?.message as string) ??
74
+ '';
75
+
76
+ // Also do a JSON stringify check specifically for the code
77
+ const fullErrorStr = JSON.stringify(err);
78
+ const hasSetuFiatCode =
79
+ fullErrorStr.includes('"code":"SETU_FIAT_SELECTED"') ||
80
+ fullErrorStr.includes("'code':'SETU_FIAT_SELECTED'");
81
+
82
+ // Only match if the error code is SETU_FIAT_SELECTED OR the exact error message
83
+ const isFiatSelected =
84
+ errorCode === 'SETU_FIAT_SELECTED' ||
85
+ errorMessage === 'Setu: fiat payment selected' ||
86
+ hasSetuFiatCode;
87
+
88
+ // Handle fiat payment selected - this is not an error, just a signal to pause
89
+ if (isFiatSelected) {
90
+ debugLog('[stream-handlers] Fiat payment selected, pausing request');
91
+ clearPendingTopup(opts.sessionId);
92
+
93
+ // Add a helpful message part telling user to complete payment
94
+ const partId = crypto.randomUUID();
95
+ await db.insert(messageParts).values({
96
+ id: partId,
97
+ messageId: opts.assistantMessageId,
98
+ index: await sharedCtx.nextIndex(),
99
+ stepIndex: getStepIndex(),
100
+ type: 'error',
101
+ content: JSON.stringify({
102
+ message: 'Balance too low — Complete your top-up, then retry.',
103
+ type: 'balance_low',
104
+ errorType: 'balance_low',
105
+ isRetryable: true,
106
+ }),
107
+ agent: opts.agent,
108
+ provider: opts.provider,
109
+ model: opts.model,
110
+ startedAt: Date.now(),
111
+ completedAt: Date.now(),
112
+ });
113
+
114
+ // Mark the message as completed (not error, not pending)
115
+ await db
116
+ .update(messages)
117
+ .set({
118
+ status: 'complete',
119
+ completedAt: Date.now(),
120
+ error: null,
121
+ errorType: null,
122
+ errorDetails: null,
123
+ })
124
+ .where(eq(messages.id, opts.assistantMessageId));
125
+
126
+ // Emit the message part
127
+ publish({
128
+ type: 'message.part.delta',
129
+ sessionId: opts.sessionId,
130
+ payload: {
131
+ messageId: opts.assistantMessageId,
132
+ partId,
133
+ type: 'error',
134
+ content: JSON.stringify({
135
+ message: 'Balance too low — Complete your top-up, then retry.',
136
+ type: 'balance_low',
137
+ errorType: 'balance_low',
138
+ isRetryable: true,
139
+ }),
140
+ },
141
+ });
142
+
143
+ // Emit message completed
144
+ publish({
145
+ type: 'message.completed',
146
+ sessionId: opts.sessionId,
147
+ payload: {
148
+ id: opts.assistantMessageId,
149
+ fiatTopupRequired: true,
150
+ },
151
+ });
152
+
153
+ // Emit a special event so UI knows to show topup modal
154
+ publish({
155
+ type: 'setu.fiat.checkout_created',
156
+ sessionId: opts.sessionId,
157
+ payload: {
158
+ messageId: opts.assistantMessageId,
159
+ needsTopup: true,
160
+ },
161
+ });
162
+
163
+ return;
164
+ }
165
+
31
166
  const errorType =
32
167
  (errObj?.apiErrorType as string) ?? (nestedError?.type as string) ?? '';
33
- const fullErrorStr = JSON.stringify(err).toLowerCase();
168
+ const fullErrorStrLower = JSON.stringify(err).toLowerCase();
34
169
 
35
170
  const isPromptTooLong =
36
- fullErrorStr.includes('prompt is too long') ||
37
- fullErrorStr.includes('maximum context length') ||
38
- fullErrorStr.includes('too many tokens') ||
39
- fullErrorStr.includes('context_length_exceeded') ||
40
- fullErrorStr.includes('request too large') ||
41
- fullErrorStr.includes('exceeds the model') ||
42
- fullErrorStr.includes('context window') ||
43
- fullErrorStr.includes('input is too long') ||
171
+ fullErrorStrLower.includes('prompt is too long') ||
172
+ fullErrorStrLower.includes('maximum context length') ||
173
+ fullErrorStrLower.includes('too many tokens') ||
174
+ fullErrorStrLower.includes('context_length_exceeded') ||
175
+ fullErrorStrLower.includes('request too large') ||
176
+ fullErrorStrLower.includes('exceeds the model') ||
177
+ fullErrorStrLower.includes('context window') ||
178
+ fullErrorStrLower.includes('input is too long') ||
44
179
  errorCode === 'context_length_exceeded' ||
45
180
  errorType === 'invalid_request_error';
46
181
 
@@ -0,0 +1,110 @@
1
+ import { logger } from '@agi-cli/sdk';
2
+
3
+ export type TopupMethod = 'crypto' | 'fiat';
4
+
5
+ export interface PendingTopup {
6
+ sessionId: string;
7
+ messageId: string;
8
+ amountUsd: number;
9
+ currentBalance: number;
10
+ resolve: (method: TopupMethod) => void;
11
+ reject: (error: Error) => void;
12
+ createdAt: number;
13
+ }
14
+
15
+ const TOPUP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
16
+
17
+ const pendingTopups = new Map<string, PendingTopup>();
18
+ const timeoutIds = new Map<string, ReturnType<typeof setTimeout>>();
19
+
20
+ export function waitForTopupMethodSelection(
21
+ sessionId: string,
22
+ messageId: string,
23
+ amountUsd: number,
24
+ currentBalance: number,
25
+ ): Promise<TopupMethod> {
26
+ return new Promise((resolve, reject) => {
27
+ const existing = pendingTopups.get(sessionId);
28
+ if (existing) {
29
+ existing.reject(new Error('Superseded by new topup request'));
30
+ clearPendingTopup(sessionId);
31
+ }
32
+
33
+ const pending: PendingTopup = {
34
+ sessionId,
35
+ messageId,
36
+ amountUsd,
37
+ currentBalance,
38
+ resolve,
39
+ reject,
40
+ createdAt: Date.now(),
41
+ };
42
+
43
+ pendingTopups.set(sessionId, pending);
44
+
45
+ const timeoutId = setTimeout(() => {
46
+ const p = pendingTopups.get(sessionId);
47
+ if (p) {
48
+ logger.warn(`Topup selection timeout for session ${sessionId}`);
49
+ p.reject(new Error('Topup selection timeout'));
50
+ clearPendingTopup(sessionId);
51
+ }
52
+ }, TOPUP_TIMEOUT_MS);
53
+
54
+ timeoutIds.set(sessionId, timeoutId);
55
+ });
56
+ }
57
+
58
+ export function resolveTopupMethodSelection(
59
+ sessionId: string,
60
+ method: TopupMethod,
61
+ ): boolean {
62
+ const pending = pendingTopups.get(sessionId);
63
+ if (!pending) {
64
+ logger.warn(
65
+ `No pending topup found for session ${sessionId} when trying to resolve`,
66
+ );
67
+ return false;
68
+ }
69
+
70
+ pending.resolve(method);
71
+ clearPendingTopup(sessionId);
72
+ return true;
73
+ }
74
+
75
+ export function rejectTopupSelection(
76
+ sessionId: string,
77
+ reason: string,
78
+ ): boolean {
79
+ const pending = pendingTopups.get(sessionId);
80
+ if (!pending) {
81
+ return false;
82
+ }
83
+
84
+ pending.reject(new Error(reason));
85
+ clearPendingTopup(sessionId);
86
+ return true;
87
+ }
88
+
89
+ export function getPendingTopup(sessionId: string): PendingTopup | undefined {
90
+ return pendingTopups.get(sessionId);
91
+ }
92
+
93
+ export function hasPendingTopup(sessionId: string): boolean {
94
+ return pendingTopups.has(sessionId);
95
+ }
96
+
97
+ export function clearPendingTopup(sessionId: string): void {
98
+ const timeoutId = timeoutIds.get(sessionId);
99
+ if (timeoutId) {
100
+ clearTimeout(timeoutId);
101
+ timeoutIds.delete(sessionId);
102
+ }
103
+ pendingTopups.delete(sessionId);
104
+ }
105
+
106
+ export function clearAllPendingTopups(): void {
107
+ for (const sessionId of pendingTopups.keys()) {
108
+ clearPendingTopup(sessionId);
109
+ }
110
+ }